Skip to content

Google Workspace connection #80

Description

@bgrgicak

Overview

Let agents talk to user-authorized third-party services (Gmail and Calendar first) through per-service MCP servers, without baking provider glue into the agent runtime. OAuth tokens live in the per-user vault under a sandbox-inaccessible group; refresh runs on the host; the sandbox sees only short-lived access tokens at the moment of need.

Dependencies

Decisions

MCP servers, not Composio / Pipedream / unified APIs

Per-service MCP servers, spawned inside the sandbox as stdio subprocesses by opencode. Reasons:

  • opencode already speaks MCP — no glue layer.
  • Per-service tool shaping: we choose what the agent sees instead of getting 250 tools dumped on it.
  • No third party in the auth path.
  • Aligns with feat: bake browser + display + MCP servers into sandbox image #76's MCP infrastructure (same registration mechanism, just per-user instead of platform-wide).

Rejected: Composio / Pipedream Connect / Arcade (third party in auth path, lock-in for early services), Beeper (chat-only, doesn't cover Gmail/Calendar), Merge.dev / Plaid / Finch (per-vertical, wrong shape for productivity).

Tokens flow through the host, not via env vars

The sandbox never sees client_secret or refresh tokens. OAuth refresh runs on the host. The sandbox MCP server fetches a short-lived access token at tool-invocation time via a sandbox-token-authenticated route. Smaller blast radius than baking the access token into env at docker exec time, and consistent with #65's "server is the only writer" principle.

ServerOnly/ group, not /sandbox/secrets

OAuth credentials live in the vault's ServerOnly/ group (introduced in #65 rethink). The generic /sandbox/secrets route filters this group out — the sandbox can't read the refresh token even by guessing the title. The dedicated OAuth refresh route is the only way the sandbox gets an access token, and it never receives the refresh token.

Vault entry shape

One KDBX entry per provider connection, in the ServerOnly/ group, using standard KDBX fields where they map naturally:

Field Use Protected?
Title gmail (or gmail:alice@example.com once multi-account is in scope) no
URL provider URL (https://mail.google.com) no
UserName connected account email no
Password refresh token yes (built-in)
custom: access_token current access token yes
custom: expires_at ISO8601 expiry no
custom: scopes space-separated scope list no
custom: provider gmail, google_calendar, ... no
custom: granted_at ISO8601 of consent no

Requires the protected-custom-fields work in #65 (in the rethink comment).

Routes

User session (browser-driven OAuth dance)

  • GET /oauth/{provider}/start — generates state + PKCE, redirects user to provider consent screen.
  • GET /oauth/{provider}/callback — exchanges code for tokens, writes the entry to ServerOnly/{Title}, redirects back to Settings → Integrations.

{provider} is allow-listed (start: gmail, google_calendar). Provider config (client_id, client_secret, scopes, auth/token endpoints) lives in server env / config, not in the vault.

Sandbox (X-Desk-Sandbox-Token)

  • POST /sandbox/oauth/{provider}/access_token — returns the cached access_token if expires_at is in the future; otherwise refreshes upstream using the stored refresh token + server-side client_secret, writes new tokens to the vault entry, returns the access_token.

The sandbox never receives the refresh token or the client_secret. Every call records an audit row (ServerOnly/, read action, actor sandbox).

Sandbox-side MCP wiring

Per #76, opencode loads MCP servers from a config file (exact path TBD by #76's investigation — likely /etc/opencode/... or ~/.config/opencode/...).

Per-user, per-run: the server materializes an opencode config snippet at run start (packages/server/runtime/src/mounts.ts) listing the user's enabled OAuth-backed MCP servers, mounted into the sandbox alongside #76's platform-level config.

Each MCP server runs as a stdio subprocess. At tool-invocation time it calls POST /sandbox/oauth/{provider}/access_token to fetch a fresh access token before making the upstream API call.

Per-service MCP server packages live under a new path (proposal: packages/mcp-servers/{provider}/ — flagging as a new directory convention). Each package: bin/ entrypoint, provider client, tool schemas. Built into the sandbox image alongside the platform MCP servers from #76, or installed at run-time from the host — decide during phase 3.

Failure modes

  • Vault locked at run time → route returns 423 VAULT_LOCKED. Agent surfaces "unlock vault to use Gmail" to the user.
  • Refresh token revoked upstream → route returns 401 OAUTH_REVOKED. Agent prompts user to reconnect.
  • OAuth scopes insufficient → route returns 403 OAUTH_SCOPE. Agent prompts user to reconnect with the broader scope set.
  • Provider rate limit → route surfaces upstream 429 faithfully, no special handling.

Settings UI

New "Integrations" section in the Settings modal (sibling to Secrets), backed by the ServerOnly/ group:

  • List of supported providers + connection status.
  • Connect / Reconnect / Disconnect buttons.
  • Connected account email (read from UserName).
  • Granted scopes (read from scopes).
  • Disconnect deletes the ServerOnly/{provider} entry; revoking upstream is a follow-up.

Settings → Secrets stays scoped to Default/ and never shows ServerOnly/ entries.

Implementation phases

  1. Generic OAuth scaffoldingoauth/{provider} route family, provider config registry, PKCE + state, callback writes to ServerOnly/.
  2. Sandbox refresh routePOST /sandbox/oauth/{provider}/access_token, refresh + write-back, audit logging.
  3. Gmail provider — provider config, MCP server package under packages/mcp-servers/gmail/, settings UI row, end-to-end test.
  4. Per-run MCP config materialization — server emits opencode config listing enabled servers; depends on feat: bake browser + display + MCP servers into sandbox image #76 landing.
  5. Calendar provider — second provider, validates the abstraction.
  6. Settings → Integrations UI — list, connect, disconnect; can be parallelized with phases 3–5.

Out of scope

  • Multiple accounts per provider (single connection per provider per user; entry title gmail only). Multi-account is a follow-up; design leaves room (gmail:alice@example.com titles).
  • Non-OAuth services (those use the existing Default/ vault path — no new endpoint needed).
  • Provider-specific scope-picker UI (always request a fixed scope set per provider in v1).
  • Upstream token revocation on disconnect (deletes vault entry; revoking at the provider is a follow-up).
  • Webhook / push subscriptions (Gmail watch API, Calendar push notifications) — read/send only in v1.
  • Background OAuth refresh without an active user session — depends on the broader unlock-without-session question flagged in feat(secrets): per-user encrypted credentials vault (issue #47) #65.

Acceptance criteria

  • User can connect Gmail and Google Calendar from Settings → Integrations.
  • Agent in a sandbox can list inbox / send email / list events / create event using the connected accounts.
  • No client_secret or refresh token is reachable from inside the sandbox by any path (verified by integration test that exhaustively probes /sandbox/secrets/* and direct file reads).
  • Vault locked → run surfaces a clear VAULT_LOCKED error and doesn't crash.
  • Disconnecting removes the ServerOnly/{provider} entry and the MCP server stops appearing in the sandbox's opencode config on the next run.
  • Every /sandbox/oauth/{provider}/access_token call produces one audit row.
  • Integration tests cover the full flow per AGENTS.md (real SQLite, real KDBX, real Docker, real OAuth round-trip — recorded fixtures only for the upstream provider's API surface).

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions