You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
feat(secrets): per-user encrypted credentials vault (issue #47) #65 — per-user encrypted credentials vault, including the rethink comment introducing the Default/ vs ServerOnly/ group partition, protected custom fields, and ServerOnly/ audit log. This issue assumes that revised vault.
feat: bake browser + display + MCP servers into sandbox image #76 — bake browser + display + MCP servers into the sandbox image. Establishes how opencode discovers MCP server config inside the sandbox; this issue reuses that mechanism for per-user, per-run MCP servers.
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.
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.
Calendar provider — second provider, validates the abstraction.
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.
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).
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
Default/vsServerOnly/group partition, protected custom fields, andServerOnly/audit log. This issue assumes that revised vault.Decisions
MCP servers, not Composio / Pipedream / unified APIs
Per-service MCP servers, spawned inside the sandbox as stdio subprocesses by opencode. Reasons:
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_secretor 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 atdocker exectime, and consistent with #65's "server is the only writer" principle.ServerOnly/group, not/sandbox/secretsOAuth credentials live in the vault's
ServerOnly/group (introduced in #65 rethink). The generic/sandbox/secretsroute 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:Titlegmail(orgmail:alice@example.comonce multi-account is in scope)URLhttps://mail.google.com)UserNamePasswordaccess_tokenexpires_atscopesprovidergmail,google_calendar, ...granted_atRequires 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 toServerOnly/{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 cachedaccess_tokenifexpires_atis in the future; otherwise refreshes upstream using the stored refresh token + server-sideclient_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/,readaction, actorsandbox).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_tokento 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
423 VAULT_LOCKED. Agent surfaces "unlock vault to use Gmail" to the user.401 OAUTH_REVOKED. Agent prompts user to reconnect.403 OAUTH_SCOPE. Agent prompts user to reconnect with the broader scope set.429faithfully, no special handling.Settings UI
New "Integrations" section in the Settings modal (sibling to Secrets), backed by the
ServerOnly/group:UserName).scopes).ServerOnly/{provider}entry; revoking upstream is a follow-up.Settings → Secrets stays scoped to
Default/and never showsServerOnly/entries.Implementation phases
oauth/{provider}route family, provider config registry, PKCE + state, callback writes toServerOnly/.POST /sandbox/oauth/{provider}/access_token, refresh + write-back, audit logging.packages/mcp-servers/gmail/, settings UI row, end-to-end test.Out of scope
gmailonly). Multi-account is a follow-up; design leaves room (gmail:alice@example.comtitles).Default/vault path — no new endpoint needed).Acceptance criteria
client_secretor 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_LOCKEDerror and doesn't crash.ServerOnly/{provider}entry and the MCP server stops appearing in the sandbox's opencode config on the next run./sandbox/oauth/{provider}/access_tokencall produces one audit row.Related
.deskrcworkspace persistence (orthogonal: complementary mechanism for per-workspace, not per-user, tools)