Skip to content

Commit bd4cd82

Browse files
dcramerclaude
andcommitted
Wire Google OAuth credentials to bridge subprocess & remove fallback
Pass GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET from [skills.google] config through to the bridge subprocess via a new `env` field on CapabilityProviderConfig and SubprocessCapabilityProvider. Remove the legacy authorization_code placeholder fallback from _handle_auth_begin — auth now fails loudly with capability_backend_unavailable when credentials are missing instead of silently returning a fake auth.gog.local URL. Add GOOGLE_OAUTH_BASE_URL env var to the bridge so tests can point at a local HTTP fixture. Rewrite e2e and bridge tests to exercise the full device code flow (auth_begin → auth_poll → invoke) against a fake Google OAuth server. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 372cb9a commit bd4cd82

7 files changed

Lines changed: 573 additions & 181 deletions

File tree

src/ash/capabilities/providers/subprocess.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def __init__(
4646
command: list[str] | str,
4747
timeout_seconds: float = 30.0,
4848
context_token_service: ContextTokenService | None = None,
49+
env: dict[str, str] | None = None,
4950
) -> None:
5051
normalized_namespace = str(namespace).strip()
5152
if not normalized_namespace:
@@ -56,6 +57,7 @@ def __init__(
5657
self._context_token_service = (
5758
context_token_service or get_default_context_token_service()
5859
)
60+
self._extra_env: dict[str, str] = dict(env) if env else {}
5961

6062
@property
6163
def namespace(self) -> str:
@@ -300,6 +302,8 @@ async def _execute_command(self, payload: dict[str, Any]) -> dict[str, Any]:
300302

301303
def _bridge_environment(self) -> dict[str, str]:
302304
env = dict(os.environ)
305+
if self._extra_env:
306+
env.update(self._extra_env)
303307
env[ENV_SECRET] = self._context_token_service.export_verifier_secret()
304308
return env
305309

src/ash/config/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ class CapabilityProviderConfig(BaseModel):
246246
namespace: str | None = None
247247
command: list[str]
248248
timeout_seconds: float = Field(default=30.0, ge=1.0, le=300.0)
249+
env: dict[str, str] = Field(default_factory=dict)
249250

250251
@model_validator(mode="before")
251252
@classmethod
@@ -788,6 +789,17 @@ def _apply_google_skill_provider_preset(data: dict[str, Any]) -> None:
788789
],
789790
)
790791
provider_gog.setdefault("timeout_seconds", 30.0)
792+
793+
# Wire Google OAuth credentials from skill config into provider env
794+
# so the bridge subprocess receives them.
795+
provider_env = provider_gog.setdefault("env", {})
796+
google_client_id = google_skill.get("google_client_id")
797+
google_client_secret = google_skill.get("google_client_secret")
798+
if isinstance(google_client_id, str) and google_client_id.strip():
799+
provider_env.setdefault("GOOGLE_CLIENT_ID", google_client_id.strip())
800+
if isinstance(google_client_secret, str) and google_client_secret.strip():
801+
provider_env.setdefault("GOOGLE_CLIENT_SECRET", google_client_secret.strip())
802+
791803
providers["gog"] = provider_gog
792804
capabilities["providers"] = providers
793805
data["capabilities"] = capabilities

src/ash/integrations/capabilities.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,5 @@ def _create_capability_provider(
126126
namespace=config.namespace or provider_name,
127127
command=config.command,
128128
timeout_seconds=config.timeout_seconds,
129+
env=config.env or None,
129130
)

src/ash/skills/bundled/gog/scripts/gogcli_bridge.py

Lines changed: 58 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@
4242
ENV_GOOGLE_CLIENT_ID = "GOOGLE_CLIENT_ID"
4343
ENV_GOOGLE_CLIENT_SECRET = "GOOGLE_CLIENT_SECRET" # noqa: S105
4444

45-
GOOGLE_DEVICE_CODE_URL = "https://oauth2.googleapis.com/device/code"
46-
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" # noqa: S105
45+
ENV_GOOGLE_OAUTH_BASE_URL = "GOOGLE_OAUTH_BASE_URL"
46+
DEFAULT_GOOGLE_OAUTH_BASE_URL = "https://oauth2.googleapis.com"
4747
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
4848

4949
CAPABILITY_SCOPES: dict[str, str] = {
@@ -324,6 +324,19 @@ def _require_namespaced_capability(capability_id: Any) -> str:
324324
return capability
325325

326326

327+
def _google_oauth_base_url() -> str:
328+
value = _optional_text(os.environ.get(ENV_GOOGLE_OAUTH_BASE_URL))
329+
return value or DEFAULT_GOOGLE_OAUTH_BASE_URL
330+
331+
332+
def _google_device_code_url() -> str:
333+
return f"{_google_oauth_base_url()}/device/code"
334+
335+
336+
def _google_token_url() -> str:
337+
return f"{_google_oauth_base_url()}/token"
338+
339+
327340
def _google_client_id() -> str:
328341
value = _optional_text(os.environ.get(ENV_GOOGLE_CLIENT_ID))
329342
if not value:
@@ -434,73 +447,45 @@ def _handle_auth_begin(params: dict[str, Any]) -> dict[str, Any]:
434447
flow_id = f"gaf_{secrets.token_hex(12)}"
435448
expires_epoch = now_epoch + _auth_flow_ttl_seconds()
436449

437-
# Use device code flow when Google OAuth credentials are configured,
438-
# otherwise fall back to legacy authorization_code placeholder flow.
439-
client_id = _optional_text(os.environ.get(ENV_GOOGLE_CLIENT_ID))
440-
client_secret = _optional_text(os.environ.get(ENV_GOOGLE_CLIENT_SECRET))
450+
# Require Google OAuth credentials — fail loudly when not configured.
451+
client_id = _google_client_id()
452+
_google_client_secret() # validate presence early
441453
scope = CAPABILITY_SCOPES.get(capability_id)
442-
443-
if client_id and client_secret and scope:
444-
# Device code flow (RFC 8628)
445-
device_resp = _http_post_form(
446-
GOOGLE_DEVICE_CODE_URL,
447-
{"client_id": client_id, "scope": scope},
454+
if not scope:
455+
raise BridgeError(
456+
"capability_invalid_input",
457+
f"no OAuth scopes configured for {capability_id}",
448458
)
449-
device_error = _optional_text(device_resp.get("error"))
450-
if device_error:
451-
error_desc = (
452-
_optional_text(device_resp.get("error_description")) or device_error
453-
)
454-
raise BridgeError(
455-
"capability_backend_unavailable",
456-
f"Google device code request failed: {error_desc}",
457-
)
458-
459-
device_code = _optional_text(device_resp.get("device_code"))
460-
user_code = _optional_text(device_resp.get("user_code"))
461-
verification_url = _optional_text(device_resp.get("verification_url"))
462-
google_interval = _int_claim(device_resp, "interval") or 5
463-
google_expires_in = _int_claim(device_resp, "expires_in")
464-
465-
if not device_code or not user_code or not verification_url:
466-
raise BridgeError(
467-
"capability_backend_unavailable",
468-
"Google device code response missing required fields",
469-
)
470459

471-
if google_expires_in and google_expires_in < (expires_epoch - now_epoch):
472-
expires_epoch = now_epoch + google_expires_in
460+
# Device code flow (RFC 8628)
461+
device_resp = _http_post_form(
462+
_google_device_code_url(),
463+
{"client_id": client_id, "scope": scope},
464+
)
465+
device_error = _optional_text(device_resp.get("error"))
466+
if device_error:
467+
error_desc = (
468+
_optional_text(device_resp.get("error_description")) or device_error
469+
)
470+
raise BridgeError(
471+
"capability_backend_unavailable",
472+
f"Google device code request failed: {error_desc}",
473+
)
473474

474-
state = _read_state()
475-
_prune_expired_flows(state=state, now_epoch=now_epoch)
476-
state["auth_flows"][flow_id] = {
477-
"user_id": claims.user_id,
478-
"capability_id": capability_id,
479-
"account_hint": account_hint,
480-
"nonce": nonce,
481-
"issued_at": now_epoch,
482-
"expires_at": expires_epoch,
483-
"device_code": device_code,
484-
"poll_interval": google_interval,
485-
}
486-
_write_state(state)
475+
device_code = _optional_text(device_resp.get("device_code"))
476+
user_code = _optional_text(device_resp.get("user_code"))
477+
verification_url = _optional_text(device_resp.get("verification_url"))
478+
google_interval = _int_claim(device_resp, "interval") or 5
479+
google_expires_in = _int_claim(device_resp, "expires_in")
487480

488-
flow_state = {
489-
"flow_id": flow_id,
490-
"nonce": nonce,
491-
"device_code": device_code,
492-
}
493-
return {
494-
"auth_url": verification_url,
495-
"expires_at": _iso8601_utc(expires_epoch),
496-
"flow_state": flow_state,
497-
"flow_type": "device_code",
498-
"user_code": user_code,
499-
"poll_interval_seconds": google_interval,
500-
}
481+
if not device_code or not user_code or not verification_url:
482+
raise BridgeError(
483+
"capability_backend_unavailable",
484+
"Google device code response missing required fields",
485+
)
501486

502-
# Legacy authorization_code placeholder flow (no Google credentials configured)
503-
from urllib.parse import quote_plus
487+
if google_expires_in and google_expires_in < (expires_epoch - now_epoch):
488+
expires_epoch = now_epoch + google_expires_in
504489

505490
state = _read_state()
506491
_prune_expired_flows(state=state, now_epoch=now_epoch)
@@ -511,23 +496,23 @@ def _handle_auth_begin(params: dict[str, Any]) -> dict[str, Any]:
511496
"nonce": nonce,
512497
"issued_at": now_epoch,
513498
"expires_at": expires_epoch,
499+
"device_code": device_code,
500+
"poll_interval": google_interval,
514501
}
515502
_write_state(state)
503+
516504
flow_state = {
517505
"flow_id": flow_id,
518506
"nonce": nonce,
507+
"device_code": device_code,
519508
}
520-
auth_url = (
521-
"https://auth.gog.local/authorize"
522-
f"?capability={quote_plus(capability_id)}"
523-
f"&account={quote_plus(account_hint)}"
524-
f"&flow_id={quote_plus(flow_id)}"
525-
f"&nonce={quote_plus(nonce)}"
526-
)
527509
return {
528-
"auth_url": auth_url,
510+
"auth_url": verification_url,
529511
"expires_at": _iso8601_utc(expires_epoch),
530512
"flow_state": flow_state,
513+
"flow_type": "device_code",
514+
"user_code": user_code,
515+
"poll_interval_seconds": google_interval,
531516
}
532517

533518

@@ -589,7 +574,7 @@ def _handle_auth_poll(params: dict[str, Any]) -> dict[str, Any]:
589574
client_secret = _google_client_secret()
590575

591576
token_resp = _http_post_form(
592-
GOOGLE_TOKEN_URL,
577+
_google_token_url(),
593578
{
594579
"client_id": client_id,
595580
"client_secret": client_secret,
@@ -897,7 +882,7 @@ def _refresh_token_if_needed(*, account_key: str, vault_ref: str | None) -> None
897882
except BridgeError:
898883
return
899884
token_resp = _http_post_form(
900-
GOOGLE_TOKEN_URL,
885+
_google_token_url(),
901886
{
902887
"client_id": client_id,
903888
"client_secret": client_secret,

0 commit comments

Comments
 (0)