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 e8aada95..7f89396e 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,19 @@ 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 + 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 // (e.g. the OpenCode sidebar) can re-render without a request flowing through @@ -445,7 +469,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 +545,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 +839,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 +881,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 @@ -938,6 +987,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 +1038,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) }) : [] @@ -1086,6 +1157,7 @@ function accountRuntimeState(account: FallbackAccount) { } return objectWithDefinedEntries({ authLineageId: account.authLineageId, + claustrumHandle: account.claustrumHandle, access: account.access, refresh: account.refresh, expires: account.expires, @@ -1264,6 +1336,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( @@ -1305,9 +1391,7 @@ function mergeAccountRuntimeState( if (!('lastQuotaRefreshError' in effectiveIncoming)) { delete merged.lastQuotaRefreshError } - if (!('lastRefreshError' in effectiveIncoming)) { - delete merged.lastRefreshError - } + merged.lastRefreshError = preferredRefreshError return merged } @@ -1315,6 +1399,7 @@ function mergeAccountRuntimeState( ...merged, quota: existingEntry.quota, lastQuotaRefreshError: existingEntry.lastQuotaRefreshError, + lastRefreshError: preferredRefreshError, } } const merged: AccountRuntimeEntry = { @@ -1328,9 +1413,7 @@ function mergeAccountRuntimeState( if (!('lastQuotaRefreshError' in effectiveIncoming)) { delete merged.lastQuotaRefreshError } - if (!('lastRefreshError' in effectiveIncoming)) { - delete merged.lastRefreshError - } + merged.lastRefreshError = preferredRefreshError return merged } @@ -1370,6 +1453,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 @@ -1383,6 +1467,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 @@ -1925,6 +2016,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, @@ -1943,31 +2080,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] - } } } @@ -2546,6 +2730,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 @@ -3542,6 +3730,21 @@ 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 resolveFallbackAccessToken: ( + account: OAuthAccount, + storage: AccountStorage, + ) => { token: string; source: 'vault' | 'sidecar' } | undefined + 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 @@ -3551,6 +3754,23 @@ 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.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 this.clearIntervalImpl = @@ -3594,17 +3814,20 @@ export class FallbackAccountManager { } startBackgroundRefresh() { - const run = async () => { + const run = async (initial = false) => { + await this.onBackgroundRefresh?.(initial) await this.refreshDueAccounts() await this.refreshQuotaForDueAccounts() } - void 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 } stopBackgroundRefresh() { @@ -3628,7 +3851,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 && @@ -3656,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 @@ -3748,7 +3984,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()) ) { @@ -3790,7 +4031,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()) ) { @@ -3809,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) @@ -3834,7 +4079,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.isFallbackAccountVaultServed(next.id, storage) && + !this.isFallbackAccountVaultEnabled(next.id, storage) + ) { const refreshError = next.lastRefreshError if ( refreshError && @@ -3856,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) @@ -3875,8 +4124,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 @@ -3890,9 +4140,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( @@ -4038,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 @@ -4060,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 } @@ -4088,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 @@ -4119,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 new file mode 100644 index 00000000..ea2cb69a --- /dev/null +++ b/packages/core/src/claustrum.ts @@ -0,0 +1,724 @@ +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 raw: string + try { + raw = await readFile(path, 'utf8') + } catch (error) { + 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: 'invalid JSON', + } + } + + 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') + if (this.#reconnecting) { + await this.#reconnecting + return + } + const now = Date.now() + if (now < this.#nextReconnectAt) { + throw new Error('Claustrum client reconnect is backed off') + } + 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) + } + + seedForTest(handle: string, credential: ClaustrumCredential): void { + this.#cache.set(handle, credential) + } + + 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/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/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 1fa5054e..24b74911 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, @@ -130,6 +141,7 @@ import { QuotaHeaderFeedRegistry, QuotaManager, type QuotaState, + quotaSnapshotCheckedAt, quotaSnapshotHasStandardWindows, quotaSnapshotModelScopeIsExhausted, quotaSnapshotPassesModelScope, @@ -212,6 +224,7 @@ import { pushNotification, } from './rpc/notifications.ts' import { + type AccountDialogAccount, type ApplyRequest, type ApplyResult, COMMAND_MODAL_NAMES, @@ -634,6 +647,15 @@ type FableRequestContext = { standbyBridgeLogged?: boolean } +type ClaustrumAccessResolution = { + accessToken?: string + served?: { + accountId: string + handle: string + recordVersion: number + } +} + type StickyOAuthRoute = { id: string access: string @@ -641,6 +663,7 @@ type StickyOAuthRoute = { identity: IdentityState order: number account?: OAuthAccount + claustrum?: ClaustrumAccessResolution } type MainQuotaIdentityBinding = { @@ -811,22 +834,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 @@ -911,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 ( @@ -965,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( @@ -1286,9 +1433,7 @@ const anthropicAuthPlugin = async ( ? { ...entry, quota: mergedQuota, - checkedAt: persistedQuotaBelongsToRequest - ? (reloaded.quota?.mainQuotaCheckedAt ?? entry.checkedAt) - : entry.checkedAt, + checkedAt: entry.checkedAt, } : entry } @@ -1519,15 +1664,443 @@ 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() + 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())) + } + + 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( + (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(() => {}) }, }) - fallbackManager.startBackgroundRefresh() + + 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() type CacheDiagnosticsResponse = { @@ -1549,6 +2122,10 @@ const anthropicAuthPlugin = async ( Response, CacheDiagnosticsResponse >() + const claustrumServedCredentials = new WeakMap< + Response, + { accountId: string; handle: string; recordVersion: number } + >() const cacheKeepDiagnosticsRequests = new Map< string, CacheDiagnosticsRequestContext & { @@ -2352,6 +2929,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, @@ -3297,6 +3879,12 @@ const anthropicAuthPlugin = async ( const result = executeAccountCommand({ argumentsText, storage: storage ?? { version: 1, accounts: [] }, + claustrum: + action.type === 'status' + ? await detectClaustrumConnection( + getConfiguredClaustrumConnectionFile(), + ) + : undefined, }) if (result.updated) { @@ -3361,6 +3949,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, @@ -3387,8 +4003,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) @@ -4591,6 +5209,75 @@ const anthropicAuthPlugin = async ( return response } + async function reportClaustrumAuthFailure( + served: { + accountId: string + handle: string + recordVersion: number + }, + reporterSource: ClaustrumReporterSource = 'direct', + ): 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 + // 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, + ) + claustrumLastReportedVersion.set( + served.handle, + served.recordVersion, + ) + } 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 report + } finally { + if (claustrumAuthFailureReports.get(key) === report) { + claustrumAuthFailureReports.delete(key) + } + } + } + async function sendWithAccessToken( input: string | URL | Request, init: RequestInit | undefined, @@ -4603,8 +5290,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) @@ -4920,6 +5609,12 @@ const anthropicAuthPlugin = async ( status: response.status, streaming, }) + if (servedClaustrumCredential) { + claustrumServedCredentials.set( + response, + servedClaustrumCredential, + ) + } return response } @@ -5009,6 +5704,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) @@ -5105,10 +5801,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) @@ -5123,18 +5823,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, }) } @@ -5147,12 +5848,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, @@ -5198,17 +5905,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))) ) @@ -5243,7 +5953,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 ? [ { @@ -5285,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, @@ -5302,12 +6019,16 @@ const anthropicAuthPlugin = async ( options?.fableRequest, ) } else { - const access = account.access - if (!access) continue + const claustrumResolution = resolveClaustrumAccess( + account, + storage, + ) + if (!claustrumResolution.accessToken) continue + await cancelCurrentResponse() response = await sendWithAccessToken( input, init, - access, + claustrumResolution.accessToken, trace, `fallback_${index}`, storage, @@ -5315,8 +6036,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) { @@ -5334,19 +6061,29 @@ 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. // 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 } @@ -5481,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 = @@ -5548,6 +6286,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 ? { @@ -5905,6 +6648,7 @@ const anthropicAuthPlugin = async ( selected.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? requestMainQuotaIdentity : undefined, + selected.claustrum, ) const completeRoute = async ( selected: StickyOAuthRoute, @@ -5914,6 +6658,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', @@ -5997,12 +6746,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( @@ -6018,14 +6792,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 = @@ -6503,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', { @@ -6678,6 +7461,7 @@ const anthropicAuthPlugin = async ( }, dispose: async () => { await quotaHeaderFeedRegistry?.dispose() + claustrumCredentialCache?.close() }, methods: [ { @@ -6741,6 +7525,9 @@ 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 } 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 d68843fd..cbf34301 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() }) // --------------------------------------------------------------------------- @@ -257,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', () => { @@ -513,11 +572,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 +706,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..8cf26500 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, @@ -216,6 +218,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 +1264,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,53 +1579,411 @@ describe('account storage', () => { expect(loaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(22) }) - test('generic saves preserve a newer persisted main profile', async () => { - const staleStorage = baseStorage() - await saveAccounts(staleStorage) - - const hydratedStorage = await loadAccounts() - expect(hydratedStorage).not.toBeNull() - ;(hydratedStorage as AccountStorage).main = { - type: 'opencode', - provider: 'anthropic', - profile: { - tier: 'default_claude_max_20x', - orgType: 'claude_max', - checkedAt: 500, - tokenFingerprint: tokenFingerprint('main-access'), - }, - } - await saveAccountState(hydratedStorage as AccountStorage, accountPath, { - mainProfile: true, + 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: 10_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 saveAccounts(staleStorage) + await saveAccountState(storage, accountPath) - expect((await loadAccounts())?.main?.profile).toEqual( - hydratedStorage?.main?.profile, + 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 do not overwrite newer quota snapshots', async () => { - const storage = baseStorage() - storage.quota = { - ...storage.quota, - mainQuota: { - five_hour: { - usedPercent: 11, - remainingPercent: 89, - checkedAt: 500, + 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, + }, }, - }, - mainQuotaCheckedAt: 500, - mainQuotaToken: 'token-newer', - } - storage.accounts.push({ - id: 'fallback-1', - type: 'oauth', - access: 'access-newer', - refresh: 'refresh-newer', - expires: 999, + }), + '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: 10_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) + + const hydratedStorage = await loadAccounts() + expect(hydratedStorage).not.toBeNull() + ;(hydratedStorage as AccountStorage).main = { + type: 'opencode', + provider: 'anthropic', + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: 500, + tokenFingerprint: tokenFingerprint('main-access'), + }, + } + await saveAccountState(hydratedStorage as AccountStorage, accountPath, { + mainProfile: true, + }) + + await saveAccounts(staleStorage) + + expect((await loadAccounts())?.main?.profile).toEqual( + hydratedStorage?.main?.profile, + ) + }) + + test('runtime state saves do not overwrite newer quota snapshots', async () => { + const storage = baseStorage() + storage.quota = { + ...storage.quota, + mainQuota: { + five_hour: { + usedPercent: 11, + remainingPercent: 89, + checkedAt: 500, + }, + }, + mainQuotaCheckedAt: 500, + mainQuotaToken: 'token-newer', + } + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access-newer', + refresh: 'refresh-newer', + expires: 999, quota: { five_hour: { usedPercent: 20, @@ -2184,44 +2898,385 @@ describe('account storage', () => { 'utf8', ) - // Config file must NOT exist for this scenario. - await expect(stat(accountPath)).rejects.toThrow() + // Config file must NOT exist for this scenario. + await expect(stat(accountPath)).rejects.toThrow() + + const loaded = await loadAccounts() + expect(loaded).not.toBeNull() + expect(loaded?.accounts).toEqual([]) + expect(loaded?.refresh?.mainRefreshLeaseId).toBe('lease-abc') + expect(loaded?.refresh?.mainRefreshLeaseUntil).toBe(9_999_999_999_999) + expect(loaded?.refresh?.mainRefreshLeaseTokenHash).toBe('hash-xyz') + expect(loaded?.quota?.mainQuotaToken).toBe('token-state-only') + expect(loaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(33) + }) + + test('lease written via saveAccountState is visible to loadAccounts without a config file', async () => { + const storage: AccountStorage = { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [], + refresh: { + mainRefreshLeaseId: 'lease-from-save', + mainRefreshLeaseUntil: 9_999_999_999_999, + mainRefreshLeaseTokenHash: 'token-hash-from-save', + }, + } + await saveAccountState(storage, accountPath, { mainRefresh: true }) + + // saveAccountState must not have created the config file. + await expect(stat(accountPath)).rejects.toThrow() + + const loaded = await loadAccounts() + expect(loaded?.refresh?.mainRefreshLeaseId).toBe('lease-from-save') + expect(loaded?.refresh?.mainRefreshLeaseTokenHash).toBe( + 'token-hash-from-save', + ) + }) +}) + +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: 10_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', + 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], + ) + + await expect( + vaultManager.refreshAccountQuota(vaultAccount, vaultStorage), + ).rejects.toThrow('Claude quota check failed: 401') + expect(vaultQuotaCalls).toBe(1) + expect(vaultTokenCalls).toBe(0) + expect( + expectOAuthAccount((await loadAccounts(vaultPath))?.accounts[0]) + .lastRefreshError, + ).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 loaded = await loadAccounts() - expect(loaded).not.toBeNull() - expect(loaded?.accounts).toEqual([]) - expect(loaded?.refresh?.mainRefreshLeaseId).toBe('lease-abc') - expect(loaded?.refresh?.mainRefreshLeaseUntil).toBe(9_999_999_999_999) - expect(loaded?.refresh?.mainRefreshLeaseTokenHash).toBe('hash-xyz') - expect(loaded?.quota?.mainQuotaToken).toBe('token-state-only') - expect(loaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(33) + 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('lease written via saveAccountState is visible to loadAccounts without a config file', async () => { - const storage: AccountStorage = { - version: 1, - main: { type: 'opencode', provider: 'anthropic' }, - accounts: [], - refresh: { - mainRefreshLeaseId: 'lease-from-save', - mainRefreshLeaseUntil: 9_999_999_999_999, - mainRefreshLeaseTokenHash: 'token-hash-from-save', + 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}`) }, - } - await saveAccountState(storage, accountPath, { mainRefresh: true }) + ) as unknown as typeof fetch + const manager = new FallbackAccountManager({ + configPath: accountPath, + fetchImpl, + now: () => 1_000, + }) + const account = expectOAuthAccount((await loadAccounts())?.accounts[0]) - // saveAccountState must not have created the config file. - await expect(stat(accountPath)).rejects.toThrow() + await manager.refreshAccountQuota(account, storage) - const loaded = await loadAccounts() - expect(loaded?.refresh?.mainRefreshLeaseId).toBe('lease-from-save') - expect(loaded?.refresh?.mainRefreshLeaseTokenHash).toBe( - 'token-hash-from-save', - ) + expect(operations).toEqual(['refresh', 'usage']) }) -}) -describe('FallbackAccountManager', () => { test('refreshes expired fallback tokens and persists rotation', async () => { const storage = baseStorage() storage.accounts.push({ @@ -3296,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({ @@ -5264,6 +6384,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( @@ -5802,3 +7005,246 @@ 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)) + 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') + }, + ) 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/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/claustrum-client.test.ts b/packages/opencode/src/tests/claustrum-client.test.ts new file mode 100644 index 00000000..5cfe4f81 --- /dev/null +++ b/packages/opencode/src/tests/claustrum-client.test.ts @@ -0,0 +1,969 @@ +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), 2_000), + ), + ]) + }, + 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('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) + + 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..cfc32048 --- /dev/null +++ b/packages/opencode/src/tests/claustrum.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { chmod, 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 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"}') + + const result = await detectClaustrumConnection(path) + + 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( + 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..08e17a30 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -42,13 +42,45 @@ 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') + }) + + 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 new file mode 100644 index 00000000..1d7852d0 --- /dev/null +++ b/packages/opencode/src/tests/credential-handle-blindness.test.ts @@ -0,0 +1,991 @@ +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 +} + +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[] = [] + 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)) + 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 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: true, + checkIntervalMinutes: 5, + 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 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: 'vault-access' }), + ), + ), + expires_at_ms: Date.now() + 5 * 60 * 60 * 1000, + record_version: 17, + }, + } + } + 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 + + 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 request = () => + result.fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'report dedupe' }], + }), + }) + + 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())) + + 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]) + + seedCredentialForTest(plugin.__claustrumCredentialCache, 18) + await result.__reportClaustrumAuthFailureForTest({ + accountId: 'work-alt', + handle: HANDLE_SENTINEL, + recordVersion: 18, + }) + await plugin.dispose?.() + + 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) + 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 e336f177..9571a9f3 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, @@ -18,9 +26,12 @@ import { buildPrimeRequestBody, buildRefreshOperationError, ClaudeOAuthRefreshError, + clearClaustrumRefreshErrorPersistent, extractBillingHeaderCCH, + FALLBACK_BACKGROUND_TICK_MS, getAccountStatePath, getClaudeCodeIdentity, + getDefaultClaustrumConnectionPath, getOrCreatePrimeAuthLineageId, hashRefreshToken, isOAuthAccount, @@ -30,6 +41,7 @@ import { type OAuthQuotaSnapshot, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, PROFILE_TTL_MS, + primeStorageFingerprint, resetCache1hState, resetClaudeCodeIdentityCachesForTest, resetDumpState, @@ -39,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 { @@ -60,13 +73,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 +113,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 +513,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', @@ -453,113 +623,2748 @@ async function setupExpiredTokenLoader() { refresh: 'old-refresh', expires: Date.now() - 1000, }), - { models: {} }, - ) + { models: {} }, + ) + + return { mockClient, result } +} + +/** Fire 5 concurrent fetch requests against /v1/messages. */ +function fireConcurrentFetches(result: { fetch: typeof fetch }) { + return Promise.all( + Array.from({ length: 5 }, () => result.fetch(MESSAGES_URL, EMPTY_POST)), + ) +} + +type PluginRuntimeOverrides = Partial<{ + setTimeout: typeof globalThis.setTimeout + setInterval: typeof globalThis.setInterval + clearInterval: typeof globalThis.clearInterval + claustrumConnector: (options: unknown) => Promise + claustrumNow: () => number + clearClaustrumRefreshErrorPersistent: typeof clearClaustrumRefreshErrorPersistent +}> + +type TestTimerHandler = Parameters[0] + +function disabledPluginRuntimeOverrides(): PluginRuntimeOverrides { + 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 pluginRuntimeOverrides: PluginRuntimeOverrides = {} + +beforeEach(() => { + resetClaudeCodeIdentityCachesForTest() +}) + +async function getPlugin( + client?: ReturnType, + directory?: string, + runtimeOverrides: PluginRuntimeOverrides = {}, +) { + const defaultTimerOverrides = + globalThis.setInterval === originalSetInterval && + globalThis.clearInterval === originalClearInterval + ? disabledPluginRuntimeOverrides() + : {} + const plugin = (await ( + AnthropicAuthPlugin as unknown as ( + ctx: Parameters[0], + runtimeOverrides?: PluginRuntimeOverrides, + ) => ReturnType + )( + { + // @ts-expect-error: minimal mock for testing + client: client ?? createMockClient(), + ...(directory && { directory }), + }, + { + ...defaultTimerOverrides, + ...pluginRuntimeOverrides, + ...runtimeOverrides, + }, + )) as any + if (plugin.__fallbackRefreshReady) { + fallbackRefreshes.add(plugin.__fallbackRefreshReady) + } + 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 + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function fallbackWithRefreshError(status: number) { + const refresh = 'fallback-refresh' + const now = Date.now() + // A genuinely-dead token returns 400 invalid_grant; only that classifies as + // permanent (a bare 400 / other OAuth errors do not). + const body = status === 400 ? '{"error":"invalid_grant"}' : 'boom' + const error = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(status, body), + now, + accountIdentity: 'fallback-1', + }) + return createFallbackStorage({ + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'fallback-access', + refresh, + expires: now + 5 * 60 * 60 * 1000, + lastRefreshError: error, + }, + ], + }) + } + + test('dead (400 invalid_grant) fallback → needsReauth true', async () => { + await useTempAccountFile(fallbackWithRefreshError(400)) + const plugin = await getPlugin() + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + const state = await waitForSidebarState( + (candidate) => candidate.fallbacks[0]?.needsReauth === true, + ) + expect(state.fallbacks[0]?.needsReauth).toBe(true) + }) + + 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( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + const state = await waitForSidebarState( + (candidate) => candidate.fallbacks[0]?.needsReauth === false, + ) + expect(state.fallbacks[0]?.needsReauth).toBe(false) + expect(tokenCalls).toBe(0) + }) +}) + +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(() => { + globalThis.fetch = originalFetch + }) + + function persistedRefreshError(accountIdentity: string) { + return buildRefreshOperationError({ + error: new Error('vault unavailable'), + now: Date.now(), + accountIdentity, + }) + } + + 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], + }) + } + + 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, + }, + } + } + + 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 + } + + 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() + } + } + + 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 } } }, + }), + ) + 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, + }) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + + 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', + }, + }), + ) + 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 } } }, + }), + ) + 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: {} }, + ) + + 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' }], + }), + }) - return { mockClient, result } -} + 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?.() + }) -/** Fire 5 concurrent fetch requests against /v1/messages. */ -function fireConcurrentFetches(result: { fetch: typeof fetch }) { - return Promise.all( - Array.from({ length: 5 }, () => result.fetch(MESSAGES_URL, EMPTY_POST)), - ) -} + 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') + }) -type PluginTimerOverrides = Partial<{ - setTimeout: typeof globalThis.setTimeout - setInterval: typeof globalThis.setInterval - clearInterval: typeof globalThis.clearInterval -}> + const { authorizations, plugin, result } = await loadFallbackWithConnector( + storage, + connector, + new Response('{}', { status: 401 }), + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) -let pluginTimerOverrides: PluginTimerOverrides = {} + 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?.() + }) -beforeEach(() => { - resetClaudeCodeIdentityCachesForTest() -}) + 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) -async function getPlugin( - client?: ReturnType, - directory?: string, - timerOverrides: PluginTimerOverrides = pluginTimerOverrides, -) { - return (await ( - AnthropicAuthPlugin as unknown as ( - ctx: Parameters[0], - timers?: PluginTimerOverrides, - ) => ReturnType - )( - { - // @ts-expect-error: minimal mock for testing - client: client ?? createMockClient(), - ...(directory && { directory }), - }, - timerOverrides, - )) as Promise -} + expect(response.status).toBe(200) + expect( + calls.some((call) => call.method === 'credential.report_auth_failure'), + ).toBe(false) + await plugin.dispose?.() + }) -describe('sidebar needsReauth (dead-fallback indicator)', () => { - function fallbackWithRefreshError(status: number) { - const refresh = 'fallback-refresh' + test('reports a 401 using captured provenance after credential expiry', async () => { const now = Date.now() - // A genuinely-dead token returns 400 invalid_grant; only that classifies as - // permanent (a bare 400 / other OAuth errors do not). - const body = status === 400 ? '{"error":"invalid_grant"}' : 'boom' - const error = buildRefreshOperationError({ - error: new ClaudeOAuthRefreshError(status, body), - now, - accountIdentity: 'fallback-1', - }) - return createFallbackStorage({ + const calls: CredentialCall[] = [] + const storage = createFallbackStorage({ + routing: { mode: 'main-first' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + claustrum: { accounts: { 'vault-only': { enabled: true } } }, accounts: [ { - id: 'fallback-1', + id: 'vault-only', type: 'oauth', - access: 'fallback-access', - refresh, + 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, - lastRefreshError: error, }, ], }) - } - - test('dead (400 invalid_grant) fallback → needsReauth true', async () => { - await useTempAccountFile(fallbackWithRefreshError(400)) - const plugin = await getPlugin() - await plugin.auth.loader( + 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', + type: 'oauth' as const, access: 'main-access', refresh: 'main-refresh', - expires: Date.now() + 100000, + expires: now + 5 * 60 * 60 * 1000, }), { models: {} }, ) - const state = await waitForSidebarState( - (candidate) => candidate.fallbacks[0]?.needsReauth === true, - ) - expect(state.fallbacks[0]?.needsReauth).toBe(true) + + 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('transient (429 rate-limited) fallback → needsReauth false', async () => { - await useTempAccountFile(fallbackWithRefreshError(429)) - const plugin = await getPlugin() - await plugin.auth.loader( + 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', + type: 'oauth' as const, access: 'main-access', refresh: 'main-refresh', - expires: Date.now() + 100000, + expires: Date.now() + 100_000, }), { models: {} }, ) - const state = await waitForSidebarState( - (candidate) => candidate.fallbacks[0]?.needsReauth === false, - ) - expect(state.fallbacks[0]?.needsReauth).toBe(false) + + 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?.() }) }) @@ -643,6 +3448,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 +4171,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 +4224,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( () => @@ -1400,35 +4236,7 @@ describe('quota header feed integration', () => { { models: {} }, ) 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, - ) + await response.text() const published = ( await waitForFeedEntries( @@ -2028,106 +4836,794 @@ describe('quota header feed integration', () => { }) }) -describe('main identity boot order', () => { - afterEach(() => { - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION +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() + }) + + 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, + }), + { 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 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, + ) + 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() + }) + + 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('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: [ + { + id: accountId, + type: 'oauth', + access: 'expired-sidecar-access', + refresh: 'sidecar-refresh', + expires: Date.now() - 1, + claustrumHandle: handle, + }, + ], + claustrum: { accounts: { [accountId]: { enabled: true } } }, + }), + ) + 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(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('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), - ) + 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(() => {}), } - return Promise.resolve({ - type: 'oauth' as const, - access: `access-${authCalls}`, - refresh: `refresh-${authCalls}`, - expires: Date.now() + 100_000, - }) } + 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, }) - 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) + 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() }) -}) - -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', + 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') + }, }) - 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 + 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('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 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(() => {}), + }) + 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, + }) + 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() }) -}) -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('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') + + 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') }) }) @@ -2202,6 +5698,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 +6283,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,8 +6633,7 @@ describe('auth.loader', () => { const originalDateNow = Date.now beforeEach(async () => { - globalThis.fetch = originalFetch - pluginTimerOverrides = {} + pluginRuntimeOverrides = {} Math.random = originalRandom Date.now = originalDateNow resetCache1hState() @@ -3142,7 +6649,7 @@ describe('auth.loader', () => { afterEach(async () => { globalThis.fetch = originalFetch - pluginTimerOverrides = {} + pluginRuntimeOverrides = {} Math.random = originalRandom Date.now = originalDateNow resetNotificationsForTest() @@ -3468,6 +6975,7 @@ describe('auth.loader', () => { await useTempAccountFile( createFallbackStorage({ routing: { mode: 'fallback-first' }, + quota: { enabled: false }, accounts: [ { id: 'work-deleted', @@ -3476,49 +6984,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() - - 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', - ]) + 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, + }), + { 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 +9085,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 +9157,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 +10334,6 @@ describe('auth.loader', () => { ) await profileStarted await drainSidebarWrites() - const initialSidebarUpdatedAt = (await getSidebarState()).lastUpdated const rotated = await loadAccounts() const fallback = rotated?.accounts[0] @@ -6763,11 +10353,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', @@ -8752,6 +12347,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({ @@ -12409,14 +16275,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 +16300,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( @@ -13012,7 +16884,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, @@ -13035,7 +16907,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() @@ -13611,7 +17483,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, @@ -13630,7 +17502,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() @@ -13928,7 +17800,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 @@ -14246,11 +18118,10 @@ 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. - pluginTimerOverrides = { + pluginRuntimeOverrides = { setInterval: mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval, @@ -14260,7 +18131,7 @@ describe('killswitch fetch gate', () => { afterEach(() => { globalThis.fetch = originalFetch - pluginTimerOverrides = {} + pluginRuntimeOverrides = {} delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION }) @@ -14272,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. @@ -14651,7 +18660,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 +19001,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 +19022,7 @@ describe('claude-prime direct request', () => { 'main', process.env.OPENCODE_ANTHROPIC_AUTH_FILE, ) + mainRefreshPublished.resolve() }, ) let sends = 0 @@ -15069,7 +19079,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 +19781,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 +20153,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/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(() => { 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) + } + } + }, + } +} 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..791702d8 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 === 'na' ? 'n/a' : 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) {