diff --git a/cloud/apps/sandbox-control/src/account-providers.ts b/cloud/apps/sandbox-control/src/account-providers.ts index e8f8a7a513f..a35113effe3 100644 --- a/cloud/apps/sandbox-control/src/account-providers.ts +++ b/cloud/apps/sandbox-control/src/account-providers.ts @@ -35,6 +35,12 @@ export type AccountProvider = { * provider 401 may revoke it (agent-credential-checks.ts). 'invalid' only for a definite auth failure. */ verify?(credential: string, fetchImpl: typeof fetch): Promise<'valid' | 'invalid' | 'unknown'> + /** + * What a request still carrying this sandbox's placeholder gets once the account no longer applies + * (revoked, expired, removed, turned off): the provider's own auth-failure shape, so the agent shows its + * normal sign-in message instead of a proxy error. + */ + signedOut?: { status: number; contentType: string; body: string } } /** Anthropic's token counter: authenticated like Messages, free, and nothing is generated. */ @@ -88,7 +94,18 @@ export const claudeAccount: AccountProvider = { header: 'authorization', format: (credential) => `Bearer ${credential}`, credential: (db, keyring, userId, now) => readUsableAgentCredential(db, keyring, userId, 'claude', now), - verify: verifyClaudeToken + verify: verifyClaudeToken, + signedOut: { + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + type: 'error', + error: { + type: 'authentication_error', + message: 'Your Orca sign-in for Claude needs renewing. Sign in again in Orca (Settings > Agent accounts).' + } + }) + } } export const ACCOUNT_PROVIDERS: AccountProvider[] = [claudeAccount, codexAccount] diff --git a/cloud/apps/sandbox-control/src/egress-proxy.ts b/cloud/apps/sandbox-control/src/egress-proxy.ts index f249bffd20b..ed81aeb48f6 100644 --- a/cloud/apps/sandbox-control/src/egress-proxy.ts +++ b/cloud/apps/sandbox-control/src/egress-proxy.ts @@ -35,8 +35,11 @@ export const NO_CREDENTIALS: CredentialBroker = { authorizationFor: async () => export type ProxiedRoute = { header: string placeholders: readonly string[] - /** The header value, or null (refused), or `{ deny }` to refuse with a specific reason. */ - credential: () => Promise + /** + * The header value, or null (refused), or `{ deny }` to refuse with a specific reason. A deny may carry + * the provider-shaped answer to give instead (a signed-out account answers like the provider's 401). + */ + credential: () => Promise /** Extra headers set with the credential (e.g. an account id the client can't know). */ extraHeaders?: () => Record /** Sees the provider's status (e.g. a 401 revokes an account credential). Never sees the body. */ @@ -140,6 +143,13 @@ export async function handleEgressProxy(request: Request, deps: EgressProxyDeps) return deny('credential_unavailable') } if (typeof credential !== 'string') { + if (credential.answer) { + console.log(JSON.stringify({ event: 'egress_denied', sandboxId: row.id, host, reason: credential.deny })) + return new Response(credential.answer.body, { + status: credential.answer.status, + headers: { 'content-type': credential.answer.contentType } + }) + } return deny(credential.deny) } const headers = new Headers(request.headers) diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 80752e89649..fe482b4b3fd 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -117,7 +117,7 @@ import { reconcile } from './reconcile' import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress' import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from './secrets' import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh' -import { accountProvider, isSharedWorkspace } from './account-providers' +import { ACCOUNT_PROVIDERS, accountProvider, isSharedWorkspace } from './account-providers' import { handleAccountUnauthorized, verifyAccountCredential } from './agent-credential-checks' import { codexBrokerTick, handleCodexBrokerRoute, type BrokerDeps } from './codex-broker' import { refreshCodexLeases } from './codex-credentials' @@ -359,25 +359,27 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => label: `account:${agent}`, ...(provider.pathAllowed ? { pathAllowed: provider.pathAllowed } : {}), credential: async () => { + // The agent sees the provider's own "sign in again" answer, not a proxy error. + const signedOut = (reason: string) => ({ deny: reason, ...(provider.signedOut ? { answer: provider.signedOut } : {}) }) const now = (deps.now ?? Date.now)() // The session was set up for this owner; a workspace handed to someone else never uses // the new owner's account until its env is rebuilt for them. if (owner !== row.user_id) { scheduleEnvRefresh(env, deps, row) - return { deny: 'account_owner_changed' } + return signedOut('account_owner_changed') } if (isSharedWorkspace(row)) { scheduleEnvRefresh(env, deps, row) - return { deny: 'account_not_shared' } + return signedOut('account_not_shared') } if (!providerEnabled(env)[agent]) { scheduleEnvRefresh(env, deps, row) - return { deny: 'account_disabled' } + return signedOut('account_disabled') } real = await cachedAccountCredential(env.SANDBOX_DB, await keyring, row.user_id, agent, now) if (!real) { scheduleEnvRefresh(env, deps, row) - return { deny: 'no_agent_credential' } + return signedOut('no_agent_credential') } return provider.format(real) }, @@ -424,6 +426,20 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => } ] }) + // An account that stopped applying (revoked, expired, removed, turned off) leaves its placeholder in shells + // and agents already running, and Vercel may still forward for a moment: answer those requests the way the + // provider answers a dead token, never with a proxy error. Nothing is swapped on these routes. + const signedOutRoutes = (row: SandboxRow, host: string): ProxiedRoute[] => { + const live = new Set(egressAccountRoutes(row).filter((route) => route.host === host).map((route) => route.agent)) + return ACCOUNT_PROVIDERS.filter((provider) => provider.signedOut && provider.hosts.includes(host) && !live.has(provider.agent)).map( + (provider) => ({ + header: provider.header, + placeholders: [provider.format(provider.placeholder(row))], + label: `account:${provider.agent}:signed-out`, + credential: async () => ({ deny: 'account_signed_out', answer: provider.signedOut! }) + }) + ) + } return async (row, host) => { if (host === 'github.com') { return members.routes(row, host) @@ -437,7 +453,7 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => ...secretRoutes(row, host) ] } - return [...accountRoutes(row, host), ...secretRoutes(row, host)] + return [...accountRoutes(row, host), ...signedOutRoutes(row, host), ...secretRoutes(row, host)] } } diff --git a/cloud/apps/sandbox-control/test/agent-accounts.test.ts b/cloud/apps/sandbox-control/test/agent-accounts.test.ts index 8a287fb8e50..5ddc8fc17cb 100644 --- a/cloud/apps/sandbox-control/test/agent-accounts.test.ts +++ b/cloud/apps/sandbox-control/test/agent-accounts.test.ts @@ -37,6 +37,14 @@ function decode(encoded: string | undefined): Record { ) } +/** A request still carrying the account placeholder after the account stopped applying gets Anthropic's own + * auth-failure shape (Claude Code then shows its sign-in message), never a proxy error, and nothing upstream. */ +function expectSignedOut(answer: { status: number; body: string }): void { + expect(answer.status).toBe(401) + expect(JSON.parse(answer.body)).toMatchObject({ type: 'error', error: { type: 'authentication_error' } }) + expect(answer.body).toContain('Orca sign-in for Claude') +} + async function setup(overrides: Record = {}) { const { privateKey, publicKey } = await generateKeyPair('RS256') const jwks = { keys: [{ ...(await exportJWK(publicKey)), kid: 'v1', alg: 'RS256' }] } @@ -148,7 +156,7 @@ describe('egress-time Source (D10) and shared workspaces (D13)', () => { expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${SETUP_TOKEN}`) await h.call('DELETE', '/v1/agent-credentials/claude', { bearer: mia }) // The proxy refuses at once; the session moves back to the team key. - expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(403) + expectSignedOut(await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })) env = lastEnv(ENV_COMMAND) expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() expect(env.ANTHROPIC_API_KEY).toMatch(/^orca-ph-anthropic-api-key-/) @@ -164,7 +172,7 @@ describe('egress-time Source (D10) and shared workspaces (D13)', () => { // The workspace becomes shared: the very next request is refused, and the session refreshes off the account. await h.env.SANDBOX_DB.prepare(`UPDATE sandboxes SET visibility = 'team' WHERE id = ?`).bind(id).run() const refused = await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` }) - expect(refused.status).toBe(403) + expectSignedOut(refused) expect(lastEnv(ENV_COMMAND).CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() expect((await h.call('GET', `/v1/agent-sources?workspaceId=${id}`, { bearer: mia })).body.sharedWorkspace).toBe(true) }) @@ -179,7 +187,7 @@ describe('egress-time Source (D10) and shared workspaces (D13)', () => { const id: string = created.body.sandbox.id await h.env.SANDBOX_DB.prepare(`UPDATE sandboxes SET user_id = 'bob' WHERE id = ?`).bind(id).run() const refused = await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` }) - expect(refused).toEqual({ status: 403, body: 'account_owner_changed' }) + expectSignedOut(refused) }) it('a 401 from the provider marks the account revoked and the session falls off it', async () => { @@ -202,7 +210,7 @@ describe('egress-time Source (D10) and shared workspaces (D13)', () => { expect(refreshed.length).toBeGreaterThan(0) expect(decode(refreshed.at(-1)!.env.ORCA_SECRET_ENV).CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() }) - expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(403) + expectSignedOut(await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })) }) it('kill switch: Source "sign-in" falls back to the agent signing in inside the sandbox (no key, no placeholder)', async () => {