From fcbd9fc95f7d961fa64739442ed32f3531649ed4 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:32:16 +0200 Subject: [PATCH 1/5] test(opencode): stop the suite reaching live provider endpoints Fail-loud preload network guard for the test suite: any non-loopback HTTP(S) fetch throws, including across redirects (manual redirect following, 20-hop cap, unparseable URLs fail closed). Replaces 44 live vendor requests per run with deterministic local stubs, isolates background intervals and temp dirs, and asserts no test leaves globalThis.fetch or an interval behind. Production fixes found by the guard: account-state membership treats a missing or unparseable config as UNKNOWN (no pruning) rather than empty, loader/membership agree on whitespace-normalized ids, and rotated credentials persist under the canonical key. Loopback classification tests call assertLoopback() directly instead of opening sockets (127.0.0.2:1 hangs to timeout on Darwin), and the upstream Fable 5.1 effort test restores globalThis.fetch after itself. --- packages/core/src/accounts.ts | 108 ++- packages/core/src/quota-header-feed.ts | 1 + packages/opencode/src/index.ts | 7 +- .../src/tests/account-command.test.ts | 100 ++- packages/opencode/src/tests/accounts.test.ts | 795 ++++++++++++++++++ .../src/tests/add-account-flows.test.ts | 99 ++- packages/opencode/src/tests/index.test.ts | 526 +++++++++--- packages/opencode/src/tests/info-logs.test.ts | 71 +- .../opencode/src/tests/network-guard-utils.ts | 75 ++ .../opencode/src/tests/network-guard.test.ts | 596 +++++++++++++ packages/opencode/src/tests/setup.ts | 162 ++++ packages/opencode/src/tests/test-fetch.ts | 68 ++ .../opencode/src/tests/timer-tracking.test.ts | 23 + packages/opencode/src/tests/timer-tracking.ts | 66 ++ 14 files changed, 2543 insertions(+), 154 deletions(-) create mode 100644 packages/opencode/src/tests/network-guard-utils.ts create mode 100644 packages/opencode/src/tests/network-guard.test.ts create mode 100644 packages/opencode/src/tests/test-fetch.ts create mode 100644 packages/opencode/src/tests/timer-tracking.test.ts create mode 100644 packages/opencode/src/tests/timer-tracking.ts diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index e8aada95..d65b131c 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -938,6 +938,25 @@ function mergeConfigAccountAndState( return { ...account, ...stateAccount } } +function configAccountHasInvalidShape(account: Record) { + if (account.type !== 'api' && account.type !== 'oauth') return true + if ( + account.type === 'api' && + 'baseURL' in account && + (typeof account.baseURL !== 'string' || !isValidApiBaseURL(account.baseURL)) + ) { + return true + } + if ( + account.type === 'oauth' && + 'refresh' in account && + (typeof account.refresh !== 'string' || !account.refresh.trim()) + ) { + return true + } + return false +} + function mergeConfigAndState( configValue: unknown, stateValue: unknown, @@ -970,10 +989,13 @@ function mergeConfigAndState( const accounts = Array.isArray(configValue.accounts) ? configValue.accounts.map((account) => { if (!isRecord(account)) return account - const stateAccount: Record = - typeof account.id === 'string' && isRecord(stateAccounts[account.id]) - ? (stateAccounts[account.id] as Record) - : {} + const rawId = typeof account.id === 'string' ? account.id : undefined + const stateValue = rawId + ? (stateAccounts[rawId] ?? stateAccounts[rawId.trim()]) + : undefined + const stateAccount: Record = isRecord(stateValue) + ? (stateValue as Record) + : {} return mergeConfigAccountAndState(account, stateAccount) }) : [] @@ -1943,31 +1965,78 @@ async function saveAccountStateUnlocked( if (scope.accounts) { const ids = scope.accounts === true ? null : new Set(scope.accounts) + const config = (await readJsonIfPresent(path)).value + const configuredIds = (() => { + if (!isRecord(config) || !Array.isArray(config.accounts)) return null + if (config.accounts.length === 0) return new Set() + const stateAccounts = isRecord(next.accounts) ? next.accounts : {} + const incomingAccounts = Object.fromEntries( + storage.accounts.map((account) => [ + account.id.trim(), + accountRuntimeState(account), + ]), + ) + // Keep both forms because legacy state may still use either key while + // scoped saves must preserve entries not superseded by an incoming account. + const memberships = config.accounts.map((account) => { + if (!isRecord(account) || typeof account.id !== 'string') return null + const id = account.id.trim() + if (!id) return null + if (configAccountHasInvalidShape(account)) return null + const stateValue = stateAccounts[account.id] ?? stateAccounts[id] + const stateAccount: Record = isRecord(stateValue) + ? (stateValue as Record) + : {} + const incomingAccount = incomingAccounts[id] as + | Record + | undefined + return normalizeAccount( + mergeConfigAccountAndState(account, incomingAccount ?? stateAccount), + ) + ? [account.id, id] + : null + }) + // A populated config with any unparseable entry cannot establish safe membership. + return memberships.every((membership) => membership !== null) + ? new Set(memberships.flatMap((membership) => membership ?? [])) + : null + })() next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) } for (const account of storage.accounts) { - if (ids && !ids.has(account.id)) continue - next.accounts[account.id] = mergeAccountRuntimeState( - next.accounts[account.id], + const accountId = account.id.trim() + if (ids && !ids.has(account.id) && !ids.has(accountId)) continue + if ( + configuredIds && + !configuredIds.has(account.id) && + !configuredIds.has(accountId) + ) + continue + const legacyKeys = Object.keys(next.accounts).filter( + (key) => key !== accountId && key.trim() === accountId, + ) + const legacyKey = legacyKeys[0] + const existingAccount = + next.accounts[accountId] ?? + (legacyKey ? next.accounts[legacyKey] : undefined) + for (const key of legacyKeys) delete next.accounts[key] + next.accounts[accountId] = mergeAccountRuntimeState( + existingAccount, accountRuntimeState(account), ) } + if (configuredIds) { + // Config membership is authoritative for scoped writes too; otherwise a + // stale writer can preserve state for an account removed out of band. + for (const id of Object.keys(next.accounts)) { + if (!configuredIds.has(id)) delete next.accounts[id] + } + } if (ids) { for (const id of ids) { if (!storage.accounts.some((account) => account.id === id)) { delete next.accounts[id] } } - } else { - // Full save: drop any per-account state whose id is no longer present in - // storage.accounts. The scoped path above only prunes ids it was asked to - // save; on a removal the storage is saved with scope.accounts === true - // (ids === null), so without this branch the removed account's runtime - // state (quota/lastRefreshError/access/refresh/expires) would be orphaned - // in the state file and later merged onto a re-added same-id account. - const present = new Set(storage.accounts.map((account) => account.id)) - for (const id of Object.keys(next.accounts)) { - if (!present.has(id)) delete next.accounts[id] - } } } @@ -3598,13 +3667,14 @@ export class FallbackAccountManager { await this.refreshDueAccounts() await this.refreshQuotaForDueAccounts() } - void run().catch(() => {}) + const initialRun = run().catch(() => {}) if (!this.refreshTimer) { this.refreshTimer = this.setIntervalImpl(() => { void run().catch(() => {}) }, BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS)) if ('unref' in this.refreshTimer) this.refreshTimer.unref() } + return initialRun } stopBackgroundRefresh() { diff --git a/packages/core/src/quota-header-feed.ts b/packages/core/src/quota-header-feed.ts index bde2eabf..065f4085 100644 --- a/packages/core/src/quota-header-feed.ts +++ b/packages/core/src/quota-header-feed.ts @@ -53,6 +53,7 @@ type QuotaHeaderFeedMetadata = { schema_version: typeof QUOTA_HEADER_FEED_SCHEMA_VERSION provider: 'anthropic' configured_account_count: number + /** Header observation time; merged poll-owned fields retain their own checkedAt. */ observed_at_ms: number } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1fa5054e..70f71f7a 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1286,9 +1286,7 @@ const anthropicAuthPlugin = async ( ? { ...entry, quota: mergedQuota, - checkedAt: persistedQuotaBelongsToRequest - ? (reloaded.quota?.mainQuotaCheckedAt ?? entry.checkedAt) - : entry.checkedAt, + checkedAt: entry.checkedAt, } : entry } @@ -1527,7 +1525,7 @@ const anthropicAuthPlugin = async ( void refreshSidebarQuota().catch(() => {}) }, }) - fallbackManager.startBackgroundRefresh() + const fallbackRefreshReady = fallbackManager.startBackgroundRefresh() const cacheDiagnosticsTracker = new CacheDiagnosticsTracker() const cacheDiagnosticsBetaTracker = new CacheDiagnosticsBetaTracker() type CacheDiagnosticsResponse = { @@ -6741,6 +6739,7 @@ const anthropicAuthPlugin = async ( }, __primeManager: primeManager, __quotaManager: quotaManager, + __fallbackRefreshReady: fallbackRefreshReady, // biome-ignore lint/suspicious/noExplicitAny: Plugin type doesn't include undocumented auth/hooks } as any } diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index d68843fd..aec2ac89 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from 'bun:test' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -16,9 +24,23 @@ import { setAccountEnabledPersistent, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin } from '../index' +import { DEFAULT_FETCH_MOCK, installDefaultFetchMock } from './test-fetch' +import { + createTimerTracking, + type PluginTimerOverrides, +} from './timer-tracking' let tempDir: string let accountPath: string +const tempDirs = new Set() +const originalFetch = globalThis.fetch +const timerTracking = createTimerTracking() +const { + activeIntervals, + disabledPluginTimerOverrides, + trackedClearInterval, + trackedSetInterval, +} = timerTracking const baseStorage = (): AccountStorage => ({ version: 1, @@ -68,15 +90,48 @@ const baseStorage = (): AccountStorage => ({ }) beforeEach(async () => { + installDefaultFetchMock() + timerTracking.reset() + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } tempDir = await mkdtemp(join(tmpdir(), 'anthropic-auth-acct-cmd-')) + tempDirs.add(tempDir) accountPath = join(tempDir, 'anthropic-auth.json') process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath }) afterEach(async () => { - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - await rm(tempDir, { recursive: true, force: true }) - mock.restore() + try { + // Restore only the fixture's tagged mock; an untagged custom mock left + // installed must reach the preload's leak detector, not be masked here. + const currentFetch = globalThis.fetch as + | (typeof fetch & { [DEFAULT_FETCH_MOCK]?: true }) + | undefined + if (currentFetch?.[DEFAULT_FETCH_MOCK]) { + globalThis.fetch = originalFetch + } + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + await Promise.all( + [...tempDirs].map((directory) => + rm(directory, { recursive: true, force: true }).catch(() => {}), + ), + ) + tempDirs.clear() + mock.restore() + } finally { + // Assert last so a detected leak cannot abort the cleanup above. + expect(activeIntervals.size).toBe(0) + } +}) + +afterAll(async () => { + await Promise.all( + [...tempDirs, tempDir].map((directory) => + rm(directory, { recursive: true, force: true }).catch(() => {}), + ), + ) + tempDirs.clear() }) // --------------------------------------------------------------------------- @@ -513,11 +568,22 @@ describe('account command INFO logs (via plugin)', () => { } } - async function getPlugin() { - return (await AnthropicAuthPlugin({ - // @ts-expect-error: minimal mock for testing - client: createMockClient(), - })) as Promise + async function getPlugin(timerOverrides?: PluginTimerOverrides) { + const defaultTimerOverrides = disabledPluginTimerOverrides() + const plugin = (await ( + AnthropicAuthPlugin as unknown as ( + ctx: Parameters[0], + timers?: PluginTimerOverrides, + ) => ReturnType + )( + { + // @ts-expect-error: minimal mock for testing + client: createMockClient(), + }, + { ...defaultTimerOverrides, ...timerOverrides }, + )) as any + await plugin.__fallbackRefreshReady + return plugin } async function executeCommand( @@ -636,4 +702,20 @@ describe('account command INFO logs (via plugin)', () => { capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(0) }) + + test('does not retain a background interval unless the helper opts in', async () => { + await saveAccounts(baseStorage(), accountPath) + await getPlugin() + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) + + await timerTracking.withTrackedInterval(async () => { + await getPlugin({ + setInterval: trackedSetInterval, + clearInterval: trackedClearInterval, + }) + expect(activeIntervals.size).toBe(1) + }) + expect(activeIntervals.size).toBe(0) + }) }) diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index c2ffe410..65e47141 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -216,6 +216,92 @@ describe('main account identity', () => { }) }) +describe('scoped account-state membership', () => { + test('preserves state for a whitespace-padded configured account id', async () => { + await writeFile( + accountPath, + JSON.stringify({ + ...baseStorage(), + accounts: [{ id: ' fb-1 ', type: 'oauth', enabled: true }], + }), + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + ' fb-1 ': { + access: 'access-token', + refresh: 'valid-refresh', + expires: Date.now() + 3_600_000, + }, + x: { refresh: 'removed-refresh' }, + }, + }), + ) + + const loaded = await loadAccounts(accountPath) + expect(loaded).not.toBeNull() + expect(loaded?.accounts).toHaveLength(1) + expect(loaded?.accounts[0]?.id).toBe('fb-1') + expect(loaded?.accounts[0]?.type).toBe('oauth') + expect((loaded!.accounts[0] as OAuthAccount).refresh).toBe('valid-refresh') + + const scopedStorage = { ...loaded!, accounts: [] } + await saveAccountState(scopedStorage, accountPath, { accounts: true }) + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts).toEqual({ + ' fb-1 ': { + access: 'access-token', + refresh: 'valid-refresh', + expires: expect.any(Number), + }, + }) + const loadedAfter = await loadAccounts(accountPath) + expect(loadedAfter?.accounts).toHaveLength(1) + expect(loadedAfter?.accounts[0]?.id).toBe('fb-1') + expect((loadedAfter!.accounts[0] as OAuthAccount).refresh).toBe( + 'valid-refresh', + ) + }) + + test('accepts a trimmed state key for a padded configured account', async () => { + await writeFile( + accountPath, + JSON.stringify({ + ...baseStorage(), + accounts: [{ id: ' fb-1 ', type: 'oauth', enabled: true }], + }), + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + 'fb-1': { refresh: 'trimmed-refresh' }, + x: { refresh: 'removed-refresh' }, + }, + }), + ) + + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(1) + expect((loaded!.accounts[0] as OAuthAccount).refresh).toBe( + 'trimmed-refresh', + ) + + await saveAccountState(loaded!, accountPath, { accounts: ['fb-1'] }) + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts).toEqual({ + 'fb-1': { refresh: 'trimmed-refresh' }, + }) + }) +}) + describe('main quota clear marker', () => { test('keeps the newer marker when an older marker arrives', () => { expect(mergeMainQuotaErrorClearedAt(2_000, 1_000)).toBe(2_000) @@ -1176,6 +1262,274 @@ describe('account storage', () => { expect(loaded?.accounts.map((account) => account.id)).toEqual(['good-api']) }) + test('preserves runtime state when config membership entry is shape-invalid', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'api', + apiKey: 'api-key', + baseURL: 'https://api.example.com/v1', + }, + { + id: 'b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + }, + ] + await saveAccounts(seeded, accountPath) + const staleStorage = await loadAccounts(accountPath) + expect(staleStorage?.accounts.map((account) => account.id)).toEqual([ + 'a', + 'b', + ]) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: 'a', type: 'api', baseURL: 'garbage' }], + }), + 'utf8', + ) + + await saveAccountState(staleStorage!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toHaveProperty('a') + expect(state.accounts).toHaveProperty('b') + expect(state.accounts.a.apiKey).toBe('api-key') + expect(state.accounts.b.refresh).toBe('refresh-b') + }) + + test('prunes runtime state for a fully valid config membership set', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'api', + apiKey: 'api-key', + baseURL: 'https://api.example.com/v1', + }, + { + id: 'b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + }, + ] + await saveAccounts(seeded, accountPath) + const staleStorage = await loadAccounts(accountPath) + expect(staleStorage?.accounts.map((account) => account.id)).toEqual([ + 'a', + 'b', + ]) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [ + { id: 'a', type: 'api', baseURL: 'https://api.example.com/v1' }, + ], + }), + 'utf8', + ) + + await saveAccountState(staleStorage!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toEqual({ a: expect.any(Object) }) + expect(state.accounts.b).toBeUndefined() + }) + + test('uses incoming account credentials to establish config membership', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'x', + type: 'oauth', + access: 'access-x', + refresh: 'refresh-x', + }, + ] + await saveAccounts(seeded, accountPath) + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts.map((account) => account.id)).toEqual(['x']) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: 'y', type: 'oauth' }], + }), + 'utf8', + ) + + const incoming = baseStorage() + incoming.accounts = [ + { + id: 'y', + type: 'oauth', + access: 'access-y', + refresh: 'refresh-y', + }, + ] + await saveAccountState(incoming, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toEqual({ y: expect.any(Object) }) + expect(state.accounts.x).toBeUndefined() + expect(state.accounts.y.refresh).toBe('refresh-y') + }) + + test('does not establish membership from config-owned fields on an incoming api account', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'api', + apiKey: 'api-key-a', + baseURL: 'https://api.example.com/v1', + }, + { + id: 'b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + }, + ] + await saveAccounts(seeded, accountPath) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: 'a', type: 'api' }], + }), + 'utf8', + ) + + const incoming = baseStorage() + incoming.accounts = [ + { + id: 'a', + type: 'api', + apiKey: 'api-key-a-new', + baseURL: 'https://api.example.com/v1', + }, + ] + await saveAccountState(incoming, accountPath, { accounts: true }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts).toHaveProperty('a') + expect(state.accounts).toHaveProperty('b') + }) + + test('keeps runtime state when incoming storage cannot validate config membership', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'x', + type: 'oauth', + access: 'access-x', + refresh: 'refresh-x', + }, + ] + await saveAccounts(seeded, accountPath) + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts.map((account) => account.id)).toEqual(['x']) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: 'y', type: 'oauth' }], + }), + 'utf8', + ) + + await saveAccountState(loaded!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts.x.refresh).toBe('refresh-x') + expect(state.accounts.y).toBeUndefined() + }) + + test('does not establish membership from an id-less shape-valid config entry', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + }, + { + id: 'b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + }, + ] + await saveAccounts(seeded, accountPath) + const staleStorage = await loadAccounts(accountPath) + expect(staleStorage?.accounts.map((account) => account.id)).toEqual([ + 'a', + 'b', + ]) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ type: 'oauth', refresh: 'new-refresh' }], + }), + 'utf8', + ) + + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(1) + await saveAccountState(staleStorage!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toHaveProperty('a') + expect(state.accounts).toHaveProperty('b') + }) + + test('prunes runtime state for an explicitly empty config account list', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + }, + ] + await saveAccounts(seeded, accountPath) + const staleStorage = await loadAccounts(accountPath) + expect(staleStorage?.accounts).toHaveLength(1) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [], + }), + 'utf8', + ) + + await saveAccountState(staleStorage!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toEqual({}) + }) + test('runtime state saves do not rewrite user-editable config', async () => { const storage = baseStorage() storage.quota = { @@ -1223,6 +1577,364 @@ describe('account storage', () => { expect(loaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(22) }) + test('runtime state saves preserve accounts when config is missing', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access-before', + refresh: 'refresh-before', + expires: 1_000, + lastRefreshedAt: 100, + }) + await saveAccounts(storage, accountPath) + await rm(accountPath) + storage.accounts[0] = { + ...storage.accounts[0], + access: 'access-after', + refresh: 'refresh-after', + expires: 2_000, + lastRefreshedAt: 200, + } as OAuthAccount + + await saveAccountState(storage, accountPath) + + const rawState = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(rawState.accounts['fallback-1']).toMatchObject({ + access: 'access-after', + refresh: 'refresh-after', + expires: 2_000, + lastRefreshedAt: 200, + }) + }) + + test('runtime state saves preserve whitespace-padded configured account ids', async () => { + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [ + { id: ' fb-1 ', type: 'oauth', refresh: 'refresh-padded' }, + { id: 'fb-control', type: 'oauth', refresh: 'refresh-control' }, + ], + }), + 'utf8', + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + ' fb-1 ': { + access: 'access-padded', + refresh: 'refresh-padded', + expires: 1_000, + }, + 'fb-control': { + access: 'access-control', + refresh: 'refresh-control', + expires: 1_000, + }, + }, + }), + 'utf8', + ) + const loaded = await loadAccounts(accountPath) + expect(loaded).not.toBeNull() + expect(loaded?.accounts.map((account) => account.id)).toEqual([ + 'fb-1', + 'fb-control', + ]) + expect((loaded!.accounts[0] as OAuthAccount).refresh).toBe('refresh-padded') + + await saveAccountState(loaded!, accountPath, { + accounts: ['fb-1', 'fb-control'], + }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(Object.keys(state.accounts ?? {}).sort()).toEqual([ + 'fb-1', + 'fb-control', + ]) + expect(state.accounts?.['fb-1']?.refresh).toBe('refresh-padded') + expect(state.accounts?.['fb-control']?.refresh).toBe('refresh-control') + }) + + test('loads a trimmed legacy state key for a padded configured account', async () => { + await writeFile( + accountPath, + JSON.stringify({ + ...baseStorage(), + accounts: [{ id: ' fb-1 ', type: 'oauth', enabled: true }], + }), + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + 'fb-1': { + access: 'access-trimmed', + refresh: 'refresh-trimmed', + expires: Date.now() + 3_600_000, + }, + }, + }), + ) + + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(1) + expect((loaded!.accounts[0] as OAuthAccount).refresh).toBe( + 'refresh-trimmed', + ) + + await saveAccountState({ ...loaded!, accounts: [] }, accountPath, { + accounts: true, + }) + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts).toEqual({ + 'fb-1': { + access: 'access-trimmed', + refresh: 'refresh-trimmed', + expires: expect.any(Number), + }, + }) + expect((await loadAccounts(accountPath))?.accounts).toHaveLength(1) + }) + + test('persists rotated credentials under the key the padded loader accepts', async () => { + await writeFile( + accountPath, + JSON.stringify({ + ...baseStorage(), + accounts: [{ id: ' fb-1 ', type: 'oauth' }], + }), + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + ' fb-1 ': { + access: 'access-old', + refresh: 'refresh-old', + expires: Date.now() + 3_600_000, + lastRefreshedAt: 100, + }, + }, + }), + ) + + const loaded = await loadAccounts(accountPath) + expect((loaded!.accounts[0] as OAuthAccount).access).toBe('access-old') + const rotated = loaded!.accounts[0] as OAuthAccount + rotated.access = 'access-new' + rotated.refresh = 'refresh-new' + rotated.expires = Date.now() + 7_200_000 + rotated.lastRefreshedAt = 200 + await saveAccountState(loaded!, accountPath, { accounts: ['fb-1'] }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(Object.keys(state.accounts ?? {})).toEqual(['fb-1']) + expect((await loadAccounts(accountPath))?.accounts[0]).toMatchObject({ + access: 'access-new', + refresh: 'refresh-new', + }) + }) + + test('runtime state saves preserve all state when config has a whitespace-only account id', async () => { + const seeded = baseStorage() + seeded.accounts.push({ + id: 'fb-1', + type: 'oauth', + access: 'access-before', + refresh: 'refresh-before', + expires: 1_000, + }) + await saveAccounts(seeded, accountPath) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: ' ' }], + }), + 'utf8', + ) + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(0) + + await saveAccountState(loaded!, accountPath, { accounts: true }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(state.accounts?.['fb-1']?.refresh).toBe('refresh-before') + }) + + test('runtime state saves preserve accounts when config omits accounts', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access-before', + refresh: 'refresh-before', + expires: 1_000, + lastRefreshedAt: 100, + }) + await saveAccounts(storage, accountPath) + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + }), + 'utf8', + ) + storage.accounts[0] = { + ...storage.accounts[0], + access: 'access-after', + refresh: 'refresh-after', + expires: 2_000, + lastRefreshedAt: 200, + } as OAuthAccount + + await saveAccountState(storage, accountPath) + + const rawState = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(rawState.accounts['fallback-1']).toMatchObject({ + access: 'access-after', + refresh: 'refresh-after', + expires: 2_000, + lastRefreshedAt: 200, + }) + }) + + test('treats a partially parseable populated config as unknown membership', async () => { + const storage = baseStorage() + storage.accounts.push( + { + id: 'account-a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + expires: 1_000, + }, + { + id: 'account-b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + expires: 1_000, + }, + ) + await saveAccounts(storage) + await writeFile( + accountPath, + JSON.stringify({ version: 1, accounts: [{ id: 'account-a' }, {}] }), + 'utf8', + ) + + await saveAccountState(storage, accountPath, { accounts: true }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(Object.keys(state.accounts ?? {}).sort()).toEqual([ + 'account-a', + 'account-b', + ]) + expect(state.accounts?.['account-a']?.refresh).toBe('refresh-a') + expect(state.accounts?.['account-b']?.refresh).toBe('refresh-b') + }) + + test('scoped saves preserve accounts when populated config membership is unknown', async () => { + const storage = baseStorage() + storage.accounts.push( + { + id: 'account-a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + expires: 1_000, + }, + { + id: 'account-b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + expires: 1_000, + }, + ) + await saveAccounts(storage) + await writeFile( + accountPath, + JSON.stringify({ version: 1, accounts: [{ id: 'account-a' }, {}] }), + 'utf8', + ) + + storage.accounts[0] = { + ...storage.accounts[0], + access: 'access-a-updated', + refresh: 'refresh-a-updated', + } as OAuthAccount + await saveAccountState(storage, accountPath, { accounts: ['account-a'] }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(state.accounts?.['account-a']).toMatchObject({ + access: 'access-a-updated', + refresh: 'refresh-a-updated', + }) + expect(state.accounts?.['account-b']).toMatchObject({ + access: 'access-b', + refresh: 'refresh-b', + }) + }) + + test('a wholly unparseable populated config prunes nothing when storage is loaded from it', async () => { + const seeded = baseStorage() + seeded.accounts.push({ + id: 'account-a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + expires: 1_000, + }) + await saveAccounts(seeded) + + // Load drops the unparseable entries too, so the writer's own snapshot is + // empty for the same reason membership is unknown. Pruning against it would + // delete every credential. + await writeFile( + accountPath, + JSON.stringify({ version: 1, accounts: [{}] }), + 'utf8', + ) + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(0) + + await saveAccountState(loaded!, accountPath, { accounts: true }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(state.accounts?.['account-a']?.refresh).toBe('refresh-a') + }) + test('generic saves preserve a newer persisted main profile', async () => { const staleStorage = baseStorage() await saveAccounts(staleStorage) @@ -5264,6 +5976,89 @@ describe('multi-account persistence', () => { expect(Object.keys(state.accounts)).toEqual(['umut', 'yiyi']) }) + test('scoped saves prune state removed by an external config edit', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'keep', + type: 'oauth', + access: 'keep-access', + refresh: 'keep-refresh', + }) + storage.accounts.push({ + id: 'doomed', + type: 'oauth', + access: 'doomed-access', + refresh: 'doomed-refresh', + }) + await saveAccounts(storage, accountPath) + + const staleSnapshot = (await loadAccounts(accountPath))! + const statePath = getAccountStatePath(accountPath) + const before = JSON.parse(await readFile(statePath, 'utf8')) + expect(before.accounts?.keep).toBeDefined() + expect(before.accounts?.doomed).toBeDefined() + + const config = JSON.parse(await readFile(accountPath, 'utf8')) + config.accounts = config.accounts.filter( + (account: { id?: string }) => account.id !== 'doomed', + ) + await writeFile(accountPath, `${JSON.stringify(config)}\n`, 'utf8') + + await saveAccountState(staleSnapshot, accountPath, { + accounts: ['keep'], + }) + const afterStaleSave = JSON.parse(await readFile(statePath, 'utf8')) + expect(afterStaleSave.accounts?.doomed).toBeUndefined() + expect(afterStaleSave.accounts?.keep).toBeDefined() + }) + + test('scoped saves preserve state for configured accounts outside the scope', async () => { + const storage = baseStorage() + storage.accounts.push( + { + id: 'keep', + type: 'oauth', + access: 'keep-access', + refresh: 'keep-refresh', + }, + { + id: 'update', + type: 'oauth', + access: 'update-access', + refresh: 'update-refresh', + }, + ) + await saveAccounts(storage, accountPath) + + const scopedStorage = { + ...storage, + accounts: storage.accounts.map((account) => + account.id === 'update' + ? { + ...account, + access: 'update-access-new', + refresh: 'update-refresh-new', + } + : account, + ), + } + await saveAccountState(scopedStorage, accountPath, { + accounts: ['update'], + }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts?.keep).toMatchObject({ + access: 'keep-access', + refresh: 'keep-refresh', + }) + expect(state.accounts?.update).toMatchObject({ + access: 'update-access-new', + refresh: 'update-refresh-new', + }) + }) + test('concurrent account additions preserve both accounts', async () => { await Promise.all([ addAccountPersistent( diff --git a/packages/opencode/src/tests/add-account-flows.test.ts b/packages/opencode/src/tests/add-account-flows.test.ts index 0aeebbb2..626e3a9c 100644 --- a/packages/opencode/src/tests/add-account-flows.test.ts +++ b/packages/opencode/src/tests/add-account-flows.test.ts @@ -2,9 +2,22 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { DEFAULT_FETCH_MOCK } from './test-fetch' +import { + createTimerTracking, + type PluginTimerOverrides, +} from './timer-tracking' let tempDir: string let accountPath: string +const originalFetch = globalThis.fetch +const timerTracking = createTimerTracking() +const { + activeIntervals, + disabledPluginTimerOverrides, + trackedClearInterval, + trackedSetInterval, +} = timerTracking async function useTempAccountFile() { if (tempDir) { @@ -31,12 +44,21 @@ function createMockClient() { } } -async function getPlugin() { +async function getPlugin(timerOverrides?: PluginTimerOverrides) { const { AnthropicAuthPlugin } = await import('../index') - return (await AnthropicAuthPlugin({ - // @ts-expect-error: minimal mock for testing - client: createMockClient(), - })) as Promise + const defaultTimerOverrides = disabledPluginTimerOverrides() + return (await ( + AnthropicAuthPlugin as unknown as ( + ctx: Parameters[0], + timers?: PluginTimerOverrides, + ) => ReturnType + )( + { + // @ts-expect-error: minimal mock for testing + client: createMockClient(), + }, + { ...defaultTimerOverrides, ...timerOverrides }, + )) as Promise } async function executeCommand( @@ -98,6 +120,9 @@ let capturedRecords: Array<{ }> = [] beforeEach(async () => { + timerTracking.reset() + const { installDefaultFetchMock } = await import('./test-fetch') + installDefaultFetchMock() capturedRecords = [] const { __setLogTestSink } = await import('@cortexkit/anthropic-auth-core') __setLogTestSink((record) => { @@ -107,13 +132,54 @@ beforeEach(async () => { }) afterEach(async () => { - const { __setLogTestSink } = await import('@cortexkit/anthropic-auth-core') - __setLogTestSink(null) - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + try { + // Restore only the fixture's tagged mock; an untagged custom mock left + // installed must reach the preload's leak detector, not be masked here. + const currentFetch = globalThis.fetch as + | (typeof fetch & { [DEFAULT_FETCH_MOCK]?: true }) + | undefined + if (currentFetch?.[DEFAULT_FETCH_MOCK]) { + globalThis.fetch = originalFetch + } + const { __setLogTestSink } = await import('@cortexkit/anthropic-auth-core') + __setLogTestSink(null) + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } + mock.restore() + } finally { + // Assert last so a detected leak cannot abort the cleanup above. + expect(activeIntervals.size).toBe(0) + } +}) + +test('does not retain a background interval unless the helper opts in', async () => { + await getPlugin() + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) + + let thrown: unknown + try { + await timerTracking.withTrackedInterval(async () => { + await getPlugin({ + setInterval: trackedSetInterval, + clearInterval: trackedClearInterval, + }) + expect(activeIntervals.size).toBe(1) + throw new Error('forced timer assertion failure') + }) + } catch (error) { + thrown = error } - mock.restore() + expect((thrown as Error).message).toBe('forced timer assertion failure') + expect(activeIntervals.size).toBe(0) +}) + +test('partial timer overrides retain disabled interval defaults', async () => { + await getPlugin({ clearInterval: trackedClearInterval }) + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) }) // --------------------------------------------------------------------------- @@ -435,10 +501,13 @@ describe('add-oauth label threading', () => { async function getPluginWithClient() { const client = createMockClient() const { AnthropicAuthPlugin } = await import('../index') - const plugin = (await AnthropicAuthPlugin({ - // @ts-expect-error: minimal mock for testing - client, - })) as any + const plugin = (await AnthropicAuthPlugin( + { + // @ts-expect-error: minimal mock for testing + client, + }, + disabledPluginTimerOverrides(), + )) as any return { plugin, client } } diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index e336f177..39be449a 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from 'bun:test' import { readFileSync } from 'node:fs' import { chmod, @@ -60,13 +68,14 @@ import { setSidebarState, } from '../sidebar-state' import { rewriteRequestBody } from '../transform.ts' - -/** Extract the URL string from a fetch input (string, URL, or Request). */ -function extractUrl(input: string | URL | Request): string { - if (typeof input === 'string') return input - if (input instanceof URL) return input.toString() - return input.url -} +import { + extractUrl, + installDefaultFetchMock, + MESSAGES_URL, + PROFILE_URL, + QUOTA_URL, + TOKEN_URL, +} from './test-fetch' async function freshPrimeQuotaResponse( body: unknown, @@ -99,9 +108,163 @@ function createMockClient( } } -const MESSAGES_URL = 'https://api.anthropic.com/v1/messages' const EMPTY_POST = { method: 'POST', body: '{}' } as const let tempConfigDir: string | undefined +const tempConfigDirs = new Set() +const allTempConfigDirs = new Set() +const fallbackRefreshes = new Set>() + +beforeEach(() => { + installDefaultFetchMock() +}) + +afterEach(async () => { + await cleanupTempConfigDirs() +}) + +afterAll(async () => { + await cleanupTempConfigDirs() + await Bun.sleep(100) + await cleanupTempConfigDirs() + await Promise.all( + [...allTempConfigDirs].map((directory) => + rm(directory, { recursive: true, force: true }).catch(() => {}), + ), + ) + allTempConfigDirs.clear() +}) + +async function cleanupTempConfigDirs(drainTimeoutMs = 4_000) { + const refreshesSettled = await Promise.race([ + Promise.allSettled(fallbackRefreshes).then(() => true), + Bun.sleep(drainTimeoutMs).then(() => false), + ]) + if (refreshesSettled) fallbackRefreshes.clear() + await drainSidebarWrites() + const directories = [...tempConfigDirs, tempConfigDir].filter( + (directory): directory is string => Boolean(directory), + ) + tempConfigDirs.clear() + tempConfigDir = undefined + await Promise.all( + directories.map((directory) => + rm(directory, { recursive: true, force: true }).catch(() => {}), + ), + ) +} + +function deferred() { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +async function withDeadlockGuard( + promise: Promise, + ms: number, + message: string, +): Promise { + let timeout: ReturnType | undefined + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), ms) + }) + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } +} + +test('withDeadlockGuard clears its timeout after the primary settles', async () => { + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout + // Concurrent async work schedules unrelated timers during the override + // window; every counter is scoped to the guard's own timer (identified by + // this sentinel delay) so nothing else can flip the probe. + const sentinelDelayMs = 47 + const guardTimers = new Set() + let timeoutFired = 0 + let timeoutCleared = 0 + let unhandled = 0 + const guardMessage = 'late guard' + const onUnhandled = (reason: unknown) => { + if (reason instanceof Error && reason.message === guardMessage) { + unhandled += 1 + } + } + globalThis.setTimeout = (( + callback: (...args: any[]) => void, + delay?: number, + ) => { + const isGuardTimer = delay === sentinelDelayMs + const handle = originalSetTimeout(() => { + if (isGuardTimer) timeoutFired += 1 + callback() + }, delay) + if (isGuardTimer) guardTimers.add(handle) + return handle + }) as typeof globalThis.setTimeout + globalThis.clearTimeout = (( + timeout: ReturnType, + ) => { + if (guardTimers.delete(timeout)) timeoutCleared += 1 + originalClearTimeout(timeout) + }) as typeof globalThis.clearTimeout + process.on('unhandledRejection', onUnhandled) + try { + await withDeadlockGuard( + Promise.resolve('primary'), + sentinelDelayMs, + guardMessage, + ) + await Bun.sleep(100) + expect(timeoutFired).toBe(0) + expect(timeoutCleared).toBe(1) + expect(unhandled).toBe(0) + } finally { + process.off('unhandledRejection', onUnhandled) + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + } +}) + +test('withDeadlockGuard rejects with its message at the deadline', async () => { + await expect( + withDeadlockGuard(new Promise(() => {}), 20, 'deadline'), + ).rejects.toThrow('deadline') +}) + +test('cleanup retries a fallback refresh that outlives its bounded wait', async () => { + const slowRefresh = deferred() + let settled = false + const refresh = slowRefresh.promise.then(() => { + settled = true + }) + fallbackRefreshes.add(refresh) + + const firstStartedAt = Date.now() + await cleanupTempConfigDirs(50) + expect(Date.now() - firstStartedAt).toBeLessThan(500) + expect(settled).toBe(false) + expect(fallbackRefreshes.has(refresh)).toBe(true) + + const releaseTimer = setTimeout(() => slowRefresh.resolve(), 20) + try { + await cleanupTempConfigDirs(50) + } finally { + clearTimeout(releaseTimer) + } + expect(settled).toBe(true) + expect(fallbackRefreshes.size).toBe(0) +}) + +test('extractUrl uses canonical fetch URLs and preserves raw invalid strings', () => { + expect(extractUrl('https://example.com/a/../b')).toBe('https://example.com/b') + expect(extractUrl('/relative')).toBe('/relative') + expect(extractUrl('not a URL')).toBe('not a URL') +}) async function expectHandledCommandResponse(promise: Promise) { try { @@ -345,6 +508,8 @@ async function useTempAccountFile(storage: AccountStorage) { await rm(tempConfigDir, { recursive: true, force: true }) } tempConfigDir = await mkdtemp(join(tmpdir(), 'anthropic-plugin-test-')) + tempConfigDirs.add(tempConfigDir) + allTempConfigDirs.add(tempConfigDir) process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( tempConfigDir, 'anthropic-auth.json', @@ -472,6 +637,18 @@ type PluginTimerOverrides = Partial<{ clearInterval: typeof globalThis.clearInterval }> +function disabledPluginTimerOverrides(): PluginTimerOverrides { + return { + // Background intervals must not outlive the test-scoped fetch mock they captured. + setInterval: mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + } +} + +const originalSetInterval = globalThis.setInterval +const originalClearInterval = globalThis.clearInterval let pluginTimerOverrides: PluginTimerOverrides = {} beforeEach(() => { @@ -481,9 +658,14 @@ beforeEach(() => { async function getPlugin( client?: ReturnType, directory?: string, - timerOverrides: PluginTimerOverrides = pluginTimerOverrides, + timerOverrides: PluginTimerOverrides = {}, ) { - return (await ( + const defaultTimerOverrides = + globalThis.setInterval === originalSetInterval && + globalThis.clearInterval === originalClearInterval + ? disabledPluginTimerOverrides() + : {} + const plugin = (await ( AnthropicAuthPlugin as unknown as ( ctx: Parameters[0], timers?: PluginTimerOverrides, @@ -494,11 +676,21 @@ async function getPlugin( client: client ?? createMockClient(), ...(directory && { directory }), }, - timerOverrides, - )) as Promise + { ...defaultTimerOverrides, ...pluginTimerOverrides, ...timerOverrides }, + )) as any + if (plugin.__fallbackRefreshReady) { + fallbackRefreshes.add(plugin.__fallbackRefreshReady) + } + return plugin } describe('sidebar needsReauth (dead-fallback indicator)', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + function fallbackWithRefreshError(status: number) { const refresh = 'fallback-refresh' const now = Date.now() @@ -545,6 +737,28 @@ describe('sidebar needsReauth (dead-fallback indicator)', () => { test('transient (429 rate-limited) fallback → needsReauth false', async () => { await useTempAccountFile(fallbackWithRefreshError(429)) + const defaultFetch = globalThis.fetch + let tokenCalls = 0 + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url === TOKEN_URL) { + tokenCalls += 1 + return Promise.reject( + new Error('TOKEN_URL is outside this transient quota test'), + ) + } + if (url === QUOTA_URL) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + limits: [], + }), + ) + } + return defaultFetch(input, init) + }) as unknown as typeof fetch + const plugin = await getPlugin() await plugin.auth.loader( () => @@ -560,6 +774,7 @@ describe('sidebar needsReauth (dead-fallback indicator)', () => { (candidate) => candidate.fallbacks[0]?.needsReauth === false, ) expect(state.fallbacks[0]?.needsReauth).toBe(false) + expect(tokenCalls).toBe(0) }) }) @@ -643,6 +858,12 @@ async function loadMainAndFetch( } describe('quota header feed integration', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + test('publishes a direct harvested response with the observation timestamp', async () => { const originalNow = Date.now let clock = 1_000_000 @@ -1360,6 +1581,39 @@ describe('quota header feed integration', () => { accounts: [], }), ) + const otherCheckedAt = 2_000_000 + const statePath = getAccountStatePath( + process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, + ) + await writeFile( + statePath, + `${JSON.stringify({ + version: 1, + main: { + quota: { + source: 'headers', + accountIdentity, + five_hour: { + usedPercent: 4, + remainingPercent: 96, + checkedAt: requestCheckedAt, + }, + seven_day: { + usedPercent: 52, + remainingPercent: 48, + checkedAt: requestCheckedAt, + }, + }, + quotaCheckedAt: otherCheckedAt, + quotaToken: tokenFingerprint(accessToken), + }, + })}\n`, + ) + const mixedState = await loadAccounts() + expect(mixedState?.quota?.mainQuota?.accountIdentity).toBe( + accountIdentity, + ) + expect(mixedState?.quota?.mainQuotaCheckedAt).toBe(otherCheckedAt) globalThis.fetch = mock((input: any) => { const url = extractUrl(input) if (url.includes('/claude_cli/bootstrap')) { @@ -1380,14 +1634,6 @@ describe('quota header feed integration', () => { } return Promise.resolve(Response.json({})) }) as unknown as typeof fetch - const stateWriteLock = await acquireRefreshFileLock({ - name: 'state-write', - ttlMs: 10_000, - path: process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - renew: true, - }) - if (!stateWriteLock) throw new Error('failed to hold account state lock') - const plugin = await getPlugin() const result = await plugin.auth.loader( () => @@ -1402,34 +1648,6 @@ describe('quota header feed integration', () => { const response = await result.fetch(MESSAGES_URL, EMPTY_POST) await response.text() - const otherCheckedAt = 2_000_000 - const statePath = getAccountStatePath( - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - ) - const state = JSON.parse(await readFile(statePath, 'utf8')) as { - main?: Record - } - state.main = { - ...(state.main ?? {}), - quota: { - source: 'poll', - accountIdentity: 'account-b', - checkedAt: otherCheckedAt, - five_hour: { - usedPercent: 80, - remainingPercent: 20, - checkedAt: otherCheckedAt, - }, - }, - quotaCheckedAt: otherCheckedAt, - quotaToken: tokenFingerprint('sk-ant-oat-account-b'), - } - await writeFile(statePath, `${JSON.stringify(state)}\n`) - await stateWriteLock.release() - await waitForAccountStorage( - (loaded) => loaded?.quota?.mainQuotaCheckedAt === otherCheckedAt, - ) - const published = ( await waitForFeedEntries( (entries) => @@ -2202,6 +2420,12 @@ describe('experimental.chat.system.transform', () => { }) describe('quota header feed extended integration', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + test('disabled quota header feed publishes nothing by default or when explicitly false', async () => { for (const enabled of [undefined, false]) { const storage = createFallbackStorage({ @@ -2781,6 +3005,12 @@ test('test setup keeps sidebar state off the production default path', () => { }) describe('Fable 5.1 request-scoped effort history', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + test('carries message variants into the OAuth body and strips the internal header', async () => { await useTempAccountFile( createFallbackStorage({ @@ -3125,7 +3355,6 @@ describe('auth.loader', () => { const originalDateNow = Date.now beforeEach(async () => { - globalThis.fetch = originalFetch pluginTimerOverrides = {} Math.random = originalRandom Date.now = originalDateNow @@ -3468,6 +3697,7 @@ describe('auth.loader', () => { await useTempAccountFile( createFallbackStorage({ routing: { mode: 'fallback-first' }, + quota: { enabled: false }, accounts: [ { id: 'work-deleted', @@ -3476,49 +3706,132 @@ describe('auth.loader', () => { refresh: 'work-deleted-refresh', expires: Date.now() + 100000, }, - ], - }), - ) - const plugin = await getPlugin() - // v1.16.0's mergeAccountsForSave unions existing+incoming accounts, so a - // deletion must be declared explicitly via removedAccountIds — a plain - // save without the account no longer removes it. - await saveAccounts( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - accounts: [ { - id: 'work-current', + id: 'work-witness', type: 'oauth', - access: 'work-current-access', - refresh: 'work-current-refresh', + access: 'work-witness-stale-access', + refresh: 'work-witness-stale-refresh', expires: Date.now() + 100000, + lastRefreshedAt: 100, }, ], }), - undefined, - { removedAccountIds: ['work-deleted'] }, ) - await seedSidebarRouting('work-deleted', 'fallback-first', Date.now()) - - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'main-access', - refresh: 'main-refresh', - expires: Date.now() + 100000, + const refreshStarted = deferred() + const releaseRefresh = deferred() + globalThis.fetch = mock((input: any) => { + const url = extractUrl(input) + if (url === TOKEN_URL) { + refreshStarted.resolve() + return releaseRefresh.promise.then( + () => + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ) + } + if (url === PROFILE_URL) { + return Promise.resolve(new Response('unauthorized', { status: 401 })) + } + if (url === QUOTA_URL) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.1 }, + seven_day: { utilization: 0.1 }, + limits: [], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + return Promise.reject(new Error(`Unexpected test fetch: ${url}`)) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const backgroundRefreshReady = ( + plugin as unknown as { __fallbackRefreshReady?: Promise } + ).__fallbackRefreshReady + expect(backgroundRefreshReady).toBeInstanceOf(Promise) + try { + await withDeadlockGuard( + refreshStarted.promise, + 4_000, + 'fallback refresh never reached the token stub; the eager refresh did not start', + ) + // v1.16.0's mergeAccountsForSave unions existing+incoming accounts, so a + // deletion must be declared explicitly via removedAccountIds — a plain + // save without the account no longer removes it. + await saveAccounts( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { enabled: false }, + accounts: [ + { + id: 'work-current', + type: 'oauth', + access: 'work-current-access', + refresh: 'work-current-refresh', + expires: Date.now() + 100000, + }, + { + id: 'work-witness', + type: 'oauth', + access: 'work-witness-current-access', + refresh: 'work-witness-current-refresh', + expires: Date.now() + 100000, + lastRefreshedAt: 200, + }, + ], }), - { models: {} }, - ) - await drainSidebarWrites() + undefined, + { removedAccountIds: ['work-deleted'] }, + ) + await seedSidebarRouting('work-deleted', 'fallback-first', Date.now()) - const state = await getSidebarState() - expect(state.activeId).toBe('work-current') - expect(state.route).toBe('fallback-first') - expect(state.fallbacks.map((account) => account.id)).toEqual([ - 'work-current', - ]) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + releaseRefresh.resolve() + await withDeadlockGuard( + backgroundRefreshReady!, + 4_000, + 'fallback background refresh did not complete after the token stub was released', + ) + const disk = await loadAccounts() + expect(disk?.accounts.map((account) => account.id)).toEqual([ + 'work-current', + 'work-witness', + ]) + expect( + disk?.accounts.find((account) => account.id === 'work-current'), + ).toMatchObject({ + access: 'work-current-access', + refresh: 'work-current-refresh', + }) + expect( + disk?.accounts.find((account) => account.id === 'work-witness'), + ).toMatchObject({ + lastRefreshError: { status: 400, permanent: true }, + }) + const state = await waitForSidebarState( + (candidate) => candidate.activeId === 'work-current', + ) + expect(state.route).toBe('fallback-first') + expect(state.fallbacks.map((account) => account.id)).toEqual([ + 'work-current', + 'work-witness', + ]) + } finally { + releaseRefresh.resolve() + } }) test('boot ignores stale sidebar routing and derives fallback-first routing', async () => { @@ -5494,7 +5807,7 @@ describe('auth.loader', () => { }), }) - expect(capturedUrl).toBe('https://relay.example.test') + expect(capturedUrl).toBe('https://relay.example.test/') expect(capturedHeaders?.get('x-relay-token')).toBe('relay-token') const payload = JSON.parse(capturedBody!) expect(payload).toMatchObject({ @@ -5566,7 +5879,7 @@ describe('auth.loader', () => { }), }) - expect(capturedUrl).toBe('https://relay.example.test') + expect(capturedUrl).toBe('https://relay.example.test/') }) test('sidebar relay transport reflects current sidecar storage', async () => { @@ -6743,7 +7056,6 @@ describe('auth.loader', () => { ) await profileStarted await drainSidebarWrites() - const initialSidebarUpdatedAt = (await getSidebarState()).lastUpdated const rotated = await loadAccounts() const fallback = rotated?.accounts[0] @@ -6763,11 +7075,16 @@ describe('auth.loader', () => { }, }), ) - await waitForSidebarState( - (state) => state.lastUpdated > initialSidebarUpdatedAt, - ) - - const reloaded = await loadAccounts() + const reloaded = await waitForAccountStorage((storage) => { + const account = storage?.accounts.find( + (candidate) => candidate.id === 'fb', + ) + return ( + account?.type === 'oauth' && + account.profile?.tier === 'default_claude_max_5x' + ) + }) + await drainSidebarWrites() const reloadedFallback = reloaded?.accounts[0] expect(reloadedFallback).toMatchObject({ access: 'new-access', @@ -12409,14 +12726,18 @@ describe('auth.loader', () => { }), ) const records: LogTestRecord[] = [] + let relay503Calls = 0 + let directCalls = 0 __setLogTestSink((record) => records.push(record)) globalThis.fetch = mock((input: string | URL | Request) => { const url = extractUrl(input) - if (url === 'https://relay.example.test') { + if (url === 'https://relay.example.test/') { + relay503Calls += 1 return Promise.resolve( new Response('relay unavailable', { status: 503 }), ) } + directCalls += 1 return Promise.resolve( new Response('direct', { headers: quotaHeaders }), ) @@ -12430,6 +12751,8 @@ describe('auth.loader', () => { headers: { 'x-session-affinity': 'quota-relay-direct-fallback' }, }) expect(await response.text()).toBe('direct') + expect(relay503Calls).toBe(1) + expect(directCalls).toBe(1) await waitForState((value) => value.main?.quota?.source === 'headers') expect( records.filter( @@ -14246,7 +14569,6 @@ describe('killswitch fetch gate', () => { const originalFetch = globalThis.fetch beforeEach(() => { - globalThis.fetch = originalFetch process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' // Prevent this plugin instance's background intervals from leaking into // later tests without mutating process-global timers used by other files. @@ -14651,7 +14973,6 @@ describe('claude-prime direct request', () => { const originalSetInterval = globalThis.setInterval beforeEach(async () => { - globalThis.fetch = originalFetch globalThis.setInterval = mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval @@ -14993,6 +15314,7 @@ describe('claude-prime direct request', () => { expires: Date.now() + 5 * 60 * 60_000, } const mockClient = createMockClient() + const mainRefreshPublished = deferred() let lineageObservedBeforePublish: string | undefined let lineageObservedDuringPublish: string | undefined ;(mockClient.auth as any).set = mock( @@ -15013,6 +15335,7 @@ describe('claude-prime direct request', () => { 'main', process.env.OPENCODE_ANTHROPIC_AUTH_FILE, ) + mainRefreshPublished.resolve() }, ) let sends = 0 @@ -15069,7 +15392,16 @@ describe('claude-prime direct request', () => { const mainRefreshHandler = intervalHandlers.at(-1) expect(mainRefreshHandler).toBeDefined() mainRefreshHandler!() - await waitForMockCall(mockClient.auth.set) + await withDeadlockGuard( + mainRefreshPublished.promise, + 4_000, + 'main background refresh did not publish rotated auth', + ) + await waitForAccountStorage( + (storage) => + hostAuth.access === 'main-access-b' && + storage?.refresh?.mainRefreshLeaseId === undefined, + ) await manager.tick() const after = JSON.parse( @@ -15762,7 +16094,6 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { const originalSetInterval = globalThis.setInterval beforeEach(async () => { - globalThis.fetch = originalFetch globalThis.setInterval = mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval @@ -16135,7 +16466,6 @@ describe('claude-prime — warn dedup (R3)', () => { const originalSetInterval = globalThis.setInterval beforeEach(async () => { - globalThis.fetch = originalFetch globalThis.setInterval = mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval diff --git a/packages/opencode/src/tests/info-logs.test.ts b/packages/opencode/src/tests/info-logs.test.ts index a11b67db..15f7526f 100644 --- a/packages/opencode/src/tests/info-logs.test.ts +++ b/packages/opencode/src/tests/info-logs.test.ts @@ -9,6 +9,11 @@ import { saveAccounts, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin } from '../index' +import { DEFAULT_FETCH_MOCK, installDefaultFetchMock } from './test-fetch' +import { + createTimerTracking, + type PluginTimerOverrides, +} from './timer-tracking' function createFallbackStorage(): AccountStorage { return { @@ -31,6 +36,14 @@ function createFallbackStorage(): AccountStorage { } let tempConfigDir: string +const originalFetch = globalThis.fetch +const timerTracking = createTimerTracking() +const { + activeIntervals, + disabledPluginTimerOverrides, + trackedClearInterval, + trackedSetInterval, +} = timerTracking async function useTempAccountFile(storage: AccountStorage) { if (tempConfigDir) { @@ -53,16 +66,27 @@ function createMockClient() { } } -async function getPlugin() { - return (await AnthropicAuthPlugin({ - // @ts-expect-error: minimal mock for testing - client: createMockClient(), - })) as Promise +async function getPlugin(timerOverrides?: PluginTimerOverrides) { + const defaultTimerOverrides = disabledPluginTimerOverrides() + return (await ( + AnthropicAuthPlugin as unknown as ( + ctx: Parameters[0], + timers?: PluginTimerOverrides, + ) => ReturnType + )( + { + // @ts-expect-error: minimal mock for testing + client: createMockClient(), + }, + { ...defaultTimerOverrides, ...timerOverrides }, + )) as Promise } let capturedRecords: LogTestRecord[] = [] beforeEach(() => { + timerTracking.reset() + installDefaultFetchMock() capturedRecords = [] __setLogTestSink((record) => { capturedRecords.push(record) @@ -70,13 +94,42 @@ beforeEach(() => { }) afterEach(async () => { - __setLogTestSink(null) - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - if (tempConfigDir) { - await rm(tempConfigDir, { recursive: true, force: true }).catch(() => {}) + try { + // Restore only the fixture's tagged mock; an untagged custom mock left + // installed must reach the preload's leak detector, not be masked here. + const currentFetch = globalThis.fetch as + | (typeof fetch & { [DEFAULT_FETCH_MOCK]?: true }) + | undefined + if (currentFetch?.[DEFAULT_FETCH_MOCK]) { + globalThis.fetch = originalFetch + } + __setLogTestSink(null) + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + if (tempConfigDir) { + await rm(tempConfigDir, { recursive: true, force: true }).catch(() => {}) + } + } finally { + expect(activeIntervals.size).toBe(0) } }) +test('does not retain a background interval unless the helper opts in', async () => { + await useTempAccountFile(createFallbackStorage()) + timerTracking.reset() + await getPlugin() + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) + + await timerTracking.withTrackedInterval(async () => { + await getPlugin({ + setInterval: trackedSetInterval, + clearInterval: trackedClearInterval, + }) + expect(activeIntervals.size).toBe(1) + }) + expect(activeIntervals.size).toBe(0) +}) + async function executeCommand( plugin: any, command: string, diff --git a/packages/opencode/src/tests/network-guard-utils.ts b/packages/opencode/src/tests/network-guard-utils.ts new file mode 100644 index 00000000..3a28f60f --- /dev/null +++ b/packages/opencode/src/tests/network-guard-utils.ts @@ -0,0 +1,75 @@ +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']) + +function isIpv4Loopback(hostname: string) { + const octets = hostname.split('.') + return ( + octets.length === 4 && + octets[0] === '127' && + octets + .slice(1) + .every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ) +} + +function mappedIpv4Hostname(hostname: string) { + const match = hostname.match(/^\[::ffff:([^\]]+)\]$/i) + if (!match) return undefined + + const tail = match[1] + if (!tail) return undefined + + // URL parsing canonicalizes mapped addresses to the 2-hextet form + // ([::ffff:127.0.0.1] -> [::ffff:7f00:1]), so only hextets arrive here. + const hextets = tail.split(':') + if ( + hextets.length !== 2 || + hextets.some((hextet) => !/^[\da-f]{1,4}$/i.test(hextet)) + ) { + return undefined + } + const [highHextet, lowHextet] = hextets + if (!highHextet || !lowHextet) return undefined + const high = Number.parseInt(highHextet, 16) + const low = Number.parseInt(lowHextet, 16) + return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.') +} + +export function fetchUrl(input: Parameters[0]) { + try { + if (typeof input === 'string') return new URL(input) + if (input instanceof URL) return input + return new URL(input.url) + } catch { + return undefined + } +} + +export function assertLoopback(url: URL) { + const hostname = url.hostname.endsWith('.') + ? url.hostname.slice(0, -1) + : url.hostname + const isLoopback = + LOOPBACK_HOSTS.has(hostname) || + isIpv4Loopback(hostname) || + isIpv4Loopback(mappedIpv4Hostname(hostname) ?? '') + if (!isLoopback) { + throw new Error( + `Blocked non-loopback fetch to ${url}; stub globalThis.fetch in the test`, + ) + } +} + +export function assertPreconnectUrl( + input: Parameters[0], +) { + const url = fetchUrl( + input as unknown as Parameters[0], + ) + if (!url) { + throw new Error( + 'Blocked fetch with an unparseable URL; stub globalThis.fetch in the test', + ) + } + assertLoopback(url) + return url +} diff --git a/packages/opencode/src/tests/network-guard.test.ts b/packages/opencode/src/tests/network-guard.test.ts new file mode 100644 index 00000000..990bd2c7 --- /dev/null +++ b/packages/opencode/src/tests/network-guard.test.ts @@ -0,0 +1,596 @@ +import { describe, expect, mock, test } from 'bun:test' +import { + QUOTA_URL as CORE_QUOTA_URL, + TOKEN_URL as CORE_TOKEN_URL, +} from '@cortexkit/anthropic-auth-core' +import { assertLoopback } from './network-guard-utils' +import { createGuardedFetch, MAX_REDIRECTS } from './setup' +import { + DEFAULT_FETCH_MOCK, + installDefaultFetchMock, + MESSAGES_URL, +} from './test-fetch' + +describe('test network guard', () => { + test('rejects non-loopback fetches with an actionable error', async () => { + await expect( + globalThis.fetch('https://example.invalid/provider'), + ).rejects.toThrow( + 'Blocked non-loopback fetch to https://example.invalid/provider; stub globalThis.fetch in the test', + ) + }) + + test('rejects fetches whose URL cannot be parsed', async () => { + await expect(globalThis.fetch({} as Request)).rejects.toThrow( + 'Blocked fetch with an unparseable URL; stub globalThis.fetch in the test', + ) + }) + + test('allows fetches to loopback servers', async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response('ok'), + }) + + try { + const response = await globalThis.fetch( + `http://127.0.0.1:${server.port}/health`, + ) + expect(response.status).toBe(200) + expect(await response.text()).toBe('ok') + } finally { + server.stop(true) + } + }) + + test('allows all IPv4 addresses in the loopback block', async () => { + for (const host of ['127.0.0.2', '127.1.2.3']) { + expect(() => + assertLoopback(new URL(`http://${host}/health`)), + ).not.toThrow() + } + }) + + test('rejects near-miss IPv4 addresses and loopback-looking hostnames', async () => { + for (const host of ['128.0.0.1', '27.0.0.1', '127.0.0.1.evil.com']) { + await expect( + globalThis.fetch(`http://${host}:8443/provider`), + ).rejects.toThrow( + `Blocked non-loopback fetch to http://${host}:8443/provider; stub globalThis.fetch in the test`, + ) + } + }) + + test('rejects a loopback redirect to a non-loopback URL', async () => { + const server = Bun.serve({ + port: 0, + fetch: () => Response.redirect('https://example.invalid/pwned', 302), + }) + + try { + await expect( + globalThis.fetch(`http://127.0.0.1:${server.port}/redirect`), + ).rejects.toThrow( + 'Blocked non-loopback fetch to https://example.invalid/pwned; stub globalThis.fetch in the test', + ) + } finally { + server.stop(true) + } + }) + + test('follows redirects that stay on loopback', async () => { + let requests = 0 + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + requests += 1 + const path = new URL(request.url).pathname + if (path === '/redirect') { + return Response.redirect( + new URL('/middle', request.url).toString(), + 302, + ) + } + if (path === '/middle') { + return Response.redirect( + new URL('/final', request.url).toString(), + 302, + ) + } + return new Response( + new ReadableStream({ + start(controller) { + setTimeout(() => { + controller.enqueue( + new TextEncoder().encode('final body intact'), + ) + controller.close() + }, 25) + }, + }), + ) + }, + }) + + try { + const response = await globalThis.fetch( + `http://127.0.0.1:${server.port}/redirect`, + ) + expect(response.status).toBe(200) + expect(await response.text()).toBe('final body intact') + expect(requests).toBe(3) + } finally { + server.stop(true) + } + }) + + test('propagates abort signals through a non-switching redirect', async () => { + let requests = 0 + let secondHopStarted!: () => void + const secondHopReady = new Promise((resolve) => { + secondHopStarted = resolve + }) + let responseTimer: ReturnType | undefined + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + requests += 1 + if (requests === 1) { + return Response.redirect( + new URL('/second', request.url).toString(), + 307, + ) + } + return new Response( + new ReadableStream({ + start(controller) { + secondHopStarted() + responseTimer = setTimeout(() => { + controller.enqueue(new TextEncoder().encode('done')) + controller.close() + }, 500) + }, + cancel() { + if (responseTimer) clearTimeout(responseTimer) + }, + }), + ) + }, + }) + + try { + const controller = new AbortController() + const pending = globalThis.fetch( + `http://127.0.0.1:${server.port}/first`, + { method: 'POST', body: 'payload', signal: controller.signal }, + ) + await secondHopReady + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(requests).toBe(2) + } finally { + if (responseTimer) clearTimeout(responseTimer) + server.stop(true) + } + }) + + test('rejects after exceeding the loopback redirect cap', async () => { + const redirectHops = 21 + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + const hop = Number(new URL(request.url).pathname.slice('/hop/'.length)) + if (hop >= redirectHops) return new Response('ok') + return Response.redirect( + new URL(`/hop/${hop + 1}`, request.url).toString(), + 302, + ) + }, + }) + + try { + await expect( + globalThis.fetch(`http://127.0.0.1:${server.port}/hop/0`), + ).rejects.toThrow('Too many redirects while fetching') + } finally { + server.stop(true) + } + }) + + test('allows a redirect chain at the loopback redirect cap boundary', async () => { + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + const hop = Number(new URL(request.url).pathname.slice('/hop/'.length)) + if (hop < MAX_REDIRECTS) { + return Response.redirect( + new URL(`/hop/${hop + 1}`, request.url).toString(), + 302, + ) + } + return new Response('ok') + }, + }) + + try { + const response = await globalThis.fetch( + `http://127.0.0.1:${server.port}/hop/0`, + ) + expect(response.status).toBe(200) + expect(await response.text()).toBe('ok') + } finally { + server.stop(true) + } + }) + + test('rewrites 301 and 302 non-GET requests but preserves 307 and 308 requests', async () => { + for (const status of [301, 302, 307, 308]) { + for (const method of ['PUT', 'POST']) { + const seen: Array<{ method: string; body: string }> = [] + const nativeFetch = Object.assign( + async (request: Request) => { + seen.push({ + method: request.method, + body: await request.clone().text(), + }) + if (seen.length === 1) { + return new Response(null, { + status, + headers: { location: 'http://127.0.0.1/final' }, + }) + } + return new Response('ok') + }, + { preconnect: () => {} }, + ) as unknown as typeof globalThis.fetch + + const response = await createGuardedFetch(nativeFetch)( + 'http://127.0.0.1/first', + { method, body: 'payload' }, + ) + + expect(await response.text()).toBe('ok') + const rewrites = status === 301 || status === 302 + expect(seen).toEqual([ + { method, body: 'payload' }, + { + method: rewrites ? 'GET' : method, + body: rewrites ? '' : 'payload', + }, + ]) + } + } + }) + + test('cancels intermediate redirect bodies but preserves the final body', async () => { + let requests = 0 + let intermediateCancels = 0 + let finalCancels = 0 + const nativeFetch = Object.assign( + async () => { + requests += 1 + if (requests === 1) { + return new Response( + new ReadableStream({ + cancel() { + intermediateCancels += 1 + }, + }), + { + status: 302, + headers: { location: 'http://127.0.0.1/second' }, + }, + ) + } + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('terminal body')) + controller.close() + }, + cancel() { + finalCancels += 1 + }, + }), + ) + }, + { preconnect: () => {} }, + ) as unknown as typeof globalThis.fetch + + const guardedFetch = createGuardedFetch(nativeFetch) + const response = await guardedFetch('http://127.0.0.1/first') + + expect(await response.text()).toBe('terminal body') + expect(requests).toBe(2) + expect(intermediateCancels).toBe(1) + expect(finalCancels).toBe(0) + }) + + test('rejects a non-loopback target after multiple loopback hops', async () => { + let requests = 0 + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + requests += 1 + const path = new URL(request.url).pathname + if (path === '/first') { + return Response.redirect( + new URL('/second', request.url).toString(), + 302, + ) + } + return Response.redirect('https://example.invalid/pwned', 302) + }, + }) + + try { + await expect( + globalThis.fetch(`http://127.0.0.1:${server.port}/first`), + ).rejects.toThrow( + 'Blocked non-loopback fetch to https://example.invalid/pwned; stub globalThis.fetch in the test', + ) + expect(requests).toBe(2) + } finally { + server.stop(true) + } + }) + + // URL.hostname keeps the brackets on an IPv6 literal, so the allow-list entry + // has to carry them too or every [::1] request reads as non-loopback. + test('allows the IPv6 loopback literal', () => { + expect(() => assertLoopback(new URL('http://[::1]/health'))).not.toThrow() + }) + + test('allows IPv4-mapped IPv6 loopback and blocks non-loopback variants', () => { + for (const host of [ + '[::ffff:127.0.0.1]', + '[::ffff:7f00:1]', + '127.0.0.2', + '127.1.2.3', + ]) { + expect(() => + assertLoopback(new URL(`http://${host}/health`)), + ).not.toThrow() + } + for (const host of ['[::ffff:8.8.8.8]', '127.0.0.1.evil.com']) { + expect(() => assertLoopback(new URL(`http://${host}/health`))).toThrow( + 'Blocked non-loopback fetch to', + ) + } + }) + + test('allows one trailing dot on localhost but not ambiguous names', () => { + expect(() => + assertLoopback(new URL('http://localhost./health')), + ).not.toThrow() + expect(() => assertLoopback(new URL('http://localhost../health'))).toThrow( + 'Blocked non-loopback fetch to', + ) + expect(() => + assertLoopback(new URL('http://evil.localhost./health')), + ).toThrow('Blocked non-loopback fetch to') + }) + + test('strips credentials across loopback origins but preserves same-origin redirects', async () => { + const crossOriginHeaders: Record[] = [] + const sameOriginHeaders: Record[] = [] + const serverB = Bun.serve({ + port: 0, + fetch: (request) => { + const headers = new Headers(request.headers) + crossOriginHeaders.push({ + authorization: headers.get('authorization'), + 'proxy-authorization': headers.get('proxy-authorization'), + cookie: headers.get('cookie'), + 'x-api-key': headers.get('x-api-key'), + }) + return new Response('cross-origin') + }, + }) + const serverA = Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url) + if (url.pathname === '/same-target') { + const headers = new Headers(request.headers) + sameOriginHeaders.push({ + authorization: headers.get('authorization'), + 'proxy-authorization': headers.get('proxy-authorization'), + cookie: headers.get('cookie'), + 'x-api-key': headers.get('x-api-key'), + }) + return new Response('same-origin') + } + if (url.pathname === '/same-origin') { + return Response.redirect( + new URL('/same-target', request.url).toString(), + 307, + ) + } + return Response.redirect( + new URL( + `${url.pathname}-target`, + `http://127.0.0.1:${serverB.port}`, + ).toString(), + url.pathname === '/cross-switch' ? 302 : 307, + ) + }, + }) + + try { + const credentials = { + Authorization: 'Bearer secret', + 'Proxy-Authorization': 'Basic secret', + Cookie: 'session=secret', + 'x-api-key': 'secret', + } + for (const path of ['/cross-switch', '/cross-preserve']) { + const response = await globalThis.fetch( + `http://127.0.0.1:${serverA.port}${path}`, + { method: 'POST', headers: credentials, body: 'payload' }, + ) + expect(await response.text()).toBe('cross-origin') + } + const sameOriginFetch = await globalThis.fetch( + `http://127.0.0.1:${serverA.port}/same-origin`, + { headers: credentials }, + ) + expect(await sameOriginFetch.text()).toBe('same-origin') + } finally { + serverA.stop(true) + serverB.stop(true) + } + + expect(crossOriginHeaders).toHaveLength(2) + expect(crossOriginHeaders).toEqual([ + { + authorization: null, + 'proxy-authorization': null, + cookie: null, + 'x-api-key': null, + }, + { + authorization: null, + 'proxy-authorization': null, + cookie: null, + 'x-api-key': null, + }, + ]) + expect(sameOriginHeaders).toEqual([ + { + authorization: 'Bearer secret', + 'proxy-authorization': 'Basic secret', + cookie: 'session=secret', + 'x-api-key': 'secret', + }, + ]) + }) + + test('does not interfere with a test stub assigned to globalThis.fetch', async () => { + const realFetch = globalThis.fetch + const stub = mock(() => Promise.resolve(new Response('stubbed'))) + globalThis.fetch = stub as unknown as typeof globalThis.fetch + + try { + const response = await globalThis.fetch( + 'https://api.anthropic.com/v1/messages', + ) + expect(response.status).toBe(200) + expect(await response.text()).toBe('stubbed') + expect(stub).toHaveBeenCalledTimes(1) + } finally { + globalThis.fetch = realFetch + } + }) + + test('shared provider fixture handles OAuth token exchange locally', async () => { + const realFetch = globalThis.fetch + installDefaultFetchMock() + + try { + const response = await globalThis.fetch(CORE_TOKEN_URL) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'invalid_grant' }) + const quotaResponse = await globalThis.fetch(CORE_QUOTA_URL) + expect(quotaResponse.status).toBe(401) + } finally { + globalThis.fetch = realFetch + } + }) + + test('shared provider fixture rejects URLs that only share the messages prefix', async () => { + const realFetch = globalThis.fetch + installDefaultFetchMock() + + try { + await expect(globalThis.fetch(`${MESSAGES_URL}/count`)).rejects.toThrow( + `Unexpected test fetch: ${MESSAGES_URL}/count`, + ) + } finally { + globalThis.fetch = realFetch + } + }) + + test('shared provider fixture recognises messages query parameters', async () => { + const realFetch = globalThis.fetch + installDefaultFetchMock() + + try { + const response = await globalThis.fetch(`${MESSAGES_URL}?beta=true`) + expect(response.status).toBe(401) + } finally { + globalThis.fetch = realFetch + } + }) + + test('default provider fixture guards preconnect in both directions', () => { + const realFetch = globalThis.fetch + installDefaultFetchMock() + + try { + expect(() => + globalThis.fetch.preconnect('http://127.0.0.1:8080/provider'), + ).not.toThrow() + expect(() => + globalThis.fetch.preconnect('https://example.invalid:8443/provider'), + ).toThrow( + 'Blocked non-loopback fetch to https://example.invalid:8443/provider; stub globalThis.fetch in the test', + ) + } finally { + globalThis.fetch = realFetch + } + }) + + test('preconnect rejects non-loopback hosts', () => { + expect(() => + globalThis.fetch.preconnect('https://example.invalid:8443/provider'), + ).toThrow( + 'Blocked non-loopback fetch to https://example.invalid:8443/provider; stub globalThis.fetch in the test', + ) + }) + + test('preconnect allows loopback hosts', async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response('ok'), + }) + + try { + expect(() => + globalThis.fetch.preconnect(`http://127.0.0.1:${server.port}`), + ).not.toThrow() + const response = await globalThis.fetch( + `http://127.0.0.1:${server.port}/health`, + ) + expect(response.status).toBe(200) + } finally { + server.stop(true) + } + }) + + test('preconnect no-ops on an impl without preconnect after the loopback check', () => { + const bare = (async () => + new Response('ok')) as unknown as typeof globalThis.fetch + const guarded = createGuardedFetch(bare) + expect(() => guarded.preconnect('http://127.0.0.1:9')).not.toThrow() + expect(() => + guarded.preconnect('https://example.invalid/provider'), + ).toThrow( + 'Blocked non-loopback fetch to https://example.invalid/provider; stub globalThis.fetch in the test', + ) + }) + + test('restores the guard after a test leaves the default mock installed', () => { + installDefaultFetchMock() + expect( + (globalThis.fetch as { [DEFAULT_FETCH_MOCK]?: true })[DEFAULT_FETCH_MOCK], + ).toBe(true) + }) + + // Deliberately runs before "restores the network guard after a default fetch mock is left behind". + test('restores the network guard after a default fetch mock is left behind', async () => { + await expect(globalThis.fetch(MESSAGES_URL)).rejects.toThrow( + `Blocked non-loopback fetch to ${MESSAGES_URL}; stub globalThis.fetch in the test`, + ) + }) +}) diff --git a/packages/opencode/src/tests/setup.ts b/packages/opencode/src/tests/setup.ts index bb679b2f..278c91f2 100644 --- a/packages/opencode/src/tests/setup.ts +++ b/packages/opencode/src/tests/setup.ts @@ -1,9 +1,171 @@ +import { afterAll, afterEach } from 'bun:test' import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + assertLoopback, + assertPreconnectUrl, + fetchUrl, +} from './network-guard-utils' +import { DEFAULT_FETCH_MOCK } from './test-fetch' + +const nativeFetch = globalThis.fetch +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) +const CROSS_ORIGIN_CREDENTIAL_HEADERS = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'x-api-key', +] +export const MAX_REDIRECTS = 20 +type GuardRequest = InstanceType +type GuardRequestInit = NonNullable< + ConstructorParameters[1] +> + +function buildRedirectRequest( + request: GuardRequest, + url: URL, + status: number, +): GuardRequest { + const switchesToGet = + (status === 301 || status === 302) && + !['GET', 'HEAD'].includes(request.method) + ? true + : status === 303 && !['GET', 'HEAD'].includes(request.method) + const headers = new Headers(request.headers) + if (new URL(request.url).origin !== url.origin) { + for (const name of CROSS_ORIGIN_CREDENTIAL_HEADERS) headers.delete(name) + } + if (!switchesToGet) { + return new globalThis.Request(url.toString(), { + method: request.method, + headers, + body: request.body, + redirect: 'manual', + credentials: request.credentials, + cache: request.cache, + integrity: request.integrity, + keepalive: request.keepalive, + mode: request.mode, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + signal: request.signal, + }) + } + + for (const name of [ + 'content-encoding', + 'content-language', + 'content-location', + 'content-type', + 'content-length', + ]) { + headers.delete(name) + } + return new globalThis.Request(url.toString(), { + method: 'GET', + headers, + redirect: 'manual', + credentials: request.credentials, + cache: request.cache, + integrity: request.integrity, + keepalive: request.keepalive, + mode: request.mode, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + signal: request.signal, + }) +} + +export function createGuardedFetch( + nativeFetchImpl: typeof nativeFetch = nativeFetch, +) { + return Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + if (!fetchUrl(input)) { + throw new Error( + 'Blocked fetch with an unparseable URL; stub globalThis.fetch in the test', + ) + } + + let request = new globalThis.Request( + input as unknown as ConstructorParameters[0], + { ...init, redirect: 'manual' } as GuardRequestInit, + ) + for (let redirects = 0; ; redirects++) { + assertLoopback(new URL(request.url)) + const replayableRequest = request.clone() + const response = await Reflect.apply(nativeFetchImpl, globalThis, [ + request, + ]) + if (!REDIRECT_STATUSES.has(response.status)) return response + const location = response.headers.get('location') + if (!location) return response + await response.body?.cancel() + if (redirects >= MAX_REDIRECTS) { + throw new Error(`Too many redirects while fetching ${request.url}`) + } + + let nextURL: URL + try { + nextURL = new URL(location, request.url) + } catch { + throw new Error( + 'Blocked redirect with an unparseable URL; stub globalThis.fetch in the test', + ) + } + request = buildRedirectRequest( + replayableRequest as unknown as GuardRequest, + nextURL, + response.status, + ) + } + }, + { + preconnect: (input: Parameters[0]) => { + assertPreconnectUrl(input) + // Preconnect is an advisory hint; an impl without it has nothing to do. + // The loopback assertion above already enforced the safety property. + if (typeof nativeFetchImpl.preconnect !== 'function') return + return nativeFetchImpl.preconnect(input) + }, + }, + ) +} + +const guardedFetch = createGuardedFetch() + +afterEach(() => { + // A destroyed global is itself a leak; read the tag only off a present value + // so the report stays the named leak error rather than a raw TypeError. + const currentFetch = globalThis.fetch as + | (typeof fetch & { [DEFAULT_FETCH_MOCK]?: true }) + | undefined + const isDefaultFetchMock = Boolean(currentFetch?.[DEFAULT_FETCH_MOCK]) + if (currentFetch !== guardedFetch && !isDefaultFetchMock) { + globalThis.fetch = guardedFetch + throw new Error( + 'fetch mock leaked past its owning test; Bun reports the offending test name above', + ) + } + if (isDefaultFetchMock) globalThis.fetch = guardedFetch +}) + +// Keep a data property so Bun's spyOn can replace fetch and tests can restore +// the captured guarded implementation afterward. +globalThis.fetch = guardedFetch const testDir = mkdtempSync(join(tmpdir(), 'anthropic-auth-opencode-test-')) +afterAll(async () => { + await rm(testDir, { recursive: true, force: true }).catch(() => {}) +}) + process.env.OPENCODE_ANTHROPIC_AUTH_TEST_DIR = testDir process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join(testDir, 'anthropic-auth.json') process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( diff --git a/packages/opencode/src/tests/test-fetch.ts b/packages/opencode/src/tests/test-fetch.ts new file mode 100644 index 00000000..7b4c9f8a --- /dev/null +++ b/packages/opencode/src/tests/test-fetch.ts @@ -0,0 +1,68 @@ +import { mock } from 'bun:test' +import { QUOTA_URL, TOKEN_URL } from '@cortexkit/anthropic-auth-core' +import { assertPreconnectUrl, fetchUrl } from './network-guard-utils' + +export const MESSAGES_URL = 'https://api.anthropic.com/v1/messages' +export const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile' +export const DEFAULT_FETCH_MOCK = Symbol('anthropic-auth.default-fetch-mock') +export { QUOTA_URL, TOKEN_URL } + +const messagesURL = new URL(MESSAGES_URL) + +export type TestFetchInput = Parameters[0] + +export function extractUrl(input: TestFetchInput): string { + const parsed = fetchUrl(input) + if (parsed) return parsed.href + if (typeof input === 'string') return input + return input instanceof URL ? input.toString() : input.url +} + +function isMessagesURL(url: string) { + try { + const parsed = new URL(url) + return ( + parsed.origin === messagesURL.origin && + parsed.pathname === messagesURL.pathname + ) + } catch { + return false + } +} + +export function installDefaultFetchMock() { + const fetchMock = Object.assign( + mock((input: TestFetchInput) => { + const url = extractUrl(input) + if (url === TOKEN_URL) { + return Promise.resolve( + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ) + } + if (url === PROFILE_URL) { + return Promise.resolve(new Response('unauthorized', { status: 401 })) + } + if (url === QUOTA_URL) { + return Promise.resolve(new Response('unauthorized', { status: 401 })) + } + if (isMessagesURL(url)) { + return Promise.resolve(new Response('unauthorized', { status: 401 })) + } + return Promise.reject(new Error(`Unexpected test fetch: ${url}`)) + }), + { + preconnect: ( + input: Parameters[0], + ) => { + assertPreconnectUrl(input) + }, + }, + ) as unknown as typeof fetch & { + [DEFAULT_FETCH_MOCK]?: true + } + Object.defineProperty(fetchMock, DEFAULT_FETCH_MOCK, { value: true }) + globalThis.fetch = fetchMock +} diff --git a/packages/opencode/src/tests/timer-tracking.test.ts b/packages/opencode/src/tests/timer-tracking.test.ts new file mode 100644 index 00000000..26a6f76c --- /dev/null +++ b/packages/opencode/src/tests/timer-tracking.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'bun:test' +import { createTimerTracking } from './timer-tracking' + +test('reset disarms leaked tracked intervals before dropping their handles', async () => { + const tracking = createTimerTracking() + let ticks = 0 + const timer = tracking.trackedSetInterval(() => { + ticks += 1 + }, 10) + + try { + expect(tracking.activeIntervals.size).toBe(1) + + tracking.reset() + expect(tracking.activeIntervals.size).toBe(0) + const ticksAfterReset = ticks + await Bun.sleep(35) + + expect(ticks).toBe(ticksAfterReset) + } finally { + tracking.originalClearInterval(timer) + } +}) diff --git a/packages/opencode/src/tests/timer-tracking.ts b/packages/opencode/src/tests/timer-tracking.ts new file mode 100644 index 00000000..1fa0953a --- /dev/null +++ b/packages/opencode/src/tests/timer-tracking.ts @@ -0,0 +1,66 @@ +import { mock } from 'bun:test' + +export type PluginTimerOverrides = Partial<{ + setInterval: typeof globalThis.setInterval + clearInterval: typeof globalThis.clearInterval +}> + +export function createTimerTracking() { + const originalSetInterval = globalThis.setInterval + const originalClearInterval = globalThis.clearInterval + const activeIntervals = new Set>() + let disabledIntervalCalls = 0 + + function disabledPluginTimerOverrides(): PluginTimerOverrides { + return { + // Background intervals must not outlive the test-scoped fetch mock they captured. + setInterval: mock(() => { + disabledIntervalCalls += 1 + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof setInterval, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + } + } + + const trackedSetInterval = mock((...args: Parameters) => { + const timer = originalSetInterval(...args) + activeIntervals.add(timer) + return timer + }) as unknown as typeof setInterval + const trackedClearInterval = mock( + (timer: Parameters[0]) => { + // Forward only handles this helper created; the disabled mock's fake + // handle object must not reach the real clearInterval. + if (activeIntervals.delete(timer as ReturnType)) { + originalClearInterval(timer) + } + }, + ) as unknown as typeof clearInterval + + return { + originalSetInterval, + originalClearInterval, + activeIntervals, + disabledPluginTimerOverrides, + trackedSetInterval, + trackedClearInterval, + get disabledIntervalCalls() { + return disabledIntervalCalls + }, + reset() { + disabledIntervalCalls = 0 + for (const timer of activeIntervals) originalClearInterval(timer) + activeIntervals.clear() + }, + async withTrackedInterval(callback: () => T | Promise): Promise { + const existingIntervals = new Set(activeIntervals) + try { + return await callback() + } finally { + for (const timer of activeIntervals) { + if (!existingIntervals.has(timer)) trackedClearInterval(timer) + } + } + }, + } +} From 51e2923fb8f865b186895e73ce581aee6db3cf29 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:55:00 +0200 Subject: [PATCH 2/5] feat(claustrum): optional vault custody for fallback OAuth credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fallback OAuth account can keep its credential in Claustrum, a local credential vault reached over an IPC daemon, instead of in the sidecar. Per-account opt-in, off by default: with no vault present, or no account opted in, nothing here runs. Main-account custody stays out of scope — a vault-managed main would send a placeholder to Anthropic's token endpoint, and our classifier marks that failure permanent with no way back. The vault's slow path is kept off the response path structurally. credential.get is bimodal — microseconds when resident, seconds when it lands in the refresh window with callers queued behind one upstream exchange — so serving only ever peeks synchronously, a bounded startup warm primes the cache, and refreshes run detached. A cold peek falls through to the sidecar rather than waiting. The vault owns refresh for an account it serves: that account's sidecar refresh token is a lineage the vault may already have rotated, and spending it returns invalid_grant, which is permanent. But a vault outage must never be worse than having no vault, so an unavailable vault still permits ordinary local recovery, and a resident credential clears whatever error that recovery persisted. Identity is explicit in both directions. consumerIdentity is forced to null because the client otherwise reads SUBC_MODULE_ID and SUBC_LAUNCH_NONCE from the environment and would authenticate as whichever supervised module spawned the host. A BindIdentity is supplied because the client requires one for every managed call; its session is derived from the account-store fingerprint, so it is stable across restarts without new persisted state. A 401 is reported only for a credential the vault actually served, carried on the response rather than looked up again at report time. Invalidation is version-gated and the version in hand is usually still current, so a report raised from a locally served token would pass that gate and invalidate a healthy credential — version-correct and provenance-wrong. Failures classify so the vault's shared rate limiter stays protected: a transport failure or a terminal route failure backs off per handle instead of issuing one credential.get per request, and an unrecognised error class retries boundedly and loudly rather than blocking the account, which is the most operator-visible action available and needs positive evidence. Credential handles never reach dump artifacts, sidebar state, or RPC responses; a compile-time contract keeps them out of dump inputs. --- bun.lock | 9 +- packages/core/package.json | 1 + packages/core/src/accounts.ts | 207 +- packages/core/src/claustrum.ts | 708 +++ packages/core/src/commands/account.ts | 12 +- packages/core/src/index.ts | 1 + packages/opencode/src/index.ts | 695 ++- packages/opencode/src/rpc/protocol.ts | 17 + packages/opencode/src/sidebar-state.ts | 5 + .../src/tests/account-command.test.ts | 4 + packages/opencode/src/tests/accounts.test.ts | 426 ++ .../src/tests/claustrum-client.test.ts | 924 ++++ packages/opencode/src/tests/claustrum.test.ts | 238 + .../src/tests/command-dialogs.test.ts | 19 +- .../tests/credential-handle-blindness.test.ts | 623 +++ packages/opencode/src/tests/index.test.ts | 4064 +++++++++++++++-- packages/opencode/src/transform.ts | 42 + packages/opencode/src/tui.tsx | 20 +- packages/opencode/src/tui/command-dialogs.tsx | 81 +- packages/pi/src/commands.ts | 7 + 20 files changed, 7724 insertions(+), 379 deletions(-) create mode 100644 packages/core/src/claustrum.ts create mode 100644 packages/opencode/src/tests/claustrum-client.test.ts create mode 100644 packages/opencode/src/tests/claustrum.test.ts create mode 100644 packages/opencode/src/tests/credential-handle-blindness.test.ts diff --git a/bun.lock b/bun.lock index e3bcf6a6..7b6cad7f 100644 --- a/bun.lock +++ b/bun.lock @@ -20,8 +20,9 @@ }, "packages/core": { "name": "@cortexkit/anthropic-auth-core", - "version": "1.20.0", + "version": "1.21.0", "dependencies": { + "@cortexkit/subc-client": "^0.8.1", "xxhash-wasm": "^1.1.0", }, }, @@ -33,7 +34,7 @@ }, "packages/opencode": { "name": "@cortexkit/opencode-anthropic-auth", - "version": "1.20.0", + "version": "1.21.0", "bin": { "opencode-anthropic-auth": "dist/cli.js", }, @@ -50,7 +51,7 @@ }, "packages/pi": { "name": "@cortexkit/pi-anthropic-auth", - "version": "1.20.0", + "version": "1.21.0", "dependencies": { "@cortexkit/anthropic-auth-core": "1.20.0", }, @@ -212,6 +213,8 @@ "@cortexkit/pi-anthropic-auth": ["@cortexkit/pi-anthropic-auth@workspace:packages/pi"], + "@cortexkit/subc-client": ["@cortexkit/subc-client@0.8.1", "", {}, "sha512-8U9w3AnSff0QYlLVzcKOuTULakRtVpcGJrquGHxOQQlWZGzXZdCs8nroLMeoM2xhFGwxIh8xFk832w1KG6akAA=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.84.2", "", { "dependencies": { "@earendil-works/pi-ai": "^0.84.2", "@earendil-works/pi-telemetry": "^0.84.2", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", "yaml": "2.9.0" } }, "sha512-8Pn3wSCxj0cfo5I6jxQYVB/3uuQRmHhAlEclyjqpOuMEdQMIODHizRogv56FLdbU+dTiGnybeHQ2N+sV1/L2YA=="], diff --git a/packages/core/package.json b/packages/core/package.json index 222c9919..5b791806 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,6 +27,7 @@ "prepublishOnly": "bun run build" }, "dependencies": { + "@cortexkit/subc-client": "^0.8.1", "xxhash-wasm": "^1.1.0" } } diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index d65b131c..102dda60 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -46,6 +46,7 @@ export type AccountBase = { export type OAuthAccount = AccountBase & { type: 'oauth' authLineageId?: string + claustrumHandle?: string access?: string refresh: string expires?: number @@ -70,6 +71,14 @@ export type ApiKeyAccount = AccountBase & { export type FallbackAccount = OAuthAccount | ApiKeyAccount +export type ClaustrumAccountGate = { + enabled?: boolean +} + +export type ClaustrumConfig = { + accounts?: Record +} + export function isOAuthAccount( account: FallbackAccount, ): account is OAuthAccount { @@ -307,6 +316,7 @@ export type AccountStorage = { fallbackToDirect?: boolean transport?: 'http' | 'websocket' } + claustrum?: ClaustrumConfig killswitch?: KillswitchConfig accounts: FallbackAccount[] } @@ -327,6 +337,7 @@ export type AccountRuntimeEntry = Partial< OAuthAccount, | 'access' | 'authLineageId' + | 'claustrumHandle' | 'refresh' | 'expires' | 'lastUsed' @@ -415,6 +426,15 @@ export type AccountManagerOptions = { fetchImpl?: typeof fetch configPath?: string quotaManager?: import('./quota-manager.ts').QuotaManager + isFallbackAccountVaultServed?: ( + accountId: string, + storage: AccountStorage, + ) => boolean + isFallbackAccountVaultEnabled?: ( + accountId: string, + storage: AccountStorage, + ) => boolean + onBackgroundRefresh?: (initial?: boolean) => Promise | void // Invoked after a background quota pass persists at least one fallback storage // change (token refresh, quota update, or error recording), so consumers // (e.g. the OpenCode sidebar) can re-render without a request flowing through @@ -445,7 +465,7 @@ const DEFAULT_MINIMUM_REMAINING: Record = { seven_day: 0, } const DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA = true -const BACKGROUND_TICK_MS = 60_000 +export const FALLBACK_BACKGROUND_TICK_MS = 60_000 const BACKGROUND_TICK_JITTER_MS = 60_000 const FALLBACK_REFRESH_LOCK_TTL_MS = 10 * 60_000 const FALLBACK_REFRESH_JOIN_WAIT_MS = 10_000 @@ -521,6 +541,10 @@ function normalizeAccount(value: unknown): FallbackAccount | null { typeof value.authLineageId === 'string' && value.authLineageId.trim() ? value.authLineageId : undefined, + claustrumHandle: + typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim() + ? value.claustrumHandle.trim() + : undefined, access: typeof value.access === 'string' ? value.access : undefined, refresh: value.refresh, expires: typeof value.expires === 'number' ? value.expires : undefined, @@ -811,6 +835,7 @@ function normalizeStorage(value: unknown): AccountStorage | null { costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined, cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined, relay: isRecord(value.relay) ? value.relay : undefined, + claustrum: normalizeClaustrumConfig(value.claustrum), logging: isRecord(value.logging) ? value.logging : undefined, killswitch: isRecord(value.killswitch) ? value.killswitch : undefined, prime: (() => { @@ -852,6 +877,26 @@ function normalizeStorage(value: unknown): AccountStorage | null { } } +function normalizeClaustrumConfig(value: unknown): ClaustrumConfig | undefined { + if (!isRecord(value) || !isRecord(value.accounts)) return undefined + const accounts = Object.fromEntries( + Object.entries(value.accounts).flatMap(([id, entry]) => { + if (!isRecord(entry)) return [] + return [ + [ + id, + { + ...(typeof entry.enabled === 'boolean' && { + enabled: entry.enabled, + }), + }, + ], + ] + }), + ) + return Object.keys(accounts).length > 0 ? { accounts } : undefined +} + async function readJsonIfPresent(path: string): Promise<{ exists: boolean value: unknown @@ -1108,6 +1153,7 @@ function accountRuntimeState(account: FallbackAccount) { } return objectWithDefinedEntries({ authLineageId: account.authLineageId, + claustrumHandle: account.claustrumHandle, access: account.access, refresh: account.refresh, expires: account.expires, @@ -1286,6 +1332,20 @@ function mergeAccountRuntimeState( ), } : incoming + const preferredRefreshError = (() => { + const existingError = existingEntry.lastRefreshError + const incomingError = effectiveIncoming.lastRefreshError + if (!existingError) return incomingError + if (incomingError) { + return incomingError.checkedAt >= existingError.checkedAt + ? incomingError + : existingError + } + return (effectiveIncoming.lastRefreshedAt ?? 0) > + (existingEntry.lastRefreshedAt ?? 0) + ? undefined + : existingError + })() const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota) const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(effectiveIncoming.quota) const existingQuotaWinsEqualTimestamp = Boolean( @@ -1327,9 +1387,7 @@ function mergeAccountRuntimeState( if (!('lastQuotaRefreshError' in effectiveIncoming)) { delete merged.lastQuotaRefreshError } - if (!('lastRefreshError' in effectiveIncoming)) { - delete merged.lastRefreshError - } + merged.lastRefreshError = preferredRefreshError return merged } @@ -1337,6 +1395,7 @@ function mergeAccountRuntimeState( ...merged, quota: existingEntry.quota, lastQuotaRefreshError: existingEntry.lastQuotaRefreshError, + lastRefreshError: preferredRefreshError, } } const merged: AccountRuntimeEntry = { @@ -1350,9 +1409,7 @@ function mergeAccountRuntimeState( if (!('lastQuotaRefreshError' in effectiveIncoming)) { delete merged.lastQuotaRefreshError } - if (!('lastRefreshError' in effectiveIncoming)) { - delete merged.lastRefreshError - } + merged.lastRefreshError = preferredRefreshError return merged } @@ -1392,6 +1449,7 @@ function configFromStorage(storage: AccountStorage): Record { costZeroing: storage.costZeroing, cacheKeep: storage.cacheKeep, relay: storage.relay, + claustrum: storage.claustrum, killswitch: storage.killswitch, prime: (() => { // Config side carries ONLY the `enabled` flag — runtime counters and @@ -1405,6 +1463,13 @@ function configFromStorage(storage: AccountStorage): Record { }) } +export function isClaustrumEnabledForAccount( + storage: AccountStorage, + accountId: string, +): boolean { + return storage.claustrum?.accounts?.[accountId]?.enabled === true +} + // --------------------------------------------------------------------------- // In-process save mutex — serializes all account-store writes so concurrent // read-modify-write callers (background timers that call saveAccountState with @@ -1947,6 +2012,52 @@ export function saveOAuthProfileState( }) } +export function clearClaustrumRefreshErrorPersistent( + accountId: string, + handle: string, + path = getAccountStoragePath(), +): Promise { + return enqueueSave(async () => { + const configLock = await acquireAccountConfigWriteLock(path) + try { + const stateLock = await acquireAccountStateWriteLock(path) + try { + // Re-read under both locks so this field-scoped clear cannot write a stale credential or config snapshot. + const storage = await loadAccounts(path) + const account = storage?.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === accountId && isOAuthAccount(candidate), + ) + if ( + !storage || + !account || + account.claustrumHandle !== handle || + !isClaustrumEnabledForAccount(storage, accountId) || + !account.lastRefreshError + ) { + return false + } + account.lastRefreshError = undefined + await saveAccountStateUnlocked(storage, path, { accounts: [accountId] }) + const statePath = getAccountStatePath(path) + const state = (await readJsonIfPresent(statePath)).value + if (isRecord(state) && isRecord(state.accounts)) { + const entry = state.accounts[accountId] + if (isRecord(entry)) { + delete entry.lastRefreshError + await writeJsonAtomic(statePath, pruneUndefined(state)) + } + } + return true + } finally { + await stateLock.release() + } + } finally { + await configLock.release() + } + }) +} + async function saveAccountStateUnlocked( storage: AccountStorage, path: string, @@ -2615,6 +2726,10 @@ function refreshBeforeExpiryMs(storage: AccountStorage | null) { return Math.max(MIN_REFRESH_BEFORE_EXPIRY_MINUTES, minutes) * 60_000 } +export function getRefreshBeforeExpiryMs(storage: AccountStorage | null) { + return refreshBeforeExpiryMs(storage) +} + export function getRefreshIntervalMs(storage: AccountStorage | null) { const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES @@ -3611,6 +3726,17 @@ export class FallbackAccountManager { private refreshTimer: ReturnType | null = null private quotaTimer: ReturnType | null = null readonly quotaManager: import('./quota-manager.ts').QuotaManager | null + private readonly isFallbackAccountVaultServed: ( + accountId: string, + storage: AccountStorage, + ) => boolean + private readonly isFallbackAccountVaultEnabled: ( + accountId: string, + storage: AccountStorage, + ) => boolean + private readonly onBackgroundRefresh: + | ((initial?: boolean) => Promise | void) + | undefined private readonly onFallbackStorageChanged: (() => void) | undefined private readonly setIntervalImpl: typeof globalThis.setInterval private readonly clearIntervalImpl: typeof globalThis.clearInterval @@ -3620,6 +3746,11 @@ export class FallbackAccountManager { this.fetchImpl = options.fetchImpl ?? fetch this.configPath = options.configPath ?? getAccountStoragePath() this.quotaManager = options.quotaManager ?? null + this.isFallbackAccountVaultServed = + options.isFallbackAccountVaultServed ?? (() => false) + this.isFallbackAccountVaultEnabled = + options.isFallbackAccountVaultEnabled ?? (() => false) + this.onBackgroundRefresh = options.onBackgroundRefresh this.onFallbackStorageChanged = options.onFallbackStorageChanged this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval this.clearIntervalImpl = @@ -3663,15 +3794,17 @@ export class FallbackAccountManager { } startBackgroundRefresh() { - const run = async () => { + const run = async (initial = false) => { + await this.onBackgroundRefresh?.(initial) await this.refreshDueAccounts() await this.refreshQuotaForDueAccounts() } - const initialRun = run().catch(() => {}) + const initialRun = run(true).catch(() => {}) if (!this.refreshTimer) { - this.refreshTimer = this.setIntervalImpl(() => { - void run().catch(() => {}) - }, BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS)) + this.refreshTimer = this.setIntervalImpl( + () => run().catch(() => {}), + FALLBACK_BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS), + ) if ('unref' in this.refreshTimer) this.refreshTimer.unref() } return initialRun @@ -3698,7 +3831,19 @@ export class FallbackAccountManager { if (account.enabled === false || !isOAuthAccount(account)) continue let next = account try { - if (tokenNeedsRefresh(next, storage, this.now())) { + if ( + tokenNeedsRefresh(next, storage, this.now()) && + (!this.isFallbackAccountVaultEnabled(next.id, storage) || + next.expires === undefined || + next.expires <= this.now()) && + !this.isFallbackAccountVaultServed(next.id, storage) + ) { + if (this.isFallbackAccountVaultEnabled(next.id, storage)) { + logger.warn('refresh', 'custody override: local fallback refresh', { + accountId: next.id, + reason: 'vault credential unavailable', + }) + } const refreshError = next.lastRefreshError if ( refreshError && @@ -3818,7 +3963,12 @@ export class FallbackAccountManager { let changed = false for (const account of storage.accounts) { if (account.enabled === false || !isOAuthAccount(account)) continue - if (!tokenNeedsRefresh(account, storage, this.now())) continue + if ( + !tokenNeedsRefresh(account, storage, this.now()) || + this.isFallbackAccountVaultEnabled(account.id, storage) || + this.isFallbackAccountVaultServed(account.id, storage) + ) + continue if ( refreshBackoffActive(account.lastRefreshError, account.id, this.now()) ) { @@ -3860,7 +4010,11 @@ export class FallbackAccountManager { if (account.enabled === false || !isOAuthAccount(account)) continue let next = account try { - if (tokenNeedsRefresh(next, storage, this.now())) { + if ( + tokenNeedsRefresh(next, storage, this.now()) && + !this.isFallbackAccountVaultEnabled(next.id, storage) && + !this.isFallbackAccountVaultServed(next.id, storage) + ) { if ( refreshBackoffActive(next.lastRefreshError, next.id, this.now()) ) { @@ -3904,7 +4058,10 @@ export class FallbackAccountManager { if (account.enabled === false || !isOAuthAccount(account)) continue let next = account try { - if (tokenNeedsRefresh(next, storage, this.now())) { + if ( + tokenNeedsRefresh(next, storage, this.now()) && + !this.isFallbackAccountVaultServed(next.id, storage) + ) { const refreshError = next.lastRefreshError if ( refreshError && @@ -3945,8 +4102,9 @@ export class FallbackAccountManager { async refreshAccount( account: OAuthAccount, storage: AccountStorage, - options: { force?: boolean } = {}, + options: { force?: boolean; persistError?: boolean } = {}, ): Promise { + if (this.isFallbackAccountVaultServed(account.id, storage)) return account const existing = this.refreshPromises.get(account.id) if (existing) { const refreshed = await existing @@ -3960,9 +4118,18 @@ export class FallbackAccountManager { }, ) this.refreshPromises.set(account.id, promise) - const refreshed = await promise - updateStoredAccount(storage, refreshed) - return refreshed + try { + const refreshed = await promise + updateStoredAccount(storage, refreshed) + return refreshed + } catch (error) { + if (options.persistError) { + recordRefreshError(account, error, this.now()) + updateStoredAccount(storage, account) + await this.save(storage) + } + throw error + } } private async waitForConcurrentFallbackRefresh( diff --git a/packages/core/src/claustrum.ts b/packages/core/src/claustrum.ts new file mode 100644 index 00000000..c3ed9004 --- /dev/null +++ b/packages/core/src/claustrum.ts @@ -0,0 +1,708 @@ +import { readFile } from 'node:fs/promises' +import { userInfo } from 'node:os' +import { + type BindIdentity, + type CatalogEntry, + type CloseRouteOptions, + type ManagedCallOptions, + type ManagedCloseRouteOptions, + type RequestOptions, + type RouteHandle, + type RouteOpenOptions, + type RouteTarget, + SubcCallError, + SubcClient, + type SubscribeOptions, + type Subscription, +} from '@cortexkit/subc-client' +import { logger } from './logger' + +export type ClaustrumEndpoint = { + host: string + port: number +} + +export type ClaustrumDetection = + | { + status: 'available' + schema: number + wireVersion: number + endpoints: ClaustrumEndpoint[] + } + | { + status: 'absent' + path: string + } + | { + status: 'malformed' + path: string + reason: string + } + +export function getDefaultClaustrumConnectionPath(): string { + const uid = process.getuid?.() ?? userInfo().uid + return `/run/user/${uid}/subc-connection.json` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isEndpoint(value: unknown): value is ClaustrumEndpoint { + return ( + isRecord(value) && + typeof value.host === 'string' && + value.host.trim().length > 0 && + typeof value.port === 'number' && + Number.isInteger(value.port) && + value.port > 0 && + value.port <= 65_535 + ) +} + +export async function detectClaustrumConnection( + path = getDefaultClaustrumConnectionPath(), +): Promise { + let value: unknown + try { + value = JSON.parse(await readFile(path, 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { status: 'absent', path } + } + return { + status: 'malformed', + path, + reason: error instanceof Error ? error.message : String(error), + } + } + + if ( + !isRecord(value) || + typeof value.schema !== 'number' || + !Number.isFinite(value.schema) || + typeof value.wire_version !== 'number' || + !Number.isFinite(value.wire_version) || + !Array.isArray(value.endpoints) || + value.endpoints.length === 0 || + !value.endpoints.every(isEndpoint) + ) { + return { + status: 'malformed', + path, + reason: + 'connection file has an invalid schema, wire_version, or endpoints', + } + } + + return { + status: 'available', + schema: value.schema, + wireVersion: value.wire_version, + endpoints: value.endpoints.map((endpoint) => ({ + host: endpoint.host, + port: endpoint.port, + })), + } +} + +export type ClaustrumClientOptions = { + connectionFile?: string + handshakeTimeoutMs?: number + connector?: ClaustrumConnector +} + +export type ClaustrumConnector = (options: { + connectionFile: string + handshakeTimeoutMs?: number +}) => Promise + +export class ClaustrumClient { + #client: SubcClient + readonly #connector: ClaustrumConnector + readonly #connectionFile: string + readonly #handshakeTimeoutMs?: number + #reconnecting: Promise | null = null + #nextReconnectAt = 0 + #closed = false + + private constructor( + client: SubcClient, + connector: ClaustrumConnector, + connectionFile: string, + handshakeTimeoutMs?: number, + ) { + this.#client = client + this.#connector = connector + this.#connectionFile = connectionFile + this.#handshakeTimeoutMs = handshakeTimeoutMs + } + + static async connect( + options: ClaustrumClientOptions = {}, + ): Promise { + const connectionFile = + options.connectionFile ?? getDefaultClaustrumConnectionPath() + const connector = + options.connector ?? + ((connectOptions) => SubcClient.connect(connectOptions)) + const client = await connector({ + connectionFile, + handshakeTimeoutMs: options.handshakeTimeoutMs, + }) + return new ClaustrumClient( + client, + connector, + connectionFile, + options.handshakeTimeoutMs, + ) + } + + async catalogList(moduleId?: string): Promise { + return this.#client.catalogList(moduleId) + } + + async routeOpen( + target: RouteTarget, + identity: BindIdentity, + options: Omit = {}, + ): Promise { + // The host process inherits aft's supervised-spawn environment; null is the + // library contract that omits that identity instead of impersonating aft. + return this.#client.routeOpen(target, identity, { + ...options, + consumerIdentity: null, + }) + } + + async request( + handle: RouteHandle, + body: unknown, + options: RequestOptions = {}, + ): Promise { + return this.#client.request(handle, body, options) + } + + async call( + moduleId: string, + method: string, + params?: unknown, + options: Omit = {}, + ): Promise { + try { + return await this.#client.call(moduleId, method, params, { + ...options, + consumerIdentity: null, + }) + } catch (error) { + if (!this.#shouldReconnect(error)) throw error + await this.#reconnect() + return this.#client.call(moduleId, method, params, { + ...options, + consumerIdentity: null, + }) + } + } + + subscribe( + handle: RouteHandle, + body: unknown, + onEvent: (event: Uint8Array) => void, + options: SubscribeOptions = {}, + ): Subscription { + return this.#client.subscribe(handle, body, onEvent, options) + } + + async closeRoute( + handle: RouteHandle, + options: CloseRouteOptions = {}, + ): Promise { + return this.#client.closeRoute(handle, options) + } + + async closeManagedRoute( + target: Extract< + RouteTarget, + { kind: 'management_surface' | 'tool_provider' } + >, + identity: BindIdentity, + options: Omit = {}, + ): Promise { + return this.#client.closeManagedRoute(target, identity, { + ...options, + consumerIdentity: null, + }) + } + + close(): void { + this.#closed = true + this.#client.close() + } + + #shouldReconnect(error: unknown): boolean { + return ( + !this.#closed && + error instanceof SubcCallError && + error.kind === 'terminal' && + error.code !== 'missing_identity' && + error.code !== 'invalid_control_body' + ) + } + + async #reconnect(): Promise { + if (this.#closed) throw new Error('Claustrum client is closed') + const now = Date.now() + if (now < this.#nextReconnectAt) { + throw new Error('Claustrum client reconnect is backed off') + } + if (!this.#reconnecting) { + this.#nextReconnectAt = now + CLAUSTRUM_CREDENTIAL_REFRESH_BACKOFF_MS + this.#reconnecting = this.#connector({ + connectionFile: this.#connectionFile, + handshakeTimeoutMs: this.#handshakeTimeoutMs, + }) + .then((client) => { + const previous = this.#client + this.#client = client + previous.close() + }) + .finally(() => { + this.#reconnecting = null + }) + } + await this.#reconnecting + } +} + +export function connectClaustrumClient( + options: ClaustrumClientOptions = {}, +): Promise { + return ClaustrumClient.connect(options) +} + +export const CLAUSTRUM_MODULE_ID = 'claustrum' +export const DEFAULT_CLAUSTRUM_CREDENTIAL_MIN_TTL_MS = 120_000 +const CLAUSTRUM_CREDENTIAL_REFRESH_BACKOFF_MS = 60_000 +export const ERROR_CLASS_WIRE_SET = [ + 'transient', + 'permanent', + 'auth_required', + 'context_overflow', +] as const + +export type ClaustrumCredentialErrorClass = + (typeof ERROR_CLASS_WIRE_SET)[number] + +export type ClaustrumCredentialErrorAction = + | 'gone' + | 'reauth' + | 'retry' + | 'reduce_and_retry' + +export type ClaustrumCredential = { + payload: string + expiresAtMs: number | null + recordVersion: number + projectId?: string + accountId?: string +} + +export type ClaustrumServedCredential = Pick< + ClaustrumCredential, + 'recordVersion' +> +export type ClaustrumReporterSource = + | 'direct' + | 'relay_status_field' + | 'relay_message_parse' + +export class ClaustrumCredentialError extends Error { + readonly errorClass: ClaustrumCredentialErrorClass + + constructor( + message: string, + public readonly code: string, + errorClass: ClaustrumCredentialErrorClass, + public readonly action: ClaustrumCredentialErrorAction, + ) { + super(message) + this.name = 'ClaustrumCredentialError' + this.errorClass = errorClass + } +} + +export type ClaustrumCredentialCacheOptions = { + identity?: BindIdentity + now?: () => number + minTtlMs?: number +} + +type CredentialGetResult = { + payload: string + expiresAtMs: number | null + recordVersion: number + projectId?: string + accountId?: string +} + +function credentialErrorAction( + errorClass: ClaustrumCredentialErrorClass, +): ClaustrumCredentialErrorAction { + switch (errorClass) { + case 'permanent': + return 'gone' + case 'auth_required': + return 'reauth' + case 'context_overflow': + return 'reduce_and_retry' + case 'transient': + return 'retry' + } +} + +function asCredentialError( + response: unknown, + fallbackCode = 'invalid_response', +): ClaustrumCredentialError { + const result = + isRecord(response) && isRecord(response.result) + ? response.result + : undefined + const error = result && isRecord(result.error) ? result.error : undefined + const rawErrorClass = error?.class + const errorClass = + typeof rawErrorClass === 'string' && + (ERROR_CLASS_WIRE_SET as readonly string[]).includes(rawErrorClass) + ? (rawErrorClass as ClaustrumCredentialErrorClass) + : 'transient' + if ( + typeof rawErrorClass !== 'string' || + !(ERROR_CLASS_WIRE_SET as readonly string[]).includes(rawErrorClass) + ) { + logger.warn('claustrum', 'unrecognised credential error class', { + errorClass: rawErrorClass ?? null, + }) + } + const code = + error && typeof error.code === 'string' ? error.code : fallbackCode + return new ClaustrumCredentialError( + `Claustrum credential request failed: ${code}`, + code, + errorClass, + credentialErrorAction(errorClass), + ) +} + +function asCredentialCallError(error: unknown): Error { + if (error instanceof ClaustrumCredentialError) { + return error + } + if (error instanceof SubcCallError && error.kind === 'terminal') { + logger.warn('claustrum', 'terminal credential call failed', { + code: error.code ?? null, + message: error.message, + }) + return new ClaustrumCredentialError( + `Claustrum credential request failed: ${error.message}`, + error.code ?? 'terminal_error', + 'transient', + 'retry', + ) + } + const message = error instanceof Error ? error.message : String(error) + const code = + isRecord(error) && typeof error.code === 'string' + ? error.code + : 'transport_error' + return new ClaustrumCredentialError( + `Claustrum credential request failed: ${message}`, + code, + 'transient', + 'retry', + ) +} + +function decodeCredentialGetResponse(response: unknown): CredentialGetResult { + const result = + isRecord(response) && isRecord(response.result) + ? response.result + : undefined + + if (result && isRecord(result.error)) { + throw asCredentialError(response) + } + + const payload = result?.payload + if ( + !Array.isArray(payload) || + payload.length === 0 || + !payload.every( + (value) => + typeof value === 'number' && + Number.isInteger(value) && + value >= 0 && + value <= 255, + ) + ) { + throw asCredentialError(response) + } + + const recordVersion = result?.record_version + if ( + typeof recordVersion !== 'number' || + !Number.isSafeInteger(recordVersion) || + recordVersion < 0 + ) { + throw asCredentialError(response, 'invalid_record_version') + } + + const rawExpiresAtMs = result?.expires_at_ms + const expiresAtMs = + rawExpiresAtMs === null || rawExpiresAtMs === undefined + ? null + : typeof rawExpiresAtMs === 'number' && Number.isFinite(rawExpiresAtMs) + ? rawExpiresAtMs + : undefined + if (expiresAtMs === undefined) { + throw asCredentialError(response, 'invalid_expiry') + } + + const decoded = new TextDecoder().decode(Uint8Array.from(payload)) + + return { + payload: decoded, + expiresAtMs, + recordVersion, + ...(typeof result?.project_id === 'string' && { + projectId: result.project_id, + }), + ...(typeof result?.account_id === 'string' && { + accountId: result.account_id, + }), + } +} + +export class ClaustrumCredentialCache { + readonly #cache = new Map() + readonly #inFlight = new Map>() + readonly #client: ClaustrumClient + readonly #identity?: BindIdentity + readonly #now: () => number + readonly #refreshBackoffUntil = new Map() + #minTtlMs: number + + constructor( + client: ClaustrumClient, + options: ClaustrumCredentialCacheOptions = {}, + ) { + this.#client = client + this.#identity = options.identity + this.#now = options.now ?? Date.now + this.#minTtlMs = options.minTtlMs ?? DEFAULT_CLAUSTRUM_CREDENTIAL_MIN_TTL_MS + if (!Number.isSafeInteger(this.#minTtlMs) || this.#minTtlMs < 0) { + throw new RangeError('minTtlMs must be a non-negative safe integer') + } + } + + async get( + handle: string, + minTtlMs = this.#minTtlMs, + ): Promise { + if (!Number.isSafeInteger(minTtlMs) || minTtlMs < 0) { + throw new RangeError('minTtlMs must be a non-negative safe integer') + } + const now = this.#now() + const cached = this.#cache.get(handle) + if (cached && cached.expiresAtMs !== null && cached.expiresAtMs > now) { + if (cached.expiresAtMs - now <= minTtlMs) { + this.#refreshIfApproachingExpiry(handle, now, minTtlMs) + } + return cached + } + if (cached) { + this.#cache.delete(handle) + this.#refreshBackoffUntil.delete(handle) + } + + const pending = this.#inFlight.get(handle) + if (pending) return pending + + const load = this.#load(handle, minTtlMs) + this.#inFlight.set(handle, load) + try { + return await load + } finally { + if (this.#inFlight.get(handle) === load) this.#inFlight.delete(handle) + } + } + + peek(handle: string): ClaustrumCredential | undefined { + return this.#cache.get(handle) + } + + reduceMinTtlMs(): number { + this.#minTtlMs = Math.floor(this.#minTtlMs / 2) + return this.#minTtlMs + } + + async reportAuthFailure( + handle: string, + providerStatus: number, + servedCredential: ClaustrumServedCredential, + reporterSource?: ClaustrumReporterSource, + ): Promise + async reportAuthFailure( + handle: string, + servedCredential: ClaustrumServedCredential, + providerStatus?: number, + reporterSource?: ClaustrumReporterSource, + ): Promise + async reportAuthFailure( + handle: string, + providerStatusOrServedCredential: number | ClaustrumServedCredential, + servedCredentialOrStatus?: ClaustrumServedCredential | number, + reporterSource?: ClaustrumReporterSource, + ): Promise { + const providerStatus = + typeof providerStatusOrServedCredential === 'number' + ? providerStatusOrServedCredential + : typeof servedCredentialOrStatus === 'number' + ? servedCredentialOrStatus + : 401 + const servedCredential = + typeof providerStatusOrServedCredential === 'number' + ? typeof servedCredentialOrStatus === 'object' && + servedCredentialOrStatus !== null + ? servedCredentialOrStatus + : undefined + : providerStatusOrServedCredential + const recordVersion = servedCredential?.recordVersion + if ( + typeof recordVersion !== 'number' || + !Number.isSafeInteger(recordVersion) || + recordVersion < 0 + ) { + throw new TypeError( + 'record_version is required from the credential served to the provider', + ) + } + + try { + let response: unknown + try { + response = await this.#client.call( + CLAUSTRUM_MODULE_ID, + 'credential.report_auth_failure', + { + handle, + provider_status: providerStatus, + record_version: recordVersion, + ...(reporterSource ? { reporter_source: reporterSource } : {}), + }, + { identity: this.#identity }, + ) + } catch (error) { + throw asCredentialCallError(error) + } + const result = + isRecord(response) && isRecord(response.result) + ? response.result + : undefined + if (result && isRecord(result.error)) { + throw asCredentialError(response) + } + } finally { + this.invalidate(handle, recordVersion) + } + } + + invalidate(handle: string, recordVersion?: number): void { + const cached = this.#cache.get(handle) + if ( + cached && + (recordVersion === undefined || cached.recordVersion === recordVersion) + ) { + this.#cache.delete(handle) + } + } + + close(): void { + this.#client.close() + } + + #refreshIfApproachingExpiry( + handle: string, + now: number, + minTtlMs: number, + ): void { + if (this.#inFlight.has(handle)) return + const retryAt = this.#refreshBackoffUntil.get(handle) + if (retryAt !== undefined && retryAt > now) return + + this.#refreshBackoffUntil.set( + handle, + now + CLAUSTRUM_CREDENTIAL_REFRESH_BACKOFF_MS, + ) + const load = this.#load(handle, minTtlMs) + this.#inFlight.set(handle, load) + void load + .catch(() => {}) + .finally(() => { + if (this.#inFlight.get(handle) === load) this.#inFlight.delete(handle) + }) + } + + async #load(handle: string, minTtlMs: number): Promise { + let response: unknown + try { + response = await this.#client.call( + CLAUSTRUM_MODULE_ID, + 'credential.get', + { + handle, + force_refresh: false, + min_ttl_ms: minTtlMs, + }, + { identity: this.#identity }, + ) + } catch (error) { + throw asCredentialCallError(error) + } + const result = decodeCredentialGetResponse(response) + const credential: ClaustrumCredential = { + payload: result.payload, + expiresAtMs: result.expiresAtMs, + recordVersion: result.recordVersion, + ...(result.projectId !== undefined && { projectId: result.projectId }), + ...(result.accountId !== undefined && { accountId: result.accountId }), + } + if ( + credential.expiresAtMs !== null && + credential.expiresAtMs > this.#now() + ) { + this.#cache.set(handle, credential) + } + return credential + } +} + +export type ConnectClaustrumCredentialCacheOptions = ClaustrumClientOptions & + ClaustrumCredentialCacheOptions & { + enabled?: boolean + } + +export async function connectClaustrumCredentialCache( + options: ConnectClaustrumCredentialCacheOptions = {}, +): Promise { + if (options.enabled !== true) return null + + const { + enabled: _enabled, + identity, + now, + minTtlMs, + ...clientOptions + } = options + const client = await connectClaustrumClient(clientOptions) + return new ClaustrumCredentialCache(client, { identity, now, minTtlMs }) +} diff --git a/packages/core/src/commands/account.ts b/packages/core/src/commands/account.ts index a4aa0cc4..c2c9c077 100644 --- a/packages/core/src/commands/account.ts +++ b/packages/core/src/commands/account.ts @@ -1,4 +1,6 @@ import type { AccountStorage, FallbackAccount } from '../accounts.ts' +import { isClaustrumEnabledForAccount } from '../accounts.ts' +import type { ClaustrumDetection } from '../claustrum.ts' import { formatOAuthAccountTier } from '../oauth-profile.ts' export const CLAUDE_ACCOUNT_COMMAND_NAME = 'claude-account' @@ -169,6 +171,7 @@ const USAGE_TEXT = [ export function executeAccountCommand(input: { argumentsText: string storage: AccountStorage + claustrum?: ClaustrumDetection }): { text: string updated?: { @@ -185,13 +188,18 @@ export function executeAccountCommand(input: { if (action.type === 'status') { const list = buildAccountList(input.storage) - const lines = ['## Claude Accounts', ''] + const detection = input.claustrum?.status ?? 'unknown' + const lines = ['## Claude Accounts', '', `- Claustrum: ${detection}`, ''] for (const a of list) { const pct = a.quotaPercent != null ? ` ${Math.round(a.quotaPercent)}%` : '' const status = !a.enabled ? ' (disabled)' : '' const tier = a.tierLabel ? ` · ${a.tierLabel}` : '' - lines.push(`- **${a.label}** [${a.role}]${tier}${status}${pct}`) + const gate = + a.id === mainId + ? ' · gate n/a (OpenCode managed)' + : ` · gate ${isClaustrumEnabledForAccount(input.storage, a.id) ? 'on' : 'off'}` + lines.push(`- **${a.label}** [${a.role}]${tier}${status}${pct}${gate}`) } lines.push('', USAGE_TEXT) return { text: lines.join('\n') } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 12aa4515..1beb9db2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ export * from './cachekeep.ts' export * from './cachekeep-registry.ts' export * from './cch.ts' export * from './claude-code.ts' +export * from './claustrum.ts' export * from './commands/account.ts' export * from './constants.ts' export * from './dump.ts' diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 70f71f7a..c021f239 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -29,12 +29,19 @@ import { CLAUDE_QUOTAS_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, CLAUDE_START_COMMAND_NAME, + type ClaustrumConnector, + type ClaustrumCredentialCache, + ClaustrumCredentialError, + type ClaustrumReporterSource, + clearClaustrumRefreshErrorPersistent, computeXxhash64Hex, configuredAnthropicOAuthAccountCount, + connectClaustrumCredentialCache, createEmptyStorage, createStickyNoRouteResponse, type DumpHandle, decideStickyQuotaFailure, + detectClaustrumConnection, dumpDirectRequest, dumpResponseArtifact, exchange, @@ -48,6 +55,7 @@ import { executeLoggingCommand, executePrimeCommand, executeRoutingCommand, + FALLBACK_BACKGROUND_TICK_MS, FallbackAccountManager, fetchOAuthAccountProfile, formatOAuthAccountTier, @@ -66,6 +74,7 @@ import { getPersistedLogLevel, getPersistedMainQuota, getQuotaNextRefreshAt, + getRefreshBeforeExpiryMs, getRelayConfig, getRoutingMode, getStickyRoutingStatePath, @@ -83,6 +92,7 @@ import { isClaudeFable51Model, isClaudeFableOrMythos51Model, isClaudeOpus5Model, + isClaustrumEnabledForAccount, isCostZeroingEnabled, isDumpPersistentlyEnabled, isFastModeEnabled, @@ -123,6 +133,7 @@ import { parseLoggingCommandAction, parsePrimeCommandAction, parseRoutingCommandAction, + primeStorageFingerprint, QUOTA_HEADER_FEED_SCHEMA_VERSION, type QuotaAccountSummary, type QuotaEntry, @@ -212,6 +223,7 @@ import { pushNotification, } from './rpc/notifications.ts' import { + type AccountDialogAccount, type ApplyRequest, type ApplyResult, COMMAND_MODAL_NAMES, @@ -634,6 +646,15 @@ type FableRequestContext = { standbyBridgeLogged?: boolean } +type ClaustrumAccessResolution = { + accessToken?: string + served?: { + accountId: string + handle: string + recordVersion: number + } +} + type StickyOAuthRoute = { id: string access: string @@ -641,6 +662,7 @@ type StickyOAuthRoute = { identity: IdentityState order: number account?: OAuthAccount + claustrum?: ClaustrumAccessResolution } type MainQuotaIdentityBinding = { @@ -811,22 +833,74 @@ function zeroModelCosts>( ) as T } -type PluginRuntimeTimerOverrides = Partial<{ +type PluginRuntimeOverrides = Partial<{ setTimeout: typeof globalThis.setTimeout setInterval: typeof globalThis.setInterval clearInterval: typeof globalThis.clearInterval + claustrumConnector: ClaustrumConnector + claustrumNow: () => number + clearClaustrumRefreshErrorPersistent: typeof clearClaustrumRefreshErrorPersistent }> +// Keep boot above the resident IPC fast path, but never let a stale-marked +// refresh turn a vault treadmill into a seconds-long plugin-start delay. +const CLAUSTRUM_WARMUP_TIMEOUT_MS = 100 +const CLAUSTRUM_TRANSIENT_WARM_BACKOFF_MS = 5_000 +const CLAUSTRUM_REAUTH_WARM_BACKOFF_MS = FALLBACK_BACKGROUND_TICK_MS + +function getConfiguredClaustrumConnectionFile(): string | undefined { + const configured = + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE?.trim() + return configured || undefined +} + +function claustrumAccessToken( + credential: Awaited>, +): string | undefined { + let parsed: unknown + try { + parsed = JSON.parse(credential.payload) + } catch { + const payload = credential.payload.trim() + return payload || undefined + } + if (!parsed || typeof parsed !== 'object') return undefined + const record = parsed as { + access_token?: unknown + access?: unknown + } + if (typeof record.access_token === 'string') return record.access_token + if (typeof record.access === 'string') return record.access + return undefined +} + +function usableClaustrumAccessToken( + credential: Awaited> | undefined, + now: number, +): string | undefined { + if (!credential) return undefined + const accessToken = claustrumAccessToken(credential) + if (!accessToken) return undefined + if (credential.expiresAtMs !== null && credential.expiresAtMs <= now) { + return undefined + } + return accessToken +} + const anthropicAuthPlugin = async ( ctx: Parameters[0], - timerOverrides: PluginRuntimeTimerOverrides = {}, + runtimeOverrides: PluginRuntimeOverrides = {}, ) => { const runtimeTimers = { setTimeout: globalThis.setTimeout, setInterval: globalThis.setInterval, clearInterval: globalThis.clearInterval, - ...timerOverrides, + ...runtimeOverrides, } + const claustrumNow = runtimeOverrides.claustrumNow ?? Date.now + const clearClaustrumRefreshErrorPersistentImpl = + runtimeOverrides.clearClaustrumRefreshErrorPersistent ?? + clearClaustrumRefreshErrorPersistent startEventLoopLagMonitor() const { client } = ctx const profileFetch = globalThis.fetch @@ -1517,14 +1591,323 @@ const anthropicAuthPlugin = async ( } } + let claustrumCredentialCache: ClaustrumCredentialCache | null = null + const claustrumBlockedAccounts = new Set() + const claustrumReauthAccounts = new Set() + const claustrumWarmScheduled = new Set() + const claustrumWarmBackoffUntil = new Map() + let claustrumConnectBackoffUntil = 0 + const claustrumAccounts = initialStorage + ? initialStorage.accounts.filter( + (account): account is OAuthAccount => + isOAuthAccount(account) && + account.enabled !== false && + Boolean(account.claustrumHandle) && + isClaustrumEnabledForAccount(initialStorage, account.id), + ) + : [] + + const isFallbackAccountVaultServed = ( + accountId: string, + storage: Awaited>, + ): boolean => { + if (!storage || claustrumBlockedAccounts.has(accountId)) return false + if (!isClaustrumEnabledForAccount(storage, accountId)) return false + const account = storage.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === accountId && + candidate.enabled !== false && + isOAuthAccount(candidate), + ) + const handle = account?.claustrumHandle + if (!handle) return false + const cached = claustrumCredentialCache?.peek(handle) + return Boolean(cached && usableClaustrumAccessToken(cached, claustrumNow())) + } + const fallbackManager = new FallbackAccountManager({ quotaManager, + isFallbackAccountVaultServed, + isFallbackAccountVaultEnabled: (accountId, storage) => { + if (!isClaustrumEnabledForAccount(storage, accountId)) return false + const account = storage.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === accountId && isOAuthAccount(candidate), + ) + return account?.enabled !== false && Boolean(account?.claustrumHandle) + }, + onBackgroundRefresh: refreshVaultBackedFallbacks, setIntervalImpl: runtimeTimers.setInterval, clearIntervalImpl: runtimeTimers.clearInterval, onFallbackStorageChanged: () => { void refreshSidebarQuota().catch(() => {}) }, }) + + const clearClaustrumRefreshError = async ( + accountId: string, + handle: string, + ) => { + await clearClaustrumRefreshErrorPersistentImpl( + accountId, + handle, + accountStoragePath, + ) + } + + const clearServedClaustrumRefreshError = (served: { + accountId: string + handle: string + }) => { + void loadAccounts(accountStoragePath) + .then((storage) => { + const account = storage?.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === served.accountId && isOAuthAccount(candidate), + ) + // A local refresh can persist an error after this warm cache was populated; + // a detached fresh read keeps the locked clear conditional without putting + // locks on the response path. + if (!account?.lastRefreshError) return + return clearClaustrumRefreshError(served.accountId, served.handle) + }) + .catch((error) => { + logger.warn('claustrum', 'failed to clear stale refresh error', { + accountId: served.accountId, + error: error instanceof Error ? error.message : String(error), + }) + }) + } + + const markClaustrumCredentialReady = async ( + accountId: string, + handle?: string, + ) => { + claustrumBlockedAccounts.delete(accountId) + claustrumReauthAccounts.delete(accountId) + if (handle) claustrumWarmBackoffUntil.delete(handle) + if (handle) { + await clearClaustrumRefreshError(accountId, handle).catch((error) => { + logger.warn('claustrum', 'failed to clear stale refresh error', { + accountId, + error: error instanceof Error ? error.message : String(error), + }) + }) + } + } + const handleClaustrumCredentialError = ( + accountId: string, + error: unknown, + handle?: string, + ) => { + if (!(error instanceof ClaustrumCredentialError)) return + if (error.action === 'gone') claustrumBlockedAccounts.add(accountId) + if (error.action === 'reauth') { + claustrumReauthAccounts.add(accountId) + } + if (error.action === 'reduce_and_retry') { + claustrumCredentialCache?.reduceMinTtlMs() + } + if ((error.action === 'retry' || error.action === 'reauth') && handle) { + claustrumWarmBackoffUntil.set( + handle, + claustrumNow() + + (error.action === 'reauth' + ? CLAUSTRUM_REAUTH_WARM_BACKOFF_MS + : CLAUSTRUM_TRANSIENT_WARM_BACKOFF_MS), + ) + } + } + + async function ensureClaustrumCredentialCache(): Promise { + if (claustrumCredentialCache) return claustrumCredentialCache + const now = claustrumNow() + if (now < claustrumConnectBackoffUntil) return null + claustrumConnectBackoffUntil = now + CLAUSTRUM_TRANSIENT_WARM_BACKOFF_MS + const identity = { + project_root: ctx.directory ?? process.cwd(), + harness: 'opencode', + session: `store-${primeStorageFingerprint(accountStoragePath)}`, + } + try { + claustrumCredentialCache = await connectClaustrumCredentialCache({ + enabled: true, + identity, + ...(getConfiguredClaustrumConnectionFile() && { + connectionFile: getConfiguredClaustrumConnectionFile(), + }), + ...(runtimeOverrides.claustrumConnector && { + connector: runtimeOverrides.claustrumConnector, + }), + now: claustrumNow, + }) + return claustrumCredentialCache + } catch (error) { + logger.warn('claustrum', 'credential cache unavailable', { + error: error instanceof Error ? error.message : String(error), + }) + claustrumCredentialCache = null + return null + } + } + + async function refreshVaultBackedFallbacks(initial = false): Promise { + let cache = claustrumCredentialCache + const storage = await loadAccounts(accountStoragePath) + if (!storage) return + const minTtlMs = getRefreshBeforeExpiryMs(storage) + 30 * 60_000 + let sidebarChanged = false + + for (const account of storage.accounts) { + if ( + account.enabled === false || + !isOAuthAccount(account) || + !account.claustrumHandle || + !isClaustrumEnabledForAccount(storage, account.id) + ) + continue + const handle = account.claustrumHandle + if (!cache) cache = await ensureClaustrumCredentialCache() + if ( + initial && + cache && + usableClaustrumAccessToken(cache.peek(handle), claustrumNow()) + ) { + continue + } + const sidecarNearExpiry = + !account.access || + !account.expires || + account.expires - claustrumNow() <= getRefreshBeforeExpiryMs(storage) + const custodyOverrideRefresh = async (reason: string) => { + if ( + refreshBackoffActive( + account.lastRefreshError, + account.id, + claustrumNow(), + ) + ) { + return + } + logger.warn('refresh', 'custody override: local fallback refresh', { + accountId: account.id, + reason, + }) + await fallbackManager + .refreshAccount(account, storage, { persistError: true }) + .catch((error) => { + logger.warn('refresh', 'custody override local refresh failed', { + accountId: account.id, + error: error instanceof Error ? error.message : String(error), + }) + }) + } + if (!cache) { + if (sidecarNearExpiry) { + await custodyOverrideRefresh('vault unavailable') + } + continue + } + if (initial && !cache.peek(handle)) { + if (sidecarNearExpiry) { + await custodyOverrideRefresh('vault warmup pending') + sidebarChanged = true + } + continue + } + try { + const credential = await cache.get(handle, minTtlMs) + if (!usableClaustrumAccessToken(credential, claustrumNow())) { + log('[refresh] vault fallback credential unusable', { + accountId: account.id, + handle, + minTtlMs, + }) + if (sidecarNearExpiry) { + await custodyOverrideRefresh('vault credential unavailable') + } + } else { + await markClaustrumCredentialReady(account.id, handle) + } + sidebarChanged = true + } catch (error) { + handleClaustrumCredentialError(account.id, error, handle) + sidebarChanged = true + const action = + error instanceof ClaustrumCredentialError ? error.action : 'retry' + if (action === 'reauth' || sidecarNearExpiry) { + await custodyOverrideRefresh( + action === 'reauth' ? 'vault reauth' : 'vault unavailable', + ) + } + } + } + if (sidebarChanged) void refreshSidebarQuota().catch(() => {}) + } + if (claustrumAccounts.length > 0) { + try { + const claustrumIdentity = { + project_root: ctx.directory ?? process.cwd(), + harness: 'opencode', + session: `store-${primeStorageFingerprint(accountStoragePath)}`, + } + claustrumCredentialCache = await connectClaustrumCredentialCache({ + enabled: true, + identity: claustrumIdentity, + ...(getConfiguredClaustrumConnectionFile() && { + connectionFile: getConfiguredClaustrumConnectionFile(), + }), + ...(runtimeOverrides.claustrumConnector && { + connector: runtimeOverrides.claustrumConnector, + }), + now: claustrumNow, + }) + const cache = claustrumCredentialCache + if (cache) { + // Warm before auth hooks are exposed; request handling only peeks and + // refreshes asynchronously, so an expiry-skew vault refresh cannot + // delay a response. + const warmup = Promise.all( + claustrumAccounts.map(async (account) => { + const handle = account.claustrumHandle + if (!handle) return + try { + const credential = await cache.get(handle) + if (usableClaustrumAccessToken(credential, claustrumNow())) { + await markClaustrumCredentialReady(account.id, handle) + } + } catch (error) { + handleClaustrumCredentialError(account.id, error, handle) + logger.warn('claustrum', 'credential warmup failed', { + accountId: account.id, + error: error instanceof Error ? error.message : String(error), + }) + } + }), + ) + let timedOut = false + const timeout = new Promise((resolve) => { + const timer = globalThis.setTimeout(() => { + timedOut = true + resolve() + }, CLAUSTRUM_WARMUP_TIMEOUT_MS) + if ('unref' in timer) timer.unref() + }) + await Promise.race([warmup, timeout]) + if (timedOut) { + logger.warn('claustrum', 'credential warmup timed out', { + accounts: claustrumAccounts.length, + timeoutMs: CLAUSTRUM_WARMUP_TIMEOUT_MS, + }) + } + } + } catch (error) { + logger.warn('claustrum', 'credential cache unavailable', { + error: error instanceof Error ? error.message : String(error), + }) + claustrumCredentialCache = null + } + } const fallbackRefreshReady = fallbackManager.startBackgroundRefresh() const cacheDiagnosticsTracker = new CacheDiagnosticsTracker() const cacheDiagnosticsBetaTracker = new CacheDiagnosticsBetaTracker() @@ -1547,6 +1930,10 @@ const anthropicAuthPlugin = async ( Response, CacheDiagnosticsResponse >() + const claustrumServedCredentials = new WeakMap< + Response, + { accountId: string; handle: string; recordVersion: number } + >() const cacheKeepDiagnosticsRequests = new Map< string, CacheDiagnosticsRequestContext & { @@ -2350,6 +2737,11 @@ const anthropicAuthPlugin = async ( Date.now(), ) && isPermanentRefreshError(account.lastRefreshError), + vaultReauth: + claustrumReauthAccounts.has(account.id) && + Boolean( + account.access && account.expires && account.expires > Date.now(), + ), enabled: account.enabled !== false, })), activeId: options.activeId, @@ -3295,6 +3687,12 @@ const anthropicAuthPlugin = async ( const result = executeAccountCommand({ argumentsText, storage: storage ?? { version: 1, accounts: [] }, + claustrum: + action.type === 'status' + ? await detectClaustrumConnection( + getConfiguredClaustrumConnectionFile(), + ) + : undefined, }) if (result.updated) { @@ -3359,6 +3757,34 @@ const anthropicAuthPlugin = async ( return { text: result.text, accounts } } + async function buildAccountDialogProjection(): Promise<{ + accounts: AccountDialogAccount[] + claustrumDetection: string + }> { + const storage = await loadAccounts(accountStoragePath) + const accounts = buildAccountList(storage ?? createEmptyStorage()).map( + (account) => ({ + ...account, + claustrumGate: + account.role === 'main' + ? ('na' as const) + : isClaustrumEnabledForAccount( + storage ?? createEmptyStorage(), + account.id, + ) + ? ('on' as const) + : ('off' as const), + vaultServed: + account.role === 'fallback' && + isFallbackAccountVaultServed(account.id, storage), + }), + ) + const detection = await detectClaustrumConnection( + getConfiguredClaustrumConnectionFile(), + ) + return { accounts, claustrumDetection: detection.status } + } + async function buildDialogPayload( command: CommandModalName, args: string, @@ -3385,8 +3811,10 @@ const anthropicAuthPlugin = async ( } if (command === 'claude-account') { const result = await executePersistentAccountCommand(args, sessionId) + const accountProjection = await buildAccountDialogProjection() const knobs: Record = { - accounts: result.accounts, + accounts: accountProjection.accounts, + claustrumDetection: accountProjection.claustrumDetection, } if ('knobs' in result && result.knobs) { Object.assign(knobs, result.knobs) @@ -4589,6 +5017,142 @@ const anthropicAuthPlugin = async ( return response } + function claustrumWarmBackoffActive(handle: string): boolean { + const retryAt = claustrumWarmBackoffUntil.get(handle) + if (retryAt === undefined) return false + if (claustrumNow() >= retryAt) { + claustrumWarmBackoffUntil.delete(handle) + return false + } + return true + } + + function resolveClaustrumAccess( + account: OAuthAccount, + storage: Awaited>, + ): ClaustrumAccessResolution { + const handle = account.claustrumHandle + if ( + !handle || + !storage || + !isClaustrumEnabledForAccount(storage, account.id) || + claustrumBlockedAccounts.has(account.id) + ) { + return { accessToken: account.access } + } + + const cache = claustrumCredentialCache + if (!cache) { + if ( + account.access && + account.expires !== undefined && + account.expires > claustrumNow() + ) { + return { accessToken: account.access } + } + return {} + } + + const cached = cache.peek(handle) + const cachedAccess = usableClaustrumAccessToken( + cached, + claustrumNow(), + ) + if (cached && cachedAccess) { + return { + accessToken: cachedAccess, + served: { + accountId: account.id, + handle, + recordVersion: cached.recordVersion, + }, + } + } + + // Never send an expired vault value; the sidecar is only a + // bounded degradation path while the detached refresh completes. + // Defer the refresh until selection has captured sidecar provenance; + // the detached refresh must never turn an expired peek into a report. + if (!claustrumWarmBackoffActive(handle)) { + scheduleClaustrumWarm(account.id, handle) + } + if ( + account.access && + account.expires !== undefined && + account.expires > claustrumNow() + ) { + return { accessToken: account.access } + } + return {} + } + + async function warmClaustrumCredential( + accountId: string, + handle: string, + ): Promise { + const cache = claustrumCredentialCache + if (!cache) return + try { + const credential = await cache.get(handle) + if (usableClaustrumAccessToken(credential, claustrumNow())) { + await markClaustrumCredentialReady(accountId, handle) + } + } catch (error) { + handleClaustrumCredentialError(accountId, error, handle) + logger.warn('claustrum', 'credential refresh failed', { + accountId, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + function scheduleClaustrumWarm(accountId: string, handle: string) { + if (claustrumWarmScheduled.has(handle)) return + claustrumWarmScheduled.add(handle) + runtimeTimers.setTimeout(() => { + claustrumWarmScheduled.delete(handle) + void warmClaustrumCredential(accountId, handle) + }, 0) + } + + async function reportClaustrumAuthFailure( + served: { + accountId: string + handle: string + recordVersion: number + }, + reporterSource: ClaustrumReporterSource = 'direct', + ): Promise { + const cache = claustrumCredentialCache + if (!cache) return + const current = cache.peek(served.handle) + // Version match makes reports single-shot per served version. Accepted + // tradeoff: an unrelated cache eviction also suppresses a genuine + // report (worst case one delayed cycle until the next served 401). + if (!current || current.recordVersion !== served.recordVersion) + return + try { + await cache.reportAuthFailure( + served.handle, + 401, + { + recordVersion: served.recordVersion, + }, + reporterSource, + ) + } catch (error) { + handleClaustrumCredentialError( + served.accountId, + error, + served.handle, + ) + logger.warn('claustrum', 'failed to report credential failure', { + accountId: served.accountId, + error: error instanceof Error ? error.message : String(error), + }) + } + } + async function sendWithAccessToken( input: string | URL | Request, init: RequestInit | undefined, @@ -4601,8 +5165,10 @@ const anthropicAuthPlugin = async ( fableRequest?: FableRequestContext, laneStartRequest = false, mainQuotaIdentity?: MainQuotaIdentityBinding, + claustrumResolution?: ClaustrumAccessResolution, ) { const start = nowMs() + const servedClaustrumCredential = claustrumResolution?.served let requestStorage = currentStorage const getRequestStorage = async () => { requestStorage ??= await loadAccounts(accountStoragePath) @@ -4918,6 +5484,12 @@ const anthropicAuthPlugin = async ( status: response.status, streaming, }) + if (servedClaustrumCredential) { + claustrumServedCredentials.set( + response, + servedClaustrumCredential, + ) + } return response } @@ -5007,6 +5579,7 @@ const anthropicAuthPlugin = async ( const usable: Array = [] for (const account of storageArg?.accounts ?? []) { if (isOAuthAccount(account)) { + if (claustrumBlockedAccounts.has(account.id)) continue const usableAccount = usableOAuthById.get(account.id) if (usableAccount) { usable.push(usableAccount) @@ -5103,10 +5676,14 @@ const anthropicAuthPlugin = async ( latestStorage?.accounts ?? [] ).entries()) { if (stored.enabled === false || !isOAuthAccount(stored)) continue + if (claustrumBlockedAccounts.has(stored.id)) continue const account = usableById.get(stored.id) ?? stored + const credential = resolveClaustrumAccess(account, latestStorage) + const servedByClaustrum = Boolean(credential.served) if ( - !account.access || - isPermanentRefreshError(account.lastRefreshError) + !credential.accessToken || + (!servedByClaustrum && + isPermanentRefreshError(account.lastRefreshError)) ) continue let accountQuota = getFallbackQuota(account) @@ -5121,18 +5698,19 @@ const anthropicAuthPlugin = async ( try { accountQuota = await quotaManager.refreshFallback( account.id, - account.access, + credential.accessToken, account, ) } catch {} } allRoutes.push({ id: account.id, - access: account.access, + access: credential.accessToken, quota: accountQuota, identity: { kind: 'known', accountId: account.id }, order: index + 1, account, + claustrum: credential, }) } @@ -5145,12 +5723,18 @@ const anthropicAuthPlugin = async ( route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? latestStorage?.refresh?.mainLastRefreshError : route.account?.lastRefreshError + const sidecarRefreshError = !route.claustrum?.served const accountIdentity = route.identity.kind === 'known' ? route.identity.accountId : undefined - if (isPermanentRefreshError(refreshError)) return [] if ( + sidecarRefreshError && + isPermanentRefreshError(refreshError) + ) + return [] + if ( + sidecarRefreshError && refreshBackoffActive( refreshError, accountIdentity, @@ -5196,17 +5780,20 @@ const anthropicAuthPlugin = async ( route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? latestStorage?.refresh?.mainLastRefreshError : route.account?.lastRefreshError + const sidecarRefreshError = !route.claustrum?.served const accountIdentity = route.identity.kind === 'known' ? route.identity.accountId : undefined if ( - isPermanentRefreshError(refreshError) || - (refreshBackoffActive( - refreshError, - accountIdentity, - Date.now(), - ) && + (sidecarRefreshError && + isPermanentRefreshError(refreshError)) || + (sidecarRefreshError && + refreshBackoffActive( + refreshError, + accountIdentity, + Date.now(), + ) && (route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID || !usableIds.has(route.id))) ) @@ -5241,7 +5828,8 @@ const anthropicAuthPlugin = async ( input.requestedModelId, ) && (route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID || - usableIds.has(route.id)))) + usableIds.has(route.id) || + Boolean(route.claustrum?.served)))) return passes ? [ { @@ -5300,12 +5888,15 @@ const anthropicAuthPlugin = async ( options?.fableRequest, ) } else { - const access = account.access - if (!access) continue + const claustrumResolution = resolveClaustrumAccess( + account, + storage, + ) + if (!claustrumResolution.accessToken) continue response = await sendWithAccessToken( input, init, - access, + claustrumResolution.accessToken, trace, `fallback_${index}`, storage, @@ -5313,8 +5904,14 @@ const anthropicAuthPlugin = async ( account.authLineageId, options?.fableRequest, options?.laneStartRequest, + undefined, + claustrumResolution, ) } + const servedClaustrum = claustrumServedCredentials.get(response) + if (response.status === 401 && servedClaustrum) { + await reportClaustrumAuthFailure(servedClaustrum, 'direct') + } lastResponse = response let fallbackAgain = shouldFallbackStatus(response.status, storage) if (!fallbackAgain) { @@ -5332,6 +5929,9 @@ const anthropicAuthPlugin = async ( isApiKeyAccount(account) ? 'api' : 'oauth', ) await fallbackManager.markUsed(account) + if (servedClaustrum) { + clearServedClaustrumRefreshError(servedClaustrum) + } await options?.onSuccess?.(account) // Active-route every-N refresh: this fallback just served the // request, so keep its quota fresh on the same cadence as main. @@ -5546,6 +6146,11 @@ const anthropicAuthPlugin = async ( responseMode: 'json' as const, } : {}), + onRelayUpstreamError: ({ status, source }) => { + if (status !== 401) return + const served = claustrumServedCredentials.get(response) + if (served) void reportClaustrumAuthFailure(served, source) + }, contentFilterModel: fablePlan?.requestedModel, ...(!fablePlan?.downgraded && fablePlan ? { @@ -5903,6 +6508,7 @@ const anthropicAuthPlugin = async ( selected.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? requestMainQuotaIdentity : undefined, + selected.claustrum, ) const completeRoute = async ( selected: StickyOAuthRoute, @@ -5912,6 +6518,11 @@ const anthropicAuthPlugin = async ( if (markUsed && selected.account) { await fallbackManager.markUsed(selected.account) } + if (markUsed && selected.claustrum?.served) { + clearServedClaustrumRefreshError( + selected.claustrum.served, + ) + } await writeCurrentSidebarState( selected.id, 'sticky-balanced', @@ -5995,12 +6606,37 @@ const anthropicAuthPlugin = async ( if (inspected.response.status === 401) { const authRouteId = route.id + const servedClaustrum = claustrumServedCredentials.get( + inspected.response, + ) try { if (authRouteId === STICKY_ROUTING_MAIN_ACCOUNT_ID) { auth.access = await refreshMainAccessToken( route.access, ) route = { ...route, access: auth.access } + } else if (servedClaustrum) { + await reportClaustrumAuthFailure( + servedClaustrum, + 'direct', + ) + const refreshedClaustrum = route.account + ? resolveClaustrumAccess( + route.account, + stickyRoutes.storage, + ) + : undefined + if (refreshedClaustrum?.accessToken) { + route = { + ...route, + access: refreshedClaustrum.accessToken, + claustrum: refreshedClaustrum, + } + } else { + // A cold vault must yield to the next route while its + // detached refresh repopulates the cache. + permanentAuthFailure = true + } } else if (route.account && stickyRoutes.storage) { const refreshed = await fallbackManager.refreshAccount( @@ -6016,14 +6652,19 @@ const anthropicAuthPlugin = async ( } } } - await inspected.response.body?.cancel().catch(() => {}) - inspected = await inspectResponse( - await sendRoute(route), - ) - if (!inspected.routeFailure) { - return completeRoute(route, inspected.response) + if (!permanentAuthFailure) { + await inspected.response.body + ?.cancel() + .catch(() => {}) + inspected = await inspectResponse( + await sendRoute(route), + ) + if (!inspected.routeFailure) { + return completeRoute(route, inspected.response) + } + permanentAuthFailure = + inspected.response.status === 401 } - permanentAuthFailure = inspected.response.status === 401 } catch (error) { const latest = await loadAccounts(accountStoragePath) const refreshError = @@ -6676,6 +7317,7 @@ const anthropicAuthPlugin = async ( }, dispose: async () => { await quotaHeaderFeedRegistry?.dispose() + claustrumCredentialCache?.close() }, methods: [ { @@ -6740,6 +7382,7 @@ const anthropicAuthPlugin = async ( __primeManager: primeManager, __quotaManager: quotaManager, __fallbackRefreshReady: fallbackRefreshReady, + __claustrumCredentialCache: claustrumCredentialCache, // biome-ignore lint/suspicious/noExplicitAny: Plugin type doesn't include undocumented auth/hooks } as any } diff --git a/packages/opencode/src/rpc/protocol.ts b/packages/opencode/src/rpc/protocol.ts index f2b9e81e..1a0e2a78 100644 --- a/packages/opencode/src/rpc/protocol.ts +++ b/packages/opencode/src/rpc/protocol.ts @@ -14,6 +14,23 @@ export const COMMAND_MODAL_NAMES = [ export type CommandModalName = (typeof COMMAND_MODAL_NAMES)[number] +export interface AccountDialogAccount { + id: string + label: string + role: string + enabled: boolean + quotaPercent: number | null + tierLabel?: string + claustrumGate: 'on' | 'off' | 'na' + vaultServed: boolean +} + +export interface AccountDialogKnobs { + accounts: AccountDialogAccount[] + claustrumDetection: string + [key: string]: unknown +} + export interface OpenDialogPayload { command: CommandModalName text: string diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index b2238798..e8b0ae1d 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -34,6 +34,8 @@ export interface SidebarAccountState { // True when the account's refresh token is permanently dead (400 // invalid_grant) and it needs a re-login — distinct from a transient backoff. needsReauth: boolean + // True when the vault copy needs re-importing while the sidecar remains usable. + vaultReauth?: boolean tierLabel?: string } @@ -335,6 +337,7 @@ export function normalizeSidebarState(raw: unknown): SidebarState { enabled: typeof entry.enabled === 'boolean' ? entry.enabled : false, needsReauth: typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false, + ...(entry.vaultReauth === true && { vaultReauth: true }), tierLabel: typeof entry.tierLabel === 'string' && entry.tierLabel.trim() ? entry.tierLabel.trim() @@ -715,6 +718,8 @@ export async function setSidebarState( lastUpdated: Math.max(current.lastUpdated, state.lastUpdated), } } + // The state file is a cross-process artifact; future account fields must fail closed. + stateToWrite = normalizeSidebarState(stateToWrite) result = await writeSidebarStateAtomic( stateFile, stateToWrite, diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index aec2ac89..cbf34301 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -312,6 +312,10 @@ describe('executeAccountCommand status', () => { expect(result.text).toContain('Disabled account') expect(result.text).toContain('42%') expect(result.text).toContain('(disabled)') + expect(result.text).toContain( + '**OpenCode anthropic** [main] 42% · gate n/a (OpenCode managed)', + ) + expect(result.text).toContain('**Work account** [fallback] · gate off') }) test('usage returns usage text', () => { diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 65e47141..6ee653e0 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { + __setLogTestSink, type AccountStorage, acquireRefreshFileLock, addAccountPersistent, @@ -39,6 +40,7 @@ import { isPrimePersistentlyEnabled, type KillswitchThresholds, killswitchPassesPolicy, + type LogTestRecord, loadAccounts, mergeHeaderQuotaSnapshot, mergeMainQuotaErrorClearedAt, @@ -2934,6 +2936,192 @@ describe('account storage', () => { }) describe('FallbackAccountManager', () => { + test('only prunes runtime account state for a genuinely empty account config', async () => { + const cases = [ + { + name: 'real id', + configAccounts: [{ id: 'fallback-1' }], + expectedIds: ['fallback-1'], + }, + { name: 'empty', configAccounts: [], expectedIds: [] }, + { + name: 'entries without ids', + configAccounts: [{}], + expectedIds: ['fallback-1'], + }, + { + name: 'entries with non-string ids', + configAccounts: [{ id: 123 }], + expectedIds: ['fallback-1'], + }, + ] + + for (const testCase of cases) { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access', + refresh: 'refresh', + expires: 1_000, + }) + await saveAccounts(storage) + await writeFile( + accountPath, + JSON.stringify({ version: 1, accounts: testCase.configAccounts }), + 'utf8', + ) + + await saveAccountState(storage, accountPath, { accounts: true }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(Object.keys(state.accounts ?? {}), testCase.name).toEqual( + testCase.expectedIds, + ) + if (testCase.expectedIds.length > 0) { + expect(state.accounts?.['fallback-1']?.refresh, testCase.name).toBe( + 'refresh', + ) + } + } + }) + + test('quota 401 force retry refreshes plain accounts but skips vault accounts', async () => { + const plainPath = accountPath + const plainStorage = baseStorage() + plainStorage.accounts.push({ + id: 'plain-quota-401', + type: 'oauth', + access: 'plain-access', + refresh: 'plain-refresh', + expires: 1_000, + }) + await saveAccounts(plainStorage, plainPath) + + let plainQuotaCalls = 0 + let plainTokenCalls = 0 + const plainFetch = mock( + (input: string | URL | Request, init?: RequestInit) => { + const url = String(input) + if (url.includes('/api/oauth/usage')) { + plainQuotaCalls += 1 + if (plainQuotaCalls === 1) { + return Promise.resolve( + new Response('expired access', { status: 401 }), + ) + } + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + { status: 200 }, + ), + ) + } + if (url.includes('/v1/oauth/token')) { + plainTokenCalls += 1 + expect(JSON.parse(String(init?.body))).toMatchObject({ + refresh_token: 'plain-refresh', + }) + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'plain-refreshed-access', + refresh_token: 'plain-refreshed-refresh', + expires_in: 3_600, + }), + { status: 200 }, + ), + ) + } + throw new Error(`unexpected URL: ${url}`) + }, + ) as unknown as typeof fetch + const plainManager = new FallbackAccountManager({ + configPath: plainPath, + fetchImpl: plainFetch, + now: () => 1_000, + }) + + const plainAccount = expectOAuthAccount( + (await loadAccounts(plainPath))?.accounts[0], + ) + await expect( + plainManager.refreshAccountQuota(plainAccount, plainStorage), + ).resolves.toMatchObject({ + account: { access: 'plain-refreshed-access' }, + }) + expect(plainQuotaCalls).toBe(2) + expect(plainTokenCalls).toBe(1) + + const vaultPath = join(tempDir, 'vault-quota-401.json') + const vaultStorage = baseStorage() + vaultStorage.accounts.push({ + id: 'vault-quota-401', + type: 'oauth', + access: 'vault-sidecar-access', + refresh: 'vault-sidecar-refresh', + expires: 1_000, + claustrumHandle: 'vault-quota-401-handle', + }) + await saveAccounts(vaultStorage, vaultPath) + + let vaultQuotaCalls = 0 + let vaultTokenCalls = 0 + const vaultFetch = mock((input: string | URL | Request) => { + const url = String(input) + if (url.includes('/api/oauth/usage')) { + vaultQuotaCalls += 1 + if (vaultQuotaCalls === 1) { + return Promise.resolve( + new Response('expired access', { status: 401 }), + ) + } + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + { status: 200 }, + ), + ) + } + if (url.includes('/v1/oauth/token')) { + vaultTokenCalls += 1 + return Promise.resolve( + new Response('{"error":"invalid_grant"}', { status: 400 }), + ) + } + throw new Error(`unexpected URL: ${url}`) + }) as unknown as typeof fetch + const vaultManager = new FallbackAccountManager({ + configPath: vaultPath, + fetchImpl: vaultFetch, + now: () => 1_000, + isFallbackAccountVaultServed: (id) => id === 'vault-quota-401', + }) + const vaultAccount = expectOAuthAccount( + (await loadAccounts(vaultPath))?.accounts[0], + ) + + await expect( + vaultManager.refreshAccountQuota(vaultAccount, vaultStorage), + ).resolves.toMatchObject({ + account: { access: 'vault-sidecar-access' }, + }) + expect(vaultQuotaCalls).toBe(2) + expect(vaultTokenCalls).toBe(0) + expect( + expectOAuthAccount((await loadAccounts(vaultPath))?.accounts[0]) + .lastRefreshError, + ).toBeUndefined() + }) + test('refreshes expired fallback tokens and persists rotation', async () => { const storage = baseStorage() storage.accounts.push({ @@ -6597,3 +6785,241 @@ describe('getOrCreatePrimeAuthLineageId', () => { expect(await readFile(statePath, 'utf8')).toBe(persisted) }) }) + +describe('vault-served fallback refresh gating', () => { + function dueAccount( + id: string, + refresh: string, + quota?: OAuthQuotaSnapshot, + claustrumHandle?: string, + ): OAuthAccount { + return { + id, + type: 'oauth', + access: `${id}-access`, + refresh, + expires: 1_000, + ...(quota ? { quota } : {}), + ...(claustrumHandle ? { claustrumHandle } : {}), + } + } + + function passingQuota(checkedAt: number): OAuthQuotaSnapshot { + return { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + } + } + + function refreshResponse(accountId: string): Response { + return new Response( + JSON.stringify({ + access_token: `${accountId}-refreshed-access`, + refresh_token: `${accountId}-refreshed-refresh`, + expires_in: 86_400, + }), + { status: 200 }, + ) + } + + test('background refresh skips a vault-served account while refreshing a plain OAuth control', async () => { + const storage = baseStorage() + const checkedAt = 1_500 + storage.claustrum = { + accounts: { + 'vault-served': { enabled: true }, + }, + } + storage.accounts.push( + dueAccount( + 'vault-served', + 'vault-refresh', + passingQuota(checkedAt), + 'handle-vault-served', + ), + dueAccount('plain-control', 'plain-refresh', passingQuota(checkedAt)), + ) + await saveAccounts(storage, accountPath) + + const refreshTokens: string[] = [] + const fetchImpl = mock( + async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe('https://platform.claude.com/v1/oauth/token') + const refreshToken = ( + JSON.parse(String(init?.body)) as { refresh_token: string } + ).refresh_token + refreshTokens.push(refreshToken) + if (refreshToken === 'vault-refresh') { + return new Response('{"error":"invalid_grant"}', { status: 400 }) + } + return refreshResponse('plain-control') + }, + ) as unknown as typeof fetch + + const manager = new FallbackAccountManager({ + configPath: accountPath, + fetchImpl, + now: () => 2_000, + isFallbackAccountVaultServed: (accountId: string) => + accountId === 'vault-served', + } as never) + + await manager.startBackgroundRefresh() + manager.stopBackgroundRefresh() + + const saved = await loadAccounts(accountPath) + const vault = expectOAuthAccount( + saved?.accounts.find((account) => account.id === 'vault-served'), + ) + const plain = expectOAuthAccount( + saved?.accounts.find((account) => account.id === 'plain-control'), + ) + expect(refreshTokens).toEqual(['plain-refresh']) + expect(vault.access).toBe('vault-served-access') + expect(vault.lastRefreshError).toBeUndefined() + expect(plain.access).toBe('plain-control-refreshed-access') + }) + + test('request-path fallback selection skips a vault-served account while refreshing a plain OAuth control', async () => { + const storage = baseStorage() + storage.quota = { enabled: false, failClosedOnUnknownQuota: false } + storage.claustrum = { + accounts: { + 'vault-served': { enabled: true }, + }, + } + storage.accounts.push( + dueAccount( + 'vault-served', + 'vault-refresh', + undefined, + 'handle-vault-served', + ), + dueAccount('plain-control', 'plain-refresh'), + ) + await saveAccounts(storage, accountPath) + + const refreshTokens: string[] = [] + const fetchImpl = mock( + async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe('https://platform.claude.com/v1/oauth/token') + const refreshToken = ( + JSON.parse(String(init?.body)) as { refresh_token: string } + ).refresh_token + refreshTokens.push(refreshToken) + if (refreshToken === 'vault-refresh') { + return new Response('{"error":"invalid_grant"}', { status: 400 }) + } + return refreshResponse('plain-control') + }, + ) as unknown as typeof fetch + + const manager = new FallbackAccountManager({ + configPath: accountPath, + fetchImpl, + now: () => 2_000, + isFallbackAccountVaultServed: (accountId: string) => + accountId === 'vault-served', + } as never) + + const usable = await manager.getUsableFallbackAccounts() + const saved = await loadAccounts(accountPath) + const vault = expectOAuthAccount( + saved?.accounts.find((account) => account.id === 'vault-served'), + ) + + expect(refreshTokens).toEqual(['plain-refresh']) + expect(usable.map((account) => account.id)).toEqual([ + 'vault-served', + 'plain-control', + ]) + expect(vault.lastRefreshError).toBeUndefined() + }) + + test('a vault-enabled account whose vault is unavailable still refreshes locally', async () => { + const storage = baseStorage() + storage.quota = { enabled: false, failClosedOnUnknownQuota: false } + storage.claustrum = { + accounts: { + 'vault-served': { enabled: true }, + 'vault-unavailable': { enabled: true }, + }, + } + storage.accounts.push( + dueAccount( + 'vault-served', + 'vault-refresh', + undefined, + 'handle-vault-served', + ), + dueAccount( + 'vault-unavailable', + 'outage-refresh', + undefined, + 'handle-vault-unavailable', + ), + ) + await saveAccounts(storage, accountPath) + + const refreshTokens: string[] = [] + const logs: LogTestRecord[] = [] + __setLogTestSink((record) => logs.push(record)) + const fetchImpl = mock( + async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe('https://platform.claude.com/v1/oauth/token') + const refreshToken = ( + JSON.parse(String(init?.body)) as { refresh_token: string } + ).refresh_token + refreshTokens.push(refreshToken) + if (refreshToken === 'vault-refresh') { + return new Response('{"error":"invalid_grant"}', { status: 400 }) + } + return refreshResponse('vault-unavailable') + }, + ) as unknown as typeof fetch + + const manager = new FallbackAccountManager({ + configPath: accountPath, + fetchImpl, + now: () => 2_000, + isFallbackAccountVaultServed: (accountId: string) => + accountId === 'vault-served', + isFallbackAccountVaultEnabled: (accountId: string) => + accountId === 'vault-unavailable', + } as never) + + await manager.getUsableFallbackAccounts() + + const saved = await loadAccounts(accountPath) + const served = expectOAuthAccount( + saved?.accounts.find((account) => account.id === 'vault-served'), + ) + const unavailable = expectOAuthAccount( + saved?.accounts.find((account) => account.id === 'vault-unavailable'), + ) + expect(refreshTokens).toEqual(['outage-refresh']) + expect(served.access).toBe('vault-served-access') + expect(served.lastRefreshError).toBeUndefined() + expect(unavailable.access).toBe('vault-unavailable-refreshed-access') + expect(logs).toContainEqual( + expect.objectContaining({ + level: 'warn', + channel: 'refresh', + message: 'custody override: local fallback refresh', + payload: { + accountId: 'vault-unavailable', + reason: 'vault credential unavailable', + }, + }), + ) + __setLogTestSink(null) + }) +}) diff --git a/packages/opencode/src/tests/claustrum-client.test.ts b/packages/opencode/src/tests/claustrum-client.test.ts new file mode 100644 index 00000000..1e4d48ab --- /dev/null +++ b/packages/opencode/src/tests/claustrum-client.test.ts @@ -0,0 +1,924 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { randomUUID } from 'node:crypto' +import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { createServer, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + __setLogTestSink, + ClaustrumCredentialCache, + ClaustrumCredentialError, + connectClaustrumClient, + connectClaustrumCredentialCache, + getDefaultClaustrumConnectionPath, + type LogTestRecord, +} from '@cortexkit/anthropic-auth-core' +import { + buildFlags, + buildFrame, + computeProof, + decodeHeader, + encodeFrame, + FrameType, + HEADER_LEN, + PROTOCOL_VERSION, + Priority, + SERVER_PROOF_DOMAIN, + SubcCallError, + SubcClient, +} from '@cortexkit/subc-client' + +const key = Uint8Array.from({ length: 32 }, (_, index) => index + 1) +const daemonId = Uint8Array.from({ length: 16 }, (_, index) => 200 + index) +const moduleId = 'aft' +const launchNonce = 'inherited-live-nonce' + +type FakeDaemon = { + port: number + connections: number + routeOpenBodies: Record[] + requestBodies: Record[] + responseBodies: unknown[] + waitForRequests: (count: number) => Promise + waitForResponses: (count: number) => Promise + releaseResponses: () => void + goodbyes: number + waitForGoodbye: () => Promise + stop: () => Promise +} + +const tempDirs: string[] = [] +const clients: Array<{ close: () => void }> = [] +const daemons: FakeDaemon[] = [] +const originalModuleId = process.env.SUBC_MODULE_ID +const originalLaunchNonce = process.env.SUBC_LAUNCH_NONCE + +function frameBody(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(value)) +} + +function guardedConnector(expectedPath: string) { + return async (options: { + connectionFile: string + handshakeTimeoutMs?: number + }) => { + expect(options.connectionFile).toBe(expectedPath) + return SubcClient.connect(options) + } +} + +function writeHandshakeMessage(socket: Socket, value: unknown): void { + const body = Buffer.from(JSON.stringify(value), 'utf8') + const prefix = Buffer.alloc(4) + prefix.writeUInt32LE(body.length) + socket.write(Buffer.concat([prefix, body])) +} + +async function startFakeDaemon( + options: { holdResponses?: boolean } = {}, +): Promise { + const routeOpenBodies: Record[] = [] + const requestBodies: Record[] = [] + const responseBodies: unknown[] = [] + const requestWaiters: Array<{ + count: number + resolve: () => void + }> = [] + let responseCount = 0 + const responseWaiters: Array<{ + count: number + resolve: () => void + }> = [] + let releaseResponses: (() => void) | null = null + const responsesReleased = options.holdResponses + ? new Promise((resolve) => { + releaseResponses = resolve + }) + : Promise.resolve() + let connections = 0 + let goodbyes = 0 + let resolveGoodbye: (() => void) | null = null + const goodbyeSeen = new Promise((resolve) => { + resolveGoodbye = resolve + }) + const server = createServer((socket) => { + connections += 1 + let buffer = Buffer.alloc(0) + let phase: 'hello' | 'auth' | 'frames' = 'hello' + + socket.on('error', () => {}) + socket.on('data', async (chunk) => { + buffer = Buffer.concat([ + buffer, + typeof chunk === 'string' ? Buffer.from(chunk) : chunk, + ]) + for (;;) { + if (phase !== 'frames') { + if (buffer.length < 4) return + const length = buffer.readUInt32LE(0) + if (buffer.length < 4 + length) return + const body = JSON.parse( + buffer.subarray(4, 4 + length).toString('utf8'), + ) as Record + buffer = buffer.subarray(4 + length) + if (phase === 'hello') { + const clientNonce = Uint8Array.from(body.client_nonce as number[]) + const serverNonce = Uint8Array.from( + { length: 32 }, + (_, index) => 100 + index, + ) + writeHandshakeMessage(socket, { + daemon_id: Array.from(daemonId), + server_nonce: Array.from(serverNonce), + daemon_ver: 'fake-daemon', + server_proof: Array.from( + computeProof( + key, + SERVER_PROOF_DOMAIN, + clientNonce, + serverNonce, + daemonId, + ), + ), + }) + phase = 'auth' + } else { + phase = 'frames' + } + continue + } + + if (buffer.length < HEADER_LEN) return + const header = decodeHeader(buffer.subarray(0, HEADER_LEN)) + if (buffer.length < HEADER_LEN + header.len) return + const body = buffer.subarray(HEADER_LEN, HEADER_LEN + header.len) + buffer = buffer.subarray(HEADER_LEN + header.len) + if (header.ty === FrameType.Goodbye) { + goodbyes += 1 + resolveGoodbye?.() + resolveGoodbye = null + continue + } + if (header.ty !== FrameType.Request) continue + const request = JSON.parse(body.toString('utf8')) as Record< + string, + unknown + > + if (header.channel === 0 && request.op === 'route.open') { + routeOpenBodies.push(request) + } else if (header.channel !== 7) { + continue + } else { + requestBodies.push(request) + for (const waiter of requestWaiters.splice(0)) { + if (requestBodies.length >= waiter.count) waiter.resolve() + else requestWaiters.push(waiter) + } + } + if (header.channel !== 0) await responsesReleased + const response = buildFrame( + FrameType.Response, + buildFlags(false, Priority.Interactive, false), + header.channel, + header.epoch, + header.corr, + frameBody( + header.channel === 0 + ? { route_channel: 7, route_epoch: 1 } + : (responseBodies.shift() ?? { result: 'ok' }), + ), + ) + socket.write(Buffer.from(encodeFrame(response))) + if (header.channel !== 0) { + responseCount += 1 + for (const waiter of responseWaiters.splice(0)) { + if (responseCount >= waiter.count) waiter.resolve() + else responseWaiters.push(waiter) + } + } + } + }) + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolve()) + }) + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('fake daemon has no TCP address') + + const daemon: FakeDaemon = { + port: address.port, + get connections() { + return connections + }, + routeOpenBodies, + requestBodies, + responseBodies, + waitForRequests(count) { + if (requestBodies.length >= count) return Promise.resolve() + return new Promise((resolve) => { + requestWaiters.push({ count, resolve }) + }) + }, + waitForResponses(count) { + if (responseCount >= count) return Promise.resolve() + return new Promise((resolve) => { + responseWaiters.push({ count, resolve }) + }) + }, + releaseResponses() { + releaseResponses?.() + releaseResponses = null + }, + get goodbyes() { + return goodbyes + }, + async waitForGoodbye() { + return Promise.race([ + goodbyeSeen.then(() => true), + new Promise((resolve) => + setTimeout(() => resolve(false), 100), + ), + ]) + }, + async stop() { + await new Promise((resolve) => server.close(() => resolve())) + }, + } + daemons.push(daemon) + return daemon +} + +async function writeConnectionFile(path: string, port: number): Promise { + await writeFile( + path, + JSON.stringify({ + schema: 1, + wire_version: PROTOCOL_VERSION, + endpoints: [{ host: '127.0.0.1', port }], + key: Array.from(key), + daemon_id: Array.from(daemonId), + pid: process.pid, + daemon_ver: 'fake-daemon', + }), + { mode: 0o600 }, + ) + await chmod(path, 0o600) +} + +async function makeConnectionFile( + port: number, + name = 'connection.json', +): Promise { + const dir = await mkdtemp(join(tmpdir(), 'claustrum-client-test-')) + tempDirs.push(dir) + const path = join(dir, name) + await writeConnectionFile(path, port) + return path +} + +afterEach(async () => { + __setLogTestSink(null) + if (originalModuleId === undefined) delete process.env.SUBC_MODULE_ID + else process.env.SUBC_MODULE_ID = originalModuleId + if (originalLaunchNonce === undefined) delete process.env.SUBC_LAUNCH_NONCE + else process.env.SUBC_LAUNCH_NONCE = originalLaunchNonce + for (const client of clients.splice(0)) client.close() + await Promise.all(daemons.splice(0).map((daemon) => daemon.stop())) + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ) +}) + +describe('ClaustrumClient', () => { + test('sends route.open without inherited consumer identity', async () => { + process.env.SUBC_MODULE_ID = moduleId + process.env.SUBC_LAUNCH_NONCE = launchNonce + const daemon = await startFakeDaemon() + const connectionFile = await makeConnectionFile(daemon.port) + + const unsafe = await SubcClient.connect({ connectionFile }) + clients.push(unsafe) + const unsafeRoute = await unsafe.routeOpen( + { kind: 'management_surface', module_id: 'claustrum-vault' }, + { project_root: '/tmp/project', harness: 'test', session: 'unsafe' }, + ) + expect(unsafeRoute.channel).toBe(7) + + const safe = await connectClaustrumClient({ + connectionFile, + connector: guardedConnector(connectionFile), + }) + clients.push(safe) + const safeRoute = await safe.routeOpen( + { kind: 'management_surface', module_id: 'claustrum-vault' }, + { project_root: '/tmp/project', harness: 'test', session: 'safe' }, + ) + expect(safeRoute.channel).toBe(7) + await expect( + safe.call( + 'claustrum-vault', + 'test.method', + { value: 'not-a-vault-call' }, + { + identity: { + project_root: '/tmp/project', + harness: 'test', + session: 'managed', + }, + }, + ), + ).resolves.toEqual({ result: 'ok' }) + await safe.closeManagedRoute( + { kind: 'management_surface', module_id: 'claustrum-vault' }, + { project_root: '/tmp/project', harness: 'test', session: 'managed' }, + ) + expect(await daemon.waitForGoodbye()).toBe(true) + expect(daemon.goodbyes).toBeGreaterThan(0) + + expect(daemon.routeOpenBodies).toHaveLength(3) + expect(daemon.routeOpenBodies[0]).toMatchObject({ + consumer_identity: { module_id: moduleId, launch_nonce: launchNonce }, + }) + expect(daemon.routeOpenBodies[1]).toMatchObject({ + op: 'route.open', + target: { kind: 'management_surface', module_id: 'claustrum-vault' }, + identity: { + project_root: '/tmp/project', + harness: 'test', + session: 'safe', + }, + }) + expect('consumer_identity' in daemon.routeOpenBodies[1]!).toBe(false) + expect('consumer_identity' in daemon.routeOpenBodies[2]!).toBe(false) + }) + + test('threads a configured connection path into the real transport', async () => { + const daemon = await startFakeDaemon() + const configuredPath = await makeConnectionFile( + daemon.port, + 'configured.json', + ) + const defaultPath = getDefaultClaustrumConnectionPath() + expect(configuredPath).not.toBe(defaultPath) + + const client = await connectClaustrumClient({ + connectionFile: configuredPath, + connector: guardedConnector(configuredPath), + }) + clients.push(client) + + expect(daemon.connections).toBe(1) + }) + + test('reconnects a resident client after a terminal route failure', async () => { + let connections = 0 + const first = { + call: async () => { + throw new SubcCallError( + 'terminal', + 'resident route wedged', + 'route_wedged', + ) + }, + close: () => {}, + } + const second = { + call: async () => ({ result: { ok: true } }), + close: () => {}, + } + const client = await connectClaustrumClient({ + connectionFile: '/tmp/unused-claustrum-connection.json', + connector: async () => { + connections += 1 + return (connections === 1 ? first : second) as never + }, + }) + clients.push(client) + + await expect( + client.call('claustrum', 'credential.get', { handle: 'h' }), + ).resolves.toEqual({ result: { ok: true } }) + await expect( + client.call('claustrum', 'credential.get', { handle: 'h' }), + ).resolves.toEqual({ result: { ok: true } }) + expect(connections).toBe(2) + }) + + test('keeps the connection-file key behind owner-only permissions', async () => { + const daemon = await startFakeDaemon() + const connectionFile = await makeConnectionFile(daemon.port) + + expect((await stat(connectionFile)).mode & 0o777).toBe(0o600) + await chmod(connectionFile, 0o644) + + await expect( + connectClaustrumClient({ + connectionFile, + connector: guardedConnector(connectionFile), + }), + ).rejects.toThrow('insecure permissions') + expect(daemon.connections).toBe(0) + }) + + test('normal core boot inspection remains inert', async () => { + const daemon = await startFakeDaemon() + const absentPath = join( + tmpdir(), + `claustrum-never-connects-${randomUUID()}.json`, + ) + const before = daemon.connections + const detection = await import('@cortexkit/anthropic-auth-core').then( + ({ detectClaustrumConnection }) => detectClaustrumConnection(absentPath), + ) + + expect(detection).toEqual({ status: 'absent', path: absentPath }) + expect(daemon.connections).toBe(before) + }) +}) + +describe('ClaustrumCredentialCache', () => { + const handle = 'ckh_credential-test' + + async function makeCredentialCache( + daemon: FakeDaemon, + now: () => number = () => 500, + ): Promise { + const connectionFile = await makeConnectionFile(daemon.port) + const client = await connectClaustrumClient({ + connectionFile, + connector: guardedConnector(connectionFile), + }) + clients.push(client) + return new ClaustrumCredentialCache(client, { + identity: { + project_root: '/tmp/project', + harness: 'test', + session: 'credential-cache', + }, + now, + }) + } + + test('decodes a Vec JSON number array and caches through expiry', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push({ + result: { + payload: Array.from( + new TextEncoder().encode('{"access_token":"served"}'), + ), + expires_at_ms: 1_500, + record_version: 63, + project_id: 'project-7', + account_id: 'account-7', + }, + }) + let now = 500 + const cache = await makeCredentialCache(daemon, () => now) + + const first = await cache.get(handle) + const second = await cache.get(handle) + + expect(first).toEqual({ + payload: '{"access_token":"served"}', + expiresAtMs: 1_500, + recordVersion: 63, + projectId: 'project-7', + accountId: 'account-7', + }) + expect(second).toEqual(first) + expect(daemon.requestBodies).toHaveLength(1) + expect(daemon.requestBodies[0]).toEqual({ + method: 'credential.get', + params: { + handle, + force_refresh: false, + min_ttl_ms: 120_000, + }, + }) + expect(daemon.routeOpenBodies.at(-1)).toMatchObject({ + target: { kind: 'management_surface', module_id: 'claustrum' }, + }) + + now = 1_500 + daemon.responseBodies.push({ + result: { + payload: Array.from(new TextEncoder().encode('refreshed')), + expires_at_ms: 2_500, + record_version: 64, + }, + }) + await expect(cache.get(handle)).resolves.toMatchObject({ + payload: 'refreshed', + recordVersion: 64, + }) + expect(daemon.requestBodies).toHaveLength(2) + }) + + test('uses the requested minimum TTL to refresh near expiry without refreshing above it', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push({ + result: { + payload: Array.from(new TextEncoder().encode('served')), + expires_at_ms: 2_000, + record_version: 1, + }, + }) + let now = 500 + const cache = await makeCredentialCache(daemon, () => now) + + await cache.get(handle, 1_000) + now = 800 + await cache.get(handle, 1_000) + expect(daemon.requestBodies).toHaveLength(1) + + daemon.responseBodies.push({ + result: { + payload: Array.from(new TextEncoder().encode('refreshed')), + expires_at_ms: 3_000, + record_version: 2, + }, + }) + now = 1_100 + await cache.get(handle, 1_000) + await daemon.waitForRequests(2) + expect(daemon.requestBodies.at(-1)).toEqual({ + method: 'credential.get', + params: { + handle, + force_refresh: false, + min_ttl_ms: 1_000, + }, + }) + }) + + test('handles the nested error arm and pins the top-level anti-pattern', async () => { + const daemon = await startFakeDaemon() + const nestedResponse = { + result: { error: { code: 'future_code', class: 'permanent' } }, + } + daemon.responseBodies.push(nestedResponse) + const cache = await makeCredentialCache(daemon) + + const topLevelErrorCheck = (response: typeof nestedResponse) => + 'error' in response ? response.error : undefined + expect(topLevelErrorCheck(nestedResponse)).toBeUndefined() + expect(nestedResponse.result.error).toEqual({ + code: 'future_code', + class: 'permanent', + }) + try { + await cache.get(handle) + throw new Error('expected credential error') + } catch (error) { + expect(error).toMatchObject({ + code: 'future_code', + errorClass: 'permanent', + action: 'gone', + }) + expect( + error != null && + typeof error === 'object' && + Object.hasOwn(error, 'class'), + ).toBe(false) + expect(error).toBeInstanceOf(ClaustrumCredentialError) + } + }) + + test('rejects a result that has metadata but no non-empty payload', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push({ + result: { + payload: [], + expires_at_ms: 1_500, + record_version: 63, + }, + }) + const cache = await makeCredentialCache(daemon) + + await expect(cache.get(handle)).rejects.toMatchObject({ + code: 'invalid_response', + errorClass: 'transient', + action: 'retry', + }) + }) + + test('maps every wire error class without branching on producer codes', async () => { + const daemon = await startFakeDaemon() + const cache = await makeCredentialCache(daemon) + const cases = [ + ['not_found', 'permanent', 'gone'], + ['needs_reauth', 'auth_required', 'reauth'], + ['refresh_failed', 'transient', 'retry'], + ['ttl_unsatisfiable', 'context_overflow', 'reduce_and_retry'], + ] as const + + for (const [code, errorClass, action] of cases) { + daemon.responseBodies.push({ + result: { error: { code, class: errorClass } }, + }) + await expect(cache.get(`${handle}-${code}`)).rejects.toMatchObject({ + code, + errorClass, + action, + }) + } + }) + + test('classifies a connection failure as transient with retry backoff', async () => { + const client = { + call: async () => { + throw new Error('connection reset by peer') + }, + close: () => {}, + } + const cache = new ClaustrumCredentialCache(client as never) + + await expect(cache.get(handle)).rejects.toMatchObject({ + code: 'transport_error', + errorClass: 'transient', + action: 'retry', + }) + }) + + test('warns and bounds an unknown wire error class as transient', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push({ + result: { error: { code: 'future_code', class: 'future_class' } }, + }) + const cache = await makeCredentialCache(daemon) + const logs: LogTestRecord[] = [] + __setLogTestSink((record) => logs.push(record)) + + await expect(cache.get(`${handle}-future`)).rejects.toMatchObject({ + code: 'future_code', + errorClass: 'transient', + action: 'retry', + }) + + expect(logs).toContainEqual( + expect.objectContaining({ + level: 'warn', + channel: 'claustrum', + payload: expect.objectContaining({ errorClass: 'future_class' }), + }), + ) + + daemon.responseBodies.push({ + result: { error: { code: 'missing_class' } }, + }) + await expect(cache.get(`${handle}-missing-class`)).rejects.toMatchObject({ + code: 'missing_class', + errorClass: 'transient', + action: 'retry', + }) + expect(logs).toContainEqual( + expect.objectContaining({ + level: 'warn', + channel: 'claustrum', + payload: expect.objectContaining({ errorClass: null }), + }), + ) + }) + + test('single-flights concurrent misses for one handle', async () => { + const daemon = await startFakeDaemon({ holdResponses: true }) + daemon.responseBodies.push({ + result: { + payload: Array.from(new TextEncoder().encode('shared')), + expires_at_ms: 1_500, + record_version: 63, + }, + }) + const cache = await makeCredentialCache(daemon) + + const pending = Promise.all([cache.get(handle), cache.get(handle)]) + await daemon.waitForRequests(1) + expect(daemon.requestBodies).toHaveLength(1) + daemon.releaseResponses() + + const [first, second] = await pending + expect(first).toBe(second) + }) + + test('refreshes inside the minimum-TTL window while serving the cached credential', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push( + { + result: { + payload: Array.from(new TextEncoder().encode('credential-v1')), + expires_at_ms: 121_000, + record_version: 1, + }, + }, + { + result: { + payload: Array.from(new TextEncoder().encode('credential-v2')), + expires_at_ms: 121_000, + record_version: 2, + }, + }, + ) + let now = 0 + const cache = await makeCredentialCache(daemon, () => now) + + await expect(cache.get(handle)).resolves.toMatchObject({ + payload: 'credential-v1', + recordVersion: 1, + }) + now = 2_000 + await expect(cache.get(handle)).resolves.toMatchObject({ + payload: 'credential-v1', + recordVersion: 1, + }) + await daemon.waitForRequests(2) + await daemon.waitForResponses(2) + expect(daemon.requestBodies).toHaveLength(2) + + await expect(cache.get(handle)).resolves.toMatchObject({ + payload: 'credential-v1', + recordVersion: 1, + }) + expect(daemon.requestBodies).toHaveLength(2) + }) + + test('does not retain a credential whose expiry is absent', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push( + { + result: { + payload: Array.from(new TextEncoder().encode('unbounded-1')), + expires_at_ms: null, + record_version: 63, + }, + }, + { + result: { + payload: Array.from(new TextEncoder().encode('unbounded-2')), + expires_at_ms: 1_500, + record_version: 64, + }, + }, + ) + const cache = await makeCredentialCache(daemon) + + await expect(cache.get(handle)).resolves.toMatchObject({ + payload: 'unbounded-1', + }) + await expect(cache.get(handle)).resolves.toMatchObject({ + payload: 'unbounded-2', + recordVersion: 64, + }) + expect(daemon.requestBodies).toHaveLength(2) + }) + + test('reports the served record version and invalidates only that cache entry', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push( + { + result: { + payload: Array.from(new TextEncoder().encode('served-v63')), + expires_at_ms: 1_000, + record_version: 63, + }, + }, + { result: { ok: true } }, + { + result: { + payload: Array.from(new TextEncoder().encode('served-v64')), + expires_at_ms: 2_000, + record_version: 64, + }, + }, + ) + const cache = await makeCredentialCache(daemon) + const served = await cache.get(handle) + + await cache.reportAuthFailure(handle, 401, served, 'direct') + + const report = daemon.requestBodies[1] + expect(report).toBeDefined() + if (!report) throw new Error('report request was not captured') + expect(report).toMatchObject({ + method: 'credential.report_auth_failure', + params: { + handle, + provider_status: 401, + record_version: 63, + reporter_source: 'direct', + }, + }) + expect( + Object.hasOwn( + (report.params ?? {}) as Record, + 'record_version', + ), + ).toBe(true) + await expect(cache.get(handle)).resolves.toMatchObject({ + payload: 'served-v64', + recordVersion: 64, + }) + expect(daemon.requestBodies).toHaveLength(3) + }) + + test('keeps a newer local version after reporting a stale served version', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push( + { + result: { + payload: Array.from(new TextEncoder().encode('served-v63')), + expires_at_ms: 1_000, + record_version: 63, + }, + }, + { + result: { + payload: Array.from(new TextEncoder().encode('served-v64')), + expires_at_ms: 2_000, + record_version: 64, + }, + }, + { result: { ok: true } }, + ) + let now = 500 + const cache = await makeCredentialCache(daemon, () => now) + const staleServed = await cache.get(handle) + now = 1_000 + await expect(cache.get(handle)).resolves.toMatchObject({ + payload: 'served-v64', + recordVersion: 64, + }) + + await cache.reportAuthFailure(handle, 401, staleServed) + + expect(daemon.requestBodies[2]).toMatchObject({ + method: 'credential.report_auth_failure', + params: { record_version: 63 }, + }) + now = 1_500 + await expect(cache.get(handle)).resolves.toMatchObject({ + payload: 'served-v64', + recordVersion: 64, + }) + expect(daemon.requestBodies).toHaveLength(3) + }) + + test('omits reporter source when the caller has no source', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push( + { + result: { + payload: Array.from(new TextEncoder().encode('served-v63')), + expires_at_ms: 1_000, + record_version: 63, + }, + }, + { result: { ok: true } }, + ) + const cache = await makeCredentialCache(daemon) + const served = await cache.get(handle) + + await cache.reportAuthFailure(handle, 401, served) + + const params = daemon.requestBodies[1]?.params + expect(params).toBeDefined() + expect(Object.hasOwn(params ?? {}, 'reporter_source')).toBe(false) + }) + + test('requires served provenance instead of reporting the current cache entry', async () => { + const daemon = await startFakeDaemon() + daemon.responseBodies.push({ + result: { + payload: Array.from(new TextEncoder().encode('current-cache-value')), + expires_at_ms: 2_000, + record_version: 64, + }, + }) + const cache = await makeCredentialCache(daemon) + await cache.get(handle) + + // @ts-expect-error served provenance is required by the public overload + await expect(cache.reportAuthFailure(handle, 401)).rejects.toThrow( + 'record_version is required from the credential served to the provider', + ) + expect(daemon.requestBodies).toHaveLength(1) + expect(cache.peek(handle)?.recordVersion).toBe(64) + }) + + test('does not connect or get when the runtime gate is disabled', async () => { + let connectorCalls = 0 + const cache = await connectClaustrumCredentialCache({ + enabled: false, + connectionFile: '/tmp/claustrum-disabled-test.json', + connector: async () => { + connectorCalls += 1 + throw new Error('disabled path connected') + }, + }) + + expect(cache).toBeNull() + expect(connectorCalls).toBe(0) + }) +}) diff --git a/packages/opencode/src/tests/claustrum.test.ts b/packages/opencode/src/tests/claustrum.test.ts new file mode 100644 index 00000000..d503a5e8 --- /dev/null +++ b/packages/opencode/src/tests/claustrum.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type AccountStorage, + detectClaustrumConnection, + executeAccountCommand, + getDefaultClaustrumConnectionPath, + isClaustrumEnabledForAccount, + loadAccounts, + saveAccounts, +} from '@cortexkit/anthropic-auth-core' + +let tempDir: string +let accountPath: string + +const baseStorage = (): AccountStorage => ({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [ + { + id: 'account-a', + type: 'oauth', + refresh: 'refresh-a', + enabled: true, + }, + { + id: 'account-b', + type: 'oauth', + refresh: 'refresh-b', + enabled: true, + }, + ], +}) + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'anthropic-auth-claustrum-')) + accountPath = join(tempDir, 'anthropic-auth.json') +}) + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) +}) + +describe('Claustrum connection detection', () => { + test('reports an available connection without projecting its bearer key', async () => { + const path = join(tempDir, 'subc-connection.json') + await writeFile( + path, + JSON.stringify({ + schema: 1, + wire_version: 2, + key: 'bearer-secret', + endpoints: [ + { host: '127.0.0.1', port: 8757 }, + { host: '[::1]', port: 8757 }, + ], + }), + ) + + const result = await detectClaustrumConnection(path) + + expect(result).toEqual({ + status: 'available', + schema: 1, + wireVersion: 2, + endpoints: [ + { host: '127.0.0.1', port: 8757 }, + { host: '[::1]', port: 8757 }, + ], + }) + expect(JSON.stringify(result)).not.toContain('bearer-secret') + expect('key' in result).toBe(false) + }) + + test('reports an absent connection file distinctly', async () => { + const result = await detectClaustrumConnection( + join(tempDir, 'missing.json'), + ) + + expect(result.status).toBe('absent') + }) + + test('reports malformed JSON and invalid shape distinctly from absence', async () => { + const path = join(tempDir, 'malformed.json') + await writeFile(path, '{"schema":1,"wire_version":"2"}') + + const result = await detectClaustrumConnection(path) + + expect(result.status).toBe('malformed') + }) + + test('rejects an empty endpoint list as malformed', async () => { + const path = join(tempDir, 'empty-endpoints.json') + await writeFile( + path, + JSON.stringify({ schema: 1, wire_version: 2, endpoints: [] }), + ) + + const result = await detectClaustrumConnection(path) + + expect(result.status).toBe('malformed') + }) + + test('rejects an endpoint with an invalid port as malformed', async () => { + const path = join(tempDir, 'invalid-endpoint.json') + await writeFile( + path, + JSON.stringify({ + schema: 1, + wire_version: 2, + endpoints: [{ host: '127.0.0.1', port: '8757' }], + }), + ) + + const result = await detectClaustrumConnection(path) + + expect(result.status).toBe('malformed') + }) + + test('reads the explicitly configured connection path', async () => { + const configuredPath = join(tempDir, 'configured.json') + await writeFile( + configuredPath, + JSON.stringify({ + schema: 7, + wire_version: 9, + endpoints: [{ host: 'vault.test', port: 1234 }], + }), + ) + + const result = await detectClaustrumConnection(configuredPath) + + expect(result).toEqual({ + status: 'available', + schema: 7, + wireVersion: 9, + endpoints: [{ host: 'vault.test', port: 1234 }], + }) + }) + + test('derives the default connection path from the current uid', () => { + const originalGetuid = process.getuid + Object.defineProperty(process, 'getuid', { value: () => 4242 }) + try { + expect(getDefaultClaustrumConnectionPath()).toBe( + '/run/user/4242/subc-connection.json', + ) + } finally { + Object.defineProperty(process, 'getuid', { value: originalGetuid }) + } + }) +}) + +describe('per-account Claustrum gate', () => { + test('defaults off when config is absent', async () => { + await saveAccounts(baseStorage(), accountPath) + + const storage = await loadAccounts(accountPath) + + expect(isClaustrumEnabledForAccount(storage!, 'account-a')).toBe(false) + }) + + test('defaults off when the gate config is malformed', async () => { + await writeFile( + accountPath, + JSON.stringify({ ...baseStorage(), claustrum: 'on' }), + ) + + const storage = await loadAccounts(accountPath) + + expect(isClaustrumEnabledForAccount(storage!, 'account-a')).toBe(false) + }) + + test('keeps gate state independent for each account', async () => { + await saveAccounts( + { + ...baseStorage(), + claustrum: { + accounts: { + 'account-a': { enabled: true }, + 'account-b': { enabled: false }, + }, + }, + }, + accountPath, + ) + + const storage = await loadAccounts(accountPath) + + expect(isClaustrumEnabledForAccount(storage!, 'account-a')).toBe(true) + expect(isClaustrumEnabledForAccount(storage!, 'account-b')).toBe(false) + }) +}) + +describe('account status Claustrum surface', () => { + test('reports detection and each account gate without changing account behavior', () => { + const result = executeAccountCommand({ + argumentsText: '', + storage: { + ...baseStorage(), + claustrum: { + accounts: { 'account-a': { enabled: true } }, + }, + }, + claustrum: { + status: 'available', + schema: 1, + wireVersion: 2, + endpoints: [{ host: '127.0.0.1', port: 8757 }], + }, + }) + + expect(result.text).toContain('Claustrum: available') + expect(result.text).toContain('account-a') + expect(result.text).toContain('gate on') + expect(result.text).toContain('account-b') + expect(result.text).toContain('gate off') + }) +}) + +test('saving the gate persists only configuration and never introduces bearer material', async () => { + await saveAccounts( + { + ...baseStorage(), + claustrum: { accounts: { 'account-a': { enabled: true } } }, + }, + accountPath, + ) + + const config = await readFile(accountPath, 'utf8') + + expect(JSON.parse(config).claustrum).toEqual({ + accounts: { 'account-a': { enabled: true } }, + }) + expect(config).not.toContain('key') +}) diff --git a/packages/opencode/src/tests/command-dialogs.test.ts b/packages/opencode/src/tests/command-dialogs.test.ts index 409d7e14..07f885cc 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -42,13 +42,30 @@ describe('buildAccountDialogOption', () => { enabled: true, quotaPercent: 22, tierLabel: 'Team · Max 5x', + claustrumGate: 'on', + vaultServed: true, }), ).toEqual({ - title: 'Work [fallback] 22%', + title: 'Work [fallback] 22% · gate on · vault served', value: 'work', description: 'Team · Max 5x', }) }) + + test('renders gate and vault markers without exposing credentials', () => { + const option = buildAccountDialogOption({ + id: 'work', + label: 'Work', + role: 'fallback', + enabled: true, + quotaPercent: null, + claustrumGate: 'off', + vaultServed: false, + }) + expect(option.title).toContain('gate off') + expect(option.title).toContain('vault cold') + expect(option.title).not.toContain('handle') + }) }) describe('buildPrimeStatusRows', () => { diff --git a/packages/opencode/src/tests/credential-handle-blindness.test.ts b/packages/opencode/src/tests/credential-handle-blindness.test.ts new file mode 100644 index 00000000..c877ea01 --- /dev/null +++ b/packages/opencode/src/tests/credential-handle-blindness.test.ts @@ -0,0 +1,623 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + __setLogTestSink, + buildAccountList, + dumpDirectRequest, + dumpRelayRequest, + dumpResponseArtifact, + resetDumpState, + saveAccountState, + saveAccounts, + setDumpEnabled, +} from '@cortexkit/anthropic-auth-core' +import { AnthropicAuthPlugin } from '../index' +import { startRpcServer } from '../rpc/rpc-server' +import { + drainSidebarWrites, + type SidebarState, + setSidebarState, +} from '../sidebar-state' + +const HANDLE_SENTINEL = 'claustrum-handle-sentinel-7f2d' +const TOKEN_SENTINEL = 'claustrum-token-sentinel-9a41' + +const dumpInputContract: [ + 'account' extends keyof Parameters[0] + ? false + : true, + 'account' extends keyof Parameters[0] ? false : true, +] = [true, true] + +function countOccurrences(text: string, needle: string): number { + return text.split(needle).length - 1 +} + +function accountWithHandle() { + return { + id: 'work-alt', + type: 'oauth' as const, + label: 'Work', + refresh: 'refresh-token-not-for-use', + access: TOKEN_SENTINEL, + enabled: true, + claustrumHandle: HANDLE_SENTINEL, + } +} + +const storageWithHandle = () => + ({ version: 1, accounts: [accountWithHandle()] }) as never + +const sidebarStateWithAccount = (): SidebarState => + ({ + main: { + quota: { + five_hour: { + usedPercent: 20, + remainingPercent: 80, + resetsAt: '2026-08-29T12:00:00.000Z', + }, + seven_day: { usedPercent: 30, remainingPercent: 70 }, + scoped: [ + { + id: 'scope-1', + title: 'Scoped', + modelId: 'claude-opus-5', + modelName: 'Opus 5', + usedPercent: 10, + remainingPercent: 90, + }, + ], + extraUsage: { + used: { amountMinor: 10, currency: 'USD', exponent: 2 }, + limit: { amountMinor: 100, currency: 'USD', exponent: 2 }, + utilizationPercent: 10, + severity: 'ok', + exhausted: false, + }, + bindingWindow: 'five_hour', + fallbackAdvised: true, + }, + tierLabel: 'Max', + quotaBackedOff: true, + quotaBackoffUntil: 456, + refreshBackedOff: true, + refreshBackoffUntil: 789, + }, + fallbacks: [ + { + ...accountWithHandle(), + quota: { five_hour: { usedPercent: 40, remainingPercent: 60 } }, + needsReauth: true, + tierLabel: 'Pro', + }, + ], + activeId: 'work-alt', + route: 'fallback-first', + relay: { enabled: true, transport: 'websocket' }, + fastMode: true, + cacheKeep: { enabled: true, window: 'always', trackedSessions: 3 }, + prime: { + enabled: true, + accounts: [ + { + id: 'work-alt', + label: 'Work', + nextDueAt: 111, + lastPrimedAt: 222, + lastResult: 'ok', + usage: { count: 3, inputTokens: 10, outputTokens: 2, since: 1 }, + estimatedCostUsd: 0.01, + }, + ], + }, + fableRecoveries: [ + { + sessionId: 'ses-handle', + mode: 'server', + remaining: 0, + changedAt: 333, + requestedModelId: 'claude-fable-5', + targetModelId: 'claude-opus-5', + }, + ], + lastUpdated: 123, + account: accountWithHandle(), + }) as unknown as SidebarState + +async function readNonEmpty(path: string): Promise { + const bytes = await readFile(path, 'utf8') + expect(bytes.length).toBeGreaterThan(0) + return bytes +} + +describe('credential-handle blindness', () => { + const dumpDirs: string[] = [] + const sidebarDirs: string[] = [] + const accountDirs: string[] = [] + const originalFetch = globalThis.fetch + const originalAccountFile = process.env.OPENCODE_ANTHROPIC_AUTH_FILE + const originalSidebarFile = + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE + + afterEach(async () => { + resetDumpState() + globalThis.fetch = originalFetch + if (originalAccountFile === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = originalAccountFile + } + if (originalSidebarFile === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = + originalSidebarFile + } + await Promise.all( + dumpDirs + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ) + await Promise.all( + sidebarDirs + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ) + await Promise.all( + accountDirs + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ) + }) + + test('dump entry points exclude account-shaped inputs', () => { + expect(dumpInputContract).toEqual([true, true]) + }) + + test('production dump path stays blind to handle-bearing account storage', async () => { + const accountDir = await mkdtemp( + join(tmpdir(), 'opencode-handle-production-'), + ) + accountDirs.push(accountDir) + const accountPath = join(accountDir, 'anthropic-auth.json') + const dumpDir = await mkdtemp(join(tmpdir(), 'opencode-handle-dump-')) + dumpDirs.push(dumpDir) + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( + accountDir, + 'sidebar-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + setDumpEnabled(true) + + const fallbackAccount = { + ...accountWithHandle(), + access: 'fallback-access', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 50, + remainingPercent: 50, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: Date.now(), + }, + }, + } + const storage = { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + fallbackOn: [429], + quota: { enabled: false }, + dump: { enabled: true }, + claustrum: { accounts: { 'work-alt': { enabled: true } } }, + accounts: [fallbackAccount], + } as never + await saveAccounts(storage, accountPath) + await saveAccountState(storage, accountPath, { accounts: true }) + + const authorizations: Array = [] + const requestUrls: string[] = [] + globalThis.fetch = mock( + (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + requestUrls.push(url) + if (!url.startsWith('https://api.anthropic.com/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 500 })) + } + authorizations.push(new Headers(init?.headers).get('authorization')) + return Promise.resolve( + new Response( + '{"id":"msg_handle_production","type":"message","model":"claude-sonnet-4-5","content":[]}', + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ) + }, + ) as unknown as typeof fetch + + const plugin = await ( + AnthropicAuthPlugin as unknown as (ctx: { + client: unknown + }) => Promise + )({ + client: { auth: { set: mock(() => Promise.resolve()) } }, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + account: fallbackAccount, + } as never), + { models: {} }, + ) + const response = await result.fetch( + 'https://api.anthropic.com/v1/messages', + { + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'production handle blind' }], + }), + }, + ) + await response.text() + await plugin.dispose?.() + + expect(response.status).toBe(200) + expect( + requestUrls.some((url) => + url.startsWith('https://api.anthropic.com/v1/messages'), + ), + ).toBe(true) + expect(authorizations).toContain('Bearer main-access') + const files = await readdir(dumpDir) + expect(files.length).toBeGreaterThan(0) + const bytes = await Promise.all( + files.map((file) => readFile(join(dumpDir, file), 'utf8')), + ) + const artifacts = bytes.join('\n') + expect(artifacts).toContain('production handle blind') + expect(artifacts).toContain('msg_handle_production') + expect(countOccurrences(artifacts, HANDLE_SENTINEL)).toBe(0) + }) + + test('report failure logs stay blind to the served credential handle', async () => { + const accountDir = await mkdtemp( + join(tmpdir(), 'opencode-handle-report-log-'), + ) + accountDirs.push(accountDir) + const accountPath = join(accountDir, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath + + const storage = { + version: 1, + routing: { mode: 'fallback-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { 'work-alt': { enabled: true } } }, + accounts: [ + { + ...accountWithHandle(), + access: 'fallback-access', + expires: Date.now() + 5 * 60 * 60 * 1000, + }, + ], + } as never + await saveAccounts(storage, accountPath) + + const logs: Array> = [] + __setLogTestSink((record) => logs.push(record as Record)) + const calls: string[] = [] + const reportParams: unknown[] = [] + const connector = async () => + ({ + call: async (_moduleId: string, method: string, params: unknown) => { + calls.push(method) + if (method === 'credential.get') { + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: 'vault-access' }), + ), + ), + expires_at_ms: Date.now() + 60_000, + record_version: 17, + }, + } + } + if (method === 'credential.report_auth_failure') + reportParams.push(params) + throw new Error('report failed') + }, + close: () => {}, + }) as never + globalThis.fetch = mock(() => + Promise.resolve(new Response('{}', { status: 401 })), + ) as unknown as typeof fetch + + const plugin = await ( + AnthropicAuthPlugin as unknown as ( + ctx: { client: unknown }, + runtimeOverrides: { claustrumConnector: typeof connector }, + ) => Promise + )( + { client: { auth: { set: mock(() => Promise.resolve()) } } }, + { claustrumConnector: connector }, + ) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + + const response = await result.fetch( + 'https://api.anthropic.com/v1/messages', + { + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'report failure log blind' }], + }), + }, + ) + await response.text() + await plugin.dispose?.() + + expect(response.status).toBe(401) + expect(calls).toContain('credential.get') + expect(calls).toContain('credential.report_auth_failure') + expect(reportParams).toEqual([ + { + handle: HANDLE_SENTINEL, + provider_status: 401, + record_version: 17, + reporter_source: 'direct', + }, + ]) + expect(JSON.stringify(reportParams)).not.toContain('vault-access') + expect( + logs.some((record) => JSON.stringify(record).includes(HANDLE_SENTINEL)), + ).toBe(false) + __setLogTestSink(null) + }) + + test('dump body, metadata, response, and transport capture stay blind to tokens', async () => { + const dumpDir = await mkdtemp(join(tmpdir(), 'opencode-handle-dump-')) + dumpDirs.push(dumpDir) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + setDumpEnabled(true) + + const bodyText = '{"messages":[{"role":"user","content":"hello"}]}' + const direct = await dumpDirectRequest({ + affinity: 'ses-handle-direct', + route: 'oauth', + status: 200, + bodyText, + url: 'https://api.anthropic.com/v1/messages', + method: 'POST', + headers: { authorization: `Bearer ${TOKEN_SENTINEL}` }, + }) + expect(direct).not.toBeNull() + await dumpResponseArtifact(direct, { + status: 200, + message: { + id: 'msg_handle_blind', + model: 'claude-opus-5', + usage: { input_tokens: 3, output_tokens: 2 }, + diagnostics: { request_id: 'req_handle_blind' }, + }, + }) + + const relayBody = '{"messages":[{"role":"user","content":"relay"}]}' + const relay = await dumpRelayRequest({ + affinity: 'ses-handle-relay', + transport: 'websocket', + protocol: 2, + mode: 'full_sync', + status: 200, + bodyText: relayBody, + payload: { + protocol: 2, + type: 'request', + affinity: 'ses-handle-relay', + upstream: { + url: 'https://api.anthropic.com/v1/messages', + method: 'POST', + headers: { + authorization: `Bearer ${TOKEN_SENTINEL}`, + 'content-type': 'application/json', + }, + }, + next_hash: 'sha256:handle-blind', + mode: 'full_sync', + revision: 1, + body: relayBody, + }, + relayBytes: relayBody.length, + }) + expect(relay).not.toBeNull() + await dumpResponseArtifact(relay, { + status: 200, + message: { + id: 'msg_handle_blind_relay', + model: 'claude-opus-5', + usage: { input_tokens: 4, output_tokens: 1 }, + }, + }) + + const directPrefix = direct!.responsePath.replace('.response.json', '') + const relayPrefix = relay!.responsePath.replace('.response.json', '') + const artifacts = [ + { + name: 'direct body', + path: `${directPrefix}.body.json`, + positive: bodyText, + }, + { + name: 'direct metadata', + path: `${directPrefix}.meta.json`, + positive: '"route": "oauth"', + }, + { + name: 'direct request', + path: `${directPrefix}.request.json`, + positive: 'api.anthropic.com/v1/messages', + }, + { + name: 'direct response', + path: direct!.responsePath, + positive: 'msg_handle_blind', + }, + { + name: 'relay body', + path: `${relayPrefix}.body.json`, + positive: relayBody, + }, + { + name: 'relay metadata', + path: `${relayPrefix}.meta.json`, + positive: '"transport": "websocket"', + }, + { + name: 'relay capture', + path: `${relayPrefix}.relay.json`, + positive: '"type": "request"', + }, + { + name: 'relay response', + path: relay!.responsePath, + positive: 'msg_handle_blind_relay', + }, + ] + + for (const artifact of artifacts) { + const bytes = await readNonEmpty(artifact.path) + expect(bytes, artifact.name).toContain(artifact.positive) + expect(countOccurrences(bytes, TOKEN_SENTINEL), artifact.name).toBe(0) + } + expect(await readdir(dumpDir)).toHaveLength(8) + }) + + test('sidebar state writes only the projected account fields', async () => { + const sidebarDir = await mkdtemp(join(tmpdir(), 'opencode-handle-sidebar-')) + sidebarDirs.push(sidebarDir) + const stateFile = join(sidebarDir, 'sidebar-state.json') + + await setSidebarState(sidebarStateWithAccount(), stateFile) + await drainSidebarWrites() + const bytes = await readNonEmpty(stateFile) + const written = JSON.parse(bytes) as Record + expect(bytes).toContain('"work-alt"') + expect(bytes).toContain('"fallback-first"') + expect(written.main).toMatchObject({ + tierLabel: 'Max', + quotaBackedOff: true, + quotaBackoffUntil: 456, + refreshBackedOff: true, + refreshBackoffUntil: 789, + }) + expect(written.main.quota).toMatchObject({ + five_hour: { usedPercent: 20, remainingPercent: 80 }, + seven_day: { usedPercent: 30, remainingPercent: 70 }, + scoped: [{ id: 'scope-1', modelId: 'claude-opus-5' }], + extraUsage: { exhausted: false, severity: 'ok' }, + bindingWindow: 'five_hour', + fallbackAdvised: true, + }) + expect(written.fallbacks).toEqual([ + { + id: 'work-alt', + label: 'Work', + quota: { five_hour: { usedPercent: 40, remainingPercent: 60 } }, + enabled: true, + needsReauth: true, + tierLabel: 'Pro', + }, + ]) + expect(written.relay).toEqual({ enabled: true, transport: 'websocket' }) + expect(written.fastMode).toBe(true) + expect(written.cacheKeep).toEqual({ + enabled: true, + window: 'always', + trackedSessions: 3, + }) + expect(written.prime).toMatchObject({ + enabled: true, + accounts: [{ id: 'work-alt', usage: { count: 3 } }], + }) + expect(written.fableRecoveries).toEqual([ + { + sessionId: 'ses-handle', + mode: 'server', + remaining: 0, + changedAt: 333, + requestedModelId: 'claude-fable-5', + targetModelId: 'claude-opus-5', + }, + ]) + expect(countOccurrences(bytes, HANDLE_SENTINEL)).toBe(0) + expect(countOccurrences(bytes, TOKEN_SENTINEL)).toBe(0) + }) + + test('account records are projected before the RPC response boundary', async () => { + const rpcDir = await mkdtemp(join(tmpdir(), 'opencode-handle-rpc-')) + sidebarDirs.push(rpcDir) + const accounts = buildAccountList(storageWithHandle()).map((account) => ({ + ...account, + claustrumGate: + account.role === 'main' ? ('na' as const) : ('on' as const), + vaultServed: account.role === 'fallback', + })) + const server = await startRpcServer({ + dir: rpcDir, + drain: () => [], + apply: async () => ({ text: 'accounts loaded', knobs: { accounts } }), + }) + try { + const response = await fetch( + `http://127.0.0.1:${server.port}/rpc/apply`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${server.token}`, + }, + body: JSON.stringify({ command: 'claude-account', arguments: '' }), + }, + ) + expect(response.status).toBe(200) + const bytes = await response.text() + expect(bytes.length).toBeGreaterThan(0) + expect(bytes).toContain('accounts loaded') + expect(bytes).toContain('work-alt') + expect(bytes).toContain('claustrumGate') + expect(bytes).toContain('vaultServed') + expect(countOccurrences(bytes, HANDLE_SENTINEL)).toBe(0) + expect(countOccurrences(bytes, TOKEN_SENTINEL)).toBe(0) + } finally { + await server.stop() + } + }) +}) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 39be449a..9bf9f0d9 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -26,9 +26,12 @@ import { buildPrimeRequestBody, buildRefreshOperationError, ClaudeOAuthRefreshError, + clearClaustrumRefreshErrorPersistent, extractBillingHeaderCCH, + FALLBACK_BACKGROUND_TICK_MS, getAccountStatePath, getClaudeCodeIdentity, + getDefaultClaustrumConnectionPath, getOrCreatePrimeAuthLineageId, hashRefreshToken, isOAuthAccount, @@ -38,6 +41,7 @@ import { type OAuthQuotaSnapshot, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, PROFILE_TTL_MS, + primeStorageFingerprint, resetCache1hState, resetClaudeCodeIdentityCachesForTest, resetDumpState, @@ -47,6 +51,7 @@ import { setLogLevel, tokenFingerprint, } from '@cortexkit/anthropic-auth-core' +import { SubcCallError } from '@cortexkit/subc-client' import { AnthropicAuthPlugin } from '../index' import { LANE_START_REQUEST_HEADER, LANE_START_TEXT } from '../lane-start' import { @@ -631,13 +636,16 @@ function fireConcurrentFetches(result: { fetch: typeof fetch }) { ) } -type PluginTimerOverrides = Partial<{ +type PluginRuntimeOverrides = Partial<{ setTimeout: typeof globalThis.setTimeout setInterval: typeof globalThis.setInterval clearInterval: typeof globalThis.clearInterval + claustrumConnector: (options: unknown) => Promise + claustrumNow: () => number + clearClaustrumRefreshErrorPersistent: typeof clearClaustrumRefreshErrorPersistent }> -function disabledPluginTimerOverrides(): PluginTimerOverrides { +function disabledPluginRuntimeOverrides(): PluginRuntimeOverrides { return { // Background intervals must not outlive the test-scoped fetch mock they captured. setInterval: mock( @@ -649,7 +657,7 @@ function disabledPluginTimerOverrides(): PluginTimerOverrides { const originalSetInterval = globalThis.setInterval const originalClearInterval = globalThis.clearInterval -let pluginTimerOverrides: PluginTimerOverrides = {} +let pluginRuntimeOverrides: PluginRuntimeOverrides = {} beforeEach(() => { resetClaudeCodeIdentityCachesForTest() @@ -658,17 +666,17 @@ beforeEach(() => { async function getPlugin( client?: ReturnType, directory?: string, - timerOverrides: PluginTimerOverrides = {}, + runtimeOverrides: PluginRuntimeOverrides = {}, ) { const defaultTimerOverrides = globalThis.setInterval === originalSetInterval && globalThis.clearInterval === originalClearInterval - ? disabledPluginTimerOverrides() + ? disabledPluginRuntimeOverrides() : {} const plugin = (await ( AnthropicAuthPlugin as unknown as ( ctx: Parameters[0], - timers?: PluginTimerOverrides, + runtimeOverrides?: PluginRuntimeOverrides, ) => ReturnType )( { @@ -676,7 +684,11 @@ async function getPlugin( client: client ?? createMockClient(), ...(directory && { directory }), }, - { ...defaultTimerOverrides, ...pluginTimerOverrides, ...timerOverrides }, + { + ...defaultTimerOverrides, + ...pluginRuntimeOverrides, + ...runtimeOverrides, + }, )) as any if (plugin.__fallbackRefreshReady) { fallbackRefreshes.add(plugin.__fallbackRefreshReady) @@ -684,6 +696,110 @@ async function getPlugin( return plugin } +function installRelayResponseStart( + status: number, + errorEvent?: { status?: number; message?: string }, +) { + const originalWebSocket = globalThis.WebSocket + + class RelayWebSocket extends EventTarget { + static OPEN = 1 + binaryType = 'arraybuffer' + readyState = RelayWebSocket.OPEN + + constructor() { + super() + queueMicrotask(() => { + this.dispatchEvent(new Event('open')) + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'ready', + state: null, + }), + }), + ) + }) + } + + send(data: string) { + const payload = JSON.parse(data) + queueMicrotask(() => { + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'accepted', + id: payload.id, + hash: payload.next_hash, + revision: payload.revision, + }), + }), + ) + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'response_start', + id: payload.id, + status, + }), + }), + ) + if (errorEvent) { + const event = `event: error\ndata: ${JSON.stringify({ + type: 'error', + error: { + type: 'relay_upstream_error', + ...(errorEvent.status === undefined + ? {} + : { status: errorEvent.status }), + ...(errorEvent.message === undefined + ? {} + : { message: errorEvent.message }), + }, + })}\n\n` + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'chunk', + id: payload.id, + base64: btoa(event), + }), + }), + ) + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'done', + id: payload.id, + }), + }), + ) + } + }) + } + + close() { + this.readyState = 3 + this.dispatchEvent(new Event('close')) + } + } + + globalThis.WebSocket = RelayWebSocket as unknown as typeof WebSocket + return () => { + globalThis.WebSocket = originalWebSocket + } +} + +type CredentialCall = { + method: string + params: Record +} + describe('sidebar needsReauth (dead-fallback indicator)', () => { const originalFetch = globalThis.fetch @@ -778,170 +894,2593 @@ describe('sidebar needsReauth (dead-fallback indicator)', () => { }) }) -async function readFeedEntries() { - const directory = process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR! - try { - const files = await readdir(directory) - const records = await Promise.all( - files.map(async (file) => - JSON.parse(await readFile(join(directory, file), 'utf8')), - ), - ) - return records.flatMap((record) => Object.values(record.entries ?? {})) - } catch { - return [] +describe('fallback Claustrum credential resolution', () => { + const originalFetch = globalThis.fetch + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function persistedRefreshError(accountIdentity: string) { + return buildRefreshOperationError({ + error: new Error('vault unavailable'), + now: Date.now(), + accountIdentity, + }) } -} -async function waitForFeedEntries( - predicate: (entries: Awaited>) => boolean, - description: string, -) { - const deadline = performance.now() + 2_500 - let entries = await readFeedEntries() - while (!predicate(entries) && performance.now() < deadline) { - await Bun.sleep(10) - entries = await readFeedEntries() + function fallbackWithClaustrum(overrides?: Record) { + const { claustrum, ...accountOverrides } = overrides ?? {} + const account = { + id: 'fallback-1', + type: 'oauth' as const, + access: 'stored-fallback-access', + refresh: 'stored-fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + ...accountOverrides, + } + return createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: claustrum as AccountStorage['claustrum'], + accounts: [account as OAuthAccount], + }) } - if (!predicate(entries)) { - throw new Error( - `Quota header feed did not match ${description}: ${JSON.stringify(entries)}`, - ) + + function credentialResponse( + accessToken: string, + recordVersion: number, + expiresAtMs = Date.now() + 60_000, + ) { + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: accessToken }), + ), + ), + expires_at_ms: expiresAtMs, + record_version: recordVersion, + }, + } } - return entries -} -async function waitForLogRecord( - records: LogTestRecord[], - predicate: (record: LogTestRecord) => boolean, - description: string, -) { - const deadline = performance.now() + 2_500 - while (!records.some(predicate) && performance.now() < deadline) { - await Bun.sleep(10) + function connectorFor( + calls: CredentialCall[], + handler: (method: string, params: Record) => unknown, + ) { + return async () => + ({ + call: async (_moduleId: string, method: string, params: unknown) => { + const normalized = (params ?? {}) as Record + calls.push({ method, params: normalized }) + return handler(method, normalized) + }, + close: () => {}, + }) as never } - if (!records.some(predicate)) { - throw new Error( - `Expected log record ${description}: ${JSON.stringify(records)}`, + + async function clearAfterConcurrentSnapshot( + accountId: string, + handle: string, + concurrent: AccountStorage, + path: string, + ) { + const statePath = getAccountStatePath(path) + const staleState = await readFile(statePath, 'utf8') + const concurrentPath = join(dirname(path), `${accountId}-concurrent.json`) + await saveAccounts(concurrent, concurrentPath) + const concurrentConfig = await readFile(concurrentPath, 'utf8') + const concurrentState = await readFile( + getAccountStatePath(concurrentPath), + 'utf8', ) + const configLock = await acquireRefreshFileLock({ + name: 'config-write', + ttlMs: 10_000, + path, + renew: true, + }) + if (!configLock) throw new Error('failed to hold account config lock') + + await rm(statePath, { force: true }) + const fifo = Bun.spawnSync(['mkfifo', statePath]) + expect(fifo.exitCode).toBe(0) + const clearPromise = clearClaustrumRefreshErrorPersistent( + accountId, + handle, + path, + ) + const writer = Bun.spawn([ + process.execPath, + '--eval', + `import { writeFile } from 'node:fs/promises'; await writeFile(${JSON.stringify(statePath)}, ${JSON.stringify(staleState)}, 'utf8')`, + ]) + let released = false + try { + const staleRead = await Promise.race([ + writer.exited.then((code) => { + expect(code).toBe(0) + return true + }), + Bun.sleep(100).then(() => false), + ]) + if (!staleRead) { + writer.kill() + await writer.exited + } + await rm(statePath, { force: true }) + await writeFile(path, concurrentConfig, 'utf8') + await writeFile(statePath, concurrentState, 'utf8') + await configLock.release() + released = true + return await clearPromise + } finally { + writer.kill() + if (!released) await configLock.release() + } } -} - -async function loadMainAndFetch( - storage: AccountStorage, - response: Response | (() => Response), - requestHeaders?: Record, -) { - await useTempAccountFile(storage) - globalThis.fetch = mock(() => - Promise.resolve( - typeof response === 'function' ? response() : response.clone(), - ), - ) as unknown as typeof fetch - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'main-access', - refresh: 'main-refresh', - expires: Date.now() + 100000, - }), - { models: {} }, - ) - const fetched = await result.fetch(MESSAGES_URL, { - method: 'POST', - headers: requestHeaders, - body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), - }) - return { plugin, response: fetched } -} - -describe('quota header feed integration', () => { - const originalFetch = globalThis.fetch - - afterEach(() => { - globalThis.fetch = originalFetch - }) - test('publishes a direct harvested response with the observation timestamp', async () => { - const originalNow = Date.now - let clock = 1_000_000 - Date.now = () => clock - try { - await useTempAccountFile({ - ...createFallbackStorage({ - quotaHeaderFeed: { enabled: true }, - mainAccountId: 'main-fixed-id', + test.serial( + 'backs off terminal route.open failures while logging their raw code', + async () => { + await useTempAccountFile( + fallbackWithClaustrum({ + claustrumHandle: 'terminal-route-open', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, }), - accounts: [], + ) + const logs: LogTestRecord[] = [] + __setLogTestSink((record) => logs.push(record)) + let credentialGets = 0 + const connector = async () => + ({ + call: async () => { + credentialGets += 1 + throw new SubcCallError( + 'terminal', + 'route.open failed for module claustrum', + 'missing_identity', + ) + }, + close: () => {}, + }) as never + globalThis.fetch = mock((input: unknown) => { + if ( + extractUrl(input as string | URL | Request).startsWith(MESSAGES_URL) + ) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + return Promise.reject( + new Error( + `unexpected URL: ${extractUrl(input as string | URL | Request)}`, + ), + ) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + claustrumNow: () => 0, }) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('{}', { - status: 200, - headers: { - 'anthropic-ratelimit-unified-5h-utilization': '0.25', - 'anthropic-ratelimit-unified-5h-reset': '1800000000', - }, - }), - ), - ) as unknown as typeof fetch - - const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ - type: 'oauth', + type: 'oauth' as const, access: 'main-access', refresh: 'main-refresh', - expires: Date.now() + 100000, + expires: Date.now() + 100_000, }), { models: {} }, ) - const response = await result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), - }) - await response.text() - clock += 1_000 - const feedDirectory = process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR! - await waitForFeedEntries( - (entries) => entries.length === 1, - 'one published entry', + for (let attempt = 0; attempt < 12; attempt++) { + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(response.status).toBe(200) + await Bun.sleep(5) + } + + expect(credentialGets).toBe(1) + expect(logs).toContainEqual( + expect.objectContaining({ + level: 'warn', + channel: 'claustrum', + message: 'terminal credential call failed', + payload: { + code: 'missing_identity', + message: 'route.open failed for module claustrum', + }, + }), ) - const files = await readdir(feedDirectory) - expect(files).toHaveLength(1) - const record = JSON.parse( - await readFile(join(feedDirectory, files[0]!), 'utf8'), + await plugin.dispose?.() + await drainSidebarWrites() + __setLogTestSink(null) + }, + ) + + test.serial( + 'backs off latched vault probes until the next background tick', + async () => { + await useTempAccountFile( + fallbackWithClaustrum({ + claustrumHandle: 'latched-warm-backoff', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + }), ) - const raw = await readFile(join(feedDirectory, files[0]!), 'utf8') - const published = Object.values(record.entries)[0] as any - expect(published).toEqual( - expect.objectContaining({ - identity_source: 'account_ref', - account_ref: 'main-fixed-id', - configured_account_count: 1, - observed_at_ms: 1_000_000, - quota: expect.objectContaining({ - five_hour: expect.objectContaining({ checkedAt: 1_000_000 }), + let now = 0 + let credentialGets = 0 + const connector = async () => + ({ + call: async () => { + credentialGets += 1 + return { + result: { error: { class: 'auth_required', code: 'latched' } }, + } + }, + close: () => {}, + }) as never + globalThis.fetch = mock((input: unknown) => { + if ( + extractUrl(input as string | URL | Request).startsWith(MESSAGES_URL) + ) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + return Promise.reject( + new Error( + `unexpected URL: ${extractUrl(input as string | URL | Request)}`, + ), + ) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + claustrumNow: () => now, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, }), - }), + { models: {} }, ) - expect(published).not.toHaveProperty('credential_id') - for (const secret of [ - 'main-access', - 'main-refresh', - 'test-access-token', - 'test-refresh-token', - 'authorization', - 'anthropic-ratelimit-unified-5h-utilization', - 'claude-sonnet-4-5', - 'vault', - 'credential_id', - ]) { - expect(raw).not.toContain(secret) + + for (let attempt = 0; attempt < 12; attempt++) { + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(response.status).toBe(200) + await Bun.sleep(5) + } + expect(credentialGets).toBe(1) + + now = FALLBACK_BACKGROUND_TICK_MS + 1 + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(response.status).toBe(200) + for (let attempt = 0; attempt < 12 && credentialGets < 2; attempt++) { + await Bun.sleep(5) + } + expect(credentialGets).toBe(2) + await plugin.dispose?.() + }, + ) + + test('background refresh leaves a live vault account untouched while refreshing a plain control', async () => { + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-live-refresh-gate', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + }) + const vaultAccount = storage.accounts[0] as OAuthAccount + vaultAccount.expires = Date.now() - 1 + storage.accounts.push({ + id: 'plain-control', + type: 'oauth', + access: 'plain-access', + refresh: 'plain-refresh', + expires: Date.now() - 1, + }) + await useTempAccountFile(storage) + + const calls: CredentialCall[] = [] + const refreshTokens: string[] = [] + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url === TOKEN_URL) { + const refreshToken = ( + JSON.parse(String(init?.body)) as { refresh_token: string } + ).refresh_token + refreshTokens.push(refreshToken) + if (refreshToken === 'plain-refresh') { + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'plain-refreshed-access', + refresh_token: 'plain-refreshed-refresh', + expires_in: 86_400, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve( + new Response('{"error":"invalid_grant"}', { status: 400 }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') { + return credentialResponse('vault-live-access', 17) + } + return { result: {} } + }) + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + }) + await waitForAccountStorage( + (candidate) => + candidate?.accounts.some( + (account) => + account.id === 'plain-control' && + (account as OAuthAccount).access === 'plain-refreshed-access', + ) ?? false, + ) + + const saved = await loadAccounts() + const vault = saved?.accounts.find( + (account) => account.id === 'fallback-1', + ) as OAuthAccount + expect(refreshTokens).toEqual(['plain-refresh']) + expect(vault.lastRefreshError).toBeUndefined() + await plugin.dispose?.() + }) + + test('clearing a sidecar error does not roll back a concurrent routing change', async () => { + const accountId = 'routing-race' + const handle = 'handle-routing-race' + const storage = createFallbackStorage({ + routing: { mode: 'main-first' }, + claustrum: { accounts: { [accountId]: { enabled: true } } }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'stored-access', + refresh: 'stored-refresh', + expires: Date.now() + 60_000, + claustrumHandle: handle, + lastRefreshError: persistedRefreshError(accountId), + }, + ], + }) + await useTempAccountFile(storage) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const before = (await loadAccounts(path))?.accounts[0] as + | OAuthAccount + | undefined + expect(before?.lastRefreshError).toBeDefined() + const concurrent = structuredClone((await loadAccounts(path))!) + concurrent.routing = { mode: 'fallback-first' } + + const clearResult = await clearAfterConcurrentSnapshot( + accountId, + handle, + concurrent, + path, + ) + + const saved = await loadAccounts(path) + const account = saved?.accounts.find( + (candidate) => candidate.id === accountId, + ) as OAuthAccount | undefined + expect(clearResult).toBe(true) + expect(saved?.routing?.mode).toBe('fallback-first') + expect(account?.lastRefreshError).toBeUndefined() + }) + + test('clearing a sidecar error does not roll back a concurrent access-token change', async () => { + const accountId = 'credential-race' + const handle = 'handle-credential-race' + const storage = createFallbackStorage({ + claustrum: { accounts: { [accountId]: { enabled: true } } }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'old-access', + refresh: 'stored-refresh', + expires: Date.now() + 60_000, + claustrumHandle: handle, + lastRefreshError: persistedRefreshError(accountId), + }, + ], + }) + await useTempAccountFile(storage) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const before = (await loadAccounts(path))?.accounts[0] as + | OAuthAccount + | undefined + expect(before?.lastRefreshError).toBeDefined() + const concurrent = structuredClone((await loadAccounts(path))!) + const account = concurrent.accounts.find( + (candidate) => candidate.id === accountId, + ) + if (account?.type !== 'oauth') throw new Error('missing OAuth account') + account.access = 'new-access-from-refresh' + + const clearResult = await clearAfterConcurrentSnapshot( + accountId, + handle, + concurrent, + path, + ) + + const saved = await loadAccounts(path) + const savedAccount = saved?.accounts.find( + (candidate) => candidate.id === accountId, + ) as OAuthAccount | undefined + expect(clearResult).toBe(true) + expect(savedAccount?.type).toBe('oauth') + expect(savedAccount?.access).toBe('new-access-from-refresh') + expect(savedAccount?.lastRefreshError).toBeUndefined() + }) + + test('clears a persisted sidecar refresh error for an enabled Claustrum account', async () => { + const accountId = 'clear-control' + const handle = 'handle-clear-control' + await useTempAccountFile( + createFallbackStorage({ + claustrum: { accounts: { [accountId]: { enabled: true } } }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'stored-access', + refresh: 'stored-refresh', + expires: Date.now() + 60_000, + claustrumHandle: handle, + lastRefreshError: persistedRefreshError(accountId), + }, + ], + }), + ) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const before = (await loadAccounts(path))?.accounts[0] as + | OAuthAccount + | undefined + expect(before?.lastRefreshError).toBeDefined() + + const clearResult = await clearClaustrumRefreshErrorPersistent( + accountId, + handle, + path, + ) + + expect(clearResult).toBe(true) + const after = (await loadAccounts(path))?.accounts[0] as + | OAuthAccount + | undefined + expect(after?.lastRefreshError).toBeUndefined() + }) + + test('does not take a locked clear on a clean warm vault request', async () => { + const accountId = 'clean-warm-request' + const handle = 'handle-clean-warm-request' + await useTempAccountFile( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { [accountId]: { enabled: true } } }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'stored-access', + refresh: 'stored-refresh', + expires: Date.now() + 60_000, + claustrumHandle: handle, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: Date.now(), + }, + }, + }, + ], + }), + ) + + const calls: CredentialCall[] = [] + const clearCalls: string[] = [] + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') + return credentialResponse('vault-access', 23) + return { result: {} } + }) + const realClear = clearClaustrumRefreshErrorPersistent + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + clearClaustrumRefreshErrorPersistent: async (...args) => { + clearCalls.push(args[0]) + return realClear(...args) + }, + }) + clearCalls.length = 0 + + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + if ( + extractUrl(input as string | URL | Request).includes('/v1/messages') + ) { + expect(new Headers(init?.headers).get('authorization')).toBe( + 'Bearer vault-access', + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const cleanResponse = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(cleanResponse.status).toBe(200) + expect(clearCalls).toHaveLength(0) + + const withError = await loadAccounts() + const account = withError?.accounts.find( + (candidate) => candidate.id === accountId, + ) as OAuthAccount | undefined + if (!withError || !account) throw new Error('expected warm OAuth account') + account.lastRefreshError = persistedRefreshError(accountId) + await saveAccounts(withError) + const persisted = await loadAccounts() + expect( + ( + persisted?.accounts.find((candidate) => candidate.id === accountId) as + | OAuthAccount + | undefined + )?.lastRefreshError, + ).toBeDefined() + + const erroredResponse = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(erroredResponse.status).toBe(200) + await waitForAccountStorage( + (candidate) => + ( + candidate?.accounts.find((candidate) => candidate.id === accountId) as + | OAuthAccount + | undefined + )?.lastRefreshError === undefined, + ) + expect(clearCalls).toEqual([accountId]) + await plugin.dispose?.() + }) + + test('leaves a plain OAuth account refresh error untouched', async () => { + const plainId = 'plain-oauth' + const controlId = 'enabled-control' + const plainError = persistedRefreshError(plainId) + const controlError = persistedRefreshError(controlId) + await useTempAccountFile( + createFallbackStorage({ + claustrum: { accounts: { [controlId]: { enabled: true } } }, + accounts: [ + { + id: plainId, + type: 'oauth', + access: 'plain-access', + refresh: 'plain-refresh', + expires: Date.now() + 60_000, + lastRefreshError: plainError, + }, + { + id: controlId, + type: 'oauth', + access: 'control-access', + refresh: 'control-refresh', + expires: Date.now() + 60_000, + claustrumHandle: 'handle-enabled-control', + lastRefreshError: controlError, + }, + ], + }), + ) + const path = process.env.OPENCODE_ANTHROPIC_AUTH_FILE! + const before = await loadAccounts(path) + expect( + ( + before?.accounts.find((account) => account.id === plainId) as + | OAuthAccount + | undefined + )?.lastRefreshError, + ).toEqual(plainError) + expect( + ( + before?.accounts.find((account) => account.id === controlId) as + | OAuthAccount + | undefined + )?.lastRefreshError, + ).toEqual(controlError) + + const plainResult = await clearClaustrumRefreshErrorPersistent( + plainId, + 'not-configured', + path, + ) + const controlResult = await clearClaustrumRefreshErrorPersistent( + controlId, + 'handle-enabled-control', + path, + ) + + const saved = await loadAccounts(path) + const plain = saved?.accounts.find((account) => account.id === plainId) as + | OAuthAccount + | undefined + const control = saved?.accounts.find( + (account) => account.id === controlId, + ) as OAuthAccount | undefined + expect(plainResult).toBe(false) + expect(controlResult).toBe(true) + expect(plain?.lastRefreshError?.message).toBe(plainError.message) + expect(control?.lastRefreshError).toBeUndefined() + }) + + test('attempts local refresh when a resident vault credential is expired during an outage', async () => { + let claustrumClock = 0 + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-expired-resident', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + }) + const account = storage.accounts[0] as OAuthAccount + account.expires = Date.now() - 1 + await useTempAccountFile(storage) + + const calls: CredentialCall[] = [] + let credentialGets = 0 + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') { + credentialGets += 1 + if (credentialGets === 1) { + // The startup clock sees this entry as resident; the request clock sees + // the same entry as expired and must reopen the sidecar degradation path. + return credentialResponse('vault-expired-resident', 18, 1_000) + } + throw new Error('vault unavailable') + } + return { result: {} } + }) + const authorizations: string[] = [] + let tokenCalls = 0 + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url === TOKEN_URL) { + tokenCalls += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'locally-refreshed-access', + refresh_token: 'locally-refreshed-refresh', + expires_in: 86_400, + }), + { status: 200 }, + ), + ) + } + if (url.includes('/v1/messages')) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + claustrumNow: () => claustrumClock, + }) + claustrumClock = 2_000 + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-opus-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + + expect(response.status).toBe(200) + expect(tokenCalls).toBe(1) + expect(authorizations).toEqual(['Bearer locally-refreshed-access']) + // The expiry-triggered warm refresh is detached from request handling. + for (let attempt = 0; attempt < 50 && credentialGets < 2; attempt++) { + await Bun.sleep(10) + } + expect(credentialGets).toBe(2) + await plugin.dispose?.() + }) + + test('clears a persisted sidecar refresh error when the vault later becomes resident', async () => { + const now = Date.now() + const plainPermanentError = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(400, '{"error":"invalid_grant"}'), + now, + accountIdentity: 'plain-control', + }) + const storage = createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { 'vault-recovered': { enabled: true } } }, + accounts: [ + { + id: 'vault-recovered', + type: 'oauth', + access: 'expired-vault-access', + refresh: 'expired-vault-refresh', + expires: now - 1, + claustrumHandle: 'handle-vault-recovered', + }, + { + id: 'plain-control', + type: 'oauth', + access: 'plain-access', + refresh: 'plain-refresh', + expires: now + 5 * 60 * 60 * 1000, + lastRefreshError: plainPermanentError, + }, + ], + }) + await useTempAccountFile(storage) + + const coldCalls: CredentialCall[] = [] + const coldConnector = connectorFor(coldCalls, (method) => { + if (method === 'credential.get') throw new Error('vault unavailable') + return { result: {} } + }) + let coldTokenCalls = 0 + globalThis.fetch = mock((input: unknown) => { + const url = extractUrl(input as string | URL | Request) + if (url === TOKEN_URL) { + coldTokenCalls += 1 + return Promise.resolve( + new Response('{"error":"invalid_grant"}', { status: 400 }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + + const coldPlugin = await getPlugin(undefined, undefined, { + claustrumConnector: coldConnector, + }) + await waitForAccountStorage( + (candidate) => + ( + candidate?.accounts.find( + (account) => account.id === 'vault-recovered', + ) as OAuthAccount | undefined + )?.lastRefreshError?.permanent === true, + ) + expect(coldTokenCalls).toBe(1) + await coldPlugin.dispose?.() + + const residentCalls: CredentialCall[] = [] + const residentConnector = connectorFor(residentCalls, (method) => { + if (method === 'credential.get') + return credentialResponse('vault-recovered-access', 19) + return { result: {} } + }) + const authorizations: string[] = [] + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url.includes('/v1/messages')) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + + const residentPlugin = await getPlugin(undefined, undefined, { + claustrumConnector: residentConnector, + }) + const result = await residentPlugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: now + 100_000, + }), + { models: {} }, + ) + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-opus-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + + const saved = await waitForAccountStorage( + (candidate) => + ( + candidate?.accounts.find( + (account) => account.id === 'vault-recovered', + ) as OAuthAccount | undefined + )?.lastRefreshError === undefined, + ) + const plain = saved?.accounts.find( + (account) => account.id === 'plain-control', + ) as OAuthAccount + expect(response.status).toBe(200) + expect(authorizations).toEqual(['Bearer vault-recovered-access']) + expect( + residentCalls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + expect(plain.lastRefreshError?.permanent).toBe(true) + await residentPlugin.dispose?.() + }) + + test('clears a late local refresh error on a subsequent warm vault request', async () => { + const accountId = 'vault-late-refresh-error' + const handle = 'handle-vault-late-refresh-error' + const now = Date.now() + const initialPermanentError = { + ...buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(400, '{"error":"invalid_grant"}'), + now: now - 120_000, + accountIdentity: accountId, + }), + nextRetryAt: now - 1, + } + const storage = createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { [accountId]: { enabled: true } } }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'expired-vault-access', + refresh: 'expired-vault-refresh', + expires: now - 1, + claustrumHandle: handle, + lastRefreshError: initialPermanentError, + }, + ], + }) + await useTempAccountFile(storage) + const before = (await loadAccounts())?.accounts.find( + (account) => account.id === accountId, + ) as OAuthAccount | undefined + expect(before?.lastRefreshError?.permanent).toBe(true) + + const releaseWarm = deferred() + const refreshStarted = deferred() + const releaseLocalRefresh = deferred() + const requestReachedVault = deferred() + const releaseResponse = deferred() + const calls: CredentialCall[] = [] + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') { + return releaseWarm.promise.then(() => + credentialResponse('vault-warm-access', 31), + ) + } + return { result: {} } + }) + const authorizations: string[] = [] + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url === TOKEN_URL) { + refreshStarted.resolve() + return releaseLocalRefresh.promise.then( + () => new Response('{"error":"invalid_grant"}', { status: 400 }), + ) + } + if (url.includes('/v1/messages')) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + requestReachedVault.resolve() + return releaseResponse.promise.then( + () => new Response('{}', { status: 200 }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + }) + await Promise.race([ + refreshStarted.promise, + Bun.sleep(4_000).then(() => { + throw new Error( + 'fallback refresh never reached the token stub; the eager refresh did not start', + ) + }), + ]) + + releaseWarm.resolve() + await waitForAccountStorage( + (candidate) => + ( + candidate?.accounts.find((account) => account.id === accountId) as + | OAuthAccount + | undefined + )?.lastRefreshError === undefined, + ) + + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: now + 100_000, + }), + { models: {} }, + ) + + const responsePromise = result.fetch(MESSAGES_URL, EMPTY_POST) + await Promise.race([ + requestReachedVault.promise, + Bun.sleep(4_000).then(() => { + throw new Error('vault request never reached the response stub') + }), + ]) + + releaseLocalRefresh.resolve() + const savedWithLateError = await waitForAccountStorage( + (candidate) => + ( + candidate?.accounts.find((account) => account.id === accountId) as + | OAuthAccount + | undefined + )?.lastRefreshError?.permanent === true, + ) + expect( + ( + savedWithLateError?.accounts.find( + (account) => account.id === accountId, + ) as OAuthAccount | undefined + )?.lastRefreshError, + ).toBeDefined() + + releaseResponse.resolve() + const response = await responsePromise + expect(response.status).toBe(200) + expect(authorizations).toEqual(['Bearer vault-warm-access']) + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + + const savedAfterWarmRequest = await waitForAccountStorage( + (candidate) => + ( + candidate?.accounts.find((account) => account.id === accountId) as + | OAuthAccount + | undefined + )?.lastRefreshError === undefined, + ) + expect( + ( + savedAfterWarmRequest?.accounts.find( + (account) => account.id === accountId, + ) as OAuthAccount | undefined + )?.lastRefreshError, + ).toBeUndefined() + await plugin.dispose?.() + }) + + async function loadFallbackWithConnector( + storage: AccountStorage, + connector: (options: unknown) => Promise, + response: Response | (() => Response), + runtimeOverrides: Record = {}, + ) { + await useTempAccountFile(storage) + const authorizations: string[] = [] + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + if ( + extractUrl(input as string | URL | Request).includes('/v1/messages') + ) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return Promise.resolve( + typeof response === 'function' ? response() : response.clone(), + ) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + ...runtimeOverrides, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + return { authorizations, plugin, result } + } + + test('disabled gate does not connect and uses the stored token', async () => { + const calls: CredentialCall[] = [] + let connectorCalls = 0 + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-disabled', + claustrum: undefined, + }) + const connector = async () => { + connectorCalls += 1 + return connectorFor(calls, () => credentialResponse('vault-access', 1))() + } + + const { authorizations, plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 200 }), + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(authorizations).toContain('Bearer stored-fallback-access') + expect(connectorCalls).toBe(0) + expect(calls).toEqual([]) + await plugin.dispose?.() + }) + + test('account status inspects the configured Claustrum connection file', async () => { + const storage = createFallbackStorage({ accounts: [] }) + await useTempAccountFile(storage) + const connectionFile = join(tempConfigDir!, 'configured-claustrum.json') + await writeFile(connectionFile, '{ not valid JSON') + const previousConnectionFile = + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = + connectionFile + + try { + const mockClient = createMockClient() + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: '', + sessionID: 'configured-connection-status', + }), + ) + const text = (mockClient.session.promptAsync as any).mock.calls.at( + -1, + )?.[0]?.body.parts[0]?.text + expect(text).toContain('- Claustrum: malformed') + await plugin.dispose?.() + } finally { + if (previousConnectionFile === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + else + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = + previousConnectionFile + } + }) + + test('account modal payload projects Claustrum status through the real builder', async () => { + const sessionID = 'account-modal-projection' + const storage = fallbackWithClaustrum({ + claustrumHandle: 'projection-handle', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + }) + await useTempAccountFile(storage) + const connectionFile = join(tempConfigDir!, 'configured-claustrum.json') + await writeFile( + connectionFile, + JSON.stringify({ + schema: 1, + wire_version: 1, + endpoints: [{ host: '127.0.0.1', port: 1234 }], + }), + ) + const previousConnectionFile = + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = + connectionFile + + try { + resetNotificationsForTest() + const plugin = await getPlugin(createMockClient(), tempConfigDir!) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + drainNotifications(0, sessionID) + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: '', + sessionID, + }), + ) + const notification = drainNotifications(0, sessionID).at(-1) + const payload = notification?.payload + const accounts = payload?.knobs.accounts as Array<{ + id: string + claustrumGate: string + vaultServed: boolean + }> + expect(payload?.command).toBe('claude-account') + expect(payload?.knobs.claustrumDetection).toBe('available') + expect( + accounts.find((account) => account.id === 'main')?.claustrumGate, + ).toBe('na') + expect( + accounts.find((account) => account.id === 'fallback-1')?.claustrumGate, + ).toBe('on') + expect( + accounts.find((account) => account.id === 'fallback-1')?.vaultServed, + ).toBe(false) + + storage.claustrum = { accounts: { 'fallback-1': { enabled: false } } } + await saveAccounts(storage) + const offSessionID = 'account-modal-projection-off' + drainNotifications(0, offSessionID) + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: '', + sessionID: offSessionID, + }), + ) + const offPayload = drainNotifications(0, offSessionID).at(-1)?.payload + const offAccounts = offPayload?.knobs.accounts as Array<{ + id: string + claustrumGate: string + }> + expect( + offAccounts.find((account) => account.id === 'fallback-1') + ?.claustrumGate, + ).toBe('off') + await plugin.dispose?.() + } finally { + if (previousConnectionFile === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + else + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = + previousConnectionFile + } + }) + + test('treats an empty Claustrum connection setting as unset', async () => { + const previousConnectionFile = + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + + try { + for (const configuredConnectionFile of ['', ' \t']) { + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = + configuredConnectionFile + const calls: CredentialCall[] = [] + let connectionFile: string | undefined + const connector = async (options: unknown) => { + connectionFile = (options as { connectionFile?: string }) + .connectionFile + return connectorFor(calls, (method) => { + if (method === 'credential.get') + return credentialResponse('vault-empty-setting', 8) + return { result: {} } + })() + } + const { plugin } = await loadFallbackWithConnector( + fallbackWithClaustrum({ + claustrumHandle: 'handle-empty-setting', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never), + connector, + new Response('{}', { status: 200 }), + ) + + expect(connectionFile).toBe(getDefaultClaustrumConnectionPath()) + await plugin.dispose?.() + } + } finally { + if (previousConnectionFile === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + else + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = + previousConnectionFile + } + }) + + test('enabled gate serves the resident vault credential', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-enabled', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') + return credentialResponse('vault-access', 7) + return { result: {} } + }) + + const { authorizations, plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 200 }), + ) + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(authorizations).toContain('Bearer vault-access') + expect(authorizations).not.toContain('Bearer stored-fallback-access') + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + await plugin.dispose?.() + }) + + test('does not use a JSON credential payload without a token as bearer auth', async () => { + const calls: CredentialCall[] = [] + const malformedPayload = JSON.stringify({ kind: 'opaque-credential' }) + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-malformed-payload', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') { + return { + result: { + payload: Array.from(new TextEncoder().encode(malformedPayload)), + expires_at_ms: Date.now() + 60_000, + record_version: 8, + }, + } + } + return { result: {} } + }) + + const { authorizations, plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 401 }), + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(401) + expect(authorizations).toContain('Bearer main-access') + expect(authorizations).toContain('Bearer stored-fallback-access') + expect(authorizations).not.toContain(`Bearer ${malformedPayload}`) + await plugin.dispose?.() + }) + + test('backs off repeated transport Claustrum warm failures per handle', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + routing: { mode: 'fallback-first' }, + claustrumHandle: 'handle-transient-backoff', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') { + throw new Error('vault connection reset') + } + return { result: {} } + }) + const { plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 200 }), + ) + + const requestCount = 12 + for (let index = 0; index < requestCount; index += 1) { + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(response.status).toBe(200) + await Bun.sleep(10) + } + + const credentialGets = calls.filter( + (call) => call.method === 'credential.get', + ).length + expect(credentialGets).toBeLessThan(requestCount) + expect(credentialGets).toBe(1) + await plugin.dispose?.() + }) + + test('production cache construction supplies a bind identity to vault calls', async () => { + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-production-identity', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + await useTempAccountFile(storage) + const wireCalls: Array<{ + method: string + options?: Record + }> = [] + const connector = async () => + ({ + call: async ( + _moduleId: string, + method: string, + _params: unknown, + options?: unknown, + ) => { + const normalizedOptions = (options ?? {}) as Record + if (method === 'credential.get' && !normalizedOptions.identity) { + throw new Error('managed call requires a BindIdentity') + } + wireCalls.push({ method, options: normalizedOptions }) + return credentialResponse('vault-production-access', 73) + }, + close: () => {}, + }) as never + + const plugin = await getPlugin(undefined, '/project-root', { + claustrumConnector: connector, + }) + const identity = wireCalls.find((call) => call.method === 'credential.get') + ?.options?.identity as Record | undefined + + expect(identity).toEqual({ + project_root: '/project-root', + harness: 'opencode', + session: `store-${primeStorageFingerprint( + process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, + )}`, + }) + await plugin.dispose?.() + }) + + test('sticky quota refresh prefers the vault credential over sidecar access', async () => { + const staleCheckedAt = Date.now() - 10 * 60 * 1000 + const storage = createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + }, + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'stale-sidecar-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + claustrumHandle: 'handle-sticky-quota', + quota: { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: staleCheckedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: staleCheckedAt, + }, + }, + }, + ], + }) + const calls: CredentialCall[] = [] + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') + return credentialResponse('vault-fresh-access', 91) + return { result: {} } + }) + await useTempAccountFile(storage) + const messageAuthorizations: string[] = [] + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url.includes('/v1/messages')) { + messageAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve(new Response('{}', { status: 200 })) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + }) + await plugin.__fallbackRefreshReady + const quotaManager = plugin.__quotaManager as any + const originalGetFallback = quotaManager.getFallback.bind(quotaManager) + const quotaTokens: string[] = [] + quotaManager.getFallback = (accountId: string, account?: unknown) => { + if (accountId === 'fallback-1') return undefined + return originalGetFallback(accountId, account) + } + quotaManager.refreshFallback = async ( + _accountId: string, + accessToken: string, + ) => { + quotaTokens.push(accessToken) + return { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: Date.now(), + }, + } + } + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'sticky-quota-session' }, + body: JSON.stringify({ + model: 'claude-opus-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + + expect(response.status).toBe(200) + expect(messageAuthorizations).toContain('Bearer vault-fresh-access') + expect(quotaTokens).toEqual(['vault-fresh-access']) + await plugin.dispose?.() + }) + + test('uses a fresh sidecar token without awaiting a slow vault refresh', async () => { + const calls: CredentialCall[] = [] + const refreshEntered = deferred() + const refreshCompleted = deferred() + const slowRefresh = deferred() + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-slow', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + let credentialGets = 0 + const connector = connectorFor(calls, async (method) => { + if (method !== 'credential.get') return { result: {} } + credentialGets += 1 + if (credentialGets === 1) { + return credentialResponse('vault-stale-access', 11, Date.now() + 1) + } + refreshEntered.resolve() + await slowRefresh.promise + refreshCompleted.resolve() + return credentialResponse('vault-refreshed-access', 12) + }) + let tick!: () => Promise + const setInterval = mock((callback: () => Promise) => { + if (!tick) tick = callback + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + + const { authorizations, plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 200 }), + { setInterval }, + ) + void tick() + await refreshEntered.promise + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(authorizations).toContain('Bearer stored-fallback-access') + slowRefresh.resolve() + await refreshCompleted.promise + await plugin.dispose?.() + }) + + test('skips a Claustrum account when both vault and sidecar credentials are expired', async () => { + const now = Date.now() + const calls: CredentialCall[] = [] + const storage = createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'expired-stored-access', + refresh: 'expired-stored-refresh', + expires: now - 1, + claustrumHandle: 'handle-expired', + }, + { + id: 'fallback-2', + type: 'oauth', + access: 'backup-access', + refresh: 'backup-refresh', + expires: now + 5 * 60 * 60 * 1000, + }, + ], + }) + const connector = connectorFor(calls, () => + credentialResponse('vault-expired-access', 31, now - 1), + ) + + const { authorizations, plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 200 }), + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(authorizations).toEqual(['Bearer backup-access']) + expect(calls.some((call) => call.method === 'credential.get')).toBe(true) + await plugin.dispose?.() + }) + + test('bounds startup warmup and continues with a cold account', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-wedged', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + const connector = connectorFor(calls, async (method) => { + if (method === 'credential.get') await new Promise(() => {}) + return { result: {} } + }) + await useTempAccountFile(storage) + const authorizations: string[] = [] + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + if ( + extractUrl(input as string | URL | Request).includes('/v1/messages') + ) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await Promise.race([ + getPlugin(undefined, undefined, { claustrumConnector: connector }), + Bun.sleep(650).then(() => { + throw new Error('Claustrum warmup blocked plugin startup') + }), + ]) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(authorizations).toContain('Bearer stored-fallback-access') + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + await plugin.dispose?.() + }) + + test('detaches a stale-marked startup refresh and uses its result when it lands', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-stale-marked', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + let releaseWarm!: () => void + const warmGate = new Promise((resolve) => { + releaseWarm = resolve + }) + const connector = connectorFor(calls, async (method) => { + if (method === 'credential.get') { + await warmGate + return credentialResponse('vault-after-stale-refresh', 52) + } + return { result: {} } + }) + await useTempAccountFile(storage) + const authorizations: string[] = [] + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + if ( + extractUrl(input as string | URL | Request).includes('/v1/messages') + ) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await Promise.race([ + getPlugin(undefined, undefined, { claustrumConnector: connector }), + Bun.sleep(250).then(() => { + throw new Error('stale-marked warmup blocked plugin startup') + }), + ]) + releaseWarm() + await Bun.sleep(10) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(authorizations).toContain('Bearer vault-after-stale-refresh') + expect( + calls.filter((call) => call.method === 'credential.get'), + ).toHaveLength(1) + await plugin.dispose?.() + }) + + test('reports a 401 with the record version of the served credential', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-401', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') + return credentialResponse('vault-401-access', 23) + return { result: {} } + }) + const { plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 401 }), + ) + + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(401) + expect(calls).toContainEqual({ + method: 'credential.report_auth_failure', + params: { + handle: 'handle-401', + provider_status: 401, + record_version: 23, + reporter_source: 'direct', + }, + }) + await plugin.dispose?.() + }) + + test('reports a vault 401 surfaced by the optimistic websocket relay', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-relay-401', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) as AccountStorage + storage.relay = { + enabled: true, + url: 'https://relay.example.test', + token: 'relay-token', + fallbackToDirect: true, + transport: 'websocket', + } + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') + return credentialResponse('vault-relay-access', 73) + return { result: {} } + }) + await useTempAccountFile(storage) + globalThis.fetch = mock(() => + Promise.resolve(new Response('{}', { status: 500 })), + ) as unknown as typeof fetch + const restoreWebSocket = installRelayResponseStart(401) + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + + try { + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'relay-vault-401' }, + body: JSON.stringify({ + stream: true, + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'relay auth failure' }], + }), + }) + expect(response.status).toBe(200) + expect(await response.text()).toContain('relay_upstream_error') + } finally { + restoreWebSocket() + } + + expect(calls).toContainEqual({ + method: 'credential.report_auth_failure', + params: { + handle: 'handle-relay-401', + provider_status: 401, + record_version: 73, + reporter_source: 'relay_message_parse', + }, + }) + await plugin.dispose?.() + }) + + test('reports a sticky vault 401 surfaced by the optimistic websocket relay', async () => { + const checkedAt = Date.now() + const fallbackQuota = { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + } + const storage = createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + relay: { + enabled: true, + url: 'https://relay.example.test', + token: 'relay-token', + fallbackToDirect: true, + transport: 'websocket', + }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: false, + mainQuota: { + five_hour: { usedPercent: 100, remainingPercent: 0, checkedAt }, + seven_day: { usedPercent: 100, remainingPercent: 0, checkedAt }, + }, + mainQuotaCheckedAt: checkedAt, + }, + claustrum: { accounts: { 'sticky-relay-vault': { enabled: true } } }, + accounts: [ + { + id: 'sticky-relay-vault', + type: 'oauth', + refresh: 'sticky-relay-refresh', + expires: checkedAt + 5 * 60 * 60 * 1000, + claustrumHandle: 'handle-sticky-relay-401', + quota: fallbackQuota, + }, + ], + }) + const calls: CredentialCall[] = [] + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') + return credentialResponse('sticky-relay-access', 74) + return { result: {} } + }) + await useTempAccountFile(storage) + globalThis.fetch = mock(() => + Promise.resolve(new Response('{}', { status: 500 })), + ) as unknown as typeof fetch + const restoreWebSocket = installRelayResponseStart(200, { status: 401 }) + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: checkedAt + 5 * 60 * 60 * 1000, + }), + { models: {} }, + ) + + try { + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'sticky-relay-vault-401' }, + body: JSON.stringify({ + stream: true, + model: 'claude-opus-5', + messages: [{ role: 'user', content: 'sticky relay auth failure' }], + }), + }) + expect(response.status).toBe(200) + expect(await response.text()).toContain('relay_upstream_error') + } finally { + restoreWebSocket() + } + + expect(calls).toContainEqual({ + method: 'credential.report_auth_failure', + params: { + handle: 'handle-sticky-relay-401', + provider_status: 401, + record_version: 74, + reporter_source: 'relay_status_field', + }, + }) + await plugin.dispose?.() + }) + + test('does not report relay 401s for non-vault routes', async () => { + const storage = createFallbackStorage({ + routing: { mode: 'fallback-first' }, + relay: { + enabled: true, + url: 'https://relay.example.test', + token: 'relay-token', + fallbackToDirect: true, + transport: 'websocket', + }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + accounts: [ + { + id: 'plain-fallback', + type: 'oauth', + access: 'plain-fallback-access', + refresh: 'plain-fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + }, + ], + }) + await useTempAccountFile(storage) + globalThis.fetch = mock(() => + Promise.resolve(new Response('{}', { status: 500 })), + ) as unknown as typeof fetch + const restoreWebSocket = installRelayResponseStart(401) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + + try { + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'relay-plain-401' }, + body: JSON.stringify({ + stream: true, + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'plain relay auth failure' }], + }), + }) + expect(response.status).toBe(200) + expect(await response.text()).toContain('relay_upstream_error') + } finally { + restoreWebSocket() + await plugin.dispose?.() + } + }) + + test('suppresses a raced 401 report when the cache already holds a newer version', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-raced-401', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + let credentialGets = 0 + const connector = connectorFor(calls, (method) => { + if (method !== 'credential.get') return { result: {} } + credentialGets += 1 + return credentialResponse( + credentialGets === 1 ? 'vault-old-access' : 'vault-new-access', + credentialGets === 1 ? 41 : 42, + ) + }) + await useTempAccountFile(storage) + let cache: any + globalThis.fetch = mock(async (input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url.includes('/v1/messages')) { + const authorization = new Headers(init?.headers).get('authorization') + if ( + credentialGets === 1 && + authorization === 'Bearer vault-old-access' + ) { + cache.invalidate('handle-raced-401', 41) + await cache.get('handle-raced-401') + return new Response('{}', { status: 401 }) + } + } + return new Response('{}', { status: 200 }) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + }) + cache = plugin.__claustrumCredentialCache + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + + expect(credentialGets).toBe(1) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(credentialGets).toBe(2) + expect( + calls.some((call) => call.method === 'credential.report_auth_failure'), + ).toBe(false) + await plugin.dispose?.() + }) + + test('falls back to the stored token when the vault is unavailable', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-outage', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + const connector = connectorFor(calls, () => { + throw new Error('vault unavailable') + }) + + const { authorizations, plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 200 }), + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(authorizations).toContain('Bearer stored-fallback-access') + expect(calls.some((call) => call.method === 'credential.get')).toBe(true) + expect( + calls.some((call) => call.method === 'credential.report_auth_failure'), + ).toBe(false) + await plugin.dispose?.() + }) + + test('refreshes the sidecar credential after a 401 when the vault is unavailable', async () => { + const checkedAt = Date.now() + const fallbackQuota = { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + } + const storage = createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt, + }, + seven_day: { + usedPercent: 100, + remainingPercent: 0, + checkedAt, + }, + }, + mainQuotaCheckedAt: checkedAt, + }, + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'stored-fallback-access', + refresh: 'stored-fallback-refresh', + expires: checkedAt + 5 * 60 * 60 * 1000, + claustrumHandle: 'handle-outage-recovery', + quota: fallbackQuota, + }, + ], + }) + const calls: CredentialCall[] = [] + const connector = connectorFor(calls, () => { + throw new Error('vault unavailable') + }) + let tokenCalls = 0 + const authorizations: string[] = [] + await useTempAccountFile(storage) + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url === TOKEN_URL) { + tokenCalls += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'refreshed-sidecar-access', + refresh_token: 'refreshed-sidecar-refresh', + expires_in: 3600, + }), + { status: 200 }, + ), + ) + } + if (url.includes('/v1/messages')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + authorizations.push(authorization) + if (authorization === 'Bearer stored-fallback-access') + return Promise.resolve(new Response('{}', { status: 401 })) + if (authorization === 'Bearer refreshed-sidecar-access') + return Promise.resolve(new Response('{}', { status: 200 })) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: checkedAt + 5 * 60 * 60 * 1000, + }), + { models: {} }, + ) + + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'vault-outage-recovery' }, + body: JSON.stringify({ + model: 'claude-opus-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + + expect(response.status).toBe(200) + expect(tokenCalls).toBe(1) + expect(authorizations).toEqual([ + 'Bearer stored-fallback-access', + 'Bearer refreshed-sidecar-access', + ]) + expect( + calls.filter((call) => call.method === 'credential.report_auth_failure'), + ).toHaveLength(0) + await plugin.dispose?.() + }) + + test('does not report a 401 from a sidecar-served fallback token', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-sidecar-401', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + const connector = connectorFor(calls, () => { + throw new Error('vault unavailable') + }) + + const { authorizations, plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 401 }), + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(401) + expect(authorizations).toContain('Bearer stored-fallback-access') + expect( + calls.some((call) => call.method === 'credential.report_auth_failure'), + ).toBe(false) + await plugin.dispose?.() + }) + + test('does not report a 401 when expiry-aware fallback serves a fresh sidecar token', async () => { + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-expired-sidecar-401', + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + const connector = connectorFor(calls, () => + credentialResponse('vault-expiring-access', 61, Date.now() + 1), + ) + await useTempAccountFile(storage) + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + if ( + extractUrl(input as string | URL | Request).includes('/v1/messages') + ) { + if ( + new Headers(init?.headers).get('authorization') === + 'Bearer main-access' + ) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + return Promise.resolve(new Response('{}', { status: 401 })) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await Bun.sleep(10) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect( + calls.some((call) => call.method === 'credential.report_auth_failure'), + ).toBe(false) + await plugin.dispose?.() + }) + + test('reports a 401 using captured provenance after credential expiry', async () => { + const now = Date.now() + const calls: CredentialCall[] = [] + const storage = createFallbackStorage({ + routing: { mode: 'main-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { 'vault-only': { enabled: true } } }, + accounts: [ + { + id: 'vault-only', + type: 'oauth', + refresh: 'vault-only-refresh', + expires: now + 5 * 60 * 60 * 1000, + claustrumHandle: 'handle-expiry-skew', + quota: { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: now, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: now, + }, + }, + }, + { + id: 'fallback-2', + type: 'oauth', + access: 'backup-access', + refresh: 'backup-refresh', + expires: now + 5 * 60 * 60 * 1000, + }, + ], + }) + let fallbackPhase = false + let claustrumClockReads = 0 + const connector = connectorFor(calls, (method) => { + if (method === 'credential.get') { + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: 'vault-expiry-skew-access' }), + ), + ), + expires_at_ms: 1_000, + record_version: 70, + }, + } + } + return { result: {} } + }) + const authorizations: string[] = [] + await useTempAccountFile(storage) + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url === TOKEN_URL) { + return Promise.resolve( + new Response('refresh unavailable', { status: 500 }), + ) + } + if (url.includes('/v1/messages')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + authorizations.push(authorization) + if (authorization === 'Bearer main-access') fallbackPhase = true + if (authorization === 'Bearer main-access') + return Promise.resolve(new Response('{}', { status: 401 })) + if (authorization === 'Bearer vault-expiry-skew-access') { + return Promise.resolve(new Response('{}', { status: 401 })) + } + if (authorization === 'Bearer backup-access') { + return Promise.resolve(new Response('{}', { status: 200 })) + } + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + claustrumNow: () => { + if (!fallbackPhase) return 0 + return claustrumClockReads++ < 2 ? 0 : 2_000 + }, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: now + 5 * 60 * 60 * 1000, + }), + { models: {} }, + ) + + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(authorizations).toEqual([ + 'Bearer main-access', + 'Bearer vault-expiry-skew-access', + 'Bearer backup-access', + ]) + expect( + calls.filter((call) => call.method === 'credential.report_auth_failure'), + ).toHaveLength(1) + await plugin.dispose?.() + }) + + test('does not report a 401 after the vault credential expires before serving sidecar auth', async () => { + let now = 1_000 + const calls: CredentialCall[] = [] + const storage = fallbackWithClaustrum({ + claustrumHandle: 'handle-stale-resident-401', + expires: Date.now() + 5 * 60 * 60 * 1000, + claustrum: { accounts: { 'fallback-1': { enabled: true } } }, + } as never) + let credentialGets = 0 + let releaseRefresh!: () => void + let refreshStarted!: () => void + const refreshReleased = new Promise((resolve) => { + releaseRefresh = resolve + }) + const refreshEntered = new Promise((resolve) => { + refreshStarted = resolve + }) + const deferredTimers: Array<() => void> = [] + const setTimeoutOverride = ((handler: () => void, timeout?: number) => { + if (timeout === 0) { + deferredTimers.push(handler) + return 0 as unknown as ReturnType + } + return globalThis.setTimeout(handler, timeout) + }) as typeof globalThis.setTimeout + const connector = connectorFor(calls, (method) => { + if (method !== 'credential.get') return { result: {} } + credentialGets += 1 + if (credentialGets === 1) { + return credentialResponse('vault-expired-access', 62, now + 10) + } + refreshStarted() + return refreshReleased.then(() => + credentialResponse('vault-refreshed-access', 63, now + 5_000), + ) + }) + await useTempAccountFile(storage) + const authorizations: string[] = [] + let messageRequests = 0 + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + if ( + extractUrl(input as string | URL | Request).includes('/v1/messages') + ) { + messageRequests += 1 + const authorization = new Headers(init?.headers).get('authorization') + if (authorization) authorizations.push(authorization) + if (authorization === 'Bearer main-access') { + return Promise.resolve( + new Response(messageRequests === 1 ? null : '{}', { + status: messageRequests === 1 ? 401 : 200, + }), + ) + } + return Promise.resolve(new Response(null, { status: 401 })) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + claustrumNow: () => now, + setTimeout: setTimeoutOverride, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + + now = 2_000 + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + for (const deferredTimer of deferredTimers.splice(0)) deferredTimer() + await refreshEntered + releaseRefresh() + await Bun.sleep(0) + + expect(response.status).toBe(200) + expect(authorizations).toContain('Bearer stored-fallback-access') + expect(credentialGets).toBe(2) + expect( + calls.some((call) => call.method === 'credential.report_auth_failure'), + ).toBe(false) + await plugin.dispose?.() + }) +}) + +async function readFeedEntries() { + const directory = process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR! + try { + const files = await readdir(directory) + const records = await Promise.all( + files.map(async (file) => + JSON.parse(await readFile(join(directory, file), 'utf8')), + ), + ) + return records.flatMap((record) => Object.values(record.entries ?? {})) + } catch { + return [] + } +} + +async function waitForFeedEntries( + predicate: (entries: Awaited>) => boolean, + description: string, +) { + const deadline = performance.now() + 2_500 + let entries = await readFeedEntries() + while (!predicate(entries) && performance.now() < deadline) { + await Bun.sleep(10) + entries = await readFeedEntries() + } + if (!predicate(entries)) { + throw new Error( + `Quota header feed did not match ${description}: ${JSON.stringify(entries)}`, + ) + } + return entries +} + +async function waitForLogRecord( + records: LogTestRecord[], + predicate: (record: LogTestRecord) => boolean, + description: string, +) { + const deadline = performance.now() + 2_500 + while (!records.some(predicate) && performance.now() < deadline) { + await Bun.sleep(10) + } + if (!records.some(predicate)) { + throw new Error( + `Expected log record ${description}: ${JSON.stringify(records)}`, + ) + } +} + +async function loadMainAndFetch( + storage: AccountStorage, + response: Response | (() => Response), + requestHeaders?: Record, +) { + await useTempAccountFile(storage) + globalThis.fetch = mock(() => + Promise.resolve( + typeof response === 'function' ? response() : response.clone(), + ), + ) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + const fetched = await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), + }) + return { plugin, response: fetched } +} + +describe('quota header feed integration', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('publishes a direct harvested response with the observation timestamp', async () => { + const originalNow = Date.now + let clock = 1_000_000 + Date.now = () => clock + try { + await useTempAccountFile({ + ...createFallbackStorage({ + quotaHeaderFeed: { enabled: true }, + mainAccountId: 'main-fixed-id', + }), + accounts: [], + }) + globalThis.fetch = mock(() => + Promise.resolve( + new Response('{}', { + status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.25', + 'anthropic-ratelimit-unified-5h-reset': '1800000000', + }, + }), + ), + ) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), + }) + await response.text() + clock += 1_000 + + const feedDirectory = process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR! + await waitForFeedEntries( + (entries) => entries.length === 1, + 'one published entry', + ) + const files = await readdir(feedDirectory) + expect(files).toHaveLength(1) + const record = JSON.parse( + await readFile(join(feedDirectory, files[0]!), 'utf8'), + ) + const raw = await readFile(join(feedDirectory, files[0]!), 'utf8') + const published = Object.values(record.entries)[0] as any + expect(published).toEqual( + expect.objectContaining({ + identity_source: 'account_ref', + account_ref: 'main-fixed-id', + configured_account_count: 1, + observed_at_ms: 1_000_000, + quota: expect.objectContaining({ + five_hour: expect.objectContaining({ checkedAt: 1_000_000 }), + }), + }), + ) + expect(published).not.toHaveProperty('credential_id') + for (const secret of [ + 'main-access', + 'main-refresh', + 'test-access-token', + 'test-refresh-token', + 'authorization', + 'anthropic-ratelimit-unified-5h-utilization', + 'claude-sonnet-4-5', + 'vault', + 'credential_id', + ]) { + expect(raw).not.toContain(secret) } expect(raw).not.toContain('"credential_id":"main"') expect(raw).not.toContain('"account_ref":"main"') @@ -2200,152 +4739,840 @@ describe('quota header feed integration', () => { return Promise.resolve(new Response('{}', { status: 200 })) }) as unknown as typeof fetch - const plugin = await getPlugin() - const tokenA = 'sk-ant-oat-race-resolution-a' - const tokenB = 'sk-ant-oat-race-resolution-b' - let currentAuthA = { - type: 'oauth' as const, - access: 'compat-a', - refresh: 'refresh-a', - expires: Date.now() + 100_000, - } - const resultA = await plugin.auth.loader( - () => Promise.resolve(currentAuthA), - { models: {} }, - ) - currentAuthA = { ...currentAuthA, access: tokenA } - const requestA = resultA.fetch(MESSAGES_URL, EMPTY_POST) - await bootstrapAStartedPromise + const plugin = await getPlugin() + const tokenA = 'sk-ant-oat-race-resolution-a' + const tokenB = 'sk-ant-oat-race-resolution-b' + let currentAuthA = { + type: 'oauth' as const, + access: 'compat-a', + refresh: 'refresh-a', + expires: Date.now() + 100_000, + } + const resultA = await plugin.auth.loader( + () => Promise.resolve(currentAuthA), + { models: {} }, + ) + currentAuthA = { ...currentAuthA, access: tokenA } + const requestA = resultA.fetch(MESSAGES_URL, EMPTY_POST) + await bootstrapAStartedPromise + + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: tokenB, + refresh: 'refresh-b', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + releaseBootstrapA( + Response.json({ oauth_account: { account_uuid: 'account-a' } }), + ) + + await expect(requestA).rejects.toThrow( + 'Main OAuth identity changed while resolving request credentials', + ) + expect(bootstrapCalls).toBe(2) + } finally { + if (originalProfileHydration === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = + originalProfileHydration + } + } + }) +}) + +describe('main identity boot order', () => { + afterEach(() => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + }) + + test('mints before background refresh can observe storage and survives token rotation', async () => { + await useTempAccountFile( + createFallbackStorage({ quota: { enabled: false }, accounts: [] }), + ) + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + const observedIds: (string | undefined)[] = [] + const timerObservedIds: (string | undefined)[] = [] + let authCalls = 0 + const auth = () => { + authCalls += 1 + if (authCalls > 1) { + void loadAccounts().then((storage) => + observedIds.push(storage?.mainAccountId), + ) + } + return Promise.resolve({ + type: 'oauth' as const, + access: `access-${authCalls}`, + refresh: `refresh-${authCalls}`, + expires: Date.now() + 100_000, + }) + } + const plugin = await getPlugin(undefined, undefined, { + setInterval: ((callback: () => void) => { + const config = JSON.parse( + readFileSync(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), + ) as { mainAccountId?: unknown } + timerObservedIds.push( + typeof config.mainAccountId === 'string' + ? config.mainAccountId + : undefined, + ) + callback() + return { unref() {} } as unknown as ReturnType + }) as typeof setInterval, + clearInterval: (() => {}) as typeof clearInterval, + }) + timerObservedIds.length = 0 + + await plugin.auth.loader(auth, { models: {} }) + await Bun.sleep(25) + const first = (await loadAccounts())?.mainAccountId + await plugin.auth.loader(auth, { models: {} }) + const second = (await loadAccounts())?.mainAccountId + + expect(first).toMatch(/^[0-9a-f-]{36}$/) + expect(second).toBe(first) + expect(timerObservedIds).toEqual([first, first]) + expect(observedIds).toContain(first) + }) +}) + +describe('package metadata', () => { + test('exports a runtime-loadable TUI entrypoint', async () => { + const packageJson = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8'), + ) as { + exports?: Record + files?: string[] + 'oc-plugin'?: string[] + scripts?: Record + dependencies?: Record + } + + expect(packageJson.exports?.['./tui']).toEqual({ + types: './dist/tui.d.ts', + import: './src/tui/entry.mjs', + }) + expect(packageJson.files).toContain('src/tui.tsx') + expect(packageJson.files).toContain('src/tui') + expect(packageJson.files).toContain('src/tui-compiled') + expect(packageJson.files).toContain('src/sidebar-state.ts') + expect(packageJson['oc-plugin']).toEqual(['server', 'tui']) + expect(packageJson.scripts?.build).toContain('bun run build:tui') + for (const dependency of ['@opentui/core', '@opentui/solid', 'solid-js']) { + expect(packageJson.dependencies?.[dependency]).toMatch(/^\d/) + } + }) + + test('raw TUI fallback is loadable for development hosts', async () => { + const mod = await import('../tui.tsx') + expect(mod.default?.id).toBe('cortexkit.anthropic-auth') + expect(mod.default?.tui).toBeFunction() + }) +}) + +describe('AnthropicAuthPlugin', () => { + test('returns an object with auth properties', async () => { + const plugin = await getPlugin() + expect(plugin.auth).toBeDefined() + expect(plugin.auth.provider).toBe('anthropic') + expect(plugin.auth.loader).toBeFunction() + expect(plugin.auth.methods).toBeArray() + expect(plugin.provider?.id).toBe('anthropic') + expect(plugin.provider?.models).toBeFunction() + }) + + test('background refresh keeps vault-enabled fallback freshness in Claustrum', async () => { + const now = 1_000_000 + let currentNow = now + const accountId = 'vault-fallback' + const handle = 'vault-handle' + const vaultExpiry = now + 60 * 60_000 + const credentialPayload = Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: 'vault-access' }), + ), + ) + const calls: Array<{ method: string; args: Record }> = [] + const client = { + call: mock( + async ( + _module: string, + method: string, + args: Record, + ) => { + calls.push({ method, args }) + return { + result: { + payload: credentialPayload, + expires_at_ms: + calls.filter((call) => call.method === 'credential.get') + .length > 1 + ? currentNow + : currentNow + 60 * 60_000, + record_version: calls.length, + }, + } + }, + ), + close: mock(() => {}), + } + let tick: (() => Promise) | undefined + const setInterval = mock((callback: () => Promise) => { + tick = callback + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + const localRefresh = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'local-access', + refresh_token: 'local-refresh', + expires_in: 3600, + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + await useTempAccountFile( + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'sidecar-access', + refresh: 'sidecar-refresh', + expires: Date.now() + 3 * 60 * 60_000, + claustrumHandle: handle, + }, + ], + claustrum: { accounts: { [accountId]: { enabled: true } } }, + }), + ) + globalThis.fetch = localRefresh + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: async () => client, + claustrumNow: () => currentNow, + setInterval, + }) + await plugin.__fallbackRefreshReady + currentNow = vaultExpiry + 1 + await tick?.() + + expect(localRefresh).not.toHaveBeenCalled() + const credentialGets = calls.filter( + (call) => call.method === 'credential.get', + ) + expect(credentialGets.length).toBeGreaterThanOrEqual(2) + expect(credentialGets.at(-1)?.args).toEqual( + expect.objectContaining({ + handle, + min_ttl_ms: 240 * 60_000 + 30 * 60_000, + }), + ) + installDefaultFetchMock() + }) - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: tokenB, - refresh: 'refresh-b', - expires: Date.now() + 100_000, + test('vault reauth keeps a healthy sidecar alive and projects a vault marker', async () => { + const accountId = 'vault-reauth-fallback' + const handle = 'vault-reauth-handle' + const sidecarRefresh = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'refreshed-sidecar-access', + refresh_token: 'refreshed-sidecar-refresh', + expires_in: 3600, }), - { models: {} }, - ) - releaseBootstrapA( - Response.json({ oauth_account: { account_uuid: 'account-a' } }), - ) + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + const client = { + call: mock(async () => ({ + result: { + error: { class: 'auth_required', code: 'latched' }, + }, + })), + close: mock(() => {}), + } + let tick: (() => Promise) | undefined + const setInterval = mock((callback: () => Promise) => { + tick = callback + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval - await expect(requestA).rejects.toThrow( - 'Main OAuth identity changed while resolving request credentials', + await useTempAccountFile( + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'sidecar-access', + refresh: 'sidecar-refresh', + expires: Date.now() + 60 * 60_000, + claustrumHandle: handle, + }, + ], + claustrum: { accounts: { [accountId]: { enabled: true } } }, + }), + ) + globalThis.fetch = sidecarRefresh + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: async () => client, + setInterval, + }) + await plugin.__fallbackRefreshReady + await tick?.() + const sidebar = await waitForSidebarState((state) => { + const account = state.fallbacks.find( + (candidate) => candidate.id === accountId, ) - expect(bootstrapCalls).toBe(2) - } finally { - if (originalProfileHydration === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = - originalProfileHydration - } - } + return account?.vaultReauth === true + }) + + const account = sidebar.fallbacks.find( + (candidate) => candidate.id === accountId, + ) + expect(sidecarRefresh).toHaveBeenCalled() + expect(account?.vaultReauth).toBe(true) + expect(account?.needsReauth).toBe(false) + installDefaultFetchMock() }) -}) -describe('main identity boot order', () => { - afterEach(() => { - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + test('transient vault failure preserves an unexpired sidecar without local refresh', async () => { + const accountId = 'vault-transient-healthy' + const handle = 'vault-transient-healthy-handle' + const localRefresh = mock(() => + Promise.resolve(new Response('{}', { status: 200 })), + ) as unknown as typeof fetch + const client = { + call: mock(async () => ({ + result: { error: { class: 'transient', code: 'unreachable' } }, + })), + close: mock(() => {}), + } + let tick: (() => Promise) | undefined + const setInterval = mock((callback: () => Promise) => { + tick = callback + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + await useTempAccountFile( + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'sidecar-access', + refresh: 'sidecar-refresh', + expires: Date.now() + 5 * 60 * 60_000, + claustrumHandle: handle, + }, + ], + claustrum: { accounts: { [accountId]: { enabled: true } } }, + }), + ) + globalThis.fetch = localRefresh + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: async () => client, + setInterval, + }) + await plugin.__fallbackRefreshReady + await tick?.() + expect(localRefresh).not.toHaveBeenCalled() + installDefaultFetchMock() }) - test('mints before background refresh can observe storage and survives token rotation', async () => { + test('transient vault failure refreshes an expired sidecar with a custody override', async () => { + const accountId = 'vault-transient-expired' + const handle = 'vault-transient-expired-handle' + const logs: LogTestRecord[] = [] + const localRefresh = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'local-access', + refresh_token: 'local-refresh', + expires_in: 3600, + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + const client = { + call: mock(async () => ({ + result: { error: { class: 'transient', code: 'timeout' } }, + })), + close: mock(() => {}), + } + let tick: (() => Promise) | undefined + const setInterval = mock((callback: () => Promise) => { + tick = callback + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval await useTempAccountFile( - createFallbackStorage({ quota: { enabled: false }, accounts: [] }), + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'expired-sidecar-access', + refresh: 'sidecar-refresh', + expires: Date.now() - 1, + claustrumHandle: handle, + }, + ], + claustrum: { accounts: { [accountId]: { enabled: true } } }, + }), ) - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - const observedIds: (string | undefined)[] = [] - const timerObservedIds: (string | undefined)[] = [] - let authCalls = 0 - const auth = () => { - authCalls += 1 - if (authCalls > 1) { - void loadAccounts().then((storage) => - observedIds.push(storage?.mainAccountId), + globalThis.fetch = localRefresh + __setLogTestSink((record) => logs.push(record)) + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: async () => client, + setInterval, + }) + await plugin.__fallbackRefreshReady + await tick?.() + __setLogTestSink(null) + expect(localRefresh).toHaveBeenCalled() + expect( + logs.some( + (record) => + record.message === 'custody override: local fallback refresh', + ), + ).toBe(true) + installDefaultFetchMock() + }) + + test('latches permanent custody refresh failures until vault recovery', async () => { + const accountId = 'vault-permanent-custody' + const handle = 'vault-permanent-custody-handle' + let now = Date.now() + let credentialGets = 0 + const credentialResponse = ( + accessToken: string, + recordVersion: number, + expiresAtMs: number, + ) => ({ + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: accessToken }), + ), + ), + expires_at_ms: expiresAtMs, + record_version: recordVersion, + }, + }) + let sidecarRefreshes = 0 + const localRefresh = mock((input: unknown) => { + if (extractUrl(input as string | URL | Request) === TOKEN_URL) { + sidecarRefreshes += 1 + return Promise.resolve( + new Response('{"error":"invalid_grant"}', { status: 400 }), ) } - return Promise.resolve({ - type: 'oauth' as const, - access: `access-${authCalls}`, - refresh: `refresh-${authCalls}`, - expires: Date.now() + 100_000, - }) + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const connector = async () => ({ + call: mock(async () => { + credentialGets += 1 + if (credentialGets === 1) { + return credentialResponse('vault-initial-access', 1, now + 60_000) + } + if (credentialGets === 2 || credentialGets === 3) { + return { + result: { error: { class: 'transient', code: 'unavailable' } }, + } + } + return credentialResponse('vault-recovered-access', 2, now + 60_000) + }), + close: mock(() => {}), + }) + let tick!: () => Promise + const setInterval = mock((callback: () => Promise) => { + if (!tick) tick = callback + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + await useTempAccountFile( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'dead-sidecar-access', + refresh: 'dead-sidecar-refresh', + expires: Date.now() + 3 * 60 * 60_000, + claustrumHandle: handle, + }, + ], + claustrum: { accounts: { [accountId]: { enabled: true } } }, + }), + ) + globalThis.fetch = localRefresh + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + claustrumNow: () => now, + setInterval, + }) + await plugin.__fallbackRefreshReady + expect(credentialGets).toBe(1) + + now += 60_001 + await tick() + expect(credentialGets).toBe(2) + expect(sidecarRefreshes).toBe(1) + const latched = await waitForAccountStorage( + (storage) => + storage?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + )?.lastRefreshError?.permanent === true, + ) + expect( + latched?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + )?.lastRefreshError?.permanent, + ).toBe(true) + const latchedSidebar = await waitForSidebarState((state) => { + const account = state.fallbacks.find( + (candidate) => candidate.id === accountId, + ) + return account?.needsReauth === true + }) + const latchedSidebarAccount = latchedSidebar.fallbacks.find( + (account) => account.id === accountId, + ) + expect(latchedSidebarAccount?.needsReauth).toBe(true) + expect(latchedSidebarAccount?.vaultReauth ?? false).toBe(false) + + await tick() + expect(sidecarRefreshes).toBe(1) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 5 * 60 * 60_000, + }), + { models: {} }, + ) + await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(sidecarRefreshes).toBe(1) + + await result.fetch(MESSAGES_URL, EMPTY_POST) + now += 60_001 + await tick() + expect(credentialGets).toBe(4) + await clearClaustrumRefreshErrorPersistent(accountId, handle) + const recovered = await waitForAccountStorage( + (storage) => + storage?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + )?.lastRefreshError === undefined, + ) + expect( + recovered?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + )?.lastRefreshError, + ).toBeUndefined() + await plugin.dispose?.() + installDefaultFetchMock() + }) + + test('connects when vault custody is enabled after boot', async () => { + const accountId = 'mid-session-vault' + const handle = 'mid-session-vault-handle' + let connectAttempts = 0 + let credentialGets = 0 + let tick!: () => Promise + const setInterval = mock((callback: () => Promise) => { + if (!tick) tick = callback + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + const connector = async () => { + connectAttempts += 1 + return { + call: mock(async () => { + credentialGets += 1 + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: 'mid-session-vault-access' }), + ), + ), + expires_at_ms: Date.now() + 60 * 60_000, + record_version: credentialGets, + }, + } + }), + close: mock(() => {}), + } } + await useTempAccountFile( + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'sidecar-access', + refresh: 'sidecar-refresh', + expires: Date.now() + 5 * 60 * 60_000, + claustrumHandle: handle, + }, + ], + }), + ) + globalThis.fetch = mock(() => + Promise.resolve(new Response('{}', { status: 200 })), + ) as unknown as typeof fetch const plugin = await getPlugin(undefined, undefined, { - setInterval: ((callback: () => void) => { - const config = JSON.parse( - readFileSync(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) as { mainAccountId?: unknown } - timerObservedIds.push( - typeof config.mainAccountId === 'string' - ? config.mainAccountId - : undefined, - ) - callback() - return { unref() {} } as unknown as ReturnType - }) as typeof setInterval, - clearInterval: (() => {}) as typeof clearInterval, + claustrumConnector: connector, + setInterval, + }) + await plugin.__fallbackRefreshReady + expect(connectAttempts).toBe(0) + const storage = await loadAccounts() + if (!storage) throw new Error('missing test storage') + storage.claustrum = { accounts: { [accountId]: { enabled: true } } } + await saveAccounts(storage) + await tick() + expect(connectAttempts).toBe(1) + expect(credentialGets).toBe(1) + await plugin.dispose?.() + installDefaultFetchMock() + }) + + test('backs off failed mid-session vault connections', async () => { + const accountId = 'mid-session-vault-failure' + let now = 0 + let connectAttempts = 0 + let tick!: () => Promise + const setInterval = mock((callback: () => Promise) => { + if (!tick) tick = callback + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + await useTempAccountFile( + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'sidecar-access', + refresh: 'sidecar-refresh', + expires: Date.now() + 5 * 60 * 60_000, + claustrumHandle: 'mid-session-vault-failure-handle', + }, + ], + }), + ) + globalThis.fetch = mock(() => + Promise.resolve(new Response('{}', { status: 200 })), + ) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumNow: () => now, + setInterval, + claustrumConnector: async () => { + connectAttempts += 1 + throw new Error('vault unavailable') + }, + }) + await plugin.__fallbackRefreshReady + const storage = await loadAccounts() + if (!storage) throw new Error('missing test storage') + storage.claustrum = { accounts: { [accountId]: { enabled: true } } } + await saveAccounts(storage) + await tick() + await tick() + expect(connectAttempts).toBe(1) + now = 5_001 + await tick() + expect(connectAttempts).toBe(2) + await plugin.dispose?.() + installDefaultFetchMock() + }) + + test('custody latch survives a stale fleet writer and still clears', async () => { + const accountId = 'vault-fence' + const handle = 'vault-fence-handle' + let now = Date.now() + let credentialGets = 0 + const connector = async () => ({ + call: mock(async () => { + credentialGets += 1 + if (credentialGets === 1) { + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: 'vault' }), + ), + ), + expires_at_ms: now + 60_000, + record_version: 1, + }, + } + } + return { + result: { error: { class: 'transient', code: 'unavailable' } }, + } + }), + close: mock(() => {}), }) - timerObservedIds.length = 0 - - await plugin.auth.loader(auth, { models: {} }) - await Bun.sleep(25) - const first = (await loadAccounts())?.mainAccountId - await plugin.auth.loader(auth, { models: {} }) - const second = (await loadAccounts())?.mainAccountId - - expect(first).toMatch(/^[0-9a-f-]{36}$/) - expect(second).toBe(first) - expect(timerObservedIds).toEqual([first, first]) - expect(observedIds).toContain(first) - }) -}) - -describe('package metadata', () => { - test('exports a runtime-loadable TUI entrypoint', async () => { - const packageJson = JSON.parse( - await readFile(new URL('../../package.json', import.meta.url), 'utf8'), - ) as { - exports?: Record - files?: string[] - 'oc-plugin'?: string[] - scripts?: Record - dependencies?: Record - } - - expect(packageJson.exports?.['./tui']).toEqual({ - types: './dist/tui.d.ts', - import: './src/tui/entry.mjs', + let tick!: () => Promise + const setInterval = mock((callback: () => Promise) => { + if (!tick) tick = callback + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + await useTempAccountFile( + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'dead', + refresh: 'dead-refresh', + expires: now + 3 * 60 * 60_000, + lastRefreshedAt: now - 60_000, + claustrumHandle: handle, + }, + ], + claustrum: { accounts: { [accountId]: { enabled: true } } }, + }), + ) + globalThis.fetch = mock((input: unknown) => + extractUrl(input as string | URL | Request) === TOKEN_URL + ? Promise.resolve( + new Response('{"error":"invalid_grant"}', { status: 400 }), + ) + : Promise.resolve(new Response('{}', { status: 200 })), + ) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + claustrumNow: () => now, + setInterval, }) - expect(packageJson.files).toContain('src/tui.tsx') - expect(packageJson.files).toContain('src/tui') - expect(packageJson.files).toContain('src/tui-compiled') - expect(packageJson.files).toContain('src/sidebar-state.ts') - expect(packageJson['oc-plugin']).toEqual(['server', 'tui']) - expect(packageJson.scripts?.build).toContain('bun run build:tui') - for (const dependency of ['@opentui/core', '@opentui/solid', 'solid-js']) { - expect(packageJson.dependencies?.[dependency]).toMatch(/^\d/) - } + await plugin.__fallbackRefreshReady + now += 60_001 + await tick() + const latched = await waitForAccountStorage( + (storage) => + storage?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + )?.lastRefreshError?.permanent === true, + ) + expect( + latched?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + )?.lastRefreshError?.permanent, + ).toBe(true) + const stale = await loadAccounts() + if (!stale) throw new Error('missing test storage') + const staleAccount = stale.accounts.find( + (account) => account.id === accountId, + ) as OAuthAccount + delete staleAccount.lastRefreshError + staleAccount.lastRefreshedAt = (staleAccount.lastRefreshedAt ?? now) - 1 + await saveAccounts(stale) + expect( + (await loadAccounts())?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + )?.lastRefreshError?.permanent, + ).toBe(true) + await clearClaustrumRefreshErrorPersistent(accountId, handle) + expect( + (await loadAccounts())?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + )?.lastRefreshError, + ).toBeUndefined() + await plugin.dispose?.() + installDefaultFetchMock() }) - test('raw TUI fallback is loadable for development hosts', async () => { - const mod = await import('../tui.tsx') - expect(mod.default?.id).toBe('cortexkit.anthropic-auth') - expect(mod.default?.tui).toBeFunction() - }) -}) + test('custody latch merge prefers the newer error by checkedAt', async () => { + const accountId = 'vault-fence-recency' + const handle = 'vault-fence-recency-handle' + const now = Date.now() + const errorAt = (checkedAt: number, message: string) => + buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError( + 400, + `{"error":"invalid_grant","m":"${message}"}`, + ), + now: checkedAt, + accountIdentity: accountId, + }) + await useTempAccountFile( + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'dead', + refresh: 'dead-refresh', + expires: now + 3 * 60 * 60_000, + lastRefreshedAt: now - 60_000, + lastRefreshError: errorAt(now - 200_000, 'older-latch'), + claustrumHandle: handle, + }, + ], + claustrum: { accounts: { [accountId]: { enabled: true } } }, + }), + ) + const newerSnapshot = await loadAccounts() + if (!newerSnapshot) throw new Error('missing test storage') + const newerAccount = newerSnapshot.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + ) + if (!newerAccount) throw new Error('missing account') + newerAccount.lastRefreshError = errorAt(now, 'newer-incoming') + await saveAccounts(newerSnapshot) + const afterNewer = (await loadAccounts())?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + ) + expect(afterNewer?.lastRefreshError?.message).toContain('newer-incoming') -describe('AnthropicAuthPlugin', () => { - test('returns an object with auth properties', async () => { - const plugin = await getPlugin() - expect(plugin.auth).toBeDefined() - expect(plugin.auth.provider).toBe('anthropic') - expect(plugin.auth.loader).toBeFunction() - expect(plugin.auth.methods).toBeArray() - expect(plugin.provider?.id).toBe('anthropic') - expect(plugin.provider?.models).toBeFunction() + const olderSnapshot = await loadAccounts() + if (!olderSnapshot) throw new Error('missing test storage') + const olderAccount = olderSnapshot.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + ) + if (!olderAccount) throw new Error('missing account') + olderAccount.lastRefreshError = errorAt(now - 300_000, 'older-incoming') + await saveAccounts(olderSnapshot) + const afterOlder = (await loadAccounts())?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + ) + expect(afterOlder?.lastRefreshError?.message).toContain('newer-incoming') }) }) @@ -3355,7 +6582,7 @@ describe('auth.loader', () => { const originalDateNow = Date.now beforeEach(async () => { - pluginTimerOverrides = {} + pluginRuntimeOverrides = {} Math.random = originalRandom Date.now = originalDateNow resetCache1hState() @@ -3371,7 +6598,7 @@ describe('auth.loader', () => { afterEach(async () => { globalThis.fetch = originalFetch - pluginTimerOverrides = {} + pluginRuntimeOverrides = {} Math.random = originalRandom Date.now = originalDateNow resetNotificationsForTest() @@ -9069,6 +12296,277 @@ describe('auth.loader', () => { expect(responses.map((response) => response.status)).toEqual([200, 200]) }) + test('admits a sticky route with a vault credential and no sidecar access token', async () => { + const checkedAt = Date.now() + const fallbackQuota = { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + } + const storage = createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: false, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt, + }, + seven_day: { + usedPercent: 100, + remainingPercent: 0, + checkedAt, + }, + }, + mainQuotaCheckedAt: checkedAt, + }, + claustrum: { accounts: { 'vault-only-sticky': { enabled: true } } }, + accounts: [ + { + id: 'vault-only-sticky', + type: 'oauth', + refresh: 'vault-only-sticky-refresh', + expires: checkedAt + 5 * 60 * 60 * 1000, + claustrumHandle: 'handle-vault-only-sticky', + quota: fallbackQuota, + }, + ], + }) + const calls: CredentialCall[] = [] + const vaultCredentialResponse = () => ({ + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: 'vault-only-sticky-access' }), + ), + ), + expires_at_ms: Date.now() + 60 * 60 * 1000, + record_version: 71, + }, + }) + let releaseCredential!: () => void + const pendingCredential = new Promise((resolve) => { + releaseCredential = () => resolve(vaultCredentialResponse()) + }) + let credentialGets = 0 + const connector = async () => + ({ + call: async (_moduleId: string, method: string, params: unknown) => { + calls.push({ + method, + params: (params ?? {}) as Record, + }) + if (method === 'credential.get') { + credentialGets += 1 + if (credentialGets === 1) return pendingCredential + return vaultCredentialResponse() + } + return { result: {} } + }, + close: () => {}, + }) as never + const authorizations: string[] = [] + await useTempAccountFile(storage) + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url === TOKEN_URL) + return Promise.resolve( + new Response('refresh unavailable', { status: 500 }), + ) + if (url.includes('/v1/messages')) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + claustrumNow: () => 0, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: checkedAt + 5 * 60 * 60 * 1000, + }), + { models: {} }, + ) + + const request = { + method: 'POST', + headers: { 'x-session-affinity': 'vault-only-sticky-session' }, + body: JSON.stringify({ + model: 'claude-opus-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + } + const coldResponse = await result.fetch(MESSAGES_URL, request) + expect(coldResponse.status).toBe(429) + releaseCredential() + await Bun.sleep(25) + expect(credentialGets).toBe(1) + const response = await result.fetch(MESSAGES_URL, request) + + expect(response.status).toBe(200) + expect(authorizations).toEqual(['Bearer vault-only-sticky-access']) + await plugin.dispose?.() + }) + + test('does not retry a rejected sticky vault credential', async () => { + const checkedAt = Date.now() + const fallbackQuota = { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + } + const storage = createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: false, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt, + }, + seven_day: { + usedPercent: 100, + remainingPercent: 0, + checkedAt, + }, + }, + mainQuotaCheckedAt: checkedAt, + }, + claustrum: { accounts: { 'vault-sticky': { enabled: true } } }, + accounts: [ + { + id: 'vault-sticky', + type: 'oauth', + refresh: 'vault-sticky-refresh', + expires: checkedAt + 5 * 60 * 60 * 1000, + claustrumHandle: 'handle-vault-sticky', + quota: fallbackQuota, + }, + ], + }) + const calls: CredentialCall[] = [] + const rejectedVaultCredentialResponse = () => ({ + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: 'rejected-vault-access' }), + ), + ), + expires_at_ms: 60_000, + record_version: 72, + }, + }) + let releaseInitialCredential!: () => void + const initialCredential = new Promise((resolve) => { + releaseInitialCredential = () => + resolve(rejectedVaultCredentialResponse()) + }) + let credentialGets = 0 + const connector = async () => + ({ + call: async (_moduleId: string, method: string, params: unknown) => { + calls.push({ + method, + params: (params ?? {}) as Record, + }) + if (method === 'credential.get') { + credentialGets += 1 + return credentialGets === 1 + ? initialCredential + : new Promise(() => {}) + } + return { result: {} } + }, + close: () => {}, + }) as never + const authorizations: string[] = [] + await useTempAccountFile(storage) + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url === TOKEN_URL) + return Promise.resolve( + new Response('refresh unavailable', { status: 500 }), + ) + if (url.includes('/v1/messages')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + authorizations.push(authorization) + return Promise.resolve( + new Response('{}', { + status: 401, + }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + claustrumNow: () => 0, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: checkedAt + 5 * 60 * 60 * 1000, + }), + { models: {} }, + ) + const request = { + method: 'POST', + headers: { 'x-session-affinity': 'rejected-vault-sticky-session' }, + body: JSON.stringify({ + model: 'claude-opus-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + } + + const coldResponse = await result.fetch(MESSAGES_URL, request) + expect(coldResponse.status).toBe(429) + releaseInitialCredential() + await Bun.sleep(25) + const response = await result.fetch(MESSAGES_URL, request) + + expect(authorizations).toEqual(['Bearer rejected-vault-access']) + expect(response.status).toBe(401) + expect( + calls.filter((call) => call.method === 'credential.report_auth_failure'), + ).toHaveLength(1) + await plugin.dispose?.() + }) + test('admits and sends an OAuth route when an empty quota snapshot is fail-open', async () => { await useTempAccountFile( createFallbackStorage({ @@ -13335,7 +16833,7 @@ describe('claude-start integration', () => { const originalFetch = globalThis.fetch beforeEach(async () => { - pluginTimerOverrides = { + pluginRuntimeOverrides = { setInterval: mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval, @@ -13358,7 +16856,7 @@ describe('claude-start integration', () => { afterEach(async () => { __setLogTestSink(null) globalThis.fetch = originalFetch - pluginTimerOverrides = {} + pluginRuntimeOverrides = {} resetDumpState() delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION await drainSidebarWrites() @@ -13934,7 +17432,7 @@ describe('cache diagnostics', () => { beforeEach(async () => { globalThis.fetch = originalFetch Date.now = originalDateNow - pluginTimerOverrides = { + pluginRuntimeOverrides = { setInterval: mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval, @@ -13953,7 +17451,7 @@ describe('cache diagnostics', () => { __setLogTestSink(null) globalThis.fetch = originalFetch Date.now = originalDateNow - pluginTimerOverrides = {} + pluginRuntimeOverrides = {} resetDumpState() delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION await drainSidebarWrites() @@ -14251,7 +17749,7 @@ describe('cache diagnostics', () => { } }) Date.now = mock(() => now) as unknown as typeof Date.now - pluginTimerOverrides = { + pluginRuntimeOverrides = { setInterval: mock((callback: () => unknown, ms: number) => { intervals.push({ callback, ms }) return { unref() {} } as unknown as ReturnType @@ -14572,7 +18070,7 @@ describe('killswitch fetch gate', () => { process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' // Prevent this plugin instance's background intervals from leaking into // later tests without mutating process-global timers used by other files. - pluginTimerOverrides = { + pluginRuntimeOverrides = { setInterval: mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval, @@ -14582,7 +18080,7 @@ describe('killswitch fetch gate', () => { afterEach(() => { globalThis.fetch = originalFetch - pluginTimerOverrides = {} + pluginRuntimeOverrides = {} delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION }) diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index ffed52de..4c9aee3a 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -1840,10 +1840,47 @@ function retryableAnthropicStreamErrorFromRawEvent( return retryableAnthropicStreamError(errorType, message) } +type RelayUpstreamStatus = { + status: number + source: 'relay_status_field' | 'relay_message_parse' +} + +function relayUpstreamStatusFromRawEvent( + rawEvent: string, +): RelayUpstreamStatus | undefined { + if (!rawEvent.includes('relay_upstream_error')) return undefined + + const dataLines: string[] = [] + for (const line of rawEvent.split(/\r?\n/)) { + if (line.startsWith('data:')) { + const value = line.slice('data:'.length) + dataLines.push(value.startsWith(' ') ? value.slice(1) : value) + } + } + if (!dataLines.length) return undefined + + try { + const data = asDiagnosticRecord(JSON.parse(dataLines.join('\n'))) + const error = asDiagnosticRecord(data?.error) + if (stringField(error, 'type') !== 'relay_upstream_error') return undefined + const status = error?.status + if (typeof status === 'number' && Number.isInteger(status)) + return { status, source: 'relay_status_field' } + const message = stringField(error, 'message') + const match = message?.match(/HTTP\s+(\d{3})\b/i) + return match + ? { status: Number(match[1]), source: 'relay_message_parse' } + : undefined + } catch { + return undefined + } +} + function updateSseErrorState( state: SseErrorState, text: string, maxPendingBytes: number, + onRelayUpstreamError?: (status: RelayUpstreamStatus) => void, ): RetryableAnthropicStreamError | null { if (!text) return null if (state.disabled) { @@ -1867,6 +1904,8 @@ function updateSseErrorState( const rawEvent = state.pending.slice(0, boundary.index) state.pending = state.pending.slice(boundary.index + boundary.length) + const relayStatus = relayUpstreamStatusFromRawEvent(rawEvent) + if (relayStatus !== undefined) onRelayUpstreamError?.(relayStatus) const error = retryableAnthropicStreamErrorFromRawEvent(rawEvent) retryable ??= error } @@ -1919,6 +1958,7 @@ export function createStrippedStream( stopReason?: string }) => void onMessageResponse?: (message: Record) => void + onRelayUpstreamError?: (status: RelayUpstreamStatus) => void onStreamEnd?: () => void | Promise responseMode?: 'json' laneStart?: boolean @@ -2124,6 +2164,7 @@ export function createStrippedStream( sseErrors, finalDecoded, NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + options.onRelayUpstreamError, ) ?? updateFinish(laneStartRewritten)) if (retryableStreamError) { logProgress('stream_tool_prefix_retryable_error', { @@ -2203,6 +2244,7 @@ export function createStrippedStream( sseErrors, decoded, NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + options.onRelayUpstreamError, ) ?? updateFinish(laneStartRewritten)) if (retryableStreamError) { logProgress('stream_tool_prefix_retryable_error', { diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 51bbfa3a..07ae3b53 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -313,13 +313,24 @@ function AccountBlock(props: { active: boolean pacingEnabled: boolean needsReauth?: boolean + vaultReauth?: boolean tierLabel?: string marginTop?: number }) { const statusWord = () => - props.needsReauth ? 're-login' : props.active ? 'active' : 'idle' + props.needsReauth + ? 're-login' + : props.vaultReauth + ? 'vault reauth' + : props.active + ? 'active' + : 'idle' const statusTone = (): Tone => - props.needsReauth ? 'err' : props.active ? 'ok' : 'muted' + props.needsReauth || props.vaultReauth + ? 'warn' + : props.active + ? 'ok' + : 'muted' const pacingFor = ( window: | { usedPercent: number; remainingPercent: number; resetsAt?: string } @@ -638,7 +649,9 @@ function QuotaSidebar(props: { const quotaBackedOff = () => state().main?.quotaBackedOff === true const refreshBackedOff = () => state().main?.refreshBackedOff === true const needsReauth = () => enabledFallbacks().some((f) => f.needsReauth) - const degraded = () => quotaBackedOff() || refreshBackedOff() || needsReauth() + const vaultReauth = () => enabledFallbacks().some((f) => f.vaultReauth) + const degraded = () => + quotaBackedOff() || refreshBackedOff() || needsReauth() || vaultReauth() const fableRecoverySummary = () => getFableRecoverySummary(state(), props.sessionId) @@ -773,6 +786,7 @@ function QuotaSidebar(props: { pacingEnabled={prefs().sections.pacing} tierLabel={fb.tierLabel} needsReauth={fb.needsReauth} + vaultReauth={fb.vaultReauth} marginTop={1} /> )} diff --git a/packages/opencode/src/tui/command-dialogs.tsx b/packages/opencode/src/tui/command-dialogs.tsx index cf193608..c3b3b49a 100644 --- a/packages/opencode/src/tui/command-dialogs.tsx +++ b/packages/opencode/src/tui/command-dialogs.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource @opentui/solid */ import type { PrimeAccountStatus } from '@cortexkit/anthropic-auth-core' import type { TuiPluginApi } from '@opencode-ai/plugin/tui' +import type { AccountDialogAccount } from '../rpc/protocol' import type { OpenDialogPayload } from '../rpc/protocol.js' import { formatPrimeCost, formatPrimeTime } from '../sidebar-state.js' @@ -48,21 +49,19 @@ export function buildKillswitchThresholdSeed( return seedParts.join(' ') } -export function buildAccountDialogOption(account: { - id: string - label: string - role: string - enabled: boolean - quotaPercent: number | null - tierLabel?: string -}) { +export function buildAccountDialogOption(account: AccountDialogAccount) { const pct = account.quotaPercent != null ? ` ${Math.round(account.quotaPercent)}%` : ' \u2013%' const status = !account.enabled ? ' (disabled)' : '' + const gate = ` · gate ${account.claustrumGate}` + const vault = + account.role === 'main' + ? ' · vault n/a' + : ` · vault ${account.vaultServed ? 'served' : 'cold'}` return { - title: `${account.label} [${account.role}]${status}${pct}`, + title: `${account.label} [${account.role}]${status}${pct}${gate}${vault}`, value: account.id, ...(account.tierLabel && { description: account.tierLabel }), } @@ -379,14 +378,9 @@ export function openCommandDialog( if (payload.command === 'claude-account') { const accounts = - (payload.knobs.accounts as Array<{ - id: string - label: string - role: string - enabled: boolean - quotaPercent: number | null - tierLabel?: string - }>) ?? [] + (payload.knobs.accounts as AccountDialogAccount[] | undefined) ?? [] + const claustrumDetection = + (payload.knobs.claustrumDetection as string | undefined) ?? 'unknown' const updateAccounts = (r: { text: string @@ -415,30 +409,35 @@ export function openCommandDialog( ] api.ui.dialog.setSize('xlarge') api.ui.dialog.replace(() => ( - { - if (option.value === '__add__') { - openAddType() - return - } - const account = accounts.find((a) => a.id === option.value) - if (!account) return - if (account.role === 'main') { - const pct = - account.quotaPercent != null - ? ` ${Math.round(account.quotaPercent)}%` - : ' \u2013%' - showText( - api, - `${account.label}\nRole: main (read-only)\nQuota:${pct}`, - ) - return - } - openManage(account, false) - }} - /> + + {`Claustrum: ${claustrumDetection}`} + + { + if (option.value === '__add__') { + openAddType() + return + } + const account = accounts.find((a) => a.id === option.value) + if (!account) return + if (account.role === 'main') { + const pct = + account.quotaPercent != null + ? ` ${Math.round(account.quotaPercent)}%` + : ' \u2013%' + showText( + api, + `${account.label}\nRole: main (read-only)\nQuota:${pct}`, + ) + return + } + openManage(account, false) + }} + /> + + )) } diff --git a/packages/pi/src/commands.ts b/packages/pi/src/commands.ts index 9b61c422..4273c746 100644 --- a/packages/pi/src/commands.ts +++ b/packages/pi/src/commands.ts @@ -7,6 +7,7 @@ import { CLAUDE_PRIME_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, createEmptyStorage, + detectClaustrumConnection, executeAccountCommand, executeCache1hCommand, executeCacheKeepCommand, @@ -27,6 +28,7 @@ import { isFastModePersistentlyEnabled, isPrimePersistentlyEnabled, loadAccounts, + parseAccountCommandAction, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, @@ -264,9 +266,14 @@ export function registerCommands(pi: ExtensionAPI) { handler: async (args, ctx) => { const path = getPiAccountStoragePath() const storage = await loadAccounts(path) + const action = parseAccountCommandAction(args ?? '') const result = executeAccountCommand({ argumentsText: args ?? '', storage: storage ?? createEmptyStorage(), + claustrum: + action.type === 'status' + ? await detectClaustrumConnection() + : undefined, }) if (!result.updated) { From c280a38b06bcdb435a884cee5946146b4ba91259 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:09:54 +0200 Subject: [PATCH 3/5] fix(claustrum): share in-flight reconnects and dedupe concurrent 401 reports Also normalize the main TUI gate placeholder, clean test log sinks with finally, and make fake-daemon goodbye waits event-driven. --- packages/core/src/claustrum.ts | 30 +-- packages/opencode/src/index.ts | 58 ++++-- packages/opencode/src/tests/accounts.test.ts | 101 +++++----- .../src/tests/claustrum-client.test.ts | 47 ++++- .../src/tests/command-dialogs.test.ts | 15 ++ .../tests/credential-handle-blindness.test.ts | 181 ++++++++++++++---- packages/opencode/src/tui/command-dialogs.tsx | 2 +- 7 files changed, 320 insertions(+), 114 deletions(-) diff --git a/packages/core/src/claustrum.ts b/packages/core/src/claustrum.ts index c3ed9004..0456a835 100644 --- a/packages/core/src/claustrum.ts +++ b/packages/core/src/claustrum.ts @@ -251,25 +251,27 @@ export class ClaustrumClient { async #reconnect(): Promise { if (this.#closed) throw new Error('Claustrum client is closed') + if (this.#reconnecting) { + await this.#reconnecting + return + } const now = Date.now() if (now < this.#nextReconnectAt) { throw new Error('Claustrum client reconnect is backed off') } - if (!this.#reconnecting) { - this.#nextReconnectAt = now + CLAUSTRUM_CREDENTIAL_REFRESH_BACKOFF_MS - this.#reconnecting = this.#connector({ - connectionFile: this.#connectionFile, - handshakeTimeoutMs: this.#handshakeTimeoutMs, + this.#nextReconnectAt = now + CLAUSTRUM_CREDENTIAL_REFRESH_BACKOFF_MS + this.#reconnecting = this.#connector({ + connectionFile: this.#connectionFile, + handshakeTimeoutMs: this.#handshakeTimeoutMs, + }) + .then((client) => { + const previous = this.#client + this.#client = client + previous.close() + }) + .finally(() => { + this.#reconnecting = null }) - .then((client) => { - const previous = this.#client - this.#client = client - previous.close() - }) - .finally(() => { - this.#reconnecting = null - }) - } await this.#reconnecting } } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c021f239..71bd688a 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1592,6 +1592,7 @@ const anthropicAuthPlugin = async ( } let claustrumCredentialCache: ClaustrumCredentialCache | null = null + const claustrumAuthFailureReports = new Map>() const claustrumBlockedAccounts = new Set() const claustrumReauthAccounts = new Set() const claustrumWarmScheduled = new Set() @@ -5131,25 +5132,46 @@ const anthropicAuthPlugin = async ( // report (worst case one delayed cycle until the next served 401). if (!current || current.recordVersion !== served.recordVersion) return + const key = `${served.handle}\0${served.recordVersion}` + const pending = claustrumAuthFailureReports.get(key) + if (pending) { + await pending + return + } + const report = (async () => { + try { + await cache.reportAuthFailure( + served.handle, + 401, + { + recordVersion: served.recordVersion, + }, + reporterSource, + ) + } catch (error) { + handleClaustrumCredentialError( + served.accountId, + error, + served.handle, + ) + logger.warn( + 'claustrum', + 'failed to report credential failure', + { + accountId: served.accountId, + error: + error instanceof Error ? error.message : String(error), + }, + ) + } + })() + claustrumAuthFailureReports.set(key, report) try { - await cache.reportAuthFailure( - served.handle, - 401, - { - recordVersion: served.recordVersion, - }, - reporterSource, - ) - } catch (error) { - handleClaustrumCredentialError( - served.accountId, - error, - served.handle, - ) - logger.warn('claustrum', 'failed to report credential failure', { - accountId: served.accountId, - error: error instanceof Error ? error.message : String(error), - }) + await report + } finally { + if (claustrumAuthFailureReports.get(key) === report) { + claustrumAuthFailureReports.delete(key) + } } } diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 6ee653e0..219ac0e9 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -6972,54 +6972,59 @@ describe('vault-served fallback refresh gating', () => { const refreshTokens: string[] = [] const logs: LogTestRecord[] = [] __setLogTestSink((record) => logs.push(record)) - const fetchImpl = mock( - async (input: string | URL | Request, init?: RequestInit) => { - expect(String(input)).toBe('https://platform.claude.com/v1/oauth/token') - const refreshToken = ( - JSON.parse(String(init?.body)) as { refresh_token: string } - ).refresh_token - refreshTokens.push(refreshToken) - if (refreshToken === 'vault-refresh') { - return new Response('{"error":"invalid_grant"}', { status: 400 }) - } - return refreshResponse('vault-unavailable') - }, - ) as unknown as typeof fetch - - const manager = new FallbackAccountManager({ - configPath: accountPath, - fetchImpl, - now: () => 2_000, - isFallbackAccountVaultServed: (accountId: string) => - accountId === 'vault-served', - isFallbackAccountVaultEnabled: (accountId: string) => - accountId === 'vault-unavailable', - } as never) - - await manager.getUsableFallbackAccounts() - - const saved = await loadAccounts(accountPath) - const served = expectOAuthAccount( - saved?.accounts.find((account) => account.id === 'vault-served'), - ) - const unavailable = expectOAuthAccount( - saved?.accounts.find((account) => account.id === 'vault-unavailable'), - ) - expect(refreshTokens).toEqual(['outage-refresh']) - expect(served.access).toBe('vault-served-access') - expect(served.lastRefreshError).toBeUndefined() - expect(unavailable.access).toBe('vault-unavailable-refreshed-access') - expect(logs).toContainEqual( - expect.objectContaining({ - level: 'warn', - channel: 'refresh', - message: 'custody override: local fallback refresh', - payload: { - accountId: 'vault-unavailable', - reason: 'vault credential unavailable', + try { + const fetchImpl = mock( + async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe( + 'https://platform.claude.com/v1/oauth/token', + ) + const refreshToken = ( + JSON.parse(String(init?.body)) as { refresh_token: string } + ).refresh_token + refreshTokens.push(refreshToken) + if (refreshToken === 'vault-refresh') { + return new Response('{"error":"invalid_grant"}', { status: 400 }) + } + return refreshResponse('vault-unavailable') }, - }), - ) - __setLogTestSink(null) + ) as unknown as typeof fetch + + const manager = new FallbackAccountManager({ + configPath: accountPath, + fetchImpl, + now: () => 2_000, + isFallbackAccountVaultServed: (accountId: string) => + accountId === 'vault-served', + isFallbackAccountVaultEnabled: (accountId: string) => + accountId === 'vault-unavailable', + } as never) + + await manager.getUsableFallbackAccounts() + + const saved = await loadAccounts(accountPath) + const served = expectOAuthAccount( + saved?.accounts.find((account) => account.id === 'vault-served'), + ) + const unavailable = expectOAuthAccount( + saved?.accounts.find((account) => account.id === 'vault-unavailable'), + ) + expect(refreshTokens).toEqual(['outage-refresh']) + expect(served.access).toBe('vault-served-access') + expect(served.lastRefreshError).toBeUndefined() + expect(unavailable.access).toBe('vault-unavailable-refreshed-access') + expect(logs).toContainEqual( + expect.objectContaining({ + level: 'warn', + channel: 'refresh', + message: 'custody override: local fallback refresh', + payload: { + accountId: 'vault-unavailable', + reason: 'vault credential unavailable', + }, + }), + ) + } finally { + __setLogTestSink(null) + } }) }) diff --git a/packages/opencode/src/tests/claustrum-client.test.ts b/packages/opencode/src/tests/claustrum-client.test.ts index 1e4d48ab..5cfe4f81 100644 --- a/packages/opencode/src/tests/claustrum-client.test.ts +++ b/packages/opencode/src/tests/claustrum-client.test.ts @@ -239,7 +239,7 @@ async function startFakeDaemon( return Promise.race([ goodbyeSeen.then(() => true), new Promise((resolve) => - setTimeout(() => resolve(false), 100), + setTimeout(() => resolve(false), 2_000), ), ]) }, @@ -407,6 +407,51 @@ describe('ClaustrumClient', () => { expect(connections).toBe(2) }) + test('shares an in-flight reconnect between concurrent terminal failures', async () => { + let connections = 0 + const first = { + call: async () => { + throw new SubcCallError( + 'terminal', + 'resident route wedged', + 'route_wedged', + ) + }, + close: () => {}, + } + const second = { + call: async () => ({ result: { ok: true } }), + close: () => {}, + } + const reconnect = Promise.withResolvers() + const client = await connectClaustrumClient({ + connectionFile: '/tmp/unused-claustrum-connection.json', + connector: async () => { + connections += 1 + if (connections === 1) return first as never + await reconnect.promise + return second as never + }, + }) + clients.push(client) + + const firstCall = client.call('claustrum', 'credential.get', { + handle: 'h', + }) + const secondCall = client.call('claustrum', 'credential.get', { + handle: 'h', + }) + await Promise.resolve() + await Promise.resolve() + + expect(connections).toBe(2) + reconnect.resolve() + await expect(Promise.all([firstCall, secondCall])).resolves.toEqual([ + { result: { ok: true } }, + { result: { ok: true } }, + ]) + }) + test('keeps the connection-file key behind owner-only permissions', async () => { const daemon = await startFakeDaemon() const connectionFile = await makeConnectionFile(daemon.port) diff --git a/packages/opencode/src/tests/command-dialogs.test.ts b/packages/opencode/src/tests/command-dialogs.test.ts index 07f885cc..08e17a30 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -66,6 +66,21 @@ describe('buildAccountDialogOption', () => { expect(option.title).toContain('vault cold') expect(option.title).not.toContain('handle') }) + + test('renders the main account gate placeholder as n/a', () => { + const option = buildAccountDialogOption({ + id: 'main', + label: 'Main', + role: 'main', + enabled: true, + quotaPercent: null, + claustrumGate: 'na', + vaultServed: false, + }) + + expect(option.title).toContain('gate n/a') + expect(option.title).toContain('vault n/a') + }) }) describe('buildPrimeStatusRows', () => { diff --git a/packages/opencode/src/tests/credential-handle-blindness.test.ts b/packages/opencode/src/tests/credential-handle-blindness.test.ts index c877ea01..1118572f 100644 --- a/packages/opencode/src/tests/credential-handle-blindness.test.ts +++ b/packages/opencode/src/tests/credential-handle-blindness.test.ts @@ -323,13 +323,123 @@ describe('credential-handle blindness', () => { const logs: Array> = [] __setLogTestSink((record) => logs.push(record as Record)) - const calls: string[] = [] - const reportParams: unknown[] = [] + try { + const calls: string[] = [] + const reportParams: unknown[] = [] + const connector = async () => + ({ + call: async (_moduleId: string, method: string, params: unknown) => { + calls.push(method) + if (method === 'credential.get') { + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: 'vault-access' }), + ), + ), + expires_at_ms: Date.now() + 60_000, + record_version: 17, + }, + } + } + if (method === 'credential.report_auth_failure') + reportParams.push(params) + throw new Error('report failed') + }, + close: () => {}, + }) as never + globalThis.fetch = mock(() => + Promise.resolve(new Response('{}', { status: 401 })), + ) as unknown as typeof fetch + + const plugin = await ( + AnthropicAuthPlugin as unknown as ( + ctx: { client: unknown }, + runtimeOverrides: { claustrumConnector: typeof connector }, + ) => Promise + )( + { client: { auth: { set: mock(() => Promise.resolve()) } } }, + { claustrumConnector: connector }, + ) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + + const response = await result.fetch( + 'https://api.anthropic.com/v1/messages', + { + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'report failure log blind' }], + }), + }, + ) + await response.text() + await plugin.dispose?.() + + expect(response.status).toBe(401) + expect(calls).toContain('credential.get') + expect(calls).toContain('credential.report_auth_failure') + expect(reportParams).toEqual([ + { + handle: HANDLE_SENTINEL, + provider_status: 401, + record_version: 17, + reporter_source: 'direct', + }, + ]) + expect(JSON.stringify(reportParams)).not.toContain('vault-access') + expect( + logs.some((record) => JSON.stringify(record).includes(HANDLE_SENTINEL)), + ).toBe(false) + } finally { + __setLogTestSink(null) + } + }) + + test('deduplicates concurrent reports by served handle and record version', async () => { + const accountDir = await mkdtemp( + join(tmpdir(), 'opencode-handle-report-dedupe-'), + ) + accountDirs.push(accountDir) + const accountPath = join(accountDir, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath + + const fallbackAccount = { + ...accountWithHandle(), + access: 'fallback-access', + expires: Date.now() + 5 * 60 * 60 * 1000, + } + await saveAccounts( + { + version: 1, + routing: { mode: 'fallback-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { 'work-alt': { enabled: true } } }, + accounts: [fallbackAccount], + } as never, + accountPath, + ) + + const reports: Array> = [] + const firstReportStarted = Promise.withResolvers() + const releaseFirstReport = Promise.withResolvers() + const version18Warm = Promise.withResolvers() + let credentialVersion = 17 const connector = async () => ({ call: async (_moduleId: string, method: string, params: unknown) => { - calls.push(method) if (method === 'credential.get') { + if (credentialVersion === 18) version18Warm.resolve() return { result: { payload: Array.from( @@ -338,18 +448,26 @@ describe('credential-handle blindness', () => { ), ), expires_at_ms: Date.now() + 60_000, - record_version: 17, + record_version: credentialVersion, }, } } - if (method === 'credential.report_auth_failure') - reportParams.push(params) - throw new Error('report failed') + if (method === 'credential.report_auth_failure') { + reports.push(params as Record) + if (reports.length === 1) { + firstReportStarted.resolve() + await releaseFirstReport.promise + } + return { result: { ok: true } } + } + throw new Error(`unexpected method: ${method}`) }, close: () => {}, }) as never + + let responseStatus = 401 globalThis.fetch = mock(() => - Promise.resolve(new Response('{}', { status: 401 })), + Promise.resolve(new Response('{}', { status: responseStatus })), ) as unknown as typeof fetch const plugin = await ( @@ -371,36 +489,35 @@ describe('credential-handle blindness', () => { }), { models: {} }, ) - - const response = await result.fetch( - 'https://api.anthropic.com/v1/messages', - { + const request = () => + result.fetch('https://api.anthropic.com/v1/messages', { method: 'POST', body: JSON.stringify({ model: 'claude-sonnet-4-5', - messages: [{ role: 'user', content: 'report failure log blind' }], + messages: [{ role: 'user', content: 'report dedupe' }], }), - }, - ) - await response.text() + }) + + const concurrentPromises = [request(), request()] + await firstReportStarted.promise + expect(reports).toHaveLength(1) + releaseFirstReport.resolve() + const concurrent = await Promise.all(concurrentPromises) + await Promise.all(concurrent.map((response) => response.text())) + + credentialVersion = 18 + responseStatus = 200 + const coldResponse = await request() + await coldResponse.text() + await version18Warm.promise + + responseStatus = 401 + const differentVersionResponse = await request() + await differentVersionResponse.text() await plugin.dispose?.() - expect(response.status).toBe(401) - expect(calls).toContain('credential.get') - expect(calls).toContain('credential.report_auth_failure') - expect(reportParams).toEqual([ - { - handle: HANDLE_SENTINEL, - provider_status: 401, - record_version: 17, - reporter_source: 'direct', - }, - ]) - expect(JSON.stringify(reportParams)).not.toContain('vault-access') - expect( - logs.some((record) => JSON.stringify(record).includes(HANDLE_SENTINEL)), - ).toBe(false) - __setLogTestSink(null) + expect(reports).toHaveLength(2) + expect(reports.map((report) => report.record_version)).toEqual([17, 18]) }) test('dump body, metadata, response, and transport capture stay blind to tokens', async () => { diff --git a/packages/opencode/src/tui/command-dialogs.tsx b/packages/opencode/src/tui/command-dialogs.tsx index c3b3b49a..791702d8 100644 --- a/packages/opencode/src/tui/command-dialogs.tsx +++ b/packages/opencode/src/tui/command-dialogs.tsx @@ -55,7 +55,7 @@ export function buildAccountDialogOption(account: AccountDialogAccount) { ? ` ${Math.round(account.quotaPercent)}%` : ' \u2013%' const status = !account.enabled ? ' (disabled)' : '' - const gate = ` · gate ${account.claustrumGate}` + const gate = ` · gate ${account.claustrumGate === 'na' ? 'n/a' : account.claustrumGate}` const vault = account.role === 'main' ? ' · vault n/a' From 58f2b1dd5edc8912907d5bfe66b9f4b4d948b5f0 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:11:41 +0200 Subject: [PATCH 4/5] fix(claustrum): poll fallback quota with the vault credential, never an expired token 1. Resolve fallback quota credentials from the vault-first cache seam. 2. Skip vault-cold expired credentials and refresh non-vault expired credentials before polling. 3. Persist and seed cross-process fallback 429 backoff. 4. Fence auth-failure reports monotonically by credential record version. 5. Cancel the current response only when an eligible fallback actually sends. Anthropic returns 429, not 401, when the expired sidecar token reaches the usage endpoint. --- packages/core/src/accounts.ts | 103 +++++- packages/core/src/claustrum.ts | 4 + packages/core/src/quota-manager.ts | 64 +++- packages/opencode/src/index.ts | 333 ++++++++++++------ packages/opencode/src/tests/accounts.test.ts | 234 +++++++++++- .../tests/credential-handle-blindness.test.ts | 285 ++++++++++++++- packages/opencode/src/tests/index.test.ts | 189 ++++++++++ .../opencode/src/tests/quota-manager.test.ts | 48 ++- 8 files changed, 1101 insertions(+), 159 deletions(-) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 102dda60..7f89396e 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -434,6 +434,10 @@ export type AccountManagerOptions = { accountId: string, storage: AccountStorage, ) => boolean + resolveFallbackAccessToken?: ( + account: OAuthAccount, + storage: AccountStorage, + ) => { token: string; source: 'vault' | 'sidecar' } | undefined onBackgroundRefresh?: (initial?: boolean) => Promise | void // Invoked after a background quota pass persists at least one fallback storage // change (token refresh, quota update, or error recording), so consumers @@ -3734,6 +3738,10 @@ export class FallbackAccountManager { accountId: string, storage: AccountStorage, ) => boolean + private readonly resolveFallbackAccessToken: ( + account: OAuthAccount, + storage: AccountStorage, + ) => { token: string; source: 'vault' | 'sidecar' } | undefined private readonly onBackgroundRefresh: | ((initial?: boolean) => Promise | void) | undefined @@ -3750,6 +3758,18 @@ export class FallbackAccountManager { options.isFallbackAccountVaultServed ?? (() => false) this.isFallbackAccountVaultEnabled = options.isFallbackAccountVaultEnabled ?? (() => false) + this.resolveFallbackAccessToken = + options.resolveFallbackAccessToken ?? + ((account) => { + if ( + !account.access || + account.expires === undefined || + account.expires <= this.now() + ) { + return undefined + } + return { token: account.access, source: 'sidecar' } + }) this.onBackgroundRefresh = options.onBackgroundRefresh this.onFallbackStorageChanged = options.onFallbackStorageChanged this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval @@ -3871,8 +3891,9 @@ export class FallbackAccountManager { stale && !quotaBackoffActive(next.lastQuotaRefreshError, this.now()) ) { - next = (await this.refreshAccountQuota(next, storage)).account - changed = true + const result = await this.refreshAccountQuota(next, storage) + next = result.account + changed ||= result.changed } // Single source of truth: evaluate quota policy from the unified // QuotaManager cache (the same source as the staleness check above) so @@ -4033,8 +4054,8 @@ export class FallbackAccountManager { ? this.quotaManager.isFallbackStale(next.id, next.access) : quotaIsStale(next, storage, this.now()) if (!stale) continue - await this.refreshAccountQuota(next, storage) - changed = true + const result = await this.refreshAccountQuota(next, storage) + changed ||= result.changed } catch (error) { recordQuotaRefreshError(account, error, this.now()) updateStoredAccount(storage, account) @@ -4060,7 +4081,8 @@ export class FallbackAccountManager { try { if ( tokenNeedsRefresh(next, storage, this.now()) && - !this.isFallbackAccountVaultServed(next.id, storage) + !this.isFallbackAccountVaultServed(next.id, storage) && + !this.isFallbackAccountVaultEnabled(next.id, storage) ) { const refreshError = next.lastRefreshError if ( @@ -4083,8 +4105,8 @@ export class FallbackAccountManager { } continue } - await this.refreshAccountQuota(next, storage) - changed = true + const result = await this.refreshAccountQuota(next, storage) + changed ||= result.changed } catch (error) { recordQuotaRefreshError(account, error, this.now()) updateStoredAccount(storage, account) @@ -4275,9 +4297,28 @@ export class FallbackAccountManager { } async refreshAccountQuota(account: OAuthAccount, storage: AccountStorage) { + const initialQuotaState = JSON.stringify([ + account.quota, + account.lastQuotaRefreshError, + ]) + let changed = false let target = account - if (!target.access) { - throw new Error(`Fallback account ${account.id} has no access token`) + const vaultEnabled = this.isFallbackAccountVaultEnabled(target.id, storage) + let access = this.resolveFallbackAccessToken(target, storage) + if (!access && !vaultEnabled) { + target = await this.refreshAccount(account, storage, { force: true }) + changed = true + access = this.resolveFallbackAccessToken(target, storage) + } + if (!access) { + log('[quota] fallback quota poll skipped: no usable credential', { + accountId: target.id, + }) + return { + account: target, + fetched: false, + changed, + } } // Unify on the shared QuotaManager when present: it adds inflight // deduplication and 429 backoff gating around the same quota API. Fall back @@ -4297,18 +4338,38 @@ export class FallbackAccountManager { const fetchStartedAt = this.now() let fetched = false try { - const result = await fetchSnapshot(target.access) + const result = await fetchSnapshot(access.token) target.quota = result.quota fetched = result.fetched } catch (error) { const message = error instanceof Error ? error.message : String(error) - if (!message.includes('Claude quota check failed: 401')) throw error + if ( + !message.includes('Claude quota check failed: 401') || + vaultEnabled || + access.source !== 'sidecar' + ) { + throw error + } target = await this.refreshAccount(account, storage, { force: true, }) - if (!target.access) throw error + changed = true + access = this.resolveFallbackAccessToken(target, storage) + if (!access) { + log( + '[quota] fallback quota poll skipped after refresh: no usable credential', + { + accountId: target.id, + }, + ) + return { + account: target, + fetched: false, + changed, + } + } // 401 does not arm QuotaManager backoff, so this retry proceeds. - const result = await fetchSnapshot(target.access) + const result = await fetchSnapshot(access.token) target.quota = result.quota fetched = result.fetched } @@ -4325,18 +4386,19 @@ export class FallbackAccountManager { ) { this.seedFallbackQuota(latestAccount, latestStorage) updateStoredAccount(storage, latestAccount) - return { account: latestAccount, fetched: false } + return { account: latestAccount, fetched: false, changed: false } } if ( latestStorage && - latestAccount?.access === target.access && + latestAccount && + latestAccount.access === target.access && latestAccount.quota && quotaSnapshotCheckedAt(latestAccount.quota) >= fetchStartedAt && quotaSnapshotIsFresh(latestAccount.quota, latestStorage, this.now()) ) { this.seedFallbackQuota(latestAccount, latestStorage) updateStoredAccount(storage, latestAccount) - return { account: latestAccount, fetched } + return { account: latestAccount, fetched, changed: false } } target.lastQuotaRefreshError = undefined @@ -4356,6 +4418,13 @@ export class FallbackAccountManager { target, ) } - return { account: target, fetched } + return { + account: target, + fetched, + changed: + changed || + JSON.stringify([target.quota, target.lastQuotaRefreshError]) !== + initialQuotaState, + } } } diff --git a/packages/core/src/claustrum.ts b/packages/core/src/claustrum.ts index 0456a835..e6d422e9 100644 --- a/packages/core/src/claustrum.ts +++ b/packages/core/src/claustrum.ts @@ -542,6 +542,10 @@ export class ClaustrumCredentialCache { return this.#cache.get(handle) } + seedForTest(handle: string, credential: ClaustrumCredential): void { + this.#cache.set(handle, credential) + } + reduceMinTtlMs(): number { this.#minTtlMs = Math.floor(this.#minTtlMs / 2) return this.#minTtlMs diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts index 12e75a0c..3e2d76ed 100644 --- a/packages/core/src/quota-manager.ts +++ b/packages/core/src/quota-manager.ts @@ -70,6 +70,13 @@ export type QuotaManagerOptions = { error: AccountOperationError, quotaErrorGeneration: number, ) => void + onFallbackQuotaFetched?: ( + accountId: string, + quota: OAuthQuotaSnapshot, + checkedAt: number, + fetchStartedAt: number, + ) => void + onFallbackApiError?: (accountId: string, error: AccountOperationError) => void } function mergePollCompletionWithNewerHeaders( @@ -140,6 +147,8 @@ export class QuotaManager { private readonly now: () => number private readonly onMainQuotaFetched: QuotaManagerOptions['onMainQuotaFetched'] private readonly onApiError: QuotaManagerOptions['onApiError'] + private readonly onFallbackQuotaFetched: QuotaManagerOptions['onFallbackQuotaFetched'] + private readonly onFallbackApiError: QuotaManagerOptions['onFallbackApiError'] constructor(opts: QuotaManagerOptions) { this.storage = opts.storage @@ -147,6 +156,8 @@ export class QuotaManager { this.now = opts.now ?? Date.now this.onMainQuotaFetched = opts.onMainQuotaFetched this.onApiError = opts.onApiError + this.onFallbackQuotaFetched = opts.onFallbackQuotaFetched + this.onFallbackApiError = opts.onFallbackApiError this.seedMainFromStorage(opts.storage, opts.storage?.mainAccountId) this.seedMainBackoffFromStorage(opts.storage) @@ -421,19 +432,29 @@ export class QuotaManager { return promise } - async refreshAllFallbacks(accounts: OAuthAccount[]): Promise { + async refreshAllFallbacks( + accounts: OAuthAccount[], + resolveAccessToken?: (account: OAuthAccount) => string | undefined, + ): Promise { const now = this.now() for (const account of accounts) { if (account.enabled === false) continue - if (!account.access) continue + const accessToken = + resolveAccessToken?.(account) ?? + (account.access && + account.expires !== undefined && + account.expires > now + ? account.access + : undefined) + if (!accessToken) continue this.bindFallbackLineage(account.id, account) const cached = this.getFallback(account.id) if (cached && now < cached.refreshAfter) continue try { - await this.refreshFallback(account.id, account.access, account) + await this.refreshFallback(account.id, accessToken, account) } catch { // Best-effort — keep stale cache entry if fetch fails } @@ -683,6 +704,19 @@ export class QuotaManager { const lineageChanged = this.bindFallbackLineage(account.id, account) if (lineageChanged) continue if (account.enabled === false) continue + const persistedError = account.lastQuotaRefreshError + const persistedClearAt = quotaSnapshotCheckedAt(account.quota) + const currentError = this.fallbackApiErrors.get(account.id) + if ( + persistedError && + quotaBackoffActive(persistedError, this.now()) && + persistedClearAt <= (persistedError.checkedAt ?? 0) && + (!currentError || persistedError.checkedAt >= currentError.checkedAt) + ) { + this.fallbackApiErrors.set(account.id, persistedError) + } else if (currentError && persistedClearAt >= currentError.checkedAt) { + this.fallbackApiErrors.delete(account.id) + } if (!account.quota) continue const checkedAt = quotaSnapshotCheckedAt(account.quota) if (checkedAt <= 0) continue @@ -972,6 +1006,7 @@ export class QuotaManager { throw new Error('Quota refresh is already in progress') } try { + const fetchStartedAt = this.now() const quota = await fetchOAuthQuotaSnapshot({ accessToken, fetchImpl: this.fetchImpl, @@ -1002,6 +1037,12 @@ export class QuotaManager { account, ) this.fallbackApiErrors.delete(accountId) + this.onFallbackQuotaFetched?.( + accountId, + completedQuota, + now, + fetchStartedAt, + ) return { quota: completedQuota, fetched: true } } finally { await fileLock.release() @@ -1058,14 +1099,13 @@ export class QuotaManager { if ((this.fallbackGenerations.get(accountId) ?? 0) !== generation) return if (QuotaManager.isAuthError(error)) return const previous = this.fallbackApiErrors.get(accountId) - this.fallbackApiErrors.set( - accountId, - buildQuotaOperationError({ - error, - now: this.now(), - previous, - accountIdentity: accountId, - }), - ) + const quotaError = buildQuotaOperationError({ + error, + now: this.now(), + previous, + accountIdentity: accountId, + }) + this.fallbackApiErrors.set(accountId, quotaError) + this.onFallbackApiError?.(accountId, quotaError) } } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 71bd688a..24b74911 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -141,6 +141,7 @@ import { QuotaHeaderFeedRegistry, QuotaManager, type QuotaState, + quotaSnapshotCheckedAt, quotaSnapshotHasStandardWindows, quotaSnapshotModelScopeIsExhausted, quotaSnapshotPassesModelScope, @@ -985,6 +986,35 @@ const anthropicAuthPlugin = async ( process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE || getStickyRoutingStatePath(accountStoragePath), }) + const persistFallbackQuotaError = async ( + accountId: string, + error: NonNullable, + ) => { + try { + const storage = await loadAccounts(accountStoragePath) + const account = storage?.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === accountId && isOAuthAccount(candidate), + ) + if (!storage || !account) return + if ( + account.lastQuotaRefreshError?.checkedAt !== undefined && + account.lastQuotaRefreshError.checkedAt > error.checkedAt + ) { + return + } + if (quotaSnapshotCheckedAt(account.quota) > error.checkedAt) return + account.lastQuotaRefreshError = error + await saveAccountState(storage, accountStoragePath, { + accounts: [accountId], + }) + } catch (caught) { + logger.warn('quota', 'failed to persist fallback backoff state', { + accountId, + error: caught instanceof Error ? caught.message : String(caught), + }) + } + } const quotaManager = new QuotaManager({ storage: initialStorage, onMainQuotaFetched: async ( @@ -1039,6 +1069,49 @@ const anthropicAuthPlugin = async ( }) } }, + onFallbackQuotaFetched: async ( + accountId, + quota, + _checkedAt, + fetchStartedAt, + ) => { + try { + const storage = await loadAccounts(accountStoragePath) + const account = storage?.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === accountId && isOAuthAccount(candidate), + ) + if (!storage || !account) return + const persistedCheckedAt = quotaSnapshotCheckedAt(account.quota) + if ( + persistedCheckedAt >= fetchStartedAt && + getQuotaNextRefreshAt(account.quota, storage, persistedCheckedAt) > + Date.now() + ) { + quotaManager.seedFallbacksFromAccounts( + storage.accounts.filter(isOAuthAccount), + ) + return + } + if ( + account.lastQuotaRefreshError?.checkedAt !== undefined && + account.lastQuotaRefreshError.checkedAt > fetchStartedAt + ) { + return + } + account.quota = quota + account.lastQuotaRefreshError = undefined + await saveAccountState(storage, accountStoragePath, { + accounts: [accountId], + }) + } catch (error) { + logger.warn('quota', 'failed to persist fallback quota', { + accountId, + error: error instanceof Error ? error.message : String(error), + }) + } + }, + onFallbackApiError: persistFallbackQuotaError, }) async function reconcileMainQuotaAccountIdentity( @@ -1593,6 +1666,7 @@ const anthropicAuthPlugin = async ( let claustrumCredentialCache: ClaustrumCredentialCache | null = null const claustrumAuthFailureReports = new Map>() + const claustrumLastReportedVersion = new Map() const claustrumBlockedAccounts = new Set() const claustrumReauthAccounts = new Set() const claustrumWarmScheduled = new Set() @@ -1626,9 +1700,126 @@ const anthropicAuthPlugin = async ( return Boolean(cached && usableClaustrumAccessToken(cached, claustrumNow())) } + function claustrumWarmBackoffActive(handle: string): boolean { + const retryAt = claustrumWarmBackoffUntil.get(handle) + if (retryAt === undefined) return false + if (claustrumNow() >= retryAt) { + claustrumWarmBackoffUntil.delete(handle) + return false + } + return true + } + + function resolveClaustrumAccess( + account: OAuthAccount, + storage: Awaited>, + options?: { warm?: boolean }, + ): ClaustrumAccessResolution { + const handle = account.claustrumHandle + if ( + !handle || + !storage || + !isClaustrumEnabledForAccount(storage, account.id) || + claustrumBlockedAccounts.has(account.id) + ) { + return { accessToken: account.access } + } + + const cache = claustrumCredentialCache + if (!cache) { + if ( + account.access && + account.expires !== undefined && + account.expires > claustrumNow() + ) { + return { accessToken: account.access } + } + return {} + } + + const cached = cache.peek(handle) + const cachedAccess = usableClaustrumAccessToken(cached, claustrumNow()) + if (cached && cachedAccess) { + // Claustrum record_version is monotonic per handle: refresh_commit and + // --replace never reissue an older version, so <= is stale evidence. + if ( + (claustrumLastReportedVersion.get(handle) ?? -1) >= cached.recordVersion + ) { + if (!claustrumWarmBackoffActive(handle)) { + scheduleClaustrumWarm(account.id, handle) + } + return {} + } + return { + accessToken: cachedAccess, + served: { + accountId: account.id, + handle, + recordVersion: cached.recordVersion, + }, + } + } + + // A cold vault cache must warm off-path; a usage poll cannot wait for IPC. + if (options?.warm !== false && !claustrumWarmBackoffActive(handle)) { + scheduleClaustrumWarm(account.id, handle) + } + if ( + account.access && + account.expires !== undefined && + account.expires > claustrumNow() + ) { + return { accessToken: account.access } + } + return {} + } + + function resolveFallbackAccessToken( + account: OAuthAccount, + storage: Awaited>, + options?: { warm?: boolean }, + ): { token: string; source: 'vault' | 'sidecar' } | undefined { + const resolved = resolveClaustrumAccess(account, storage, options) + if (!resolved.accessToken) return undefined + return { + token: resolved.accessToken, + source: resolved.served ? 'vault' : 'sidecar', + } + } + + async function warmClaustrumCredential( + accountId: string, + handle: string, + ): Promise { + const cache = claustrumCredentialCache + if (!cache) return + try { + const credential = await cache.get(handle) + if (usableClaustrumAccessToken(credential, claustrumNow())) { + await markClaustrumCredentialReady(accountId, handle) + } + } catch (error) { + handleClaustrumCredentialError(accountId, error, handle) + logger.warn('claustrum', 'credential refresh failed', { + accountId, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + function scheduleClaustrumWarm(accountId: string, handle: string) { + if (claustrumWarmScheduled.has(handle)) return + claustrumWarmScheduled.add(handle) + runtimeTimers.setTimeout(() => { + claustrumWarmScheduled.delete(handle) + void warmClaustrumCredential(accountId, handle) + }, 0) + } + const fallbackManager = new FallbackAccountManager({ quotaManager, isFallbackAccountVaultServed, + resolveFallbackAccessToken, isFallbackAccountVaultEnabled: (accountId, storage) => { if (!isClaustrumEnabledForAccount(storage, accountId)) return false const account = storage.accounts.find( @@ -5018,104 +5209,6 @@ const anthropicAuthPlugin = async ( return response } - function claustrumWarmBackoffActive(handle: string): boolean { - const retryAt = claustrumWarmBackoffUntil.get(handle) - if (retryAt === undefined) return false - if (claustrumNow() >= retryAt) { - claustrumWarmBackoffUntil.delete(handle) - return false - } - return true - } - - function resolveClaustrumAccess( - account: OAuthAccount, - storage: Awaited>, - ): ClaustrumAccessResolution { - const handle = account.claustrumHandle - if ( - !handle || - !storage || - !isClaustrumEnabledForAccount(storage, account.id) || - claustrumBlockedAccounts.has(account.id) - ) { - return { accessToken: account.access } - } - - const cache = claustrumCredentialCache - if (!cache) { - if ( - account.access && - account.expires !== undefined && - account.expires > claustrumNow() - ) { - return { accessToken: account.access } - } - return {} - } - - const cached = cache.peek(handle) - const cachedAccess = usableClaustrumAccessToken( - cached, - claustrumNow(), - ) - if (cached && cachedAccess) { - return { - accessToken: cachedAccess, - served: { - accountId: account.id, - handle, - recordVersion: cached.recordVersion, - }, - } - } - - // Never send an expired vault value; the sidecar is only a - // bounded degradation path while the detached refresh completes. - // Defer the refresh until selection has captured sidecar provenance; - // the detached refresh must never turn an expired peek into a report. - if (!claustrumWarmBackoffActive(handle)) { - scheduleClaustrumWarm(account.id, handle) - } - if ( - account.access && - account.expires !== undefined && - account.expires > claustrumNow() - ) { - return { accessToken: account.access } - } - return {} - } - - async function warmClaustrumCredential( - accountId: string, - handle: string, - ): Promise { - const cache = claustrumCredentialCache - if (!cache) return - try { - const credential = await cache.get(handle) - if (usableClaustrumAccessToken(credential, claustrumNow())) { - await markClaustrumCredentialReady(accountId, handle) - } - } catch (error) { - handleClaustrumCredentialError(accountId, error, handle) - logger.warn('claustrum', 'credential refresh failed', { - accountId, - error: error instanceof Error ? error.message : String(error), - }) - } - } - - function scheduleClaustrumWarm(accountId: string, handle: string) { - if (claustrumWarmScheduled.has(handle)) return - claustrumWarmScheduled.add(handle) - runtimeTimers.setTimeout(() => { - claustrumWarmScheduled.delete(handle) - void warmClaustrumCredential(accountId, handle) - }, 0) - } - async function reportClaustrumAuthFailure( served: { accountId: string @@ -5126,6 +5219,12 @@ const anthropicAuthPlugin = async ( ): Promise { const cache = claustrumCredentialCache if (!cache) return + if ( + served.recordVersion <= + (claustrumLastReportedVersion.get(served.handle) ?? -1) + ) { + return + } const current = cache.peek(served.handle) // Version match makes reports single-shot per served version. Accepted // tradeoff: an unrelated cache eviction also suppresses a genuine @@ -5148,6 +5247,10 @@ const anthropicAuthPlugin = async ( }, reporterSource, ) + claustrumLastReportedVersion.set( + served.handle, + served.recordVersion, + ) } catch (error) { handleClaustrumCredentialError( served.accountId, @@ -5893,13 +5996,19 @@ const anthropicAuthPlugin = async ( if (!accounts.length) return currentResponse ?? null const returnLastOnExhausted = options?.returnLastOnExhausted ?? true - await currentResponse?.body?.cancel().catch(() => {}) let lastResponse: Response | null = currentResponse ?? null + let canceledCurrentResponse = false + const cancelCurrentResponse = async () => { + if (canceledCurrentResponse) return + canceledCurrentResponse = true + await currentResponse?.body?.cancel().catch(() => {}) + } for (const [index, account] of accounts.entries()) { let response: Response if (isApiKeyAccount(account)) { if (!account.apiKey) continue + await cancelCurrentResponse() response = await sendWithApiAccount( input, init, @@ -5915,6 +6024,7 @@ const anthropicAuthPlugin = async ( storage, ) if (!claustrumResolution.accessToken) continue + await cancelCurrentResponse() response = await sendWithAccessToken( input, init, @@ -5960,13 +6070,20 @@ const anthropicAuthPlugin = async ( // Non-blocking; only the served account, never idle fallbacks. if ( isOAuthAccount(account) && - account.access && + mainQuotaRoutingEnabled(storage) && quotaManager.shouldRefreshOnRequestCount(sessionRequestCount) ) { - void quotaManager - .refreshFallback(account.id, account.access, account) - .then(() => options?.onSuccess?.(account)) - .catch(() => {}) + const quotaAccess = resolveFallbackAccessToken( + account, + storage, + { warm: false }, + ) + if (quotaAccess) { + void quotaManager + .refreshFallback(account.id, quotaAccess.token, account) + .then(() => options?.onSuccess?.(account)) + .catch(() => {}) + } } return response } @@ -6101,6 +6218,7 @@ const anthropicAuthPlugin = async ( return { apiKey: '', + __reportClaustrumAuthFailureForTest: reportClaustrumAuthFailure, async fetch(input: string | URL | Request, init?: RequestInit) { const incomingHeaders = mergeHeaders(input, init) const laneStartRequest = @@ -7164,7 +7282,11 @@ const anthropicAuthPlugin = async ( auth.access, requestMainQuotaIdentity?.generation, ), - quotaManager.refreshAllFallbacks(fallbackAccts), + quotaManager.refreshAllFallbacks( + fallbackAccts, + (account) => + resolveFallbackAccessToken(account, storage)?.token, + ), ]) } catch (error) { log('[quota] killswitch refresh failed', { @@ -7403,6 +7525,7 @@ const anthropicAuthPlugin = async ( }, __primeManager: primeManager, __quotaManager: quotaManager, + __persistFallbackQuotaErrorForTest: persistFallbackQuotaError, __fallbackRefreshReady: fallbackRefreshReady, __claustrumCredentialCache: claustrumCredentialCache, // biome-ignore lint/suspicious/noExplicitAny: Plugin type doesn't include undocumented auth/hooks diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 219ac0e9..8cf26500 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -1586,7 +1586,7 @@ describe('account storage', () => { type: 'oauth', access: 'access-before', refresh: 'refresh-before', - expires: 1_000, + expires: 10_000, lastRefreshedAt: 100, }) await saveAccounts(storage, accountPath) @@ -1760,7 +1760,7 @@ describe('account storage', () => { type: 'oauth', access: 'access-before', refresh: 'refresh-before', - expires: 1_000, + expires: 10_000, }) await saveAccounts(seeded, accountPath) @@ -2996,7 +2996,7 @@ describe('FallbackAccountManager', () => { type: 'oauth', access: 'plain-access', refresh: 'plain-refresh', - expires: 1_000, + expires: 10_000, }) await saveAccounts(plainStorage, plainPath) @@ -3104,6 +3104,11 @@ describe('FallbackAccountManager', () => { fetchImpl: vaultFetch, now: () => 1_000, isFallbackAccountVaultServed: (id) => id === 'vault-quota-401', + isFallbackAccountVaultEnabled: (id) => id === 'vault-quota-401', + resolveFallbackAccessToken: () => ({ + token: 'vault-quota-401-fixture-token', + source: 'vault' as const, + }), }) const vaultAccount = expectOAuthAccount( (await loadAccounts(vaultPath))?.accounts[0], @@ -3111,10 +3116,8 @@ describe('FallbackAccountManager', () => { await expect( vaultManager.refreshAccountQuota(vaultAccount, vaultStorage), - ).resolves.toMatchObject({ - account: { access: 'vault-sidecar-access' }, - }) - expect(vaultQuotaCalls).toBe(2) + ).rejects.toThrow('Claude quota check failed: 401') + expect(vaultQuotaCalls).toBe(1) expect(vaultTokenCalls).toBe(0) expect( expectOAuthAccount((await loadAccounts(vaultPath))?.accounts[0]) @@ -3122,6 +3125,158 @@ describe('FallbackAccountManager', () => { ).toBeUndefined() }) + test('quota polling uses a resident vault credential before an expired sidecar credential', async () => { + const storage = baseStorage() + const vaultToken = 'vault-quota-fixture-token' + storage.accounts.push({ + id: 'vault-quota-token-source', + type: 'oauth', + access: 'expired-sidecar-token', + refresh: 'sidecar-refresh', + expires: 999, + claustrumHandle: 'vault-quota-token-source-handle', + }) + await saveAccounts(storage) + + const authorizations: string[] = [] + const fetchImpl = mock( + (_input: string | URL | Request, init?: RequestInit) => { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + { status: 200 }, + ), + ) + }, + ) as unknown as typeof fetch + const manager = new FallbackAccountManager({ + configPath: accountPath, + fetchImpl, + now: () => 1_000, + isFallbackAccountVaultEnabled: (id: string) => + id === 'vault-quota-token-source', + isFallbackAccountVaultServed: (id: string) => + id === 'vault-quota-token-source', + resolveFallbackAccessToken: () => ({ + token: vaultToken, + source: 'vault' as const, + }), + } as never) + const account = expectOAuthAccount((await loadAccounts())?.accounts[0]) + + await manager.refreshAccountQuota(account, storage) + + expect(authorizations).toEqual([`Bearer ${vaultToken}`]) + }) + + test('vault-cold quota polling skips an expired sidecar after one resolver lookup', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'vault-cold-quota', + type: 'oauth', + access: 'expired-sidecar-token', + refresh: 'sidecar-refresh', + expires: 999, + claustrumHandle: 'vault-cold-quota-handle', + quota: { + five_hour: { usedPercent: 10, remainingPercent: 90, checkedAt: 500 }, + seven_day: { usedPercent: 20, remainingPercent: 80, checkedAt: 500 }, + }, + }) + await saveAccounts(storage) + + let resolverCalls = 0 + let usageCalls = 0 + const manager = new FallbackAccountManager({ + configPath: accountPath, + fetchImpl: mock(() => { + usageCalls += 1 + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch, + now: () => 1_000, + isFallbackAccountVaultEnabled: (id: string) => id === 'vault-cold-quota', + resolveFallbackAccessToken: () => { + resolverCalls += 1 + return undefined + }, + } as never) + const account = expectOAuthAccount((await loadAccounts())?.accounts[0]) + + const result = await manager.refreshAccountQuota(account, storage) + + expect(result.fetched).toBe(false) + expect(usageCalls).toBe(0) + expect(resolverCalls).toBe(1) + expect(account.lastQuotaRefreshError).toBeUndefined() + }) + + test('expired non-vault fallback refreshes before polling quota', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'expired-non-vault-quota', + type: 'oauth', + access: 'expired-sidecar-token', + refresh: 'sidecar-refresh', + expires: 999, + }) + await saveAccounts(storage) + + const operations: string[] = [] + const fetchImpl = mock( + (input: string | URL | Request, init?: RequestInit) => { + const url = String(input) + if (url.includes('/v1/oauth/token')) { + operations.push('refresh') + expect(JSON.parse(String(init?.body)).refresh_token).toBe( + 'sidecar-refresh', + ) + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'refreshed-sidecar-token', + refresh_token: 'refreshed-sidecar-refresh', + expires_in: 3_600, + }), + { status: 200 }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + operations.push('usage') + expect(new Headers(init?.headers).get('authorization')).toBe( + 'Bearer refreshed-sidecar-token', + ) + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + { status: 200 }, + ), + ) + } + throw new Error(`unexpected URL: ${url}`) + }, + ) as unknown as typeof fetch + const manager = new FallbackAccountManager({ + configPath: accountPath, + fetchImpl, + now: () => 1_000, + }) + const account = expectOAuthAccount((await loadAccounts())?.accounts[0]) + + await manager.refreshAccountQuota(account, storage) + + expect(operations).toEqual(['refresh', 'usage']) + }) + test('refreshes expired fallback tokens and persists rotation', async () => { const storage = baseStorage() storage.accounts.push({ @@ -4196,6 +4351,71 @@ describe('FallbackAccountManager', () => { expect(fired).toBe(1) }) + test('vault-cold background quota ticks persist and notify only after an actual change', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'vault-cold-background-quota', + type: 'oauth', + access: 'expired-sidecar-access', + refresh: 'sidecar-refresh', + expires: 999, + claustrumHandle: 'vault-cold-background-handle', + quota: { + five_hour: { usedPercent: 10, remainingPercent: 90, checkedAt: 1 }, + seven_day: { usedPercent: 20, remainingPercent: 80, checkedAt: 1 }, + }, + }) + await saveAccounts(storage) + + let vaultAccess: string | undefined + let usageCalls = 0 + let saves = 0 + let notifications = 0 + const manager = new FallbackAccountManager({ + configPath: accountPath, + now: () => 50_000_000, + fetchImpl: mock(() => { + usageCalls += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 30 }, + seven_day: { utilization: 40 }, + }), + { status: 200 }, + ), + ) + }) as unknown as typeof fetch, + isFallbackAccountVaultEnabled: (id: string) => + id === 'vault-cold-background-quota', + resolveFallbackAccessToken: () => + vaultAccess + ? { token: vaultAccess, source: 'vault' as const } + : undefined, + onFallbackStorageChanged: () => { + notifications += 1 + }, + }) + manager.save = mock(async () => { + saves += 1 + }) + + await manager.refreshQuotaForDueAccounts() + await manager.refreshQuotaForDueAccounts() + await manager.refreshQuotaForDueAccounts() + + expect(usageCalls).toBe(0) + expect(saves).toBe(0) + expect(notifications).toBe(0) + + vaultAccess = 'resident-vault-access' + await manager.refreshQuotaForDueAccounts() + + expect(usageCalls).toBe(1) + expect(saves).toBe(1) + expect(notifications).toBe(1) + }) + test('refreshes fallback token and retries quota check after stale access token 401', async () => { const storage = baseStorage() storage.accounts.push({ diff --git a/packages/opencode/src/tests/credential-handle-blindness.test.ts b/packages/opencode/src/tests/credential-handle-blindness.test.ts index 1118572f..1d7852d0 100644 --- a/packages/opencode/src/tests/credential-handle-blindness.test.ts +++ b/packages/opencode/src/tests/credential-handle-blindness.test.ts @@ -133,6 +133,14 @@ async function readNonEmpty(path: string): Promise { return bytes } +function seedCredentialForTest(cache: any, recordVersion: number) { + cache.seedForTest(HANDLE_SENTINEL, { + payload: JSON.stringify({ access_token: TOKEN_SENTINEL }), + expiresAtMs: Date.now() + 5 * 60 * 60 * 1000, + recordVersion, + }) +} + describe('credential-handle blindness', () => { const dumpDirs: string[] = [] const sidebarDirs: string[] = [] @@ -414,16 +422,33 @@ describe('credential-handle blindness', () => { const accountPath = join(accountDir, 'anthropic-auth.json') process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath + const checkedAt = Date.now() const fallbackAccount = { ...accountWithHandle(), access: 'fallback-access', expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + }, } await saveAccounts( { version: 1, routing: { mode: 'fallback-first' }, - quota: { enabled: false, failClosedOnUnknownQuota: false }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + failClosedOnUnknownQuota: false, + }, claustrum: { accounts: { 'work-alt': { enabled: true } } }, accounts: [fallbackAccount], } as never, @@ -433,13 +458,10 @@ describe('credential-handle blindness', () => { const reports: Array> = [] const firstReportStarted = Promise.withResolvers() const releaseFirstReport = Promise.withResolvers() - const version18Warm = Promise.withResolvers() - let credentialVersion = 17 const connector = async () => ({ call: async (_moduleId: string, method: string, params: unknown) => { if (method === 'credential.get') { - if (credentialVersion === 18) version18Warm.resolve() return { result: { payload: Array.from( @@ -447,8 +469,8 @@ describe('credential-handle blindness', () => { JSON.stringify({ access_token: 'vault-access' }), ), ), - expires_at_ms: Date.now() + 60_000, - record_version: credentialVersion, + expires_at_ms: Date.now() + 5 * 60 * 60 * 1000, + record_version: 17, }, } } @@ -465,9 +487,8 @@ describe('credential-handle blindness', () => { close: () => {}, }) as never - let responseStatus = 401 globalThis.fetch = mock(() => - Promise.resolve(new Response('{}', { status: responseStatus })), + Promise.resolve(new Response('{}', { status: 401 })), ) as unknown as typeof fetch const plugin = await ( @@ -505,21 +526,251 @@ describe('credential-handle blindness', () => { const concurrent = await Promise.all(concurrentPromises) await Promise.all(concurrent.map((response) => response.text())) - credentialVersion = 18 - responseStatus = 200 - const coldResponse = await request() - await coldResponse.text() - await version18Warm.promise + await plugin.dispose?.() + + expect(reports).toHaveLength(1) + expect(reports.map((report) => report.record_version)).toEqual([17]) + }) + + test('does not re-report a credential version after cache re-population', async () => { + const accountDir = await mkdtemp( + join(tmpdir(), 'opencode-handle-report-version-fence-'), + ) + accountDirs.push(accountDir) + const accountPath = join(accountDir, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath + await saveAccounts( + { + version: 1, + routing: { mode: 'fallback-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { 'work-alt': { enabled: true } } }, + accounts: [ + { + ...accountWithHandle(), + expires: Date.now() + 5 * 60 * 60 * 1000, + }, + ], + } as never, + accountPath, + ) + + const reports: Array> = [] + const connector = async () => + ({ + call: async (_moduleId: string, method: string, params: unknown) => { + if (method === 'credential.get') { + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: TOKEN_SENTINEL }), + ), + ), + expires_at_ms: Date.now() + 5 * 60 * 60 * 1000, + record_version: 17, + }, + } + } + if (method === 'credential.report_auth_failure') { + reports.push(params as Record) + return { result: { ok: true } } + } + throw new Error(`unexpected method: ${method}`) + }, + close: () => {}, + }) as never + globalThis.fetch = mock( + (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + if (!url.includes('/v1/messages')) + return Promise.resolve(new Response('{}', { status: 200 })) + const authorization = new Headers(init?.headers).get('authorization') + if (authorization === 'Bearer main-access') { + return Promise.resolve(new Response('{}', { status: 200 })) + } + return Promise.resolve(new Response('{}', { status: 401 })) + }, + ) as unknown as typeof fetch + + const plugin = await ( + AnthropicAuthPlugin as unknown as ( + ctx: { client: unknown }, + runtimeOverrides: { claustrumConnector: typeof connector }, + ) => Promise + )( + { client: { auth: { set: mock(() => Promise.resolve()) } } }, + { claustrumConnector: connector }, + ) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const request = () => + result.fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'version fence' }], + }), + }) + + await (await request()).text() + expect(reports.map((report) => report.record_version)).toEqual([17]) + + seedCredentialForTest(plugin.__claustrumCredentialCache, 17) + await result.__reportClaustrumAuthFailureForTest({ + accountId: 'work-alt', + handle: HANDLE_SENTINEL, + recordVersion: 17, + }) + expect(reports.map((report) => report.record_version)).toEqual([17]) - responseStatus = 401 - const differentVersionResponse = await request() - await differentVersionResponse.text() + seedCredentialForTest(plugin.__claustrumCredentialCache, 18) + await result.__reportClaustrumAuthFailureForTest({ + accountId: 'work-alt', + handle: HANDLE_SENTINEL, + recordVersion: 18, + }) await plugin.dispose?.() - expect(reports).toHaveLength(2) expect(reports.map((report) => report.record_version)).toEqual([17, 18]) }) + test('keeps the original response readable when the fenced fallback cannot send', async () => { + const accountDir = await mkdtemp( + join(tmpdir(), 'opencode-handle-original-response-'), + ) + accountDirs.push(accountDir) + const accountPath = join(accountDir, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath + await saveAccounts( + { + version: 1, + routing: { mode: 'main-first' }, + fallbackOn: [429], + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { 'work-alt': { enabled: true } } }, + accounts: [ + { + ...accountWithHandle(), + expires: Date.now() + 5 * 60 * 60 * 1000, + }, + ], + } as never, + accountPath, + ) + + let credentialGets = 0 + const reports: Array> = [] + const connector = async () => + ({ + call: async (_moduleId: string, method: string, params: unknown) => { + if (method === 'credential.get') { + credentialGets += 1 + if (credentialGets > 1) return new Promise(() => {}) + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: TOKEN_SENTINEL }), + ), + ), + expires_at_ms: Date.now() + 5 * 60 * 60 * 1000, + record_version: 17, + }, + } + } + if (method === 'credential.report_auth_failure') { + reports.push(params as Record) + return { result: { ok: true } } + } + throw new Error(`unexpected method: ${method}`) + }, + close: () => {}, + }) as never + let mainRequests = 0 + let fallbackRequests = 0 + globalThis.fetch = mock( + (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + const authorization = new Headers(init?.headers).get('authorization') + if (authorization === 'Bearer main-access') { + mainRequests += 1 + return Promise.resolve( + new Response( + mainRequests === 1 + ? 'first main response' + : 'original response body', + { + status: 429, + }, + ), + ) + } + fallbackRequests += 1 + return Promise.resolve(new Response('{}', { status: 401 })) + }, + ) as unknown as typeof fetch + + const plugin = await ( + AnthropicAuthPlugin as unknown as ( + ctx: { client: unknown }, + runtimeOverrides: { claustrumConnector: typeof connector }, + ) => Promise + )( + { client: { auth: { set: mock(() => Promise.resolve()) } } }, + { claustrumConnector: connector }, + ) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const request = () => + result.fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'original response' }], + }), + }) + + await (await request()).text() + expect(reports.map((report) => report.record_version)).toEqual([17]) + seedCredentialForTest(plugin.__claustrumCredentialCache, 17) + + const originalResponse = await request() + expect(originalResponse.status).toBe(429) + expect(await originalResponse.text()).toBe('original response body') + expect(fallbackRequests).toBe(1) + await plugin.dispose?.() + }) + test('dump body, metadata, response, and transport capture stay blind to tokens', async () => { const dumpDir = await mkdtemp(join(tmpdir(), 'opencode-handle-dump-')) dumpDirs.push(dumpDir) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 9bf9f0d9..9571a9f3 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -645,6 +645,8 @@ type PluginRuntimeOverrides = Partial<{ clearClaustrumRefreshErrorPersistent: typeof clearClaustrumRefreshErrorPersistent }> +type TestTimerHandler = Parameters[0] + function disabledPluginRuntimeOverrides(): PluginRuntimeOverrides { return { // Background intervals must not outlive the test-scoped fetch mock they captured. @@ -894,6 +896,55 @@ describe('sidebar needsReauth (dead-fallback indicator)', () => { }) }) +describe('fallback quota persistence ordering', () => { + test('does not restore a late quota error older than the persisted success', async () => { + const successAt = Date.now() + await useTempAccountFile( + createFallbackStorage({ + accounts: [ + { + id: 'fallback-late-error', + type: 'oauth', + access: 'fallback-access', + refresh: 'fallback-refresh', + expires: successAt + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: successAt, + }, + seven_day: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: successAt, + }, + }, + }, + ], + }), + ) + + const plugin = await getPlugin() + await plugin.__persistFallbackQuotaErrorForTest('fallback-late-error', { + message: 'older quota failure', + checkedAt: successAt - 1, + nextRetryAt: successAt + 60_000, + retryCount: 1, + accountIdentity: 'fallback-late-error', + }) + await plugin.dispose?.() + + const saved = await loadAccounts() + const savedAccount = saved?.accounts[0] + expect(savedAccount).toBeDefined() + if (!savedAccount || !isOAuthAccount(savedAccount)) { + throw new Error('missing persisted fallback account') + } + expect(savedAccount.lastQuotaRefreshError).toBeUndefined() + }) +}) + describe('fallback Claustrum credential resolution', () => { const originalFetch = globalThis.fetch afterEach(() => { @@ -18092,6 +18143,144 @@ describe('killswitch fetch gate', () => { expires: Date.now() + 100000, }) + async function runVaultKillswitchQuotaRefresh(vaultResident: boolean) { + const originalNow = Date.now + let clock = originalNow() + Date.now = () => clock + try { + const now = clock + const accountId = 'killswitch-vault-fallback' + const handle = 'killswitch-vault-handle' + const vaultAccess = 'killswitch-vault-access' + const quota = { + five_hour: { usedPercent: 10, remainingPercent: 90, checkedAt: now }, + seven_day: { usedPercent: 10, remainingPercent: 90, checkedAt: now }, + } + await useTempAccountFile( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + refreshEveryNRequests: 1, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: false, + }, + killswitch: { enabled: true, main: { five_hour: 5, seven_day: 10 } }, + claustrum: { accounts: { [accountId]: { enabled: true } } }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'sidecar-access', + refresh: 'sidecar-refresh', + expires: now + 5 * 60 * 60 * 1000, + claustrumHandle: handle, + quota, + }, + ], + }), + ) + + const connector = async () => + ({ + call: async (_moduleId: string, method: string) => { + if (method === 'credential.get') { + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ access_token: vaultAccess }), + ), + ), + expires_at_ms: now + 12 * 60 * 60 * 1000, + record_version: 1, + }, + } + } + return { result: {} } + }, + close: () => {}, + }) as never + const detachedTimers: Array<() => void> = [] + const setTimeout = mock((callback: TestTimerHandler, delay?: number) => { + if (delay === 0 && typeof callback === 'function') { + detachedTimers.push(callback as () => void) + } + return 0 as unknown as ReturnType + }) as unknown as typeof globalThis.setTimeout + const usageAuthorizations: string[] = [] + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url.includes('/api/oauth/usage')) { + usageAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('message-ok', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin(undefined, undefined, { + claustrumConnector: connector, + setTimeout, + }) + await plugin.__fallbackRefreshReady + clock = now + 6 * 60 * 60 * 1000 + plugin.__quotaManager.clearFallback(accountId) + if (!vaultResident) { + plugin.__claustrumCredentialCache.invalidate(handle) + } + const residentBeforeRequest = Boolean( + plugin.__claustrumCredentialCache.peek(handle), + ) + usageAuthorizations.length = 0 + const timerBaseline = detachedTimers.length + + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + await response.text() + await plugin.dispose?.() + + return { + fallbackUsageCalls: usageAuthorizations.filter( + (authorization) => authorization === `Bearer ${vaultAccess}`, + ).length, + residentBeforeRequest, + sidecarUsageCalls: usageAuthorizations.filter( + (authorization) => authorization === 'Bearer sidecar-access', + ).length, + scheduledWarmCount: detachedTimers.length - timerBaseline, + } + } finally { + Date.now = originalNow + } + } + + test('killswitch quota refresh schedules one detached warm for a cold vault and expired sidecar', async () => { + const result = await runVaultKillswitchQuotaRefresh(false) + + expect(result.fallbackUsageCalls).toBe(0) + expect(result.sidecarUsageCalls).toBe(0) + expect(result.scheduledWarmCount).toBe(1) + }) + + test('killswitch quota refresh polls with a resident vault credential instead of an expired sidecar', async () => { + const result = await runVaultKillswitchQuotaRefresh(true) + + expect(result.residentBeforeRequest).toBe(true) + expect(result.sidecarUsageCalls).toBe(0) + expect(result.fallbackUsageCalls).toBe(1) + expect(result.scheduledWarmCount).toBe(0) + }) + // Main below the soft routing threshold but ABOVE the killswitch threshold, // with no fallbacks: the killswitch must not hard-block — the request falls // through to main as it would with the killswitch disabled. diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts index 03dd9634..fb7a4b98 100644 --- a/packages/opencode/src/tests/quota-manager.test.ts +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -128,7 +128,7 @@ describe('QuotaManager', () => { five_hour: { usedPercent: 0, remainingPercent: 100, - checkedAt: now, + checkedAt: now - 2_000, }, seven_day: { usedPercent: 0, @@ -268,6 +268,52 @@ describe('QuotaManager', () => { expect(fetchCalls).toBe(2) }) + test('seeds an active persisted fallback 429 backoff before polling', async () => { + const fetchMock = mock(() => { + throw new Error('persisted fallback backoff must prevent a quota poll') + }) as unknown as typeof fetch + const qm = new QuotaManager({ + storage: { + quota: { checkIntervalMinutes: 5 }, + } as AccountStorage, + fetchImpl: fetchMock, + now: () => now, + }) + qm.seedFallbacksFromAccounts([ + { + id: 'fallback-persisted-backoff', + type: 'oauth', + access: 'fallback-token', + refresh: 'fallback-refresh', + expires: now + 60_000, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: now - 2_000, + }, + }, + lastQuotaRefreshError: { + message: 'Claude quota check failed: 429 — rate limited', + checkedAt: now - 1_000, + nextRetryAt: now + 60_000, + retryCount: 1, + accountIdentity: 'fallback-persisted-backoff', + }, + }, + ]) + + expect(qm.isFallbackBackedOff('fallback-persisted-backoff')).toBe(true) + await expect( + qm.refreshFallbackWithMetadata( + 'fallback-persisted-backoff', + 'fallback-token', + undefined, + ), + ).resolves.toMatchObject({ fetched: false }) + expect(fetchMock).not.toHaveBeenCalled() + }) + test('main refresh metadata also distinguishes cached backoff from a network fetch', async () => { let fetchCalls = 0 const fetchMock = mock(() => { From b62f6ac6e265ac8dad3fa912402396b7fd4af432 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:11:42 +0200 Subject: [PATCH 5/5] fix(claustrum): never echo parser text from a secret-bearing JSON parse --- packages/core/src/claustrum.ts | 20 +++++++++++---- packages/opencode/src/tests/claustrum.test.ts | 25 ++++++++++++++++++- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/core/src/claustrum.ts b/packages/core/src/claustrum.ts index e6d422e9..ea2cb69a 100644 --- a/packages/core/src/claustrum.ts +++ b/packages/core/src/claustrum.ts @@ -63,17 +63,27 @@ function isEndpoint(value: unknown): value is ClaustrumEndpoint { export async function detectClaustrumConnection( path = getDefaultClaustrumConnectionPath(), ): Promise { - let value: unknown + let raw: string try { - value = JSON.parse(await readFile(path, 'utf8')) + raw = await readFile(path, 'utf8') } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { status: 'absent', path } + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return { status: 'absent', path } + return { + status: 'malformed', + path, + reason: `unreadable (${code ?? 'unknown'})`, } + } + + let value: unknown + try { + value = JSON.parse(raw) + } catch { return { status: 'malformed', path, - reason: error instanceof Error ? error.message : String(error), + reason: 'invalid JSON', } } diff --git a/packages/opencode/src/tests/claustrum.test.ts b/packages/opencode/src/tests/claustrum.test.ts index d503a5e8..cfc32048 100644 --- a/packages/opencode/src/tests/claustrum.test.ts +++ b/packages/opencode/src/tests/claustrum.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -82,6 +82,18 @@ describe('Claustrum connection detection', () => { expect(result.status).toBe('absent') }) + test('reports an unreadable connection file without parser text', async () => { + const path = join(tempDir, 'unreadable.json') + await writeFile(path, '{}') + await chmod(path, 0o000) + + const result = await detectClaustrumConnection(path) + + expect(result.status).toBe('malformed') + expect(result).toMatchObject({ reason: 'unreadable (EACCES)' }) + expect(JSON.stringify(result)).not.toContain('invalid JSON') + }) + test('reports malformed JSON and invalid shape distinctly from absence', async () => { const path = join(tempDir, 'malformed.json') await writeFile(path, '{"schema":1,"wire_version":"2"}') @@ -91,6 +103,17 @@ describe('Claustrum connection detection', () => { expect(result.status).toBe('malformed') }) + test('does not expose parser text from malformed secret-bearing JSON', async () => { + const path = join(tempDir, 'secret-bearing-malformed.json') + const canary = 'CANARYSECRET' + await writeFile(path, `{"schema":1,"key":ckh_${canary}}`) + + const result = await detectClaustrumConnection(path) + + expect(result.status).toBe('malformed') + expect(JSON.stringify(result)).not.toContain(canary) + }) + test('rejects an empty endpoint list as malformed', async () => { const path = join(tempDir, 'empty-endpoints.json') await writeFile(