Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions nodes/src/nodes/tool_google_workspace/IInstance.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,38 +82,61 @@ def _check_connection_impl(self, probe: 'Callable[[Any], None] | None' = None) -
"""Diagnostics: service present, optional live probe, scope coverage.

``probe``: a cheap real API call taking the service handle; when it
raises, connection_ok flips false and the error is reported. A
malformed user token likewise reports connection_ok=False rather than
being swallowed — this tool exists precisely for the broken cases.
raises, connection_ok flips false and the error (plus, when Google
supplies one, the structured reason code — accessNotConfigured,
forbidden, rateLimitExceeded, ...) is reported. A malformed user
token likewise reports connection_ok=False rather than being
swallowed — this tool exists precisely for the broken cases.

Without a probe, nothing here ever calls Google, so a client that
constructs fine and a token whose claimed scopes look right can both
be true while every real call 403s (e.g. the API is disabled on the
project behind the credential). That state is reported as
connection_ok='unknown', not True — a green light nobody verified is
worse than no light, because it sends debugging effort everywhere
except the actual cause.
"""
access = self._access()
service = self._svc()
checked = ['client']
if service is None:
connection_ok: bool | str = False
elif probe is None:
connection_ok = 'unknown'
else:
connection_ok = True
out: dict = {
'connection_ok': self._svc() is not None,
'connection_ok': connection_ok,
'access': getattr(access, 'tier', None),
'requiredScopes': list(getattr(access, 'scopes', []) or []),
}
service = self._svc()
if probe is not None and service is not None:
checked.append('probe')
try:
probe(service)
except Exception as exc:
out['connection_ok'] = False
out['error'] = str(exc)[:200]
reason_code = getattr(exc, 'reason_code', None)
if reason_code:
out['errorReason'] = reason_code
try:
cfg = Config.getNodeConfig(self.IGlobal.glb.logicalType, self.IGlobal.glb.connConfig)
auth_type = (cfg.get('authType') or 'service').strip()
out['authType'] = auth_type
if auth_type == 'user':
token_str = str(cfg.get('userToken') or '').strip()
if token_str:
checked.append('scopes')
try:
_granted, covered, missing = token_scope_report(self.SERVICE, cfg, out['requiredScopes'])
out['connection_ok'] = out['connection_ok'] and covered
if not covered:
out['connection_ok'] = False
out['missingScopes'] = missing
except Exception as exc:
out['connection_ok'] = False
out['error'] = f'invalid user token data: {str(exc)[:160]}'
except Exception:
pass # config lookup diagnostics must never raise
out['checked'] = checked
return out
27 changes: 22 additions & 5 deletions nodes/src/nodes/tool_google_workspace/docs/IInstance.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@
_MAX_TABLE_COLS = 25
_DOCUMENT_FIELDS = 'documentId,title,revisionId,body(content(paragraph(elements(textRun(content)))))'

# The Docs API has no list/about-style endpoint to probe cheaply, so check_connection asks
# for a document that can't exist. Google's front-end enforces API-enablement before it
# resolves the resource, so a 404 here still proves the API itself is reachable; anything
# else (esp. a 403 accessNotConfigured) is a real connectivity problem and must propagate.
_CONNECTION_PROBE_DOCUMENT_ID = 'rocketride-connection-probe-0000000000000000'


def _probe_connection(svc) -> None:
try:
execute(svc.documents().get(documentId=_CONNECTION_PROBE_DOCUMENT_ID, fields='documentId'))
except ValueError as exc:
if getattr(exc, 'status', None) == 404:
return
raise


class IInstance(GoogleToolInstanceBase):
IGlobal: IGlobal
Expand Down Expand Up @@ -85,15 +100,17 @@ def _clamp(value: int, low: int, high: int) -> int:

@tool_function(
description=(
'Check the Google Docs connection and verify that the granted OAuth scopes cover the '
"node's configured access tier. Call this when a Docs operation fails with a scope or "
'permission error. Returns connection_ok: true when the required scopes are present.'
'Check the Google Docs connection: makes a live probe call against the Docs API and '
"verifies that the granted OAuth scopes cover the node's configured access tier. Call "
'this when a Docs operation fails with a scope or permission error. Returns '
'connection_ok: true only when the live probe succeeds and the required scopes are '
'present.'
),
input_schema={'type': 'object', 'properties': {}, 'required': []},
)
def check_connection(self, args: dict) -> dict:
"""Check Docs connection status and whether granted OAuth scopes cover the access tier. Read-only."""
return self._check_connection_impl()
"""Check Docs connection status: live API probe plus granted-scope coverage. Read-only."""
return self._check_connection_impl(probe=_probe_connection)

# =======================================================================
# READ
Expand Down
2 changes: 1 addition & 1 deletion nodes/src/nodes/tool_google_workspace/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ operation and lives in `tool_drive`.
- `table_insert` — insert an empty `rows`×`columns` table at the end of the
body (`insertTable`); `rows` is clamped to 1..1000 and `columns` to 1..25.
- **Diagnostics:** `check_connection` verifies that granted OAuth scopes cover
the configured access tier.
the configured access tier and probes the Docs API.

The entire Docs v1 surface is `documents().get` / `create` / `batchUpdate`; the
wrappers are conveniences over `batch_update`.
Expand Down
11 changes: 6 additions & 5 deletions nodes/src/nodes/tool_google_workspace/drive/IInstance.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,16 @@ def _files(self):

@tool_function(
description=(
'Check the Google Drive connection and verify that the granted OAuth scopes cover the '
"node's configured access tier. Call this when a Drive operation fails with a scope or "
'permission error. Returns connection_ok: true when the required scopes are present.'
'Check the Google Drive connection: makes a live about().get call and verifies that the '
"granted OAuth scopes cover the node's configured access tier. Call this when a Drive "
'operation fails with a scope or permission error. Returns connection_ok: true only '
'when the live probe succeeds and the required scopes are present.'
),
input_schema={'type': 'object', 'properties': {}, 'required': []},
)
def check_connection(self, args: dict) -> dict:
"""Check Drive connection status and whether granted OAuth scopes cover the access tier. Read-only."""
return self._check_connection_impl()
"""Check Drive connection status: live about() probe plus granted-scope coverage. Read-only."""
return self._check_connection_impl(probe=lambda s: execute(s.about().get(fields='user')))

# =======================================================================
# FILES — read
Expand Down
3 changes: 2 additions & 1 deletion nodes/src/nodes/tool_google_workspace/drive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ operation sets `supportsAllDrives` so shared-drive items work transparently.
`file_trash`, `file_untrash`, `folder_create`, `permission_update`,
`permission_delete`, `permission_create`.
- **Gated:** `file_delete` (permanent delete).
- **Diagnostics:** `check_connection` verifies granted scopes cover the tier.
- **Diagnostics:** `check_connection` verifies granted scopes cover the tier
and probes the Drive API.

### Download vs. export (native files)

Expand Down
13 changes: 7 additions & 6 deletions nodes/src/nodes/tool_google_workspace/gmail/IInstance.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,16 +137,17 @@ def _id_list(args: dict, key: str, op: str) -> list[str]:

@tool_function(
description=(
"Check the Gmail connection status and verify that the OAuth token's granted scopes "
"cover the node's configured access tier. Call this when a Gmail operation fails with "
'a scope or permission error, or before attempting settings-level operations for the '
'first time. Returns connection_ok: true when all required scopes are present.'
'Check the Gmail connection status: makes a live users().getProfile call and verifies '
"that the OAuth token's granted scopes cover the node's configured access tier. Call "
'this when a Gmail operation fails with a scope or permission error, or before '
'attempting settings-level operations for the first time. Returns connection_ok: true '
'only when the live probe succeeds and all required scopes are present.'
),
input_schema={'type': 'object', 'properties': {}, 'required': []},
)
def check_connection(self, args: dict) -> dict:
"""Check Gmail connection status and whether granted OAuth scopes cover the configured access tier. Read-only."""
return self._check_connection_impl()
"""Check Gmail connection status: live profile probe plus granted-scope coverage. Read-only."""
return self._check_connection_impl(probe=lambda s: execute(s.users().getProfile(userId='me')))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# =======================================================================
# MESSAGES — read
Expand Down
2 changes: 2 additions & 0 deletions nodes/src/nodes/tool_google_workspace/gmail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ enabled—permanently delete.
- **Permanent delete (gated):** `message_delete`, `messages_batchDelete` —
require `allowHardDelete` **and** the `full` tier; batch ops take an explicit
id list (never a query), capped at 1000 per call.
- **Diagnostics:** `check_connection` verifies that granted OAuth scopes cover
the configured access tier and probes the Gmail API.

Operational targets (`messageId`, `threadId`, `labelId`, `query`) are always
tool-call parameters, never node config. Outputs are cleaned shapes (ids,
Expand Down
64 changes: 47 additions & 17 deletions nodes/src/nodes/tool_google_workspace/google_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,24 +147,46 @@ def resolve_token_uri(svc: GoogleService, token_uri: object) -> str:
return token_uri


def _structured_errors(exc: Exception) -> list[dict]:
"""Parse the JSON error body's ``error.errors[]`` list, or ``[]`` if absent/malformed."""
content = getattr(exc, 'content', b'') or b''
if isinstance(content, bytes):
content = content.decode('utf-8', 'replace')
try:
errors = (json.loads(content).get('error') or {}).get('errors') or []
return [e for e in errors if isinstance(e, dict)]
except (ValueError, AttributeError, TypeError):
return []


def _error_reason_code(exc: Exception) -> str | None:
"""The first structured reason code verbatim (e.g. 'accessNotConfigured'), if present.

Google distinguishes ``accessNotConfigured`` (API disabled on the project),
``forbidden`` (permission), and ``rateLimitExceeded``/``quotaExceeded`` under the
same HTTP 403 — the reason code is what actually names the fix.
"""
for e in _structured_errors(exc):
if e.get('reason'):
return str(e['reason'])
return None


def _is_rate_limit_403(exc: Exception) -> bool:
"""True when a 403 is a transient rate/quota limit (safe to retry) vs a permission error."""
status = getattr(getattr(exc, 'resp', None), 'status', None)
if not status or int(status) != 403:
return False
rate_reasons = {'ratelimitexceeded', 'userratelimitexceeded', 'quotaexceeded'}
structured = _structured_errors(exc)
# Prefer the structured error body (error.errors[].reason) over substring matching.
if structured:
reasons = {str(e.get('reason', '')).lower() for e in structured}
return bool(reasons & rate_reasons)
# Fallback for non-JSON bodies and transport-level wrappers.
content = getattr(exc, 'content', b'') or b''
if isinstance(content, bytes):
content = content.decode('utf-8', 'replace')
# Prefer the structured error body (error.errors[].reason) over substring matching.
try:
errors = (json.loads(content).get('error') or {}).get('errors') or []
reasons = {str(e.get('reason', '')).lower() for e in errors if isinstance(e, dict)}
if reasons:
return bool(reasons & rate_reasons)
except (ValueError, AttributeError, TypeError):
pass
# Fallback for non-JSON bodies and transport-level wrappers.
blob = (str(getattr(exc, 'reason', '') or '') + ' ' + content).lower()
return any(reason in blob for reason in rate_reasons)

Expand Down Expand Up @@ -417,12 +439,20 @@ def execute(svc: GoogleService, request: Any, *, binary: bool = False) -> Any:
_time.sleep(base_delay * (2**attempt))
continue
detail = getattr(exc, 'reason', None) or str(exc)
if status and int(status) == 403:
raise ValueError(
f'{svc.product} API 403: {detail}. If this is a scope error, disconnect '
'and reconnect your Google account with the required access tier. If it is a '
'sharing/ownership error, the account may lack permission on that resource.'
) from exc
prefix = f'{svc.product} API {status}: ' if status else f'{svc.product} request failed: '
raise ValueError(f'{prefix}{detail}') from exc
reason_code = _error_reason_code(exc)
status_int = int(status) if status else None
if status_int == 403:
err = ValueError(
f'{svc.product} API 403 ({reason_code or "forbidden"}): {detail}. If this is a scope '
'error, disconnect and reconnect your Google account with the required access tier. '
'If it is a sharing/ownership error, the account may lack permission on that resource. '
'If it is accessNotConfigured, the Google Cloud project behind this credential has not '
'enabled this API.'
)
else:
prefix = f'{svc.product} API {status}: ' if status else f'{svc.product} request failed: '
err = ValueError(f'{prefix}{detail}')
err.status = status_int
err.reason_code = reason_code
raise err from exc
raise RuntimeError('execute: retry loop exhausted unexpectedly') # unreachable
27 changes: 22 additions & 5 deletions nodes/src/nodes/tool_google_workspace/sheets/IInstance.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@
_VALUE_RENDER_OPTIONS = ('FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA')
_MAJOR_DIMENSIONS = ('ROWS', 'COLUMNS')

# The Sheets API has no list/about-style endpoint to probe cheaply, so check_connection asks
# for a spreadsheet that can't exist. Google's front-end enforces API-enablement before it
# resolves the resource, so a 404 here still proves the API itself is reachable; anything
# else (esp. a 403 accessNotConfigured) is a real connectivity problem and must propagate.
_CONNECTION_PROBE_SPREADSHEET_ID = 'rocketride-connection-probe-0000000000000000'


def _probe_connection(svc) -> None:
try:
execute(svc.spreadsheets().get(spreadsheetId=_CONNECTION_PROBE_SPREADSHEET_ID, fields='spreadsheetId'))
except ValueError as exc:
if getattr(exc, 'status', None) == 404:
return
raise


class IInstance(GoogleToolInstanceBase):
IGlobal: IGlobal
Expand All @@ -86,15 +101,17 @@ def _values_arg(args: dict, key: str, op: str) -> list:

@tool_function(
description=(
'Check the Google Sheets connection and verify that the granted OAuth scopes cover the '
"node's configured access tier. Call this when a Sheets operation fails with a scope or "
'permission error. Returns connection_ok: true when the required scopes are present.'
'Check the Google Sheets connection: makes a live probe call against the Sheets API and '
"verifies that the granted OAuth scopes cover the node's configured access tier. Call "
'this when a Sheets operation fails with a scope or permission error. Returns '
'connection_ok: true only when the live probe succeeds and the required scopes are '
'present.'
),
input_schema={'type': 'object', 'properties': {}, 'required': []},
)
def check_connection(self, args: dict) -> dict:
"""Check Sheets connection status and whether granted OAuth scopes cover the access tier. Read-only."""
return self._check_connection_impl()
"""Check Sheets connection status: live API probe plus granted-scope coverage. Read-only."""
return self._check_connection_impl(probe=_probe_connection)

# =======================================================================
# VALUES — read
Expand Down
2 changes: 1 addition & 1 deletion nodes/src/nodes/tool_google_workspace/sheets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ deletion is a Drive operation and lives in `tool_drive`.
`sheet_duplicate` are convenience wrappers over the same endpoint;
`sheet_copy_to` is the separate `spreadsheets.sheets.copyTo` endpoint.
- **Diagnostics:** `check_connection` verifies that granted OAuth scopes cover
the configured access tier.
the configured access tier and probes the Sheets API.

## Setup

Expand Down
33 changes: 33 additions & 0 deletions nodes/test/tool_google_workspace/docs/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,13 +260,46 @@ def test_default_tier_is_write():
assert access.can_write is True


class _HttpErr(Exception):
def __init__(self, status, reason, content=b''):
super().__init__(reason)
self.resp = types.SimpleNamespace(status=status)
self.reason = reason
self.content = content


def test_check_connection_reports_ok():
inst = _make()
out = inst.check_connection({})
assert isinstance(out, dict)
assert out['connection_ok'] is True
assert out['access'] == 'write'
assert any('documents' in s for s in out['requiredScopes'])
assert inst.IGlobal.service.call_for('get') is not None


def test_check_connection_impl_reports_unknown_without_a_probe():
"""The shared base must not default an unverified connection to True."""
inst = _make()
out = inst._check_connection_impl()
assert out['connection_ok'] == 'unknown'
assert out['checked'] == ['client']


def test_check_connection_probe_swallows_expected_404():
"""A 404 on the probe's made-up document id proves the Docs API IS reachable."""
inst = _make(results={'get': _HttpErr(404, 'notFound')})
out = inst.check_connection({})
assert out['connection_ok'] is True


def test_check_connection_reports_probe_failure():
"""A disabled Docs API (accessNotConfigured) must flip connection_ok, not be swallowed."""
err = _HttpErr(403, 'accessNotConfigured', content=b'{"error": {"errors": [{"reason": "accessNotConfigured"}]}}')
inst = _make(results={'get': err})
out = inst.check_connection({})
assert out['connection_ok'] is False
assert out['errorReason'] == 'accessNotConfigured'


def test_check_connection_reports_missing_user_auth_scopes(monkeypatch):
Expand Down
Loading
Loading