mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user