fix(cloud): a signed-out account answers like the provider, not 403 host_not_proxied (W-106)

Once an Orca account stops applying (revoked after a confirmed 401, expired, removed, turned off, shared, owner changed), requests still carrying the sandbox's account placeholder (shells and agents already running, a Vercel forward that hasn't caught up) now get Anthropic's own 401 authentication_error, saying the Orca sign-in for Claude needs renewing, instead of 403 host_not_proxied or a bare deny. Nothing is swapped on those routes.
This commit is contained in:
Neil
2026-11-11 01:43:46 -08:00
parent 94dc1cec77
commit a206be57ef
4 changed files with 64 additions and 13 deletions
@@ -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]
+12 -2
View File
@@ -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<string | null | { deny: string }>
/**
* 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<string | null | { deny: string; answer?: { status: number; contentType: string; body: string } }>
/** Extra headers set with the credential (e.g. an account id the client can't know). */
extraHeaders?: () => Record<string, string>
/** 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)
+22 -6
View File
@@ -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)]
}
}
@@ -37,6 +37,14 @@ function decode(encoded: string | undefined): Record<string, string> {
)
}
/** 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<string, string> = {}) {
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 () => {