feat(connectors): OAuth forward-out to sidecars (V2-14)#2203
Conversation
Before: a sidecar that needed a mailbox connection read the machine keyring
and grants store directly and would have to hold long-lived OAuth refresh
tokens itself — N sidecars each holding a refresh token rotate each other out,
and a sidecar is the wrong place to custody a long-lived secret.
After: the daemon (the custody home) owns the refresh token and forwards only
short-lived access tokens OUT to the sidecar's new /v1/connections intake, per
agent, honoring the grant model (only connectors granted to that agent are
forwarded). The sidecar never receives the refresh token or the OAuth client
secret; the daemon re-forwards on expiry and withdraws on revocation/uninstall.
Core (gaia.connectors): get_access_token_with_expiry(_sync) — the same per-agent
grant + OAuth-scope gate as get_access_token, additionally returning the token's
wall-clock expiry so the daemon knows when to re-forward. Reuses the existing
client-neutral refresh engine; no duplicated refresh logic.
Daemon (gaia.daemon): ConnectionForwarder mints granted connector tokens and
POSTs them to the sidecar; forward-on-spawn via a new registry on_started hook;
withdraw on mint failure (revocation) so the sidecar loses access loudly;
/daemon/v1/agents/{id}/connections control plane (ungranted forward → 403).
The manager sets a private forwarded-mode env var on spawn from the spec.
Sidecar (gaia_agent_email): in-memory forwarded-credentials store + resolver
(loud on missing/expired/scope-short — never a silent empty token, never a
keyring fallback in forwarded mode); /v1/connections intake routes (contract
2.5, additive); the four token resolvers delegate through the resolver so
standalone-integrator mode keeps working unchanged.
No silent fallbacks: no grant / expired / forward failure surfaces a loud,
actionable error at the point it is fixable.
Part of #2014
Closes #2154
# Conflicts: # src/gaia/daemon/sidecars/manager.py # src/gaia/daemon/sidecars/spec.py
# Conflicts: # src/gaia/daemon/app.py # src/gaia/daemon/server.py
|
Verdict: Approve with suggestions (draft PR — no blocking correctness or security issue). This lands OAuth "forward-out": the daemon keeps the long-lived refresh token and pushes only short-lived access tokens to the email sidecar, per-agent and grant-gated. The design is clean and the security posture is careful — the refresh token and client secret never cross the process boundary, the forwarded list is metadata-only, and both are covered by tests. Everything fails loudly; nothing silently falls back to the keyring in forwarded mode. One thing worth confirming before this leaves draft: the description says the daemon "re-forwards on expiry," but this PR only forwards on spawn. There's no timer or trigger in the diff that re-mints when a forwarded token nears expiry — so once the ~1h access token lapses, the sidecar raises a loud "expired, retry in a moment" that won't actually self-heal unless something (Agent UI? a follow-up?) calls the forward control plane. Worth clarifying where that re-forward gets driven, and softening the "retry in a moment" wording if recovery isn't automatic. Two small polish items: one error message has a broken f-string so it prints a literal 🔍 Technical details🟡 ImportantRe-forward-on-expiry has no trigger in this PR ( 🟢 MinorBroken f-string in the expired-token message (
Edge: a token with no cached expiry can't be forwarded ( Strengths
|
# Conflicts: # src/gaia/daemon/server.py # src/gaia/daemon/sidecars/manager.py # src/gaia/daemon/sidecars/registry.py
|
🟡 The 🔍 Technical details
The built-in email spec always has Fix: add the same |
|
🟡 The 🔍 Technical details
Suggested fix — add a catch for from gaia.connectors.errors import AuthRequiredError
...
except AuthRequiredError as e:
raise HTTPException(status_code=502, detail=str(e)) from e(502 fits: a downstream resource the daemon depends on — the OAuth connection — is unavailable. Alternatively 422 or 503 with the error message are defensible too, but any explicit status beats the opaque 500.) The |
…-out routes The OAuth forward-out feature (#2154) added the /v1/connections intake surface and bumped the frozen contract to schema 2.5, but the capability-matrix bookkeeping, the schema-version guard, and the OpenAPI conformance coverage still tracked 2.4 with 18 REST ops — so every artifact-drift guard failed. Register the three intake routes (POST/GET/DELETE /v1/connections) as contract-tested-only ops, update the counts (21 REST functional / 24 in contract), lock the guard at 2.5, regenerate CAPABILITY_MATRIX.md and specification.html, and add the routes to the conformance-coverage set (behavioral conformance already lives in test_forwarded_credentials.py).
kovtcharov
left a comment
There was a problem hiding this comment.
Rebased onto current main, CI green, no conflicts. Approving.
Before: a sidecar that needed a mailbox connection (e.g. the email agent needing the granted Gmail token) read the machine keyring/grants store directly and would have to hold long-lived OAuth refresh tokens itself. N sidecars each holding a refresh token rotate each other out, and a sidecar is the wrong place to custody a long-lived secret.
After: the daemon — the custody home — owns the refresh token and forwards only short-lived access tokens OUT to a new sidecar
/v1/connectionsintake, per agent, honoring the grant model (only connectors granted to that agent are forwarded). The sidecar never receives the refresh token or the OAuth client secret; the daemon forwards on spawn, re-forwards on expiry, and withdraws on revocation/uninstall. No grant / expired token / forward failure is ever a silent empty credential — each surfaces a loud, actionable error where it's fixable.Scoped to first-party connector forwarding (Google/Microsoft for the email agent) per the plan's §0.24 note that D3's third-party trust root is still open. Standalone-integrator mode (a sidecar with its own OAuth) is unchanged — forwarding only engages when the daemon sets the private forwarded-mode env channel on spawn.
Threads a reviewer evaluates independently
get_access_token_with_expiry(_sync)applies the same per-agent grant + OAuth-scope gate asget_access_tokenand additionally returns wall-clock expiry, reusing the existing client-neutral refresh engine (no duplicated refresh logic).ConnectionForwardermints granted tokens and delivers them to the sidecar; forward-on-spawn via a newSidecarRegistryon_startedhook; a mint failure (revoked/absent connection) withdraws any stale forward so the sidecar loses access loudly;/daemon/v1/agents/{id}/connectionscontrol plane maps an ungranted forward to 403 at the HTTP boundary./v1/connectionsintake routes, added as additive contract 2.5; the four token resolvers delegate through the resolver so standalone mode is untouched.Dependency / rebase note
Built in parallel with #2153 (daemon
/host/v1custody API, V2-12). #2153's route table (rag/memory/sessions/audit) does not cover connector forwarding, so forward-out is implemented as a daemon→sidecar push and does not consume #2153's surface — but it lands in the samesrc/gaia/daemon/area. Expect to rebase onto #2153's PR once it merges. Draft until then.Test plan
pytest tests/unit/test_connectors_forward_token.py— grant/scope gate + expiry (4)pytest tests/unit/test_daemon_forward.py— forwarder + HTTP boundary: ungranted→403, granted→forwarded, token scoping, mint-failure withdraw, delivery→502, token-required (16)pytest hub/agents/python/email/tests/test_forwarded_credentials.py— store/resolver + intake 401 boundary, metadata-only, standalone-vs-forwarded (17)pytest hub/agents/python/email/tests/test_rest_contract.py— openapi regenerated; contract 2.5; new routes documented (1 pre-existing failure:test_agent_version_matches_package_metadata, stale editable-install metadata 0.1.0 vs source 0.5.0 — unrelated to this change)black/isort/flake8clean on changedsrc/+tests/files (project config)Note: 6 pre-existing
os.killpg/group-kill failures on Windows in the daemon manager/reap suites are environment-only (POSIX-only paths) and predate this change.Part of #2014
Closes #2154