From 2b23066e56bdf0fe28b0f6eef2cba7a628110a8a Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:59:03 +0200 Subject: [PATCH 01/10] fix(custody): admit vault-served accounts to credential-presence gates Six gates treated local credential bytes as a proxy for usability. A vault-served account's local slot is the provider tombstone, so quota recovery, profile hydration, /claude-quota and the killswitch's eager refresh all skipped healthy accounts once custody emptied that slot. Each site now admits a live vault binding alongside local access; none admits an account with no credential anywhere. --- packages/core/src/accounts.ts | 21 +- .../src/tests/accounts-persistence.test.ts | 48 +++++ packages/opencode/src/index.ts | 66 +++++- packages/opencode/src/tests/index.test.ts | 192 +++++++++++++++++- 4 files changed, 304 insertions(+), 23 deletions(-) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 48033d41..8088a0db 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -3724,9 +3724,15 @@ function canUseCachedQuotaAfterRefreshError( storage: AccountStorage | null, error: unknown, now: number, + vaultServed: boolean, ) { return ( - Boolean(account.access && account.expires && account.expires > now) && + // Cached quota remains attributable after a transient failure when either + // the local credential is live or a live Claustrum binding serves it. + Boolean( + (account.access && account.expires && account.expires > now) || + vaultServed, + ) && isTransientQuotaError(error) && quotaSnapshotPassesPolicy(account.quota, storage) && cachedQuotaSnapshotStillRelevant(account.quota, now) @@ -4247,8 +4253,9 @@ export class FallbackAccountManager { for (const account of storage.accounts) { if (account.enabled === false || !isOAuthAccount(account)) continue + const vaultServed = this.isFallbackAccountVaultServed(account.id, storage) if (this.isFallbackAccountVaultEnabled(account.id, storage)) { - if (!this.isFallbackAccountVaultServed(account.id, storage)) continue + if (!vaultServed) continue if ( hasNoLocalCredential(account) && !storage.quota?.minimumRemaining && @@ -4263,7 +4270,7 @@ export class FallbackAccountManager { if ( tokenNeedsRefresh(next, storage, this.now()) && !this.isFallbackAccountVaultEnabled(next.id, storage) && - !this.isFallbackAccountVaultServed(next.id, storage) + !vaultServed ) { const refreshError = next.lastRefreshError if ( @@ -4316,7 +4323,13 @@ export class FallbackAccountManager { usable.push(next) } catch (error) { if ( - canUseCachedQuotaAfterRefreshError(next, storage, error, this.now()) + canUseCachedQuotaAfterRefreshError( + next, + storage, + error, + this.now(), + vaultServed, + ) ) { log( '[refresh] fallback quota using cached quota after refresh error', diff --git a/packages/core/src/tests/accounts-persistence.test.ts b/packages/core/src/tests/accounts-persistence.test.ts index c457e2e5..c51a453d 100644 --- a/packages/core/src/tests/accounts-persistence.test.ts +++ b/packages/core/src/tests/accounts-persistence.test.ts @@ -13,6 +13,7 @@ import { saveAccountState, saveAccounts, } from '../accounts.ts' +import { custodyTombstoneOAuth } from '../claustrum.ts' const directories: string[] = [] @@ -143,6 +144,53 @@ test('excludes an empty-material vault fallback after its quota policy fails', a expect(authorizations).toEqual(['Bearer vault-fallback-access']) }) +test('keeps a live vault fallback on cached quota after a transient quota failure', async () => { + const now = 1_000_000 + const account: OAuthAccount = { + id: 'vault-fallback', + enabled: true, + ...custodyTombstoneOAuth('anthropic'), + quota: { + checkedAt: now - 60_000, + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: now - 60_000, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: now - 60_000, + }, + }, + } + const storage: AccountStorage = { + version: 1, + claustrum: { mode: 'claustrum' }, + quota: { + enabled: true, + checkIntervalMinutes: 1, + minimumRemaining: { five_hour: 10, seven_day: 10 }, + failClosedOnUnknownQuota: true, + }, + accounts: [account], + } + const manager = new FallbackAccountManager({ + now: () => now, + isFallbackAccountVaultEnabled: () => true, + isFallbackAccountVaultServed: () => true, + resolveFallbackAccessToken: () => ({ + token: 'vault-fallback-access', + source: 'vault', + }), + fetchImpl: async () => new Response('unavailable', { status: 503 }), + }) + + await expect(manager.getUsableFallbackAccounts(storage)).resolves.toEqual([ + account, + ]) +}) + test('keeps tombstone metadata when discarding a stale credential write', async () => { const directory = await mkdtemp(join(tmpdir(), 'accounts-persistence-')) directories.push(directory) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c6b45cb4..08a48233 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -51,6 +51,7 @@ import { createStickyNoRouteResponse, custodyCredentialId, custodyCredentialIdFromResolution, + custodyTombstoneOAuth, type DumpHandle, decideStickyQuotaFailure, detectClaustrumConnection, @@ -1519,6 +1520,9 @@ const anthropicAuthPlugin = async ( } const now = Date.now() const mainIdentity = mainQuotaAccountId + const vaultMainAccessToken = liveMainVaultAccess(storage) + const servedMainAccessToken = + mainServedAccessToken || mainAccessToken || vaultMainAccessToken if ( mainAccessToken && storage.main?.profile && @@ -1552,11 +1556,16 @@ const anthropicAuthPlugin = async ( profile: storage.main.profile, }).catch(() => {}) } - if (mainAccessToken && !oauthProfileIsFresh(storage.main?.profile, now)) { + // Profile hydration may use local main access or the live token serving a + // custody tombstone; an empty tombstone slot is never a usable bearer. + if ( + servedMainAccessToken && + !oauthProfileIsFresh(storage.main?.profile, now) + ) { const profile = await hydrateProfileOnce( 'main', undefined, - mainAccessToken, + servedMainAccessToken, mainProviderAccountUuid, signal, ) @@ -1570,7 +1579,7 @@ const anthropicAuthPlugin = async ( accountId: 'main', accountIdentity: mainIdentity, providerAccountUuid: mainProviderAccountUuid, - accessToken: mainAccessToken, + accessToken: servedMainAccessToken, profile, }).catch(() => {}) } @@ -2312,6 +2321,24 @@ const anthropicAuthPlugin = async ( return {} } + function liveMainVaultAccess( + storage: Awaited>, + ): string | undefined { + if (!storage || getClaustrumMode(storage) !== 'claustrum') return undefined + const account = mainCustodyAccount(custodyTombstoneOAuth('anthropic')) + const binding = resolveAccountCustodyHandle(account, storage) + if ( + binding.status !== 'resolved' || + !isOAuthAccountVaultOwned(storage, account, binding) || + claustrumBlockedAccounts.has('main') + ) { + return undefined + } + const cached = claustrumCredentialCache?.peek(binding.handle) + if (hasClaustrumIdentityMismatch(account, cached)) return undefined + return usableClaustrumAccessToken(cached, claustrumNow()) + } + function resolveFallbackAccessToken( account: OAuthAccount, storage: Awaited>, @@ -3699,11 +3726,18 @@ const anthropicAuthPlugin = async ( const hydratedAccount = hydrated.accounts.find( (candidate) => candidate.id === account.id, ) + const vaultServed = isFallbackAccountVaultServed( + account.id, + latest, + custodyDimensionsDeps, + ) + // A profile belongs to matching local access or a live vault-served + // binding; tombstones deliberately have no local access to compare. if ( !isOAuthAccount(account) || !hydratedAccount || !isOAuthAccount(hydratedAccount) || - !account.access || + (!account.access && !vaultServed) || hydratedAccount.access !== account.access ) { return account @@ -3713,8 +3747,12 @@ const anthropicAuthPlugin = async ( } const latestMainProfile = latest.main?.profile const mainState = latest.main ?? hydrated.main + const servedMainAccessToken = + mainServedAccessToken || mainAccessToken || liveMainVaultAccess(latest) + // Hydrated main state is valid with local main access or the live bearer + // serving its custody tombstone; neither admits a credential-less main. if ( - mainAccessToken && + servedMainAccessToken && mainState && (!latestMainProfile || oauthProfileMatchesIdentity(latestMainProfile, mainAccountId)) @@ -4062,8 +4100,11 @@ const anthropicAuthPlugin = async ( if (latestGetAuth) { try { const auth = await latestGetAuth() - if (auth.type === 'oauth' && auth.access) { - mainAccessToken = mainServedAccessToken ?? auth.access + const servedMainAccessToken = mainServedAccessToken ?? auth.access + // Manual quota refresh accepts local OAuth access or the live bearer + // serving a custody tombstone; an empty local slot alone remains refused. + if (auth.type === 'oauth' && servedMainAccessToken) { + mainAccessToken = servedMainAccessToken await resolveMainQuotaAccountIdentity(mainAccessToken) // /claude-quota is a manual action: force a real fetch instead of // returning the cache. refreshMain still respects 429 backoff — it @@ -8751,7 +8792,9 @@ const anthropicAuthPlugin = async ( }, ) } - // Killswitch — eagerly refresh quota so it can evaluate + // Killswitch — eagerly refresh quota for local credentials and + // live vault-served bindings so spend protection never evaluates + // a vault fallback on stale quota. if (isKillswitchEnabled(storage)) { const needsRefresh = quotaManager.needsRefresh( sessionRequestCount, @@ -8763,7 +8806,12 @@ const anthropicAuthPlugin = async ( (a): a is OAuthAccount => a.enabled !== false && isOAuthAccount(a) && - Boolean(a.access), + (Boolean(a.access) || + isFallbackAccountVaultServed( + a.id, + storage, + custodyDimensionsDeps, + )), ) await Promise.all([ quotaManager.refreshMain( diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 45a274a0..d1192be5 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1199,11 +1199,15 @@ describe('fallback Claustrum credential resolution', () => { responseStatuses?: number[] mainExpiresAt?: number fallbackExpiresAt?: number + profile?: Record } = {}, ) { let now = 1_000 const calls: CredentialCall[] = [] const authorizations: string[] = [] + const profileAuthorizations: string[] = [] + const quotaAuthorizations: string[] = [] + const client = createMockClient() let mainSlotAccess = '' let mainSlotExpires = 0 let mainExpiresAt = options.mainExpiresAt ?? 10_000 @@ -1262,9 +1266,25 @@ describe('fallback Claustrum credential resolution', () => { ...(fallback ? [{ label: 'fallback', handle: fallbackHandle }] : []), ]) globalThis.fetch = mock((input: unknown, init?: RequestInit) => { - if ( - extractUrl(input as string | URL | Request).includes('/v1/messages') - ) { + const url = extractUrl(input as string | URL | Request) + if (url === PROFILE_URL) { + profileAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve(Response.json(options.profile ?? {})) + } + if (url === QUOTA_URL) { + quotaAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + ) + } + if (url.includes('/v1/messages')) { authorizations.push( new Headers(init?.headers).get('authorization') ?? '', ) @@ -1276,7 +1296,7 @@ describe('fallback Claustrum credential resolution', () => { } return Promise.resolve(new Response('{}', { status: 200 })) }) as unknown as typeof fetch - const plugin = await getPlugin(undefined, undefined, { + const plugin = await getPlugin(client, undefined, { claustrumNow: () => now, claustrumConnector: connectorFor(calls, (method, params) => { if (method !== 'credential.get') return { result: {} } @@ -1302,9 +1322,12 @@ describe('fallback Claustrum credential resolution', () => { ) return { plugin, + client, result, calls, authorizations, + profileAuthorizations, + quotaAuthorizations, setNow(value: number) { now = value }, @@ -1320,6 +1343,73 @@ describe('fallback Claustrum credential resolution', () => { } } + test.serial( + 'hydrates the main profile through a vault-served tombstone', + async () => { + const fixture = await bootVaultMain({ + fallback: false, + profile: { + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }, + }) + + const state = await waitForSidebarState( + (candidate) => candidate.main.tierLabel === 'Max 20x', + ) + expect(fixture.profileAuthorizations).toEqual([ + 'Bearer vault-main-access', + ]) + expect(state.main.tierLabel).toBe('Max 20x') + await fixture.plugin.dispose?.() + }, + ) + + test.serial( + 'merges the hydrated main profile into sidebar state through a vault-served tombstone', + async () => { + const fixture = await bootVaultMain({ + fallback: false, + profile: { + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }, + }) + + const state = await waitForSidebarState( + (candidate) => candidate.main.tierLabel === 'Max 20x', + ) + expect(state.main.tierLabel).toBe('Max 20x') + await fixture.plugin.dispose?.() + }, + ) + + test.serial( + 'refreshes /claude-quota for a vault-served main tombstone', + async () => { + const fixture = await bootVaultMain({ fallback: false }) + + await fixture.result.fetch(MESSAGES_URL, request()) + + await expectHandledCommandResponse( + fixture.plugin['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'vault-main-quota', + }), + ) + + expect(fixture.quotaAuthorizations).toContain( + 'Bearer vault-main-access', + ) + await fixture.plugin.dispose?.() + }, + ) + test.serial( 'a refused vault main clears a legacy tombstone bearer before fallback routing', async () => { @@ -2217,7 +2307,16 @@ describe('fallback Claustrum credential resolution', () => { }) let messageRequests = 0 let claustrumNow = 0 - globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + // Scope to the message path: profile hydration also runs on the served + // main token now that a custody tombstone no longer blocks it, and it + // must not consume the deferred first response or the token ledger. + const url = String( + input instanceof Request ? input.url : (input as string | URL), + ) + if (!url.startsWith(MESSAGES_URL)) { + return Promise.resolve(new Response('denied', { status: 401 })) + } authorizations.push( new Headers(init?.headers).get('authorization') ?? '', ) @@ -5188,6 +5287,42 @@ describe('fallback Claustrum credential resolution', () => { }, ) + test.serial( + 'merges a hydrated fallback profile through a vault-served tombstone', + async () => { + const fixture = await bootRuledClaustrumRow({ + route: 'fallback-first', + fallbacks: [ + { + label: 'profiled', + handle: `ckh_${'P'.repeat(43)}`, + access: 'vault-profiled-access', + }, + ], + onFetch: (input) => + extractUrl(input as string | URL | Request) === PROFILE_URL + ? Response.json({ + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + }, + }) + : new Response('{}', { status: 200 }), + }) + + const state = await waitForSidebarState( + (candidate) => + candidate.fallbacks.find((account) => account.id === 'fallback-1') + ?.tierLabel === 'Team · Max 5x', + ) + expect( + state.fallbacks.find((account) => account.id === 'fallback-1') + ?.tierLabel, + ).toBe('Team · Max 5x') + await fixture.plugin.dispose?.() + }, + ) + test.serial( 'does not route a manifest-resolved account through a legacy per-account flag', async () => { @@ -22752,7 +22887,7 @@ describe('killswitch fetch gate', () => { }) as unknown as typeof globalThis.setTimeout const usageAuthorizations: string[] = [] const fixture = await bootSharedRuledClaustrumRow({ - route: 'fallback-first', + route: 'main-exhausted', quota: { enabled: true, checkIntervalMinutes: 5, @@ -22762,13 +22897,13 @@ describe('killswitch fetch gate', () => { mainQuota: { checkedAt: now, five_hour: { - usedPercent: 100, - remainingPercent: 0, + usedPercent: 10, + remainingPercent: 90, checkedAt: now, }, seven_day: { - usedPercent: 100, - remainingPercent: 0, + usedPercent: 10, + remainingPercent: 90, checkedAt: now, }, }, @@ -22837,11 +22972,44 @@ describe('killswitch fetch gate', () => { }) const plugin = fixture.plugin await plugin.__fallbackRefreshReady + const persisted = await loadAccounts() + if (!persisted) throw new Error('expected custody fixture storage') + const accountPath = process.env.OPENCODE_ANTHROPIC_AUTH_FILE + if (!accountPath) throw new Error('expected custody fixture account file') + await fs.writeFile( + accountPath, + JSON.stringify({ + ...persisted, + accounts: persisted.accounts.map((account) => + isOAuthAccount(account) && account.id === accountId + ? { ...account, ...custodyTombstoneOAuth('anthropic') } + : account, + ), + }), + ) + expect( + (await loadAccounts())?.accounts.find( + (account): account is OAuthAccount => + isOAuthAccount(account) && account.id === accountId, + )?.access, + ).toBe('') clock = now + 6 * 60 * 60 * 1000 plugin.__quotaManager.clearFallback(accountId) const residentBeforeRequest = Boolean( plugin.__claustrumCredentialCache.peek(handle), ) + const eagerFallbackIds: string[][] = [] + const refreshAllFallbacks = + plugin.__quotaManager.refreshAllFallbacks.bind(plugin.__quotaManager) + spyOn(plugin.__quotaManager, 'refreshAllFallbacks').mockImplementation( + async ( + accounts: OAuthAccount[], + resolveAccessToken?: (account: OAuthAccount) => string | undefined, + ) => { + eagerFallbackIds.push(accounts.map((account) => account.id)) + await refreshAllFallbacks(accounts, resolveAccessToken) + }, + ) usageAuthorizations.length = 0 const timerBaseline = detachedTimers.length @@ -22871,6 +23039,7 @@ describe('killswitch fetch gate', () => { await plugin.dispose?.() return { + eagerFallbackIds, fallbackUsageCalls: coldFallbackUsageCalls, residentBeforeRequest, sidecarUsageCalls: coldSidecarUsageCalls, @@ -22887,6 +23056,9 @@ describe('killswitch fetch gate', () => { const result = await runVaultKillswitchQuotaRefresh(true) expect(result.residentBeforeRequest).toBe(true) + expect(result.eagerFallbackIds).toContainEqual([ + 'killswitch-vault-fallback', + ]) expect(result.sidecarUsageCalls).toBe(0) expect(result.fallbackUsageCalls).toBe(1) expect(result.scheduledWarmCount).toBe(0) From bb34708d036a2e23894c93b64a22e68dbde7c9e3 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:14:21 +0200 Subject: [PATCH 02/10] fix(custody): refuse a reported-failed vault version on main paths liveMainVaultAccess hand-rolled its sibling and dropped the sibling's stale-version guard, so main profile hydration and /claude-quota could bearer a version already reported auth-failed. It now delegates to resolveClaustrumAccess, inheriting both the guard and the warm schedule. mainServedAccessToken is cleared on a successful main report only: a suppressed or failed report tells the vault nothing, so local belief must not diverge from what the vault received. --- packages/opencode/src/index.ts | 24 +++++----- packages/opencode/src/tests/index.test.ts | 53 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 08a48233..a072e949 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -2324,19 +2324,12 @@ const anthropicAuthPlugin = async ( function liveMainVaultAccess( storage: Awaited>, ): string | undefined { - if (!storage || getClaustrumMode(storage) !== 'claustrum') return undefined - const account = mainCustodyAccount(custodyTombstoneOAuth('anthropic')) - const binding = resolveAccountCustodyHandle(account, storage) - if ( - binding.status !== 'resolved' || - !isOAuthAccountVaultOwned(storage, account, binding) || - claustrumBlockedAccounts.has('main') - ) { - return undefined - } - const cached = claustrumCredentialCache?.peek(binding.handle) - if (hasClaustrumIdentityMismatch(account, cached)) return undefined - return usableClaustrumAccessToken(cached, claustrumNow()) + return ( + resolveClaustrumAccess( + mainCustodyAccount(custodyTombstoneOAuth('anthropic')), + storage, + ).accessToken || undefined + ) } function resolveFallbackAccessToken( @@ -2523,6 +2516,7 @@ const anthropicAuthPlugin = async ( reporterSource, ) claustrumLastReportedVersion.set(served.handle, served.recordVersion) + if (served.accountId === 'main') mainServedAccessToken = undefined } catch (error) { handleClaustrumCredentialError(served.accountId, error, served.handle) logger.warn('claustrum', 'failed to report credential failure', { @@ -4100,7 +4094,9 @@ const anthropicAuthPlugin = async ( if (latestGetAuth) { try { const auth = await latestGetAuth() - const servedMainAccessToken = mainServedAccessToken ?? auth.access + const latest = await loadAccounts(accountStoragePath) + const servedMainAccessToken = + mainServedAccessToken || auth.access || liveMainVaultAccess(latest) // Manual quota refresh accepts local OAuth access or the live bearer // serving a custody tombstone; an empty local slot alone remains refused. if (auth.type === 'oauth' && servedMainAccessToken) { diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index d1192be5..1bcab708 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1207,6 +1207,7 @@ describe('fallback Claustrum credential resolution', () => { const authorizations: string[] = [] const profileAuthorizations: string[] = [] const quotaAuthorizations: string[] = [] + const scheduledWarmCallbacks: Array<() => void> = [] const client = createMockClient() let mainSlotAccess = '' let mainSlotExpires = 0 @@ -1298,6 +1299,12 @@ describe('fallback Claustrum credential resolution', () => { }) as unknown as typeof fetch const plugin = await getPlugin(client, undefined, { claustrumNow: () => now, + setTimeout: mock((callback: TestTimerHandler, delay?: number) => { + if (delay === 0 && typeof callback === 'function') { + scheduledWarmCallbacks.push(callback as () => void) + } + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof setTimeout, claustrumConnector: connectorFor(calls, (method, params) => { if (method !== 'credential.get') return { result: {} } const isMain = params.handle === manifestHandle @@ -1328,6 +1335,7 @@ describe('fallback Claustrum credential resolution', () => { authorizations, profileAuthorizations, quotaAuthorizations, + scheduledWarmCallbacks, setNow(value: number) { now = value }, @@ -1410,6 +1418,51 @@ describe('fallback Claustrum credential resolution', () => { }, ) + test.serial( + 'refreshes /claude-quota at startup for a vault-served main tombstone', + async () => { + const fixture = await bootVaultMain({ fallback: false }) + + await expectHandledCommandResponse( + fixture.plugin['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'vault-main-quota-startup', + }), + ) + + expect(fixture.quotaAuthorizations).toContain( + 'Bearer vault-main-access', + ) + await fixture.plugin.dispose?.() + }, + ) + + test.serial( + 'does not reuse a main vault bearer after its 401 is reported', + async () => { + const fixture = await bootVaultMain({ + fallback: false, + responseStatus: 401, + }) + + await fixture.result.fetch(MESSAGES_URL, request()) + await expectHandledCommandResponse( + fixture.plugin['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'vault-main-stale-bearer', + }), + ) + + expect(fixture.quotaAuthorizations).not.toContain( + 'Bearer vault-main-access', + ) + expect(fixture.scheduledWarmCallbacks).toHaveLength(1) + await fixture.plugin.dispose?.() + }, + ) + test.serial( 'a refused vault main clears a legacy tombstone bearer before fallback routing', async () => { From 3fadd730d18942df3b11a4db583ab1a4a7d643f8 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:55:15 +0200 Subject: [PATCH 03/10] fix(custody): close stale vault quota admission --- packages/core/src/accounts.ts | 2 +- packages/opencode/src/tests/accounts.test.ts | 49 ++++++++++++++++++++ packages/opencode/src/tests/index.test.ts | 28 ++--------- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 8088a0db..0b76aeb6 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -4328,7 +4328,7 @@ export class FallbackAccountManager { storage, error, this.now(), - vaultServed, + this.isFallbackAccountVaultServed(next.id, storage), ) ) { log( diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 4bb29fdd..56b6da28 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -5003,6 +5003,55 @@ describe('FallbackAccountManager', () => { expect(accounts.map((account) => account.id)).toEqual(['stale-good-quota']) }) + test('does not use cached quota after vault access disappears during refresh', async () => { + const now = 10 * 60_000 + const storage = baseStorage() + storage.accounts.push({ + id: 'vault-access-race', + type: 'oauth', + access: '', + refresh: '', + expires: 0, + claustrumHandle: 'vault-access-race-handle', + quota: { + checkedAt: 1_000, + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: 1_000, + resetsAt: '2099-01-01T00:00:00Z', + }, + seven_day: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: 1_000, + resetsAt: '2099-01-01T00:00:00Z', + }, + }, + }) + + let vaultServed = true + const fetchImpl = mock(async () => { + vaultServed = false + return new Response('temporarily unavailable', { status: 503 }) + }) as unknown as typeof fetch + const manager = new FallbackAccountManager({ + fetchImpl, + now: () => now, + isFallbackAccountVaultEnabled: () => true, + isFallbackAccountVaultServed: () => vaultServed, + resolveFallbackAccessToken: () => ({ + token: 'vault-access', + source: 'vault' as const, + }), + }) + + const accounts = await manager.getUsableFallbackAccounts(storage) + + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(accounts).toEqual([]) + }) + test('keeps a concurrent replacement account when its quota probe fails', async () => { const oldStorage = baseStorage() const oldAccount: OAuthAccount = { diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 1bcab708..f99b5c79 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1352,7 +1352,7 @@ describe('fallback Claustrum credential resolution', () => { } test.serial( - 'hydrates the main profile through a vault-served tombstone', + 'hydrates and merges the main profile through a vault-served tombstone', async () => { const fixture = await bootVaultMain({ fallback: false, @@ -1375,27 +1375,6 @@ describe('fallback Claustrum credential resolution', () => { }, ) - test.serial( - 'merges the hydrated main profile into sidebar state through a vault-served tombstone', - async () => { - const fixture = await bootVaultMain({ - fallback: false, - profile: { - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_20x', - }, - }, - }) - - const state = await waitForSidebarState( - (candidate) => candidate.main.tierLabel === 'Max 20x', - ) - expect(state.main.tierLabel).toBe('Max 20x') - await fixture.plugin.dispose?.() - }, - ) - test.serial( 'refreshes /claude-quota for a vault-served main tombstone', async () => { @@ -1446,6 +1425,7 @@ describe('fallback Claustrum credential resolution', () => { responseStatus: 401, }) + const warmCallbacksBeforeFetch = fixture.scheduledWarmCallbacks.length await fixture.result.fetch(MESSAGES_URL, request()) await expectHandledCommandResponse( fixture.plugin['command.execute.before']({ @@ -1458,7 +1438,9 @@ describe('fallback Claustrum credential resolution', () => { expect(fixture.quotaAuthorizations).not.toContain( 'Bearer vault-main-access', ) - expect(fixture.scheduledWarmCallbacks).toHaveLength(1) + expect(fixture.scheduledWarmCallbacks.length).toBeGreaterThan( + warmCallbacksBeforeFetch, + ) await fixture.plugin.dispose?.() }, ) From f54c5810055dc4c22107494e3637d7de59b71a49 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:45:18 +0200 Subject: [PATCH 04/10] test(accounts): cover served vault cached quota admission --- packages/opencode/src/tests/accounts.test.ts | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 56b6da28..5d123af0 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -5052,6 +5052,57 @@ describe('FallbackAccountManager', () => { expect(accounts).toEqual([]) }) + test('uses cached quota when vault access remains served during refresh', async () => { + const now = 10 * 60_000 + const storage = baseStorage() + storage.accounts.push({ + id: 'vault-access-stays-served', + type: 'oauth', + access: '', + refresh: '', + expires: 0, + claustrumHandle: 'vault-access-stays-served-handle', + quota: { + checkedAt: 1_000, + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: 1_000, + resetsAt: '2099-01-01T00:00:00Z', + }, + seven_day: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: 1_000, + resetsAt: '2099-01-01T00:00:00Z', + }, + }, + }) + + let vaultServed = true + const fetchImpl = mock(() => + Promise.resolve(new Response('temporarily unavailable', { status: 503 })), + ) as unknown as typeof fetch + const manager = new FallbackAccountManager({ + fetchImpl, + now: () => now, + isFallbackAccountVaultEnabled: () => true, + isFallbackAccountVaultServed: () => vaultServed, + resolveFallbackAccessToken: () => ({ + token: 'vault-access', + source: 'vault' as const, + }), + }) + + const accounts = await manager.getUsableFallbackAccounts(storage) + + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(vaultServed).toBe(true) + expect(accounts.map((account) => account.id)).toEqual([ + 'vault-access-stays-served', + ]) + }) + test('keeps a concurrent replacement account when its quota probe fails', async () => { const oldStorage = baseStorage() const oldAccount: OAuthAccount = { From 10ec6e51e6efe86535ebeec93ab17ac42292920f Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:51:34 +0200 Subject: [PATCH 05/10] test(core): guard vault refresh TTL coupling --- packages/core/src/accounts.ts | 1 + .../core/src/tests/accounts-persistence.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 0b76aeb6..3d808a17 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -490,6 +490,7 @@ export type AccountRefreshError = { } const DEFAULT_FALLBACK_ON = [401, 403, 429] +// This vault-facing coupling is guarded by "keeps the vault-facing refresh TTL at 270 minutes" in accounts-persistence.test.ts. const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240 const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES const DEFAULT_REFRESH_INTERVAL_MINUTES = 10 diff --git a/packages/core/src/tests/accounts-persistence.test.ts b/packages/core/src/tests/accounts-persistence.test.ts index c51a453d..0d450c38 100644 --- a/packages/core/src/tests/accounts-persistence.test.ts +++ b/packages/core/src/tests/accounts-persistence.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from 'bun:test' +import { strictEqual } from 'node:assert' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -7,6 +8,7 @@ import { type AccountStorage, createEmptyStorage, FallbackAccountManager, + getRefreshBeforeExpiryMs, hasNoLocalCredential, loadAccounts, type OAuthAccount, @@ -32,6 +34,21 @@ test('recognizes an OAuth account with no local credential', () => { expect(hasNoLocalCredential({ access: '' })).toBe(false) }) +test('keeps the vault-facing refresh TTL at 270 minutes', () => { + const vaultMinTtlMs = + getRefreshBeforeExpiryMs(createEmptyStorage()) + 30 * 60_000 + const expectedVaultMinTtlMs = 270 * 60_000 + const guidance = [ + 'Vault coupling tripwire: this derived value sets the vault rotation period as token_lifetime - minTtl.', + "Lowering it lengthens the rotation period and repeatedly false-alarms the vault operator's stall detector.", + 'Raising it is silent for the operator.', + 'A lowering change requires advance notice to the vault operator with the new derived value.', + `The new derived value is ${vaultMinTtlMs / 60_000} minutes (${vaultMinTtlMs} ms).`, + ].join(' ') + + strictEqual(vaultMinTtlMs, expectedVaultMinTtlMs, guidance) +}) + test('preserves the Claustrum mode when a save supplies only handlesFile', async () => { const directory = await mkdtemp(join(tmpdir(), 'accounts-persistence-')) directories.push(directory) From 29120695d39c42f7433e8fa77ca02fec6c98428a Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:52:55 +0200 Subject: [PATCH 06/10] test(core): state the resulting rotation period in the vault tripwire --- .../src/tests/accounts-persistence.test.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/core/src/tests/accounts-persistence.test.ts b/packages/core/src/tests/accounts-persistence.test.ts index 0d450c38..5867ef5a 100644 --- a/packages/core/src/tests/accounts-persistence.test.ts +++ b/packages/core/src/tests/accounts-persistence.test.ts @@ -38,12 +38,22 @@ test('keeps the vault-facing refresh TTL at 270 minutes', () => { const vaultMinTtlMs = getRefreshBeforeExpiryMs(createEmptyStorage()) + 30 * 60_000 const expectedVaultMinTtlMs = 270 * 60_000 + // Anthropic OAuth access tokens live 8h; the vault refreshes a credential when + // `now + minTtl >= expires_at`, so this value alone fixes the observed rotation + // period. State the resulting PERIOD, not just the minTtl: the period is the + // number the vault operator needs to pre-seed their stall detector. + const tokenLifetimeMinutes = 480 + const newPeriodMinutes = tokenLifetimeMinutes - vaultMinTtlMs / 60_000 + const oldPeriodMinutes = tokenLifetimeMinutes - expectedVaultMinTtlMs / 60_000 const guidance = [ - 'Vault coupling tripwire: this derived value sets the vault rotation period as token_lifetime - minTtl.', - "Lowering it lengthens the rotation period and repeatedly false-alarms the vault operator's stall detector.", - 'Raising it is silent for the operator.', - 'A lowering change requires advance notice to the vault operator with the new derived value.', - `The new derived value is ${vaultMinTtlMs / 60_000} minutes (${vaultMinTtlMs} ms).`, + 'Vault coupling tripwire: this derived value is passed as minTtl to Claustrum', + '`credential.get`, and the vault refreshes when `now + minTtl >= expires_at`.', + `It therefore sets the observed rotation period to token_lifetime - minTtl =`, + `${tokenLifetimeMinutes} - ${vaultMinTtlMs / 60_000} = ${newPeriodMinutes} minutes`, + `(was ${oldPeriodMinutes} minutes at the expected ${expectedVaultMinTtlMs / 60_000}).`, + newPeriodMinutes > oldPeriodMinutes + ? 'THIS CHANGE LENGTHENS THE PERIOD, WHICH REQUIRES ADVANCE NOTICE: the vault operator alarms on MAX(recent gaps) + 30m, so the first longer gap trips a false stall alarm that REPEATS on a 30-minute cooldown until the refresh lands. Tell them the new period above before deploying so they can pre-seed it.' + : 'This change shortens the period, which is silent for the vault operator and needs no notice.', ].join(' ') strictEqual(vaultMinTtlMs, expectedVaultMinTtlMs, guidance) From 86bf6b5a807cb7f976f90a330721ac4a36b5d6e8 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:55:05 +0200 Subject: [PATCH 07/10] test(core): label roles on every number in the vault tripwire --- .../core/src/tests/accounts-persistence.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tests/accounts-persistence.test.ts b/packages/core/src/tests/accounts-persistence.test.ts index 5867ef5a..57611856 100644 --- a/packages/core/src/tests/accounts-persistence.test.ts +++ b/packages/core/src/tests/accounts-persistence.test.ts @@ -45,12 +45,20 @@ test('keeps the vault-facing refresh TTL at 270 minutes', () => { const tokenLifetimeMinutes = 480 const newPeriodMinutes = tokenLifetimeMinutes - vaultMinTtlMs / 60_000 const oldPeriodMinutes = tokenLifetimeMinutes - expectedVaultMinTtlMs / 60_000 + // Every number below is labelled with its ROLE: minTtl and period are drawn from + // the same small set of values and routinely swap places (a 240m minTtl on an 8h + // token yields a 240m period), so bare numerals invite transposition by a reader + // who lands on the assertion footer rather than the prose. const guidance = [ 'Vault coupling tripwire: this derived value is passed as minTtl to Claustrum', - '`credential.get`, and the vault refreshes when `now + minTtl >= expires_at`.', - `It therefore sets the observed rotation period to token_lifetime - minTtl =`, - `${tokenLifetimeMinutes} - ${vaultMinTtlMs / 60_000} = ${newPeriodMinutes} minutes`, - `(was ${oldPeriodMinutes} minutes at the expected ${expectedVaultMinTtlMs / 60_000}).`, + '`credential.get`, and the vault refreshes when `now + minTtl >= expires_at`,', + 'so it fixes the observed rotation period as token_lifetime - minTtl.', + `CHANGED: minTtl ${vaultMinTtlMs / 60_000}m (was ${expectedVaultMinTtlMs / 60_000}m)`, + `-> rotation period ${newPeriodMinutes}m (was ${oldPeriodMinutes}m).`, + `The +/- values below are minTtl in ms, NOT the period.`, + `token_lifetime is ASSUMED ${tokenLifetimeMinutes}m — neither side observes it`, + "(it lives inside the vault's encrypted envelope); if Anthropic changed it,", + 'this arithmetic is stale even though the assertion fired correctly.', newPeriodMinutes > oldPeriodMinutes ? 'THIS CHANGE LENGTHENS THE PERIOD, WHICH REQUIRES ADVANCE NOTICE: the vault operator alarms on MAX(recent gaps) + 30m, so the first longer gap trips a false stall alarm that REPEATS on a 30-minute cooldown until the refresh lands. Tell them the new period above before deploying so they can pre-seed it.' : 'This change shortens the period, which is silent for the vault operator and needs no notice.', From f7108bec770438891b859c5d941e9fdc377defce Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:04:15 +0200 Subject: [PATCH 08/10] test(core): guard the override floor the vault depends on --- .../src/tests/accounts-persistence.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/core/src/tests/accounts-persistence.test.ts b/packages/core/src/tests/accounts-persistence.test.ts index 57611856..6c68e9c9 100644 --- a/packages/core/src/tests/accounts-persistence.test.ts +++ b/packages/core/src/tests/accounts-persistence.test.ts @@ -67,6 +67,32 @@ test('keeps the vault-facing refresh TTL at 270 minutes', () => { strictEqual(vaultMinTtlMs, expectedVaultMinTtlMs, guidance) }) +test('floors a config override so it cannot lower the vault-facing minTtl', () => { + // The tripwire above watches the CONSTANT. This watches the other route to the + // same vault-facing value: the `refresh.refreshBeforeExpiryMinutes` config key. + // The floor in refreshBeforeExpiryMs is what makes the tripwire sufficient — + // without it, an operator could lower minTtl from config, lengthening the vault's + // rotation period, and the constant-watching tripwire would never fire. + const storage = createEmptyStorage() + storage.refresh = { ...storage.refresh, refreshBeforeExpiryMinutes: 60 } + const floored = getRefreshBeforeExpiryMs(storage) + + strictEqual( + floored, + 240 * 60_000, + [ + 'Vault coupling tripwire (config route): a below-floor override of', + '`refresh.refreshBeforeExpiryMinutes` must clamp UP to the 240m floor, but this', + `build returned ${floored / 60_000}m. The floor is load-bearing for a peer system:`, + 'it is the only reason config cannot lower minTtl, and lowering minTtl LENGTHENS the', + "vault's rotation period, which repeatedly false-alarms the vault operator's stall", + 'detector. Removing the floor makes that reachable from config alone, where the', + 'constant-watching tripwire above cannot see it. If you removed it deliberately,', + 'the vault operator holds a registered dependency on it and is owed notice.', + ].join(' '), + ) +}) + test('preserves the Claustrum mode when a save supplies only handlesFile', async () => { const directory = await mkdtemp(join(tmpdir(), 'accounts-persistence-')) directories.push(directory) From 81180fd598e3ec0199c806534f2e53b4bd1354f9 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:29:02 +0200 Subject: [PATCH 09/10] fix(claustrum): verify golden pin ancestry --- .../fixtures/claustrum-golden/SOURCE.json | 4 +- scripts/check-claustrum-golden.ts | 108 ++++++++++++++++-- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/tests/fixtures/claustrum-golden/SOURCE.json b/packages/opencode/src/tests/fixtures/claustrum-golden/SOURCE.json index dcb75544..aaf76615 100644 --- a/packages/opencode/src/tests/fixtures/claustrum-golden/SOURCE.json +++ b/packages/opencode/src/tests/fixtures/claustrum-golden/SOURCE.json @@ -1,6 +1,6 @@ { - "repo": "legion-works/claustrum", - "ref": "0e9dee77cb91e762d31a9ccb502728a69f09bcbe", + "repo": "cortexkit/claustrum", + "ref": "6817148f92dae80a0171973ca122d17b71d0d801", "paths": { "tombstone": "packages/opencode/golden/tombstone.json", "handles": "packages/opencode/golden/handles.json" diff --git a/scripts/check-claustrum-golden.ts b/scripts/check-claustrum-golden.ts index a8e3f3c3..829bb8ba 100644 --- a/scripts/check-claustrum-golden.ts +++ b/scripts/check-claustrum-golden.ts @@ -39,20 +39,106 @@ for (const [name] of paths) { } let drifted = false +let contentUnchecked = false for (const [name, sourcePath] of paths) { const url = `https://raw.githubusercontent.com/${source.repo}/${source.ref}/${sourcePath}` - const response = await fetch(url) - if (!response.ok) { - throw new Error(`Failed to fetch ${name} golden: ${response.status} ${url}`) - } - const remote = Buffer.from(await response.arrayBuffer()) - const local = await readFile(join(fixtureDir, `${name}.json`)) - if (Buffer.compare(remote, local) !== 0) { - console.error(`DRIFT: ${name}.json differs from ${url}`) - drifted = true + let response: Response + try { + response = await fetch(url) + if (!response.ok) { + console.error( + `CONTENT UNCHECKED: ${name}.json could not be fetched (${response.status} ${url})`, + ) + contentUnchecked = true + continue + } + const remote = Buffer.from(await response.arrayBuffer()) + const local = await readFile(join(fixtureDir, `${name}.json`)) + if (Buffer.compare(remote, local) !== 0) { + console.error(`CONTENT FAIL: ${name}.json differs from ${url}`) + drifted = true + continue + } + } catch (error) { + console.error( + `CONTENT UNCHECKED: ${name}.json could not be fetched (${error instanceof Error ? error.message : String(error)})`, + ) + contentUnchecked = true continue } - console.log(`${name}.json: IDENTICAL (${source.ref})`) + console.log(`CONTENT PASS: ${name}.json IDENTICAL (${source.ref})`) } -if (drifted) process.exitCode = 1 +let ancestryFailed = false +let ancestryUnchecked = false +const repositoryUrl = `https://api.github.com/repos/${source.repo}` +try { + const repositoryResponse = await fetch(repositoryUrl, { + headers: { Accept: 'application/vnd.github+json' }, + }) + if (!repositoryResponse.ok) { + console.error( + `ANCESTRY UNCHECKED: could not resolve upstream default branch (${repositoryResponse.status} ${repositoryUrl})`, + ) + ancestryUnchecked = true + } else { + const repository = (await repositoryResponse.json()) as { + default_branch?: unknown + } + if ( + typeof repository.default_branch !== 'string' || + !repository.default_branch + ) { + console.error( + `ANCESTRY UNCHECKED: upstream repository did not provide a default branch (${repositoryUrl})`, + ) + ancestryUnchecked = true + } else { + const branch = encodeURIComponent(repository.default_branch) + const compareUrl = `https://api.github.com/repos/${source.repo}/compare/${branch}...${source.ref}` + const compareResponse = await fetch(compareUrl, { + headers: { Accept: 'application/vnd.github+json' }, + }) + if (!compareResponse.ok) { + console.error( + `ANCESTRY UNCHECKED: compare API could not answer (${compareResponse.status} ${compareUrl})`, + ) + ancestryUnchecked = true + } else { + const comparison = (await compareResponse.json()) as { + status?: unknown + } + if ( + comparison.status === 'behind' || + comparison.status === 'identical' + ) { + console.log( + `ANCESTRY PASS: pin ${source.ref} is an ancestor of ${source.repo}@${repository.default_branch} (compare status: ${comparison.status})`, + ) + } else if ( + comparison.status === 'ahead' || + comparison.status === 'diverged' + ) { + console.error( + `ANCESTRY FAIL: pin ${source.ref} no longer tracks upstream ${source.repo}@${repository.default_branch} (compare status: ${comparison.status})`, + ) + ancestryFailed = true + } else { + console.error( + `ANCESTRY UNCHECKED: compare API returned an unexpected status (${String(comparison.status)})`, + ) + ancestryUnchecked = true + } + } + } + } +} catch (error) { + console.error( + `ANCESTRY UNCHECKED: upstream repository or compare API failed (${error instanceof Error ? error.message : String(error)})`, + ) + ancestryUnchecked = true +} + +if (drifted || contentUnchecked || ancestryFailed || ancestryUnchecked) { + process.exitCode = 1 +} From 9f33b9d90618f2517d3913bba2f9030e2154abb9 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:40:54 +0200 Subject: [PATCH 10/10] fix(core): share vault refresh TTL derivation --- packages/core/src/accounts.ts | 9 ++++++++- .../src/tests/accounts-persistence.test.ts | 20 ++++++++++--------- packages/opencode/src/index.ts | 4 ++-- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 3d808a17..21d93748 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -490,9 +490,10 @@ export type AccountRefreshError = { } const DEFAULT_FALLBACK_ON = [401, 403, 429] -// This vault-facing coupling is guarded by "keeps the vault-facing refresh TTL at 270 minutes" in accounts-persistence.test.ts. const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240 const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES +// Claustrum requests extra headroom beyond the local refresh threshold. +export const VAULT_REFRESH_HEADROOM_MINUTES = 30 const DEFAULT_REFRESH_INTERVAL_MINUTES = 10 const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000 const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000 @@ -3023,6 +3024,12 @@ export function getRefreshBeforeExpiryMs(storage: AccountStorage | null) { return refreshBeforeExpiryMs(storage) } +export function getVaultRefreshMinTtlMs(storage: AccountStorage | null) { + return ( + getRefreshBeforeExpiryMs(storage) + VAULT_REFRESH_HEADROOM_MINUTES * 60_000 + ) +} + export function getRefreshIntervalMs(storage: AccountStorage | null) { const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES diff --git a/packages/core/src/tests/accounts-persistence.test.ts b/packages/core/src/tests/accounts-persistence.test.ts index 6c68e9c9..8796a193 100644 --- a/packages/core/src/tests/accounts-persistence.test.ts +++ b/packages/core/src/tests/accounts-persistence.test.ts @@ -9,6 +9,7 @@ import { createEmptyStorage, FallbackAccountManager, getRefreshBeforeExpiryMs, + getVaultRefreshMinTtlMs, hasNoLocalCredential, loadAccounts, type OAuthAccount, @@ -19,6 +20,8 @@ import { custodyTombstoneOAuth } from '../claustrum.ts' const directories: string[] = [] +// These paired tripwires cover both vault-facing minTtl routes: default +// threshold/headroom derivation and the config-override floor. afterEach(async () => { await Promise.all( directories @@ -35,8 +38,7 @@ test('recognizes an OAuth account with no local credential', () => { }) test('keeps the vault-facing refresh TTL at 270 minutes', () => { - const vaultMinTtlMs = - getRefreshBeforeExpiryMs(createEmptyStorage()) + 30 * 60_000 + const vaultMinTtlMs = getVaultRefreshMinTtlMs(createEmptyStorage()) const expectedVaultMinTtlMs = 270 * 60_000 // Anthropic OAuth access tokens live 8h; the vault refreshes a credential when // `now + minTtl >= expires_at`, so this value alone fixes the observed rotation @@ -50,7 +52,7 @@ test('keeps the vault-facing refresh TTL at 270 minutes', () => { // token yields a 240m period), so bare numerals invite transposition by a reader // who lands on the assertion footer rather than the prose. const guidance = [ - 'Vault coupling tripwire: this derived value is passed as minTtl to Claustrum', + 'Vault coupling tripwire (threshold + headroom route; paired with the config-floor tripwire below): this shared value is passed as minTtl to Claustrum', '`credential.get`, and the vault refreshes when `now + minTtl >= expires_at`,', 'so it fixes the observed rotation period as token_lifetime - minTtl.', `CHANGED: minTtl ${vaultMinTtlMs / 60_000}m (was ${expectedVaultMinTtlMs / 60_000}m)`, @@ -68,11 +70,11 @@ test('keeps the vault-facing refresh TTL at 270 minutes', () => { }) test('floors a config override so it cannot lower the vault-facing minTtl', () => { - // The tripwire above watches the CONSTANT. This watches the other route to the - // same vault-facing value: the `refresh.refreshBeforeExpiryMinutes` config key. - // The floor in refreshBeforeExpiryMs is what makes the tripwire sufficient — - // without it, an operator could lower minTtl from config, lengthening the vault's - // rotation period, and the constant-watching tripwire would never fire. + // This watches the other route to the same vault-facing value: the + // `refresh.refreshBeforeExpiryMinutes` config key. The floor in + // refreshBeforeExpiryMs is what makes the paired tripwires sufficient — without + // it, an operator could lower minTtl from config, lengthening the vault's + // rotation period, and the threshold/headroom tripwire above would never fire. const storage = createEmptyStorage() storage.refresh = { ...storage.refresh, refreshBeforeExpiryMinutes: 60 } const floored = getRefreshBeforeExpiryMs(storage) @@ -87,7 +89,7 @@ test('floors a config override so it cannot lower the vault-facing minTtl', () = 'it is the only reason config cannot lower minTtl, and lowering minTtl LENGTHENS the', "vault's rotation period, which repeatedly false-alarms the vault operator's stall", 'detector. Removing the floor makes that reachable from config alone, where the', - 'constant-watching tripwire above cannot see it. If you removed it deliberately,', + 'threshold/headroom tripwire above cannot see it. If you removed it deliberately,', 'the vault operator holds a registered dependency on it and is owed notice.', ].join(' '), ) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index a072e949..4a54cb81 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -90,11 +90,11 @@ import { getPersistedLogLevel, getPersistedMainQuota, getQuotaNextRefreshAt, - getRefreshBeforeExpiryMs, getRelayConfig, getRoutingMode, getStickyRoutingStatePath, getThinkingPrefixMismatchBehavior, + getVaultRefreshMinTtlMs, hashRefreshToken, type IdentityState, incrementPrimeUsagePersistent, @@ -2606,7 +2606,7 @@ const anthropicAuthPlugin = async ( const storage = enrollment.storage if (!storage) return cache = claustrumCredentialCache - const minTtlMs = getRefreshBeforeExpiryMs(storage) + 30 * 60_000 + const minTtlMs = getVaultRefreshMinTtlMs(storage) let sidebarChanged = enrollment.enrolledAccountIds.length > 0 const mainAuth =