Merge pull request #655 from nwparker/cloud/rs2-probe-suspect

fix(cloud): RS-2 a failed auth check never revokes a sign-in; it needs a check until a real request confirms or clears it
This commit is contained in:
Neil
2026-11-13 03:11:23 -08:00
committed by GitHub
6 changed files with 286 additions and 36 deletions
@@ -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;
@@ -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<void> {
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,67 @@ 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<CheckOutcome> {
// 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) {
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<boolean> {
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.
@@ -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<CredentialRow, 'state' | 'expires_at'>
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<CredentialRow | null> {
@@ -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<string | null> {
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<boolean> {
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<boolean> {
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<number | null> {
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<void> {
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<boolean> {
@@ -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) }
}
+45 -7
View File
@@ -123,7 +123,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 {
@@ -133,7 +133,7 @@ import {
deleteAgentCredential,
providerEnabled,
putClaudeCredential,
readUsableAgentCredential
readUsableAgentCredentialState
} from './agent-credentials'
import {
AgentSourceError,
@@ -396,6 +396,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,
@@ -420,7 +422,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')
@@ -431,6 +435,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<unknown>) => (deps.waitUntil ? deps.waitUntil(work.catch(() => undefined)) : void work.catch(() => undefined))
@@ -451,7 +474,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) {
@@ -503,14 +532,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<string, { at: number; value: Promise<string | null> }>()
function cachedAccountCredential(db: D1Database, keyring: Keyring, userId: string, agent: 'claude' | 'codex', now: number): Promise<string | null> {
const accountCache = new Map<string, { at: number; value: Promise<{ value: string | null; suspect: boolean }> }>()
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
@@ -619,6 +654,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' })
}
@@ -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')
@@ -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)
})
})