From 49fb8a9d8cb73cfad2c8213e9fce1e66e997f6f9 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 13 Nov 2026 01:40:50 -0800 Subject: [PATCH 1/3] fix(cloud): RS-2 a failed auth check never revokes an Orca-account sign-in: it needs a check (shown as expired); a later real 401 revokes, a real success clears it --- .../0055_agent_credential_suspect.sql | 5 ++ .../src/agent-credential-checks.ts | 76 ++++++++++++++++--- .../sandbox-control/src/agent-credentials.ts | 72 +++++++++++++++--- cloud/apps/sandbox-control/src/index.ts | 52 +++++++++++-- .../test/agent-accounts.test.ts | 70 +++++++++++++++-- .../orca-cloud-needs-check-wire.test.ts | 45 +++++++++++ 6 files changed, 284 insertions(+), 36 deletions(-) create mode 100644 cloud/apps/sandbox-control/migrations/0055_agent_credential_suspect.sql create mode 100644 src/renderer/src/lib/orca-cloud/orca-cloud-needs-check-wire.test.ts diff --git a/cloud/apps/sandbox-control/migrations/0055_agent_credential_suspect.sql b/cloud/apps/sandbox-control/migrations/0055_agent_credential_suspect.sql new file mode 100644 index 00000000000..e7a8a627b2d --- /dev/null +++ b/cloud/apps/sandbox-control/migrations/0055_agent_credential_suspect.sql @@ -0,0 +1,5 @@ +-- RS-2: a failed auth check (count_tokens) alone no longer revokes an Orca-account sign-in. It marks the +-- sign-in "needs check" (suspect_at); the credential stays usable at egress so the next real agent request +-- can confirm it: a later 401 revokes, a success clears it. Never tested against a real OAuth token, so the +-- probe is a signal, not a verdict (agent-credential-checks.ts). +ALTER TABLE agent_credentials ADD COLUMN suspect_at INTEGER; diff --git a/cloud/apps/sandbox-control/src/agent-credential-checks.ts b/cloud/apps/sandbox-control/src/agent-credential-checks.ts index 2ea5948610d..113c421be52 100644 --- a/cloud/apps/sandbox-control/src/agent-credential-checks.ts +++ b/cloud/apps/sandbox-control/src/agent-credential-checks.ts @@ -1,12 +1,20 @@ // A provider 401 on an Orca-account request (the proxy swapped the real credential in) is a signal, not a -// verdict: one transient auth error must not sign a person out of every workspace. Before revoking, the -// control plane makes one cheap server-side auth check with the real credential (no tokens spent) and -// revokes only when that also says the credential is dead. The desktop can ask for the same check before a +// verdict: one transient auth error must not sign a person out of every workspace. The control plane makes +// one cheap server-side auth check with the real credential (no tokens spent). RS-2: that check alone never +// revokes either (it has never been tried against a real OAuth sign-in): a failed check marks the sign-in +// "needs check" (suspect), which stays usable at egress; a LATER real agent request that also gets a 401 +// revokes it, and a real request that succeeds (or a passing check) clears it. The desktop can ask for the same check before a // launch (POST /v1/agent-credentials/claude/verify). Checks are rate-limited per person and agent // (agent_credentials.checked_at), passes are remembered (verified_at), and every keep or revoke is audited // (agent_credential_audit). import type { AccountProvider } from './account-providers' -import { markAgentCredentialRevoked, readUsableAgentCredential } from './agent-credentials' +import { + agentCredentialSuspectAt, + clearAgentCredentialSuspect, + markAgentCredentialRevoked, + markAgentCredentialSuspect, + readUsableAgentCredential +} from './agent-credentials' import type { Keyring } from './secrets-crypto' /** At most one auth check (and so one revoke) per person and agent per minute. */ @@ -15,7 +23,7 @@ export const CREDENTIAL_CHECK_INTERVAL_MS = 60_000 export const CREDENTIAL_VERIFIED_FRESH_MS = 10 * 60_000 export type CredentialVerdict = 'valid' | 'invalid' | 'unknown' -export type CheckOutcome = 'revoked' | 'kept' | 'skipped' +export type CheckOutcome = 'revoked' | 'suspect' | 'kept' | 'skipped' type CheckContext = { db: D1Database @@ -28,7 +36,7 @@ type CheckContext = { async function audit( db: D1Database, - entry: { userId: string; agent: string; action: 'revoked' | 'kept'; reason: string; sandboxId: string | null; path: string | null; now: number } + entry: { userId: string; agent: string; action: 'revoked' | 'suspect' | 'kept'; reason: string; sandboxId: string | null; path: string | null; now: number } ): Promise { await db .prepare( @@ -74,6 +82,8 @@ async function checkCredential( const base = { userId, agent, now, ...where } if (verdict === 'valid') { await db.prepare(`UPDATE agent_credentials SET verified_at = ? WHERE user_id = ? AND agent = ?`).bind(now, userId, agent).run() + // A passing check clears "needs check". + await clearAgentCredentialSuspect(db, userId, agent, now) } if (verdict !== 'invalid') { // A pass on a pre-launch verify is routine; a 401 that didn't hold up, or any inconclusive check, is audited. @@ -83,21 +93,65 @@ async function checkCredential( } return 'kept' } - await markAgentCredentialRevoked(db, userId, agent, now) + // RS-2: the check's auth failure alone never revokes: the sign-in needs a real request to confirm it. + await markAgentCredentialSuspect(db, userId, agent, now) await audit(db, { ...base, - action: 'revoked', - reason: trigger.kind === 'verify' ? 'verify_confirmed_auth_failure' : 'provider_401_confirmed_by_check' + action: 'suspect', + reason: trigger.kind === 'verify' ? 'verify_check_auth_failure' : 'provider_401_check_auth_failure' }) - return 'revoked' + return 'suspect' } -export function handleAccountUnauthorized( +/** + * A real agent request got a 401 with the real credential swapped in. A sign-in already in question (a + * check failed after an EARLIER 401) is now confirmed dead: revoked. Otherwise one rate-limited check, which + * at most puts it in question. + */ +export async function handleAccountUnauthorized( ctx: CheckContext & { sandboxId: string; path: string } ): Promise { + const suspectAt = await agentCredentialSuspectAt(ctx.db, ctx.userId, ctx.provider.agent) + if (suspectAt !== null && ctx.now > suspectAt) { + await markAgentCredentialRevoked(ctx.db, ctx.userId, ctx.provider.agent, ctx.now) + await audit(ctx.db, { + userId: ctx.userId, + agent: ctx.provider.agent, + action: 'revoked', + reason: 'provider_401_after_check_failure', + sandboxId: ctx.sandboxId, + path: ctx.path, + now: ctx.now + }) + return 'revoked' + } return checkCredential(ctx, { kind: 'egress_401', sandboxId: ctx.sandboxId, path: ctx.path }) } +/** RS-2: a real agent request with the real credential succeeded: a sign-in in question is fine after all. */ +export async function handleAccountRequestSucceeded(ctx: { + db: D1Database + agent: AccountProvider['agent'] + userId: string + sandboxId: string + path: string + now: number +}): Promise { + const cleared = await clearAgentCredentialSuspect(ctx.db, ctx.userId, ctx.agent, ctx.now) + if (cleared) { + await audit(ctx.db, { + userId: ctx.userId, + agent: ctx.agent, + action: 'kept', + reason: 'request_succeeded_after_check_failure', + sandboxId: ctx.sandboxId, + path: ctx.path, + now: ctx.now + }) + } + return cleared +} + /** * Pre-launch verify: a pass within CREDENTIAL_VERIFIED_FRESH_MS answers at once; otherwise one check (rate * limited). Returns what happened; the caller re-reads the state. diff --git a/cloud/apps/sandbox-control/src/agent-credentials.ts b/cloud/apps/sandbox-control/src/agent-credentials.ts index 182369da3bf..0348e03fa2e 100644 --- a/cloud/apps/sandbox-control/src/agent-credentials.ts +++ b/cloud/apps/sandbox-control/src/agent-credentials.ts @@ -12,7 +12,11 @@ import type { SandboxCaller } from './auth' import type { Env } from './env' import type { Keyring, SecretBinding } from './secrets-crypto' -export type CredentialState = 'none' | 'active' | 'expiring' | 'expired' | 'revoked' +/** + * 'suspect' (RS-2): an auth check failed but no real request has confirmed it yet. Still usable at egress; + * shown to the desktop as expired + needsCheck (an older desktop reads a new state as 'none'). + */ +export type CredentialState = 'none' | 'active' | 'expiring' | 'expired' | 'revoked' | 'suspect' const EXPIRING_MS = 30 * 24 * 60 * 60_000 /** `claude setup-token` issues a one-year token. */ const CLAUDE_SETUP_TOKEN_TTL_MS = 365 * 24 * 60 * 60_000 @@ -27,6 +31,8 @@ type CredentialRow = { label: string | null expires_at: number | null state: 'active' | 'revoked' + /** RS-2: when an auth check said this sign-in is dead; null when not in question. */ + suspect_at?: number | null updated_at: number } @@ -73,15 +79,18 @@ export function credentialState(row: Pick if (row.expires_at !== null && row.expires_at <= now) { return 'expired' } + if (row.suspect_at !== null && row.suspect_at !== undefined) { + return 'suspect' + } if (row.expires_at !== null && row.expires_at - now <= EXPIRING_MS) { return 'expiring' } return 'active' } -/** Usable for a request now: active or expiring (still valid). */ +/** Usable for a request now: active or expiring (still valid), or suspect (a real request must confirm it). */ export function isUsable(state: CredentialState): boolean { - return state === 'active' || state === 'expiring' + return state === 'active' || state === 'expiring' || state === 'suspect' } async function rowFor(db: D1Database, userId: string, agent: CredentialAgent): Promise { @@ -106,7 +115,7 @@ export async function putAgentCredential( ON CONFLICT (user_id, agent) DO UPDATE SET account_id = excluded.account_id, kek_id = excluded.kek_id, wrapped_dek = excluded.wrapped_dek, ciphertext = excluded.ciphertext, label = excluded.label, expires_at = excluded.expires_at, state = 'active', updated_at = excluded.updated_at, - checked_at = NULL, verified_at = NULL` + checked_at = NULL, verified_at = NULL, suspect_at = NULL` ) .bind(input.userId, input.accountId, input.agent, sealed.kekId, sealed.wrappedDek, sealed.ciphertext, input.label ?? null, input.expiresAt ?? null, now, now) .run() @@ -114,21 +123,61 @@ export async function putAgentCredential( /** The real value for egress, or null when there is none or it can't be used now. */ export async function readUsableAgentCredential(db: D1Database, keyring: Keyring, userId: string, agent: CredentialAgent, now: number): Promise { + return (await readUsableAgentCredentialState(db, keyring, userId, agent, now)).value +} + +/** The real value for egress, and whether the sign-in is in question (RS-2: a real request confirms it). */ +export async function readUsableAgentCredentialState( + db: D1Database, + keyring: Keyring, + userId: string, + agent: CredentialAgent, + now: number +): Promise<{ value: string | null; suspect: boolean }> { const row = await rowFor(db, userId, agent) - if (!isUsable(credentialState(row, now)) || !row) { - return null + const state = credentialState(row, now) + if (!isUsable(state) || !row) { + return { value: null, suspect: false } } try { - return await keyring.open(credentialBinding(userId, agent), { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext }) + const value = await keyring.open(credentialBinding(userId, agent), { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext }) + return { value, suspect: state === 'suspect' } } catch { console.log(JSON.stringify({ event: 'agent_credential_unreadable', agent })) - return null + return { value: null, suspect: false } } } +/** RS-2: an auth check failed. Not revoked: a real request must confirm it first. True when newly marked. */ +export async function markAgentCredentialSuspect(db: D1Database, userId: string, agent: CredentialAgent, now: number): Promise { + const result = await db + .prepare(`UPDATE agent_credentials SET suspect_at = ?, updated_at = ? WHERE user_id = ? AND agent = ? AND state = 'active' AND suspect_at IS NULL`) + .bind(now, now, userId, agent) + .run() + return result.meta.changes === 1 +} + +/** RS-2: a real request (or an auth check) succeeded: the sign-in is fine. True when it was in question. */ +export async function clearAgentCredentialSuspect(db: D1Database, userId: string, agent: CredentialAgent, now: number): Promise { + const result = await db + .prepare(`UPDATE agent_credentials SET suspect_at = NULL, updated_at = ? WHERE user_id = ? AND agent = ? AND suspect_at IS NOT NULL`) + .bind(now, userId, agent) + .run() + return result.meta.changes === 1 +} + +/** RS-2: when this sign-in was put in question, or null. */ +export async function agentCredentialSuspectAt(db: D1Database, userId: string, agent: CredentialAgent): Promise { + const row = await db + .prepare(`SELECT suspect_at FROM agent_credentials WHERE user_id = ? AND agent = ? AND state = 'active'`) + .bind(userId, agent) + .first<{ suspect_at: number | null }>() + return row?.suspect_at ?? null +} + /** The provider said the credential is no longer valid (401 at egress): stop using it until renewed. */ export async function markAgentCredentialRevoked(db: D1Database, userId: string, agent: CredentialAgent, now: number): Promise { - await db.prepare(`UPDATE agent_credentials SET state = 'revoked', updated_at = ? WHERE user_id = ? AND agent = ?`).bind(now, userId, agent).run() + await db.prepare(`UPDATE agent_credentials SET state = 'revoked', suspect_at = NULL, updated_at = ? WHERE user_id = ? AND agent = ?`).bind(now, userId, agent).run() } export async function deleteAgentCredential(db: D1Database, userId: string, agent: CredentialAgent): Promise { @@ -141,7 +190,10 @@ export async function agentCredentialsView(db: D1Database, env: Env, userId: str const view = (agent: CredentialAgent) => { const row = results.find((r) => r.agent === agent) ?? null const state = credentialState(row, now) - return { state, ...(row?.label ? { label: row.label } : {}), ...(row?.expires_at ? { expiresAt: row.expires_at } : {}) } + // RS-2: "needs check" reads as expired (the Renew strip) on every desktop, old ones included; newer + // desktops may read needsCheck. An unknown state would read as 'none' ("isn't set up") on old ones. + const wire = state === 'suspect' ? { state: 'expired' as const, needsCheck: true } : { state } + return { ...wire, ...(row?.label ? { label: row.label } : {}), ...(row?.expires_at ? { expiresAt: row.expires_at } : {}) } } return { claude: view('claude'), codex: view('codex'), providerEnabled: providerEnabled(env), providerState: providerStates(env) } } diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 683e4b4128e..2fc962583b4 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -122,7 +122,7 @@ import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from ' import { SecretsKmsError } from './secrets-kms' import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh' import { ACCOUNT_PROVIDERS, accountProvider, isSharedWorkspace } from './account-providers' -import { handleAccountUnauthorized, verifyAccountCredential } from './agent-credential-checks' +import { handleAccountRequestSucceeded, handleAccountUnauthorized, verifyAccountCredential } from './agent-credential-checks' import { codexBrokerTick, handleCodexBrokerRoute, type BrokerDeps } from './codex-broker' import { refreshCodexLeases } from './codex-credentials' import { @@ -132,7 +132,7 @@ import { deleteAgentCredential, providerEnabled, putClaudeCredential, - readUsableAgentCredential + readUsableAgentCredentialState } from './agent-credentials' import { AgentSourceError, @@ -394,6 +394,8 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => return [] } let real: string | null = null + // RS-2: this sign-in is in question (a check failed); a real request here confirms or clears it. + let suspect = false return [ { header: provider.header, @@ -418,7 +420,9 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => scheduleEnvRefresh(env, deps, row) return signedOut('account_disabled') } - real = await cachedAccountCredential(env.SANDBOX_DB, await keyring, row.user_id, agent, now) + const account = await cachedAccountCredential(env.SANDBOX_DB, await keyring, row.user_id, agent, now) + real = account.value + suspect = account.suspect if (!real) { scheduleEnvRefresh(env, deps, row) return signedOut('no_agent_credential') @@ -429,6 +433,25 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => // Reached only after the real credential was swapped in and sent (denied or unswapped requests // never get here). onUpstreamStatus: (status, request) => { + // RS-2: a real request the provider accepted (not a 401, not a server failure) clears "needs + // check". Only while it's in question, so a healthy account costs no writes. + if (suspect && !provider.onUnauthorized && status !== 401 && status < 500) { + suspect = false + const now = (deps.now ?? Date.now)() + const work = handleAccountRequestSucceeded({ + db: env.SANDBOX_DB, + agent, + userId: row.user_id, + sandboxId: row.id, + path: request.path, + now + }).then((cleared) => (cleared ? forgetAccountCredential(row.user_id, agent) : undefined)) + if (deps.waitUntil) { + deps.waitUntil(work.catch(() => undefined)) + } else { + void work.catch(() => undefined) + } + } if (status === 401) { const now = (deps.now ?? Date.now)() const waitUntil = (work: Promise) => (deps.waitUntil ? deps.waitUntil(work.catch(() => undefined)) : void work.catch(() => undefined)) @@ -449,7 +472,13 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => fetch: deps.egressFetch ?? fetch }) ) - .then((outcome) => outcome === 'revoked') + .then((outcome) => { + // In question, not revoked: still usable; the next lookup reads the new state. + if (outcome === 'suspect') { + forgetAccountCredential(row.user_id, agent) + } + return outcome === 'revoked' + }) // Every running sandbox of this person drops the dead placeholder, not only this one. const work = handled.then(async (changed) => { if (!changed) { @@ -501,14 +530,20 @@ function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => // Account credentials per person, decrypted at most once a minute per isolate; an edit or delete drops it. const ACCOUNT_CACHE_MS = 10_000 -const accountCache = new Map }>() -function cachedAccountCredential(db: D1Database, keyring: Keyring, userId: string, agent: 'claude' | 'codex', now: number): Promise { +const accountCache = new Map }>() +function cachedAccountCredential( + db: D1Database, + keyring: Keyring, + userId: string, + agent: 'claude' | 'codex', + now: number +): Promise<{ value: string | null; suspect: boolean }> { const key = `${userId}:${agent}` const hit = accountCache.get(key) if (hit && now - hit.at < ACCOUNT_CACHE_MS) { return hit.value } - const value = readUsableAgentCredential(db, keyring, userId, agent, now) + const value = readUsableAgentCredentialState(db, keyring, userId, agent, now) accountCache.set(key, { at: now, value }) value.catch(() => accountCache.delete(key)) return value @@ -617,6 +652,9 @@ async function agentCredentialsRoute(request: Request, url: URL, env: Env, deps: forgetAccountCredential(caller.userId, 'claude') await refreshOwnersSandboxes(env, deps, caller.userId) } + if (result.outcome === 'suspect') { + forgetAccountCredential(caller.userId, 'claude') + } const view = await agentCredentialsView(db, env, caller.userId, now) return noStore({ claude: view.claude, verifiedAt: result.verifiedAt, checked: result.outcome !== 'fresh' && result.outcome !== 'skipped' }) } diff --git a/cloud/apps/sandbox-control/test/agent-accounts.test.ts b/cloud/apps/sandbox-control/test/agent-accounts.test.ts index 516bf5330b8..2035960de07 100644 --- a/cloud/apps/sandbox-control/test/agent-accounts.test.ts +++ b/cloud/apps/sandbox-control/test/agent-accounts.test.ts @@ -190,7 +190,7 @@ describe('egress-time Source (D10) and shared workspaces (D13)', () => { expectSignedOut(refused) }) - it('a 401 from the provider marks the account revoked and the session falls off it', async () => { + it('a 401 confirmed by a later real 401 revokes the account and the session falls off it', async () => { const { h, as, proxied, reply, lastEnv } = await setup() const mia = await as('mia') await h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } }) @@ -200,7 +200,11 @@ describe('egress-time Source (D10) and shared workspaces (D13)', () => { const otherSession: string = other.body.sandbox.session.id reply.status = 401 expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(401) - // Confirmed by the control plane's own auth check (dead token), then revoked. + // RS-2: the auth check (dead token) only puts it in question; a later real request's 401 revokes it. + await vi.waitFor(async () => + expect((await h.call('GET', '/v1/agent-credentials', { bearer: mia })).body.claude).toMatchObject({ state: 'expired', needsCheck: true }) + ) + expect((await proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` })).status).toBe(401) await vi.waitFor(async () => expect((await h.call('GET', '/v1/agent-credentials', { bearer: mia })).body.claude.state).toBe('revoked')) // Already off the account: no placeholder in new shells, and the proxy refuses with a clear reason. await vi.waitFor(() => expect(lastEnv(ENV_COMMAND).CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()) @@ -334,12 +338,62 @@ describe('a provider 401 revokes only when confirmed', () => { expect(await state()).toBe('active') }) - it('a confirmed dead token is revoked once and audited', async () => { + it('RS-2: a failed auth check alone never revokes: "needs check" (shown as expired), still usable', async () => { + const { h, reply, send, state, auditRows, upstream, check } = await running() + reply.status = 401 + await send() + await vi.waitFor(async () => expect(await auditRows()).toEqual([{ action: 'suspect', reason: 'provider_401_check_auth_failure', upstream_path: '/v1/messages' }])) + expect(await state()).toBe('expired') + const mia = await h.token('mia', undefined, 'sandbox') + expect((await h.call('GET', '/v1/agent-credentials', { bearer: mia })).body.claude).toMatchObject({ state: 'expired', needsCheck: true }) + // The real credential still goes out, so the next real request can confirm it either way. + reply.status = 200 + upstream.length = 0 + await send() + expect(upstream.at(-1)?.headers.get('authorization')).toBe(`Bearer ${SETUP_TOKEN}`) + expect(check.calls).toBe(1) + }) + + it('RS-2: needs check + a later real 401 revokes (audited); the probe alone never did', async () => { const { reply, send, state, auditRows } = await running() reply.status = 401 await send() + await vi.waitFor(async () => expect((await auditRows()).map((row) => row.action)).toEqual(['suspect'])) + await send() await vi.waitFor(async () => expect(await state()).toBe('revoked')) - expect(await auditRows()).toEqual([{ action: 'revoked', reason: 'provider_401_confirmed_by_check', upstream_path: '/v1/messages' }]) + expect(await auditRows()).toEqual([ + { action: 'suspect', reason: 'provider_401_check_auth_failure', upstream_path: '/v1/messages' }, + { action: 'revoked', reason: 'provider_401_after_check_failure', upstream_path: '/v1/messages' } + ]) + }) + + it('RS-2: needs check + a later real request that succeeds clears it back to active (audited)', async () => { + const { reply, send, state, auditRows } = await running() + reply.status = 401 + await send() + await vi.waitFor(async () => expect((await auditRows()).map((row) => row.action)).toEqual(['suspect'])) + reply.status = 200 + await send() + await vi.waitFor(async () => expect(await state()).toBe('active')) + expect((await auditRows()).at(-1)).toEqual({ action: 'kept', reason: 'request_succeeded_after_check_failure', upstream_path: '/v1/messages' }) + }) + + it('RS-2: a check that can\'t reach the provider changes nothing', async () => { + const ctx = await setup() + const mia = await ctx.as('mia') + await ctx.h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } }) + const created = await ctx.h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: mia }) + const id: string = created.body.sandbox.id + ctx.reply.status = 401 + ctx.check.status = 0 + await ctx.proxied(id, 'api.anthropic.com', { authorization: `Bearer orca-ph-claude-account-${id}` }) + await vi.waitFor(async () => + expect((await ctx.h.env.SANDBOX_DB.prepare('SELECT action, reason FROM agent_credential_audit').all()).results).toEqual([ + { action: 'kept', reason: 'check_inconclusive' } + ]) + ) + expect((await ctx.h.call('GET', '/v1/agent-credentials', { bearer: mia })).body.claude).toEqual(expect.objectContaining({ state: 'active' })) + expect((await ctx.h.call('GET', '/v1/agent-credentials', { bearer: mia })).body.claude.needsCheck).toBeUndefined() }) }) @@ -358,14 +412,14 @@ describe('POST /v1/agent-credentials/claude/verify (pre-launch check)', () => { expect(check.calls).toBe(1) }) - it('a dead token is revoked (and audited); an inconclusive check keeps it', async () => { + it('RS-2: a dead token only needs a check (audited, never revoked by the probe); an inconclusive check keeps it', async () => { const dead = await setup() const mia = await dead.as('mia') await dead.h.call('PUT', '/v1/agent-credentials/claude', { bearer: mia, body: { token: SETUP_TOKEN } }) - const revoked = await dead.h.call('POST', '/v1/agent-credentials/claude/verify', { bearer: mia }) - expect(revoked.body).toMatchObject({ claude: { state: 'revoked' }, checked: true, verifiedAt: null }) + const suspect = await dead.h.call('POST', '/v1/agent-credentials/claude/verify', { bearer: mia }) + expect(suspect.body).toMatchObject({ claude: { state: 'expired', needsCheck: true }, checked: true, verifiedAt: null }) const rows = (await dead.h.env.SANDBOX_DB.prepare('SELECT action, reason FROM agent_credential_audit').all()).results - expect(rows).toEqual([{ action: 'revoked', reason: 'verify_confirmed_auth_failure' }]) + expect(rows).toEqual([{ action: 'suspect', reason: 'verify_check_auth_failure' }]) const flaky = await setup() const ada = await flaky.as('ada') diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-needs-check-wire.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-needs-check-wire.test.ts new file mode 100644 index 00000000000..4ce793381be --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-needs-check-wire.test.ts @@ -0,0 +1,45 @@ +// RS-2: a sign-in whose auth check failed ("needs check") reaches the desktop as `expired` plus +// `needsCheck: true`. Every desktop, old ones included, then shows the Renew strip; an unknown state would +// have read as 'none' ("isn't set up"). The wire never sends a state an old desktop could read as active. +import { describe, expect, it } from 'vitest' +import { + AgentCredentialsResponseSchema, + isUsableAgentCredential +} from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { cloudAgentHoldReason } from './cloud-agent-launch-hold' + +const wire = { + claude: { state: 'expired', needsCheck: true, label: 'ada@example.com' }, + codex: { state: 'none' }, + providerEnabled: { claude: true, codex: true } +} + +describe('RS-2 "needs check" on the wire', () => { + it('parses as expired (needsCheck tolerated), never usable', () => { + const parsed = AgentCredentialsResponseSchema.parse(wire) + expect(parsed.claude.state).toBe('expired') + expect(isUsableAgentCredential(parsed.claude.state)).toBe(false) + }) + + it('maps to the Renew strip (accountExpired), like any expired sign-in', () => { + const parsed = AgentCredentialsResponseSchema.parse(wire) + expect( + cloudAgentHoldReason('claude', { + load: { + kind: 'ready', + status: { + credentials: parsed, + sources: { defaults: {}, coveredByKey: { claude: false, codex: false } } + } + }, + keyKinds: { claude: null, codex: null } + }) + ).toBe('accountExpired') + }) + + it('a raw unknown state (what an old server could never send) still never reads as active', () => { + const parsed = AgentCredentialsResponseSchema.parse({ ...wire, claude: { state: 'suspect' } }) + expect(parsed.claude.state).toBe('none') + expect(isUsableAgentCredential(parsed.claude.state)).toBe(false) + }) +}) From aa475971e46edd097b3bb8013017204be1af197c Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 13 Nov 2026 01:44:21 -0800 Subject: [PATCH 2/3] fix(cloud): RS-2 any later 401 on a sign-in in question confirms it (no clock comparison) --- cloud/apps/sandbox-control/src/agent-credential-checks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cloud/apps/sandbox-control/src/agent-credential-checks.ts b/cloud/apps/sandbox-control/src/agent-credential-checks.ts index 113c421be52..08f7d6cd50a 100644 --- a/cloud/apps/sandbox-control/src/agent-credential-checks.ts +++ b/cloud/apps/sandbox-control/src/agent-credential-checks.ts @@ -111,8 +111,10 @@ async function checkCredential( export async function handleAccountUnauthorized( ctx: CheckContext & { sandboxId: string; path: string } ): Promise { + // The 401 that triggered the failed check was consumed by that check, so any 401 seen here, with the + // sign-in already in question, is a different, later real request. const suspectAt = await agentCredentialSuspectAt(ctx.db, ctx.userId, ctx.provider.agent) - if (suspectAt !== null && ctx.now > suspectAt) { + if (suspectAt !== null) { await markAgentCredentialRevoked(ctx.db, ctx.userId, ctx.provider.agent, ctx.now) await audit(ctx.db, { userId: ctx.userId, From 2790a51999e387034492dbbe25df475780b048ce Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 13 Nov 2026 01:46:02 -0800 Subject: [PATCH 3/3] chore(cloud): RS-2 migration renumbered to 0056 (0055 belongs to #654) --- ...t_credential_suspect.sql => 0056_agent_credential_suspect.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cloud/apps/sandbox-control/migrations/{0055_agent_credential_suspect.sql => 0056_agent_credential_suspect.sql} (100%) diff --git a/cloud/apps/sandbox-control/migrations/0055_agent_credential_suspect.sql b/cloud/apps/sandbox-control/migrations/0056_agent_credential_suspect.sql similarity index 100% rename from cloud/apps/sandbox-control/migrations/0055_agent_credential_suspect.sql rename to cloud/apps/sandbox-control/migrations/0056_agent_credential_suspect.sql