From c99b6f59567275c9f64e50905c40986d022ee8a3 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:45:55 +0200 Subject: [PATCH 01/23] feat(feed): publish the Anthropic account uuid and the lease horizon on every entry --- packages/core/src/accounts.ts | 7 + packages/core/src/quota-header-feed.ts | 45 +++- packages/opencode/src/index.ts | 23 ++- packages/opencode/src/tests/index.test.ts | 192 ++++++++++++++++++ .../src/tests/quota-header-feed.test.ts | 45 ++++ 5 files changed, 304 insertions(+), 8 deletions(-) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index e1f55fb0..3b4b4581 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -49,6 +49,7 @@ export type AccountBase = { export type OAuthAccount = AccountBase & { type: 'oauth' authLineageId?: string + anthropicAccountUuid?: string claustrumHandle?: string access?: string refresh: string @@ -556,6 +557,11 @@ function normalizeAccount(value: unknown): FallbackAccount | null { typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim() ? value.claustrumHandle.trim() : undefined, + anthropicAccountUuid: + typeof value.anthropicAccountUuid === 'string' && + value.anthropicAccountUuid.trim() + ? value.anthropicAccountUuid.trim() + : undefined, access: typeof value.access === 'string' ? value.access : undefined, refresh: value.refresh, expires: typeof value.expires === 'number' ? value.expires : undefined, @@ -1197,6 +1203,7 @@ function accountRuntimeState(account: FallbackAccount) { } return objectWithDefinedEntries({ authLineageId: account.authLineageId, + anthropicAccountUuid: account.anthropicAccountUuid, claustrumHandle: account.claustrumHandle, access: account.access, refresh: account.refresh, diff --git a/packages/core/src/quota-header-feed.ts b/packages/core/src/quota-header-feed.ts index 065f4085..a297580e 100644 --- a/packages/core/src/quota-header-feed.ts +++ b/packages/core/src/quota-header-feed.ts @@ -28,6 +28,10 @@ import { export const QUOTA_HEADER_FEED_SCHEMA_VERSION = 3 export const QUOTA_HEADER_FEED_LEASE_MS = 180_000 +/** + * Lease files are per process. Consumers must union entries across live files, + * deduplicate by account, and never treat the newest file as a complete feed. + */ export type QuotaHeaderFeedIdentity = | { identity_source: 'credential_id'; credential_id: string } | { identity_source: 'account_ref'; account_ref: string } @@ -59,11 +63,14 @@ type QuotaHeaderFeedMetadata = { export type QuotaHeaderFeedEntry = QuotaHeaderFeedIdentity & QuotaHeaderFeedMetadata & { + /** Always present: null means UUID resolution failed; absence denotes an old producer. */ + anthropic_account_uuid: string | null quota: QuotaHeaderFeedQuota } export type QuotaHeaderFeedPublishEntry = QuotaHeaderFeedIdentity & QuotaHeaderFeedMetadata & { + anthropic_account_uuid: string | null quota: Omit & { fieldSources?: QuotaFieldSources } @@ -72,6 +79,7 @@ export type QuotaHeaderFeedPublishEntry = QuotaHeaderFeedIdentity & type FeedRecord = { version: typeof QUOTA_HEADER_FEED_SCHEMA_VERSION + lease_horizon_ms: number entries: Record } @@ -105,6 +113,12 @@ function validIdentity( ) return false if (!entry.quota || typeof entry.quota !== 'object') return false + if ( + !Object.hasOwn(entry, 'anthropic_account_uuid') || + (entry.anthropic_account_uuid !== null && + typeof entry.anthropic_account_uuid !== 'string') + ) + return false if (entry.identity_source === 'none') { return !('credential_id' in entry) && !('account_ref' in entry) } @@ -302,16 +316,33 @@ export class QuotaHeaderFeedRegistry { } publish(entry: QuotaHeaderFeedPublishEntry): Promise { - const { accountKey, quota, ...entryWithoutQuota } = entry - const cleanEntry = { - ...entryWithoutQuota, - quota: projectQuota(quota), - } as QuotaHeaderFeedEntry + const { accountKey, quota } = entry try { - validatePublishEntry(cleanEntry) + validatePublishEntry(entry) } catch (error) { return Promise.reject(error) } + const identity = + entry.identity_source === 'credential_id' + ? { + identity_source: 'credential_id' as const, + credential_id: entry.credential_id, + } + : entry.identity_source === 'account_ref' + ? { + identity_source: 'account_ref' as const, + account_ref: entry.account_ref, + } + : { identity_source: 'none' as const } + const cleanEntry: QuotaHeaderFeedEntry = { + ...identity, + schema_version: entry.schema_version, + provider: entry.provider, + configured_account_count: entry.configured_account_count, + observed_at_ms: entry.observed_at_ms, + anthropic_account_uuid: entry.anthropic_account_uuid, + quota: projectQuota(quota), + } if (!accountKey) return Promise.reject(new Error('Invalid quota header feed account key')) this.writeChain = this.writeChain @@ -338,7 +369,7 @@ export class QuotaHeaderFeedRegistry { try { await writeFile( tempPath, - `${JSON.stringify({ version: QUOTA_HEADER_FEED_SCHEMA_VERSION, entries })}\n`, + `${JSON.stringify({ version: QUOTA_HEADER_FEED_SCHEMA_VERSION, lease_horizon_ms: this.options.leaseMs ?? QUOTA_HEADER_FEED_LEASE_MS, entries })}\n`, { mode: 0o600 }, ) await chmod(tempPath, 0o600) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5822c771..ffa61b69 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1416,6 +1416,7 @@ const anthropicAuthPlugin = async ( accountId: 'main' | string accessToken: string authLineageId?: string + anthropicAccountUuid?: string mainQuotaIdentity?: MainQuotaIdentityBinding }, entry: QuotaEntry, @@ -1482,6 +1483,7 @@ const anthropicAuthPlugin = async ( ...entry.quota, accountIdentity: account.id, } + account.anthropicAccountUuid = served.anthropicAccountUuid await saveAccountState(storage, accountStoragePath, { accounts: [served.accountId], }) @@ -1523,6 +1525,7 @@ const anthropicAuthPlugin = async ( served: { accountId: 'main' | string accessToken: string + anthropicAccountUuid?: string mainQuotaIdentity?: MainQuotaIdentityBinding }, entry: QuotaEntry, @@ -1573,6 +1576,7 @@ const anthropicAuthPlugin = async ( provider: 'anthropic', configured_account_count: configuredAccountCount, observed_at_ms: observedAtMs, + anthropic_account_uuid: served.anthropicAccountUuid ?? null, quota, accountKey, } @@ -1584,6 +1588,7 @@ const anthropicAuthPlugin = async ( provider: 'anthropic', configured_account_count: configuredAccountCount, observed_at_ms: observedAtMs, + anthropic_account_uuid: served.anthropicAccountUuid ?? null, quota, accountKey, } @@ -1594,6 +1599,7 @@ const anthropicAuthPlugin = async ( provider: 'anthropic', configured_account_count: configuredAccountCount, observed_at_ms: observedAtMs, + anthropic_account_uuid: served.anthropicAccountUuid ?? null, quota, accountKey, } @@ -1604,6 +1610,7 @@ const anthropicAuthPlugin = async ( provider: 'anthropic', configured_account_count: configuredAccountCount, observed_at_ms: observedAtMs, + anthropic_account_uuid: served.anthropicAccountUuid ?? null, quota, accountKey, } @@ -1622,6 +1629,7 @@ const anthropicAuthPlugin = async ( accountId: 'main' | string accessToken: string authLineageId?: string + anthropicAccountUuid?: string mainQuotaIdentity?: MainQuotaIdentityBinding }, ): void { @@ -5681,11 +5689,24 @@ const anthropicAuthPlugin = async ( } } - const relayConfig = getRelayConfig(await getRequestStorage()) + const requestStorageForIdentity = await getRequestStorage() + const relayConfig = getRelayConfig(requestStorageForIdentity) + const persistedFallbackAccountUuid = + oauthAccountId === 'main' + ? undefined + : requestStorageForIdentity?.accounts.find( + (account): account is OAuthAccount => + account.id === oauthAccountId && isOAuthAccount(account), + )?.anthropicAccountUuid const served = { accountId: oauthAccountId, accessToken, authLineageId: fallbackAuthLineageId, + anthropicAccountUuid: + identity.accountUuid ?? + (oauthAccountId === 'main' + ? mainQuotaIdentity?.accountIdentity + : persistedFallbackAccountUuid), ...(oauthAccountId === 'main' && mainQuotaIdentity ? { mainQuotaIdentity } : {}), diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 6a2f0e6e..55df1f58 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -7,6 +7,7 @@ import { mock, test, } from 'bun:test' +import { randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' import { chmod, @@ -3588,6 +3589,7 @@ describe('quota header feed integration', () => { expect.objectContaining({ identity_source: 'account_ref', account_ref: 'main-fixed-id', + anthropic_account_uuid: 'main-fixed-id', configured_account_count: 1, observed_at_ms: 1_000_000, quota: expect.objectContaining({ @@ -3595,6 +3597,7 @@ describe('quota header feed integration', () => { }), }), ) + expect(record.lease_horizon_ms).toBe(180_000) expect(published).not.toHaveProperty('credential_id') for (const secret of [ 'main-access', @@ -3616,6 +3619,195 @@ describe('quota header feed integration', () => { } }) + test('publishes and persists a fallback Anthropic account UUID', async () => { + const fallbackUuid = '11111111-1111-1111-1111-111111111111' + const fallbackAccess = `sk-ant-oat-${randomUUID()}` + await useTempAccountFile( + createFallbackStorage({ + quotaHeaderFeed: { enabled: true }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: fallbackAccess, + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: Date.now(), + }, + }, + }, + ], + }), + ) + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve( + Response.json({ oauth_account: { account_uuid: fallbackUuid } }), + ) + } + if (url.includes('/v1/messages')) { + const authorization = new Headers(init?.headers).get('authorization') + if (authorization === `Bearer ${fallbackAccess}`) { + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, + }), + ) + } + return Promise.resolve(new Response(null, { status: 429 })) + } + return Promise.resolve(Response.json({})) + }) as unknown as typeof fetch + + 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: {} }, + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + await response.text() + + const [published] = await waitForFeedEntries( + (entries) => + entries.some( + (entry: any) => entry.anthropic_account_uuid === fallbackUuid, + ), + 'a fallback UUID feed entry', + ) + expect(published).toEqual( + expect.objectContaining({ + identity_source: 'account_ref', + account_ref: 'fallback-1', + anthropic_account_uuid: fallbackUuid, + }), + ) + const persisted = await waitForAccountStorage( + (candidate) => + ( + candidate?.accounts.find( + (account) => account.id === 'fallback-1', + ) as any + )?.anthropicAccountUuid === fallbackUuid, + ) + expect( + ( + persisted?.accounts.find( + (account) => account.id === 'fallback-1', + ) as any + )?.anthropicAccountUuid, + ).toBe(fallbackUuid) + }) + + test('uses a persisted fallback Anthropic account UUID before bootstrap resolves it', async () => { + const fallbackUuid = '22222222-2222-2222-2222-222222222222' + const fallbackAccess = `sk-ant-oat-${randomUUID()}` + await useTempAccountFile( + createFallbackStorage({ + quotaHeaderFeed: { enabled: true }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: fallbackAccess, + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + anthropicAccountUuid: fallbackUuid, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: Date.now(), + }, + }, + }, + ], + }), + ) + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) + return Promise.resolve(Response.json({})) + if (url.includes('/v1/messages')) { + const authorization = new Headers(init?.headers).get('authorization') + if (authorization === `Bearer ${fallbackAccess}`) { + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, + }), + ) + } + return Promise.resolve(new Response(null, { status: 429 })) + } + return Promise.resolve(Response.json({})) + }) as unknown as typeof fetch + + 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: {} }, + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + await response.text() + + const [published] = await waitForFeedEntries( + (entries) => + entries.some( + (entry: any) => entry.anthropic_account_uuid === fallbackUuid, + ), + 'a persisted fallback UUID feed entry', + ) + expect(published).toEqual( + expect.objectContaining({ + account_ref: 'fallback-1', + anthropic_account_uuid: fallbackUuid, + }), + ) + const persisted = await waitForAccountStorage( + (candidate) => + ( + candidate?.accounts.find( + (account) => account.id === 'fallback-1', + ) as any + )?.anthropicAccountUuid === fallbackUuid, + ) + expect( + ( + persisted?.accounts.find( + (account) => account.id === 'fallback-1', + ) as any + )?.anthropicAccountUuid, + ).toBe(fallbackUuid) + }) + test('cache-seeded poll fields survive a later header harvest into the feed', async () => { const originalNow = Date.now let clock = 1_000_000 diff --git a/packages/opencode/src/tests/quota-header-feed.test.ts b/packages/opencode/src/tests/quota-header-feed.test.ts index 69ad1a18..309972f4 100644 --- a/packages/opencode/src/tests/quota-header-feed.test.ts +++ b/packages/opencode/src/tests/quota-header-feed.test.ts @@ -32,6 +32,7 @@ function entry(overrides: Record = {}): QuotaHeaderFeedEntry { provider: 'anthropic', configured_account_count: 1, observed_at_ms: 1_000, + anthropic_account_uuid: null, quota, ...overrides, } as QuotaHeaderFeedEntry @@ -77,6 +78,50 @@ describe('quota header feed', () => { expect(QUOTA_HEADER_FEED_SCHEMA_VERSION).toBe(3) expect(raw.entries.a.schema_version).toBe(3) expect(raw.entries.a.provider).toBe('anthropic') + expect(raw.lease_horizon_ms).toBe(QUOTA_HEADER_FEED_LEASE_MS) + expect(raw.entries.a).toHaveProperty('anthropic_account_uuid', null) + }) + + test('projects only the documented entry keys', async () => { + const registry = new QuotaHeaderFeedRegistry({ + directory, + instanceId: 'entry-allowlist', + }) + await registry.publish({ + ...entry({ + anthropic_account_uuid: 'uuid-1', + unexpected_entry_secret: 'must-not-publish', + }), + accountKey: 'a', + } as unknown as QuotaHeaderFeedPublishEntry) + + const raw = JSON.parse( + await readFile(join(directory, 'entry-allowlist.json'), 'utf8'), + ) + expect(raw.entries.a).toEqual({ + identity_source: 'credential_id', + credential_id: 'cred-1', + schema_version: QUOTA_HEADER_FEED_SCHEMA_VERSION, + provider: 'anthropic', + configured_account_count: 1, + observed_at_ms: 1_000, + anthropic_account_uuid: 'uuid-1', + quota, + }) + expect(JSON.stringify(raw)).not.toContain('must-not-publish') + }) + + test('publishes the configured lease horizon from the registry seam', async () => { + const registry = new QuotaHeaderFeedRegistry({ + directory, + instanceId: 'lease-horizon', + leaseMs: 17, + }) + await registry.publish({ ...entry(), accountKey: 'a' }) + const raw = JSON.parse( + await readFile(join(directory, 'lease-horizon.json'), 'utf8'), + ) + expect(raw.lease_horizon_ms).toBe(17) }) test.each([1, 2, 999])( From 64e0fa9cfa76ceaf8eb2c65c020cd902f7547230 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:49:38 +0200 Subject: [PATCH 02/23] fix(feed): reap stale sibling leases and document the per-process union contract --- README.md | 6 ++ packages/core/src/quota-header-feed.ts | 36 ++++++++- .../src/tests/quota-header-feed.test.ts | 74 +++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 87f8b1de..63ad305c 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,12 @@ In OpenCode, this includes the main Anthropic account and sidecar fallback accou Reset times are rendered as relative durations, such as `resets in 10m` or `resets in 1h 15m`. +### Quota header feed + +The optional quota header feed writes one lease file per process under `/tmp/opencode-anthropic-auth/quota-header-feed/`. A file contains only accounts whose response headers THAT process harvested. Consumers MUST union entries from every file inside `lease_horizon_ms`, then deduplicate by account. "Newest file wins" drops accounts seen by other processes. + +Each entry always includes `anthropic_account_uuid`. A UUID identifies the Anthropic account. `null` means this producer could not resolve it. An absent key identifies an older producer. On a fallback entry, `account_ref` is the store-local sidecar account ID, not the Anthropic UUID. + ## Safety fallback (OpenCode) Eligible Fable 5/5.1 and Opus 5 OAuth requests try Anthropic's server-side safety fallback first. The plugin sends `fallbacks: "default"` with Anthropic's server-side fallback beta, preserves fallback conversation boundaries in OpenCode history, and reports model handoffs and restoration in the TUI sidebar or OpenCode Desktop. Follow-up requests may remain on Anthropic's selected fallback model for approximately one hour. diff --git a/packages/core/src/quota-header-feed.ts b/packages/core/src/quota-header-feed.ts index a297580e..7f548628 100644 --- a/packages/core/src/quota-header-feed.ts +++ b/packages/core/src/quota-header-feed.ts @@ -6,6 +6,7 @@ import { readFile, rename, rm, + stat, writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -29,8 +30,11 @@ export const QUOTA_HEADER_FEED_SCHEMA_VERSION = 3 export const QUOTA_HEADER_FEED_LEASE_MS = 180_000 /** - * Lease files are per process. Consumers must union entries across live files, - * deduplicate by account, and never treat the newest file as a complete feed. + * Lease files are per process; each carries only the accounts whose response + * headers THAT process harvested. Consumers MUST union entries across all files + * inside `lease_horizon_ms`, deduplicating by account; "newest file wins" is wrong. + * `anthropic_account_uuid` is always present: null is unresolvable, while absence + * identifies an old producer. A fallback `account_ref` is store-local. */ export type QuotaHeaderFeedIdentity = | { identity_source: 'credential_id'; credential_id: string } @@ -306,6 +310,7 @@ export class QuotaHeaderFeedRegistry { now?: () => number leaseMs?: number instanceId?: string + removeFile?: (path: string) => Promise } = {}, ) { const instanceId = options.instanceId ?? `${process.pid}-${randomUUID()}` @@ -352,6 +357,7 @@ export class QuotaHeaderFeedRegistry { this.options.directory ?? getDefaultQuotaHeaderFeedDirectory() await mkdir(directory, { recursive: true, mode: 0o700 }) await chmod(directory, 0o700) + await this.reapStaleSiblingLeases(directory) let entries: Record = {} try { const record = JSON.parse( @@ -381,6 +387,32 @@ export class QuotaHeaderFeedRegistry { return this.writeChain } + private async reapStaleSiblingLeases(directory: string): Promise { + const now = this.options.now?.() ?? Date.now() + const leaseMs = this.options.leaseMs ?? QUOTA_HEADER_FEED_LEASE_MS + let names: string[] + try { + names = await readdir(directory) + } catch { + return + } + await Promise.all( + names + .filter((name) => /^\d+-[0-9a-f-]+\.json$/i.test(name)) + .map(async (name) => { + const path = join(directory, name) + if (path === this.filePath) return + try { + const file = await stat(path) + if (file.mtimeMs > now || now - file.mtimeMs < leaseMs) return + await (this.options.removeFile ?? ((target) => rm(target)))(path) + } catch { + // A missed cleanup must not prevent this process from refreshing its lease. + } + }), + ) + } + async list(): Promise { await this.writeChain.catch(() => {}) const directory = diff --git a/packages/opencode/src/tests/quota-header-feed.test.ts b/packages/opencode/src/tests/quota-header-feed.test.ts index 309972f4..9c00ee3b 100644 --- a/packages/opencode/src/tests/quota-header-feed.test.ts +++ b/packages/opencode/src/tests/quota-header-feed.test.ts @@ -7,6 +7,7 @@ import { readFile, rm, stat, + utimes, writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -555,4 +556,77 @@ describe('quota header feed', () => { entry({ observed_at_ms: 1_002, credential_id: 'new' }), ]) }) + + test('reaps stale sibling process leases without touching fresh or foreign files', async () => { + const now = Date.now() + await mkdir(directory, { recursive: true }) + const staleNames = [ + '101-11111111-1111-1111-1111-111111111111.json', + '102-22222222-2222-2222-2222-222222222222.json', + '103-33333333-3333-3333-3333-333333333333.json', + ] + const freshNames = [ + '104-44444444-4444-4444-4444-444444444444.json', + '105-55555555-5555-5555-5555-555555555555.json', + ] + for (const name of [...staleNames, ...freshNames]) { + const path = join(directory, name) + await writeFile(path, '{}') + const age = staleNames.includes(name) + ? QUOTA_HEADER_FEED_LEASE_MS + 1 + : QUOTA_HEADER_FEED_LEASE_MS - 1 + await utimes(path, (now - age) / 1_000, (now - age) / 1_000) + } + await writeFile(join(directory, 'foreign-named-file.json'), '{}') + await utimes( + join(directory, 'foreign-named-file.json'), + (now - QUOTA_HEADER_FEED_LEASE_MS - 1) / 1_000, + (now - QUOTA_HEADER_FEED_LEASE_MS - 1) / 1_000, + ) + + const registry = new QuotaHeaderFeedRegistry({ + directory, + instanceId: '106-66666666-6666-6666-6666-666666666666', + now: () => now, + }) + await registry.publish({ ...entry(), accountKey: 'a' }) + + const names = await readdir(directory) + for (const name of staleNames) expect(names).not.toContain(name) + for (const name of freshNames) expect(names).toContain(name) + expect(names).toContain('foreign-named-file.json') + expect(names).toContain('106-66666666-6666-6666-6666-666666666666.json') + }) + + test('continues publishing when a sibling lease sweep cannot unlink a stale file', async () => { + const now = Date.now() + const stalePath = join( + directory, + '101-11111111-1111-1111-1111-111111111111.json', + ) + await mkdir(directory, { recursive: true }) + await writeFile(stalePath, '{}') + await utimes( + stalePath, + (now - QUOTA_HEADER_FEED_LEASE_MS - 1) / 1_000, + (now - QUOTA_HEADER_FEED_LEASE_MS - 1) / 1_000, + ) + const ownFile = '102-22222222-2222-2222-2222-222222222222.json' + const registry = new QuotaHeaderFeedRegistry({ + directory, + instanceId: ownFile.slice(0, -'.json'.length), + now: () => now, + removeFile: async () => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + }, + }) + + await expect( + registry.publish({ ...entry(), accountKey: 'a' }), + ).resolves.toBeUndefined() + expect(await readdir(directory)).toContain(ownFile) + expect(await readdir(directory)).toContain( + '101-11111111-1111-1111-1111-111111111111.json', + ) + }) }) From 82105d347f4a3e53ac57ffc29f0ac6034c4f3e1e Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:01:56 +0200 Subject: [PATCH 03/23] fix(feed): fence the account uuid on credential lineage and make lease reaping compare-and-delete --- README.md | 6 +- packages/core/src/accounts.ts | 70 +++- packages/core/src/claude-code.ts | 4 +- packages/core/src/quota-header-feed.ts | 6 + packages/opencode/src/index.ts | 206 +++++----- packages/opencode/src/tests/accounts.test.ts | 105 +++++ .../opencode/src/tests/claude-code.test.ts | 31 +- packages/opencode/src/tests/index.test.ts | 369 ++++++++++++------ .../src/tests/quota-header-feed.test.ts | 44 +++ 9 files changed, 618 insertions(+), 223 deletions(-) diff --git a/README.md b/README.md index 63ad305c..e462208e 100644 --- a/README.md +++ b/README.md @@ -334,9 +334,11 @@ Reset times are rendered as relative durations, such as `resets in 10m` or `rese ### Quota header feed -The optional quota header feed writes one lease file per process under `/tmp/opencode-anthropic-auth/quota-header-feed/`. A file contains only accounts whose response headers THAT process harvested. Consumers MUST union entries from every file inside `lease_horizon_ms`, then deduplicate by account. "Newest file wins" drops accounts seen by other processes. +The optional quota header feed writes one lease file per process under `${TMPDIR:-/tmp}/opencode-anthropic-auth/quota-header-feed/`. Set `OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR` to override the directory. A file contains only accounts whose response headers THAT process harvested. Consumers MUST union entries from every file inside `lease_horizon_ms`, then deduplicate by account. "Newest file wins" drops accounts seen by other processes. -Each entry always includes `anthropic_account_uuid`. A UUID identifies the Anthropic account. `null` means this producer could not resolve it. An absent key identifies an older producer. On a fallback entry, `account_ref` is the store-local sidecar account ID, not the Anthropic UUID. +For each account, the newest entry wins for quota values. Resolve `anthropic_account_uuid` from any entry in that account's group that carries it. During a rollout, an older pre-restart publisher can write the newest entry without that field beside newer code that has it. + +Each entry always includes `anthropic_account_uuid`; an absent key identifies an older producer. Its value is provider-derived or `null`, never a local substitute. A consumer that sees `null` must count a gap, not fall back to `account_ref`; that field is store-local and never a join key. ## Safety fallback (OpenCode) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 3b4b4581..582eef1d 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -3721,10 +3721,16 @@ export function upsertAccount( (account.label && candidate.label === account.label), ) if (index >= 0) { - storage.accounts[index] = { - ...storage.accounts[index], + const existing = storage.accounts[index] + if (!existing) return + const lineageChanged = + existing.type === 'oauth' && + account.type === 'oauth' && + existing.authLineageId !== account.authLineageId + const updated: FallbackAccount = { + ...existing, ...account, - addedAt: storage.accounts[index]?.addedAt ?? account.addedAt, + addedAt: existing.addedAt ?? account.addedAt, ...(account.type === 'oauth' && { quota: account.quota, profile: account.profile, @@ -3733,11 +3739,69 @@ export function upsertAccount( lastQuotaRefreshError: account.lastQuotaRefreshError, }), } + if (lineageChanged && updated.type === 'oauth') { + delete updated.anthropicAccountUuid + } + storage.accounts[index] = updated return } storage.accounts.push(account) } +export function persistFallbackQuotaHeaderPersistent( + input: { + accountId: string + authLineageId?: string + quota: OAuthQuotaSnapshot + anthropicAccountUuid?: string + }, + path = getAccountStoragePath(), +): Promise { + return enqueueSave(async () => { + const configLock = await acquireAccountConfigWriteLock(path) + try { + const stateLock = await acquireAccountStateWriteLock(path) + try { + const storage = await loadAccounts(path) + const account = storage?.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === input.accountId && isOAuthAccount(candidate), + ) + if ( + !storage || + !account || + account.authLineageId !== input.authLineageId + ) { + return false + } + account.quota = { + ...input.quota, + accountIdentity: account.id, + } + if (input.anthropicAccountUuid !== undefined) { + account.anthropicAccountUuid = input.anthropicAccountUuid + } + await saveAccountStateUnlocked(storage, path, { + accounts: [input.accountId], + }) + return true + } finally { + await stateLock.release() + } + } finally { + await configLock.release() + } + }) +} + +export function fallbackAccountUuidForLineage( + account: OAuthAccount | undefined, + authLineageId?: string, +): string | null { + if (!account || account.authLineageId !== authLineageId) return null + return account.anthropicAccountUuid ?? null +} + export function removeAccount(storage: AccountStorage, id: string): boolean { const index = storage.accounts.findIndex((c) => c.id === id) if (index < 0) return false diff --git a/packages/core/src/claude-code.ts b/packages/core/src/claude-code.ts index ac093ebf..45d97ad4 100644 --- a/packages/core/src/claude-code.ts +++ b/packages/core/src/claude-code.ts @@ -188,7 +188,9 @@ export async function resolveClaudeCodeIdentity( setBounded(identityCache, accessToken, identity) } - if (!accessToken.startsWith('sk-ant-oat')) return identity + if (!accessToken.startsWith('sk-ant-oat')) { + return clearCachedAccountUuid(cacheKey, identity) + } const now = Date.now() // A slot-stable identity survives account replacement; bootstrap is the diff --git a/packages/core/src/quota-header-feed.ts b/packages/core/src/quota-header-feed.ts index 7f548628..594c41b8 100644 --- a/packages/core/src/quota-header-feed.ts +++ b/packages/core/src/quota-header-feed.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import { chmod, + lstat, mkdir, readdir, readFile, @@ -311,6 +312,7 @@ export class QuotaHeaderFeedRegistry { leaseMs?: number instanceId?: string removeFile?: (path: string) => Promise + beforeRemoveFile?: (path: string) => Promise } = {}, ) { const instanceId = options.instanceId ?? `${process.pid}-${randomUUID()}` @@ -405,6 +407,10 @@ export class QuotaHeaderFeedRegistry { try { const file = await stat(path) if (file.mtimeMs > now || now - file.mtimeMs < leaseMs) return + await this.options.beforeRemoveFile?.(path) + const current = await lstat(path) + if (current.ino !== file.ino || current.mtimeMs !== file.mtimeMs) + return await (this.options.removeFile ?? ((target) => rm(target)))(path) } catch { // A missed cleanup must not prevent this process from refreshing its lease. diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ffa61b69..305bf896 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -58,6 +58,7 @@ import { executeRoutingCommand, FALLBACK_BACKGROUND_TICK_MS, FallbackAccountManager, + fallbackAccountUuidForLineage, fetchOAuthAccountProfile, formatOAuthAccountTier, formatQuotaBackoffMessage, @@ -135,6 +136,7 @@ import { parseLoggingCommandAction, parsePrimeCommandAction, parseRoutingCommandAction, + persistFallbackQuotaHeaderPersistent, primeStorageFingerprint, QUOTA_HEADER_FEED_SCHEMA_VERSION, type QuotaAccountSummary, @@ -687,14 +689,35 @@ type StickyOAuthRoute = { } type MainQuotaIdentityBinding = { - accountIdentity: string | undefined + quotaKey: string | undefined generation: number } type MainQuotaIdentityResolution = MainQuotaIdentityBinding & { + providerAccountUuid: ProviderAccountUuid | undefined stale: boolean } +declare const providerAccountUuidBrand: unique symbol + +type ProviderAccountUuid = string & { + readonly [providerAccountUuidBrand]: 'ProviderAccountUuid' +} + +type ServedQuotaHeaders = { + accountId: 'main' | string + accessToken: string + authLineageId?: string + anthropicAccountUuid?: ProviderAccountUuid | null + mainQuotaIdentity?: MainQuotaIdentityBinding +} + +function asProviderAccountUuid( + value: string | null | undefined, +): ProviderAccountUuid | undefined { + return value as ProviderAccountUuid | undefined +} + const FABLE_SWITCHED_TO_OPUS_NOTICE = 'Fable content filter detected. Switched to Opus 4.8 for a 10-response recovery window while keeping the Fable cache warm.' const FABLE_RESTORED_NOTICE = @@ -1133,29 +1156,29 @@ const anthropicAuthPlugin = async ( async function reconcileMainQuotaAccountIdentity( accessToken: string, - accountIdentity: string | undefined, + quotaKey: string | undefined, ): Promise { if ( mainQuotaIdentityAccessToken === accessToken && - mainQuotaAccountId === accountIdentity + mainQuotaAccountId === quotaKey ) { return } mainQuotaIdentityAccessToken = accessToken - mainQuotaAccountId = accountIdentity + mainQuotaAccountId = quotaKey const clearedGeneration = quotaManager.setMainQuotaAccountIdentity( - accountIdentity, + quotaKey, accessToken.startsWith('sk-ant-oat'), ) - if (accountIdentity === undefined) return + if (quotaKey === undefined) return const storage = await loadAccounts(accountStoragePath) const persistedError = storage?.quota?.mainLastQuotaApiError if ( !storage || !persistedError || - persistedError.accountIdentity === accountIdentity || + persistedError.accountIdentity === quotaKey || (!accessToken.startsWith('sk-ant-oat') && persistedError.accountIdentity === undefined) ) { @@ -1189,25 +1212,27 @@ const anthropicAuthPlugin = async ( model, mainAccountId, ) - // Adapters that do not use Claude's OAuth token format cannot be bootstrapped; - // keep the slot identity only for that compatibility path. Claude OAuth - // credentials use the oat prefix and stay unknown when bootstrap fails. - const quotaIdentity = + // Non-oat adapters retain their local slot for quota fencing, but it is + // not a provider identity and must never reach a provider-facing field. + const quotaKey = identity.accountUuid ?? (!accessToken.startsWith('sk-ant-oat') ? mainAccountId : undefined) + const providerAccountUuid = asProviderAccountUuid(identity.accountUuid) if (resolutionGeneration !== mainQuotaIdentityResolutionGeneration) { const identityChanged = mainQuotaIdentityAccessToken !== accessToken || - mainQuotaAccountId !== quotaIdentity + mainQuotaAccountId !== quotaKey return { - accountIdentity: quotaIdentity, + quotaKey, + providerAccountUuid, generation: quotaManager.getMainQuotaIdentityGeneration(), stale: identityChanged, } } - await reconcileMainQuotaAccountIdentity(accessToken, quotaIdentity) + await reconcileMainQuotaAccountIdentity(accessToken, quotaKey) return { - accountIdentity: quotaIdentity, + quotaKey, + providerAccountUuid, generation: quotaManager.getMainQuotaIdentityGeneration(), stale: false, } @@ -1410,22 +1435,17 @@ const anthropicAuthPlugin = async ( } const warnedQuotaNormalizeErrors = new Set() + const warnedStaleFallbackQuotaPersists = new Set() async function persistPushedQuota( - served: { - accountId: 'main' | string - accessToken: string - authLineageId?: string - anthropicAccountUuid?: string - mainQuotaIdentity?: MainQuotaIdentityBinding - }, + served: ServedQuotaHeaders, entry: QuotaEntry, ): Promise { const storage = (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() if (served.accountId === 'main') { - const accountIdentity = served.mainQuotaIdentity?.accountIdentity - if (!accountIdentity || entry.quota.accountIdentity !== accountIdentity) { + const quotaKey = served.mainQuotaIdentity?.quotaKey + if (!quotaKey || entry.quota.accountIdentity !== quotaKey) { logger.trace( 'quota', 'skipped quota persistence without verified main account identity', @@ -1435,14 +1455,14 @@ const anthropicAuthPlugin = async ( storage.quota = storage.quota ?? {} storage.quota.mainQuota = { ...entry.quota, - accountIdentity, + accountIdentity: quotaKey, } storage.quota.mainQuotaCheckedAt = entry.checkedAt await saveAccountState(storage, accountStoragePath, { mainQuota: true }) const reloaded = await loadAccounts(accountStoragePath) const persistedQuota = reloaded?.quota?.mainQuota const persistedQuotaBelongsToRequest = Boolean( - persistedQuota && persistedQuota.accountIdentity === accountIdentity, + persistedQuota && persistedQuota.accountIdentity === quotaKey, ) const mergedQuota = persistedQuotaBelongsToRequest ? mergeHeaderQuotaForPersistence(persistedQuota, entry.quota) @@ -1455,23 +1475,28 @@ const anthropicAuthPlugin = async ( } : entry } - const account = storage.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === served.accountId && isOAuthAccount(candidate), + const persisted = await persistFallbackQuotaHeaderPersistent( + { + accountId: served.accountId, + authLineageId: served.authLineageId, + quota: entry.quota, + anthropicAccountUuid: served.anthropicAccountUuid ?? undefined, + }, + accountStoragePath, ) - if (!account) return null - // A lineage mismatch is a confirmed replacement; two absent markers remain - // legacy-compatible, while one-sided absence is too ambiguous to persist. - if (account.authLineageId !== served.authLineageId) { - logger.trace( - 'quota', - 'skipped stale fallback quota persistence after lineage change', - { - accountId: served.accountId, - storedLineage: account.authLineageId, - servedLineage: served.authLineageId, - }, - ) + if (!persisted) { + const key = `${served.accountId}:${served.authLineageId ?? ''}` + if (!warnedStaleFallbackQuotaPersists.has(key)) { + warnedStaleFallbackQuotaPersists.add(key) + logger.debug( + 'quota', + 'skipped stale fallback quota persistence after lineage change', + { + accountId: served.accountId, + servedLineage: served.authLineageId, + }, + ) + } // The persistence fence is authoritative; discard the optimistic cache // write unless a newer replacement observation already won the race. if (quotaManager.getAllFallbacks().get(served.accountId) === entry) { @@ -1479,14 +1504,6 @@ const anthropicAuthPlugin = async ( } return null } - account.quota = { - ...entry.quota, - accountIdentity: account.id, - } - account.anthropicAccountUuid = served.anthropicAccountUuid - await saveAccountState(storage, accountStoragePath, { - accounts: [served.accountId], - }) return entry } @@ -1522,19 +1539,11 @@ const anthropicAuthPlugin = async ( } async function publishQuotaHeaderFeed( - served: { - accountId: 'main' | string - accessToken: string - anthropicAccountUuid?: string - mainQuotaIdentity?: MainQuotaIdentityBinding - }, + served: ServedQuotaHeaders, entry: QuotaEntry, ): Promise { if (!quotaHeaderFeedRegistry) return - if ( - served.accountId === 'main' && - !served.mainQuotaIdentity?.accountIdentity - ) { + if (served.accountId === 'main' && !served.mainQuotaIdentity?.quotaKey) { // Observed-at staleness exposes silence; an unknown key must not replace verified data. return } @@ -1566,7 +1575,7 @@ const anthropicAuthPlugin = async ( const accountKey = credentialId ?? (served.accountId === 'main' - ? (served.mainQuotaIdentity?.accountIdentity ?? 'main') + ? (served.mainQuotaIdentity?.quotaKey ?? 'main') : served.accountId) const feedEntry: QuotaHeaderFeedPublishEntry = credentialId ? { @@ -1580,10 +1589,10 @@ const anthropicAuthPlugin = async ( quota, accountKey, } - : served.accountId === 'main' && served.mainQuotaIdentity?.accountIdentity + : served.accountId === 'main' && served.mainQuotaIdentity?.quotaKey ? { identity_source: 'account_ref', - account_ref: served.mainQuotaIdentity.accountIdentity, + account_ref: served.mainQuotaIdentity.quotaKey, schema_version: QUOTA_HEADER_FEED_SCHEMA_VERSION, provider: 'anthropic', configured_account_count: configuredAccountCount, @@ -1625,13 +1634,7 @@ const anthropicAuthPlugin = async ( function harvestQuotaHeaders( headers: Headers, - served: { - accountId: 'main' | string - accessToken: string - authLineageId?: string - anthropicAccountUuid?: string - mainQuotaIdentity?: MainQuotaIdentityBinding - }, + served: ServedQuotaHeaders, ): void { try { if (!isQuotaBearingHeaderFrame(headers)) { @@ -1650,7 +1653,7 @@ const anthropicAuthPlugin = async ( mainQuotaIdentity.generation ) { logger.trace('quota', 'discarded stale main quota headers', { - boundIdentity: mainQuotaIdentity?.accountIdentity, + boundIdentity: mainQuotaIdentity?.quotaKey, currentGeneration: quotaManager.getMainQuotaIdentityGeneration(), }) return @@ -1659,7 +1662,7 @@ const anthropicAuthPlugin = async ( const entry = served.accountId === 'main' ? quotaManager.pushMainFromHeaders( - mainQuotaIdentity?.accountIdentity, + mainQuotaIdentity?.quotaKey, incoming, ) : quotaManager.pushFallbackFromHeaders(served.accountId, incoming, { @@ -3085,14 +3088,13 @@ const anthropicAuthPlugin = async ( ) { if (!accessToken) return if (storage?.quota?.enabled !== true) return - const accountIdentity = - mainQuotaIdentity?.accountIdentity ?? mainQuotaAccountId - if (quotaManager.getMain(accountIdentity)) return + const quotaKey = mainQuotaIdentity?.quotaKey ?? mainQuotaAccountId + if (quotaManager.getMain(quotaKey)) return if (sidebarMainQuotaRefreshInFlight) return sidebarMainQuotaRefreshInFlight = true void quotaManager - .refreshMain(accountIdentity, accessToken, mainQuotaIdentity?.generation) + .refreshMain(quotaKey, accessToken, mainQuotaIdentity?.generation) .then(() => refreshSidebarQuota()) .catch(() => {}) .finally(() => { @@ -5407,7 +5409,7 @@ const anthropicAuthPlugin = async ( fallbackAuthLineageId?: string, fableRequest?: FableRequestContext, laneStartRequest = false, - mainQuotaIdentity?: MainQuotaIdentityBinding, + mainQuotaIdentity?: MainQuotaIdentityResolution, claustrumResolution?: ClaustrumAccessResolution, ) { const start = nowMs() @@ -5691,22 +5693,26 @@ const anthropicAuthPlugin = async ( const requestStorageForIdentity = await getRequestStorage() const relayConfig = getRelayConfig(requestStorageForIdentity) - const persistedFallbackAccountUuid = + const persistedFallbackAccount = oauthAccountId === 'main' ? undefined : requestStorageForIdentity?.accounts.find( (account): account is OAuthAccount => account.id === oauthAccountId && isOAuthAccount(account), - )?.anthropicAccountUuid + ) + const persistedFallbackAccountUuid = fallbackAccountUuidForLineage( + persistedFallbackAccount, + fallbackAuthLineageId, + ) const served = { accountId: oauthAccountId, accessToken, authLineageId: fallbackAuthLineageId, anthropicAccountUuid: - identity.accountUuid ?? + asProviderAccountUuid(identity.accountUuid) ?? (oauthAccountId === 'main' - ? mainQuotaIdentity?.accountIdentity - : persistedFallbackAccountUuid), + ? mainQuotaIdentity?.providerAccountUuid + : asProviderAccountUuid(persistedFallbackAccountUuid)), ...(oauthAccountId === 'main' && mainQuotaIdentity ? { mainQuotaIdentity } : {}), @@ -5799,7 +5805,7 @@ const anthropicAuthPlugin = async ( ) { if (!accessToken) return false const entry = quotaManager.getMain( - mainQuotaIdentity?.accountIdentity ?? mainQuotaAccountId, + mainQuotaIdentity?.quotaKey ?? mainQuotaAccountId, ) // A genuine response header is live routing evidence like a 429, but // it gets no exemption from the shared freshness and token gates. @@ -5817,7 +5823,7 @@ const anthropicAuthPlugin = async ( if (!accessToken) return false try { await quotaManager.refreshMain( - mainQuotaIdentity?.accountIdentity ?? mainQuotaAccountId, + mainQuotaIdentity?.quotaKey ?? mainQuotaAccountId, accessToken, mainQuotaIdentity?.generation, ) @@ -5895,8 +5901,8 @@ const anthropicAuthPlugin = async ( requestedModelId?: string mainQuotaIdentity?: MainQuotaIdentityBinding }) { - const mainQuotaIdentity = input.mainQuotaIdentity?.accountIdentity - const mainEntry = quotaManager.getMain(mainQuotaIdentity) + const mainQuotaKey = input.mainQuotaIdentity?.quotaKey + const mainEntry = quotaManager.getMain(mainQuotaKey) let mainQuota = mainEntry?.quota if ( !stickyQuotaSnapshotIsFresh( @@ -5908,7 +5914,7 @@ const anthropicAuthPlugin = async ( ) { try { mainQuota = await quotaManager.refreshMain( - mainQuotaIdentity, + mainQuotaKey, input.mainAccessToken, input.mainQuotaIdentity?.generation, ) @@ -6606,7 +6612,9 @@ const anthropicAuthPlugin = async ( trace.done('non_oauth_passthrough', { status: response.status }) return response } - let requestMainQuotaIdentity: MainQuotaIdentityBinding | undefined + let requestMainQuotaIdentity: + | MainQuotaIdentityResolution + | undefined if (auth.access) { const resolution = await resolveMainQuotaAccountIdentity( auth.access, @@ -6991,7 +6999,7 @@ const anthropicAuthPlugin = async ( quota = route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? await quotaManager.refreshMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, route.access, requestMainQuotaIdentity?.generation, ) @@ -7223,7 +7231,7 @@ const anthropicAuthPlugin = async ( function showQuotaToastFromCache() { if (storage?.quota?.showToasts !== true) return const mainEntry = quotaManager.getMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, ) if (!mainEntry) return // Prefer the shared QuotaManager cache for fallback quota so the @@ -7264,17 +7272,17 @@ const anthropicAuthPlugin = async ( // Identity-aware read prevents routing with a previous main // account's quota after a slot switch. let routingQuotaEntry = quotaManager.getMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, ) let routingQuota = routingQuotaEntry?.quota if (!routingQuota) { routingQuota = await quotaManager.refreshMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, auth.access, requestMainQuotaIdentity?.generation, ) routingQuotaEntry = quotaManager.getMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, ) showQuotaToastFromCache() } else if ( @@ -7297,12 +7305,12 @@ const anthropicAuthPlugin = async ( // below still refuses API-key routes because the entry is not // fresh. routingQuota = await quotaManager.refreshMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, auth.access, requestMainQuotaIdentity?.generation, ) routingQuotaEntry = quotaManager.getMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, ) } else { // Stale OR every-N request boundary — background refresh, @@ -7310,7 +7318,7 @@ const anthropicAuthPlugin = async ( // sidebar and show the toast once the new main quota lands. void quotaManager .refreshMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, auth.access, requestMainQuotaIdentity?.generation, ) @@ -7386,7 +7394,7 @@ const anthropicAuthPlugin = async ( } let mainQuota = quotaManager.getMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, )?.quota if ( storage?.quota?.failClosedOnUnknownQuota && @@ -7440,7 +7448,7 @@ const anthropicAuthPlugin = async ( ) await Promise.all([ quotaManager.refreshMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, auth.access, requestMainQuotaIdentity?.generation, ), @@ -7462,7 +7470,7 @@ const anthropicAuthPlugin = async ( // against fresh quota. The initial read above is null on the // first request, before the refresh populates the cache. mainQuota = quotaManager.getMain( - requestMainQuotaIdentity?.accountIdentity, + requestMainQuotaIdentity?.quotaKey, )?.quota } diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 6e7581c4..f484f6a1 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -20,6 +20,7 @@ import { buildRefreshOperationError, ClaudeOAuthRefreshError, FallbackAccountManager, + fallbackAccountUuidForLineage, fetchOAuthAccountProfile, fetchOAuthQuotaSnapshot, formatOAuthAccountTier, @@ -50,6 +51,7 @@ import { type OAuthQuotaSnapshot, oauthProfileIsFresh, PROFILE_TTL_MS, + persistFallbackQuotaHeaderPersistent, QuotaManager, quotaFieldSource, quotaSnapshotModelScopeIsExhausted, @@ -6449,6 +6451,109 @@ describe('upsertAccount', () => { expect(merged.lastRefreshError?.message).toBe('new') expect(merged.lastQuotaRefreshError?.message).toBe('new-quota') }) + + test('drops an Anthropic account UUID when an OAuth lineage is replaced', () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'oauth-1', + type: 'oauth', + refresh: 'refresh-a', + authLineageId: 'lineage-a', + anthropicAccountUuid: 'uuid-from-lineage-a', + }) + + upsertAccount(storage, { + id: 'oauth-1', + type: 'oauth', + refresh: 'refresh-b', + authLineageId: 'lineage-b', + }) + + expect( + (storage.accounts[0] as OAuthAccount).anthropicAccountUuid, + ).toBeUndefined() + }) + + test('keeps an Anthropic account UUID across a same-lineage token rotation', () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'oauth-1', + type: 'oauth', + refresh: 'refresh-a', + authLineageId: 'lineage-a', + anthropicAccountUuid: 'uuid-from-lineage-a', + }) + + upsertAccount(storage, { + id: 'oauth-1', + type: 'oauth', + refresh: 'refresh-b', + authLineageId: 'lineage-a', + }) + + expect((storage.accounts[0] as OAuthAccount).anthropicAccountUuid).toBe( + 'uuid-from-lineage-a', + ) + }) +}) + +describe('fallback UUID lineage fences', () => { + test('does not use a persisted UUID from a replaced credential lineage', () => { + const account: OAuthAccount = { + id: 'fallback-1', + type: 'oauth', + refresh: 'refresh-a', + authLineageId: 'lineage-a', + anthropicAccountUuid: 'uuid-from-lineage-a', + } + + expect(fallbackAccountUuidForLineage(account, 'lineage-b')).toBeNull() + expect(fallbackAccountUuidForLineage(account, 'lineage-a')).toBe( + 'uuid-from-lineage-a', + ) + }) + + test('does not persist a harvested UUID after the stored lineage was replaced', async () => { + await saveAccounts( + { + ...baseStorage(), + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + refresh: 'refresh-b', + authLineageId: 'lineage-b', + }, + ], + }, + accountPath, + ) + + expect( + await persistFallbackQuotaHeaderPersistent( + { + accountId: 'fallback-1', + authLineageId: 'lineage-a', + anthropicAccountUuid: 'uuid-from-lineage-a', + quota: { + source: 'headers', + checkedAt: 1_000, + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: 1_000, + }, + }, + }, + accountPath, + ), + ).toBe(false) + + expect( + expectOAuthAccount((await loadAccounts(accountPath))?.accounts[0]) + .anthropicAccountUuid, + ).toBeUndefined() + }) }) describe('removeAccount', () => { diff --git a/packages/opencode/src/tests/claude-code.test.ts b/packages/opencode/src/tests/claude-code.test.ts index f01ce4f0..bf247807 100644 --- a/packages/opencode/src/tests/claude-code.test.ts +++ b/packages/opencode/src/tests/claude-code.test.ts @@ -295,12 +295,12 @@ describe('Claude Code fingerprint helpers', () => { const identityA = await pendingA expect(identityA.accountUuid).toBeUndefined() - const compatibility = await resolveClaudeCodeIdentity( - 'compatibility-token-after-late-failure', + const sameCredential = await resolveClaudeCodeIdentity( + tokenB, undefined, 'main-slot', ) - expect(compatibility.accountUuid).toBe('account-b') + expect(sameCredential.accountUuid).toBe('account-b') }) test('orders serialized body fields like captured Claude Code requests', () => { @@ -430,6 +430,31 @@ describe('Claude Code bootstrap identity lookup', () => { expect(fetchMock).toHaveBeenCalledTimes(2) }) + test('drops a bootstrapped UUID when a slot receives a non-oat credential', async () => { + const oatToken = 'sk-ant-oat-main-to-non-oat' + globalThis.fetch = mock(async () => + Response.json({ + oauth_account: { account_uuid: 'main-oat-account-uuid' }, + }), + ) as unknown as typeof fetch + + const oatIdentity = await resolveClaudeCodeIdentity( + oatToken, + undefined, + 'main-slot', + ) + const nonOatIdentity = await resolveClaudeCodeIdentity( + 'main-non-oat-token', + undefined, + 'main-slot', + ) + + expect(oatIdentity.accountUuid).toBe('main-oat-account-uuid') + expect(nonOatIdentity.accountUuid).toBeUndefined() + expect(nonOatIdentity.deviceId).toBe(oatIdentity.deviceId) + expect(nonOatIdentity.sessionId).toBe(oatIdentity.sessionId) + }) + test('keeps device identity stable while refreshing bootstrap UUID per credential', async () => { const accountUuid = 'c7b3bc43-f4d8-48c6-a30f-7fd81a8db03f' const fetchMock = mock( diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 55df1f58..53e3721e 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -3531,7 +3531,96 @@ describe('quota header feed integration', () => { globalThis.fetch = originalFetch }) - test('publishes a direct harvested response with the observation timestamp', async () => { + async function publishFallbackUuid({ + fallbackUuid, + persistedUuid, + bootstrapResponse, + }: { + fallbackUuid: string + persistedUuid?: string + bootstrapResponse: unknown + }) { + const fallbackAccess = `sk-ant-oat-${randomUUID()}` + await useTempAccountFile( + createFallbackStorage({ + quotaHeaderFeed: { enabled: true }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: fallbackAccess, + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + ...(persistedUuid && { anthropicAccountUuid: persistedUuid }), + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: Date.now(), + }, + }, + }, + ], + }), + ) + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve(Response.json(bootstrapResponse)) + } + if (url.includes('/v1/messages')) { + const authorization = new Headers(init?.headers).get('authorization') + if (authorization === `Bearer ${fallbackAccess}`) { + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, + }), + ) + } + return Promise.resolve(new Response(null, { status: 429 })) + } + return Promise.resolve(Response.json({})) + }) as unknown as typeof fetch + + 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: {} }, + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + await response.text() + + const [published] = await waitForFeedEntries( + (entries) => + entries.some( + (entry: any) => entry.anthropic_account_uuid === fallbackUuid, + ), + 'a fallback UUID feed entry', + ) + const persisted = await waitForAccountStorage( + (candidate) => + ( + candidate?.accounts.find( + (account) => account.id === 'fallback-1', + ) as any + )?.anthropicAccountUuid === fallbackUuid, + ) + return { published, persisted } + } + + test('non-oat main feed leaves provider UUID null while retaining its quota slot', async () => { const originalNow = Date.now let clock = 1_000_000 Date.now = () => clock @@ -3574,7 +3663,7 @@ describe('quota header feed integration', () => { clock += 1_000 const feedDirectory = process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR! - await waitForFeedEntries( + const [feedEntry] = await waitForFeedEntries( (entries) => entries.length === 1, 'one published entry', ) @@ -3589,7 +3678,7 @@ describe('quota header feed integration', () => { expect.objectContaining({ identity_source: 'account_ref', account_ref: 'main-fixed-id', - anthropic_account_uuid: 'main-fixed-id', + anthropic_account_uuid: null, configured_account_count: 1, observed_at_ms: 1_000_000, quota: expect.objectContaining({ @@ -3614,60 +3703,47 @@ describe('quota header feed integration', () => { } expect(raw).not.toContain('"credential_id":"main"') expect(raw).not.toContain('"account_ref":"main"') + expect(feedEntry).toEqual( + expect.objectContaining({ + account_ref: 'main-fixed-id', + anthropic_account_uuid: null, + }), + ) + const persisted = await waitForAccountStorage( + (storage) => + storage?.quota?.mainQuota?.accountIdentity === 'main-fixed-id', + ) + expect(persisted?.quota?.mainQuota?.accountIdentity).toBe('main-fixed-id') } finally { Date.now = originalNow } }) - test('publishes and persists a fallback Anthropic account UUID', async () => { - const fallbackUuid = '11111111-1111-1111-1111-111111111111' - const fallbackAccess = `sk-ant-oat-${randomUUID()}` + test('oat main feed preserves its bootstrapped Anthropic account UUID', async () => { + const mainAccountId = 'main-oat-slot' + const providerAccountUuid = '33333333-3333-3333-3333-333333333333' await useTempAccountFile( createFallbackStorage({ + mainAccountId, quotaHeaderFeed: { enabled: true }, - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - access: fallbackAccess, - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 25, - remainingPercent: 75, - checkedAt: Date.now(), - }, - seven_day: { - usedPercent: 30, - remainingPercent: 70, - checkedAt: Date.now(), - }, - }, - }, - ], + accounts: [], }), ) - globalThis.fetch = mock((input: any, init?: RequestInit) => { + globalThis.fetch = mock((input: any) => { const url = extractUrl(input) if (url.includes('/claude_cli/bootstrap')) { return Promise.resolve( - Response.json({ oauth_account: { account_uuid: fallbackUuid } }), + Response.json({ + oauth_account: { account_uuid: providerAccountUuid }, + }), ) } - if (url.includes('/v1/messages')) { - const authorization = new Headers(init?.headers).get('authorization') - if (authorization === `Bearer ${fallbackAccess}`) { - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, - }), - ) - } - return Promise.resolve(new Response(null, { status: 429 })) - } - return Promise.resolve(Response.json({})) + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, + }), + ) }) as unknown as typeof fetch const plugin = await getPlugin() @@ -3675,7 +3751,7 @@ describe('quota header feed integration', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat-main-feed', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -3687,81 +3763,44 @@ describe('quota header feed integration', () => { const [published] = await waitForFeedEntries( (entries) => entries.some( - (entry: any) => entry.anthropic_account_uuid === fallbackUuid, + (entry: any) => entry.anthropic_account_uuid === providerAccountUuid, ), - 'a fallback UUID feed entry', + 'a bootstrapped main UUID feed entry', ) expect(published).toEqual( expect.objectContaining({ - identity_source: 'account_ref', - account_ref: 'fallback-1', - anthropic_account_uuid: fallbackUuid, + account_ref: providerAccountUuid, + anthropic_account_uuid: providerAccountUuid, }), ) - const persisted = await waitForAccountStorage( - (candidate) => - ( - candidate?.accounts.find( - (account) => account.id === 'fallback-1', - ) as any - )?.anthropicAccountUuid === fallbackUuid, - ) - expect( - ( - persisted?.accounts.find( - (account) => account.id === 'fallback-1', - ) as any - )?.anthropicAccountUuid, - ).toBe(fallbackUuid) }) - test('uses a persisted fallback Anthropic account UUID before bootstrap resolves it', async () => { - const fallbackUuid = '22222222-2222-2222-2222-222222222222' - const fallbackAccess = `sk-ant-oat-${randomUUID()}` + test('main feed drops a cached oat UUID after the current credential becomes non-oat', async () => { + const mainAccountId = 'main-oat-to-non-oat-slot' + const providerAccountUuid = '44444444-4444-4444-4444-444444444444' + let liveAccess = 'sk-ant-oat-main-feed-before-rotation' await useTempAccountFile( createFallbackStorage({ + mainAccountId, quotaHeaderFeed: { enabled: true }, - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - access: fallbackAccess, - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - anthropicAccountUuid: fallbackUuid, - quota: { - five_hour: { - usedPercent: 25, - remainingPercent: 75, - checkedAt: Date.now(), - }, - seven_day: { - usedPercent: 30, - remainingPercent: 70, - checkedAt: Date.now(), - }, - }, - }, - ], + accounts: [], }), ) - globalThis.fetch = mock((input: any, init?: RequestInit) => { + globalThis.fetch = mock((input: any) => { const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) - return Promise.resolve(Response.json({})) - if (url.includes('/v1/messages')) { - const authorization = new Headers(init?.headers).get('authorization') - if (authorization === `Bearer ${fallbackAccess}`) { - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, - }), - ) - } - return Promise.resolve(new Response(null, { status: 429 })) + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: providerAccountUuid }, + }), + ) } - return Promise.resolve(Response.json({})) + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, + }), + ) }) as unknown as typeof fetch const plugin = await getPlugin() @@ -3769,7 +3808,68 @@ describe('quota header feed integration', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: liveAccess, + refresh: `refresh-${liveAccess}`, + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await (await result.fetch(MESSAGES_URL, EMPTY_POST)).text() + await waitForFeedEntries( + (entries) => + entries.some( + (entry: any) => entry.anthropic_account_uuid === providerAccountUuid, + ), + 'a bootstrapped main UUID feed entry', + ) + + liveAccess = 'main-feed-non-oat-after-rotation' + await (await result.fetch(MESSAGES_URL, EMPTY_POST)).text() + + const feedEntries = await waitForFeedEntries( + (entries) => + entries.some( + (entry: any) => + entry.account_ref === mainAccountId && + entry.anthropic_account_uuid === null, + ), + 'a non-oat main feed entry without a provider UUID', + ) + const published = feedEntries.find( + (entry: any) => + entry.account_ref === mainAccountId && + entry.anthropic_account_uuid === null, + ) + expect(published).toEqual( + expect.objectContaining({ + account_ref: mainAccountId, + anthropic_account_uuid: null, + }), + ) + }) + + test('fresh non-oat main install persists a quota slot and publishes no provider UUID', async () => { + await useTempAccountFile({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + quotaHeaderFeed: { enabled: true }, + accounts: [], + }) + globalThis.fetch = mock(() => + Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, + }), + ), + ) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'fresh-main-non-oat', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -3778,26 +3878,66 @@ describe('quota header feed integration', () => { const response = await result.fetch(MESSAGES_URL, EMPTY_POST) await response.text() + const persisted = await waitForAccountStorage( + (storage) => + storage?.mainAccountId !== undefined && + storage.quota?.mainQuota?.accountIdentity === storage.mainAccountId, + ) const [published] = await waitForFeedEntries( (entries) => entries.some( - (entry: any) => entry.anthropic_account_uuid === fallbackUuid, + (entry: any) => + entry.account_ref === persisted?.mainAccountId && + entry.anthropic_account_uuid === null, ), - 'a persisted fallback UUID feed entry', + 'a fresh non-oat main feed entry', + ) + expect(persisted?.mainAccountId).toBeDefined() + expect(persisted?.quota?.mainQuota?.accountIdentity).toBe( + persisted?.mainAccountId, ) expect(published).toEqual( expect.objectContaining({ + account_ref: persisted?.mainAccountId, + anthropic_account_uuid: null, + }), + ) + }) + + test('publishes and persists a fallback Anthropic account UUID', async () => { + const fallbackUuid = '11111111-1111-1111-1111-111111111111' + const { published, persisted } = await publishFallbackUuid({ + fallbackUuid, + bootstrapResponse: { oauth_account: { account_uuid: fallbackUuid } }, + }) + expect(published).toEqual( + expect.objectContaining({ + identity_source: 'account_ref', account_ref: 'fallback-1', anthropic_account_uuid: fallbackUuid, }), ) - const persisted = await waitForAccountStorage( - (candidate) => - ( - candidate?.accounts.find( - (account) => account.id === 'fallback-1', - ) as any - )?.anthropicAccountUuid === fallbackUuid, + expect( + ( + persisted?.accounts.find( + (account) => account.id === 'fallback-1', + ) as any + )?.anthropicAccountUuid, + ).toBe(fallbackUuid) + }) + + test('uses a persisted fallback Anthropic account UUID before bootstrap resolves it', async () => { + const fallbackUuid = '22222222-2222-2222-2222-222222222222' + const { published, persisted } = await publishFallbackUuid({ + fallbackUuid, + persistedUuid: fallbackUuid, + bootstrapResponse: {}, + }) + expect(published).toEqual( + expect.objectContaining({ + account_ref: 'fallback-1', + anthropic_account_uuid: fallbackUuid, + }), ) expect( ( @@ -4214,12 +4354,11 @@ describe('quota header feed integration', () => { await waitForLogRecord( records, (record) => - record.level === 'trace' && + record.level === 'debug' && record.channel === 'quota' && record.message === 'skipped stale fallback quota persistence after lineage change' && record.payload?.accountId === 'fallback-1' && - record.payload?.storedLineage === 'lineage-b' && record.payload?.servedLineage === 'lineage-a', 'stale fallback quota persistence discard', ) diff --git a/packages/opencode/src/tests/quota-header-feed.test.ts b/packages/opencode/src/tests/quota-header-feed.test.ts index 9c00ee3b..ee387cc4 100644 --- a/packages/opencode/src/tests/quota-header-feed.test.ts +++ b/packages/opencode/src/tests/quota-header-feed.test.ts @@ -5,6 +5,7 @@ import { mkdtemp, readdir, readFile, + rename, rm, stat, utimes, @@ -598,6 +599,49 @@ describe('quota header feed', () => { expect(names).toContain('106-66666666-6666-6666-6666-666666666666.json') }) + test('does not unlink a fresh lease published after the stale lease was inspected', async () => { + const now = Date.now() + const siblingName = '101-11111111-1111-1111-1111-111111111111.json' + const siblingPath = join(directory, siblingName) + const replacementPath = join(directory, 'fresh-publisher.tmp') + await mkdir(directory, { recursive: true }) + await writeFile(siblingPath, '{"stale":true}') + await utimes( + siblingPath, + (now - QUOTA_HEADER_FEED_LEASE_MS - 1) / 1_000, + (now - QUOTA_HEADER_FEED_LEASE_MS - 1) / 1_000, + ) + expect(now - (await stat(siblingPath)).mtimeMs).toBeGreaterThanOrEqual( + QUOTA_HEADER_FEED_LEASE_MS, + ) + + let publisherRan = false + let publisherError: unknown + const registry = new QuotaHeaderFeedRegistry({ + directory, + instanceId: '102-22222222-2222-2222-2222-222222222222', + now: () => now, + beforeRemoveFile: async (path: string) => { + publisherRan = true + try { + await writeFile(replacementPath, '{"fresh":true}') + await utimes(replacementPath, now / 1_000, now / 1_000) + await rename(replacementPath, siblingPath) + expect(path).toBe(siblingPath) + } catch (error) { + publisherError = error + throw error + } + }, + }) + + await registry.publish({ ...entry(), accountKey: 'a' }) + + expect(publisherRan).toBe(true) + expect(publisherError).toBeUndefined() + expect(await readFile(siblingPath, 'utf8')).toBe('{"fresh":true}') + }) + test('continues publishing when a sibling lease sweep cannot unlink a stale file', async () => { const now = Date.now() const stalePath = join( From 7ebf6e7b9dffc3bb24647cf65fb339b6e36700fc Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:37:39 +0200 Subject: [PATCH 04/23] feat(custody): per-account custody toggle and the global-mode custody state machine spec Squashed from: feat(custody): add /claude-account custody on|off for vault-served fallbacks docs(custody): add the global-mode custody state machine docs(custody): record the handoff and the unresolved design questions docs(custody): record the implementation go-ahead and its three binding constraints docs(custody): source the host-write race and collapse unobservable main-slot states --- CHANGELOG.md | 6 + README.md | 7 + docs/custody-state-machine.md | 441 ++++++++++ packages/core/src/accounts.ts | 126 ++- packages/core/src/claustrum.ts | 16 +- packages/core/src/commands/account.ts | 166 +++- packages/opencode/src/index.ts | 386 +++++++-- packages/opencode/src/rpc/protocol.ts | 5 +- packages/opencode/src/sidebar-state.ts | 8 + .../src/tests/account-command.test.ts | 309 +++++-- packages/opencode/src/tests/accounts.test.ts | 83 ++ packages/opencode/src/tests/claustrum.test.ts | 145 +++- .../src/tests/command-dialogs.test.ts | 87 +- .../tests/credential-handle-blindness.test.ts | 6 + packages/opencode/src/tests/index.test.ts | 769 +++++++++++++++++- packages/opencode/src/tui/command-dialogs.tsx | 195 +++-- packages/pi/src/commands.ts | 6 +- packages/pi/src/tests/commands.test.ts | 40 +- 18 files changed, 2578 insertions(+), 223 deletions(-) create mode 100644 docs/custody-state-machine.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c51de3b..3f4845a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. The OpenCode package is a fork of the original `@ex-machina/opencode-anthropic-auth` plugin, so older entries below the initial CortexKit release are inherited from upstream package history. +## Unreleased + +### Patch Changes + +- Document `/claude-account custody on|off`, including its fail-closed OpenCode behavior and Pi refusal. + ## 1.22.0 ### Minor Changes diff --git a/README.md b/README.md index 87f8b1de..8444ebaf 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,13 @@ OpenCode can obtain an opted-in fallback OAuth account's access credential from The request path reads only a resident in-memory credential. Startup warming and periodic custody ticks perform vault I/O and keep idle credentials refreshed; a cold or unavailable vault falls back to the sidecar credential path. Vault-served 401 reports carry the exact record version and response provenance, including relay-stream 401s, so a sidecar-served failure cannot invalidate a healthy vault credential. `/claude-account` and the OpenCode account modal show the gate, current vault service, and vault reauthentication state without exposing capability handles. +Use `/claude-account custody on|off` to change an eligible fallback OAuth account. +`on` verifies a usable vault credential under the account refresh lock, then persists the gate. A failed check leaves the gate off. +`off` persists first, invalidates the resident credential, and returns the account to sidecar service. +Refusals are explicit: `Cannot change custody for the main account.`, `Custody requires an OAuth fallback account.`, and `Cannot enable custody for disabled account "".` +Vault failures report `No custody handle for .`, `Claustrum is not available (...)`, `Vault reports the handle as unknown or revoked.`, `Vault credential needs re-login (...)`, or `Vault unavailable: ... Retry.` +Pi accepts the command but refuses it with `Custody is OpenCode-only in this version.` + Custody currently applies only to fallback OAuth accounts. Main-account vault service is not implemented. If Claustrum has replaced the main host credential with its provider-bound tombstone, the plugin rejects refresh locally without contacting Anthropic or persisting a permanent `invalid_grant` state. ## Quota-aware routing diff --git a/docs/custody-state-machine.md b/docs/custody-state-machine.md new file mode 100644 index 00000000..800cd5f7 --- /dev/null +++ b/docs/custody-state-machine.md @@ -0,0 +1,441 @@ +# Custody state machine: global `claustrum` | `local` mode with main-account takeover + +Design record for PR #196 (rework). This file is the artefact that gates implementation of the +mode transition; the PR comment stream (Rev 1, Rev 2, three addenda) is superseded by it. The +reasoning is kept in-tree because a diff will not carry it and every row here was paid for by a +ruling or an incident. + +Status: **implementation baseline** (maintainer, 2026-09-05 06:04Z, PR #196). This document is the +baseline for the implementation pass; the maintainer reviews the result and owns final integration +and remaining corrections. The go-ahead is permission to build, **not** approval to merge or to +activate takeover against live credentials; no live migration or release is authorised by it. Three +constraints bind the implementation (§13). The branch still carries the superseded per-account +toggle, which will not be merged as-is. + +## 1. Scope and vocabulary + +Two global modes, persisted in `anthropic-auth.json`: + +- `local` (default): the plugin refreshes and serves OAuth credentials from OpenCode's `auth.json` + (main) and its own sidecar (fallbacks). No vault calls are made in this mode. +- `claustrum`: every OAuth route, **main included**, is served from the Claustrum vault through a + handle-manifest binding. The vault is the sole refresher of every bound family. + +Commands are exactly `/claude-account claustrum`, `/claude-account local`, and bare +`/claude-account` for status. There is no per-account custody toggle, no `claustrum.enabled` +flag, and no `on|off` synonym. Account membership is decided by the handle manifest, not by +configuration. API-key routes are out of scope and unaffected in both modes. + +**Mode records intent; credential state proves servability.** `mode=claustrum` licenses the +takeover barrier and the custody serving path. It never by itself makes an account servable, and +a per-account verdict never by itself changes the mode. + +## 2. The tombstone: one write set, one classifier, one wider refusal + +Main's slot in `auth.json` cannot be empty: OpenCode runs a plugin's `auth.loader` only when a +stored entry exists (`provider/provider.ts:1604-1619`, `if (!stored) continue`), and an entry +that fails the `Info` decode is silently filtered by `Auth.all()` (`auth/index.ts:56-67`). An +absent or malformed slot therefore means **our request path does not exist**: no loader, no fetch +hook, no custody. Under `claustrum` the slot holds a non-secret tombstone whose only job is to +make the loader run. + +### 2.1 WRITE set (production writes exactly this, nothing else) + +```json +{ "type": "oauth", "access": "", "refresh": "claustrum-tombstone:v1:anthropic", "expires": 0 } +``` + +`access` is **empty**, and that is load-bearing, not cosmetic. OpenCode's `Info` schema is +`access: Schema.String` with no non-empty constraint, so the slot decodes and the loader runs +(verified live on 1.18.26 by the maintainer, reproduced on `openai`). Claustrum's deployed sealer +runs a shape gate that **aborts on empty access but accepts sentinel access**; an empty-access +tombstone therefore fails vault import by construction, independently of Claustrum's +reserved-prefix refusal (#28). Anything that later "tidies" this to carry a descriptive `access` +value silently re-arms the destructive import path. The constant carries this comment. + +### 2.2 RECOGNISE (classifier: "is this MY provider's tombstone?") + +``` +auth.type === 'oauth' && auth.refresh === custodyTombstoneKey(provider) +``` + +`access` and `expires` are **not** conjuncts. Once the exact provider-scoped sentinel is present in +`refresh`, a different `access`/`expires` is a partial write or corrupt state that must still enter +the custody path, never approach local refresh. Every extra conjunct is another way to *miss*, and a +miss falls through to a wrong-state boot: the loader does not refuse, `mainAccountId` is minted, +quota-identity resolution runs its compat substitution, and the background refresh loop starts +before the exchange-level guard finally throws. A spurious match merely refuses to serve. + +Both artefact shapes are pinned by tests: the empty-access shape production writes, and the +sentinel-access shape Claustrum's vendored golden encodes (legacy). A golden-only test is green +about a shape production never writes. + +### 2.3 REFUSE (barrier: "is this tombstone material at all?") + +Prefix-wide, at the last point before the irreversible act, on the **value committed** rather than +the record it came from: + +- token exchange: `assertNotCustodyTombstone` is the first statement of `refreshClaudeOAuthToken`, + keyed on `refresh.startsWith(CUSTODY_TOMBSTONE_PREFIX)`, before any `URLSearchParams` is built; +- send boundary: the bearer value is checked for the prefix before header construction. + +These deliberately do **not** share a predicate with §2.2. Recognition is exact so a foreign +provider's tombstone is not adopted as ours; refusal is wide so a foreign provider's tombstone can +never reach Anthropic's token endpoint. Two reviewers on two plugins independently reached to merge +them in one afternoon; the merge would have narrowed the barrier to the classifier, which is the +same failure as having no barrier. + +**Containment invariant, pinned by one test:** `refusal ⊋ recognition`. Every shape recognised at +the loader is refused at the exchange and the send boundary; the witness for strictness is a +**foreign-provider** tombstone (`claustrum-tombstone:v1:openai`), which recognition answers *no* +and refusal answers *yes*. A same-provider witness cannot distinguish the two predicates and so +cannot protect the split. + +## 3. Axes + +Evaluated independently for main and for **each enabled OAuth fallback**. + +| axis | values | notes | +|---|---|---| +| `mode` | `local` · `claustrum` | global, durable; the only global write in the barrier | +| `binding` | `VALID` · `INVALID` · `ABSENT` | the account's entry `{label, handle, credentialId}` in our provider block of the shared handle manifest. `INVALID` = entry present, handle or credentialId fails validation | +| `local` | `REAL` · `INERT` · `GONE` | main: `REAL` = usable material, `INERT` = recognise-set tombstone, `GONE` = slot absent **as observed through the SDK**. An unparseable main slot is unobservable: `Auth.all()` runs `Record.filterMap(decode)` (`auth/index.ts:65-66`), so a slot failing the `Info` schema reads as `undefined` and any later host write rewrites the file without it; for main, `GONE ≡ SLOT_ABSENT`. Fallback: `REAL` = usable refresh material, `INERT` = refresh material absent, row otherwise valid, `GONE` = `ROW_UNPARSEABLE` (our own store, so the distinction survives there; a row that is *absent* while a binding exists is the discovery operation, §7, not a coordinate) | +| `vault` | `USABLE` · `COLD` · `REAUTH` · `N/A` | resolved through the binding's handle. `COLD` = daemon unreachable or credential not resident (transient). `REAUTH` = record latched `needs_reauth`. `N/A` ⇔ `binding ∈ {ABSENT, INVALID}` | + +Two facts about `GONE` for main, both from OpenCode source (`339536bc22`), change what it means: + +- neither `SLOT_ABSENT` nor `SLOT_UNPARSEABLE` reaches `auth.loader`, so reconciliation for main runs + in the **plugin factory**, which is invoked during the first Provider-state construction via + `plugin.list()` (`provider.ts:1436`) but **before** the provider reaches `auth.all()` and the + loader pass (`:1591-1622`). An **awaited** write from the factory is visible to the first loader. +- today `normalizeAccount` null-drops an unparseable fallback row and `Auth.all()` hides an absent + or malformed slot. Reconciliation reads **raw** rows and slots so `GONE` is surfaced, and a `GONE` + fallback's state secrets are **retained**, never pruned, never normalised into success. + +## 4. Fences (three, never combined) + +| fence | compares | when | effect | +|---|---|---|---| +| **RECORD_VERSION** | the `record_version` captured from the resolution that served *this* request's token, passed unchanged to `report_auth_failure` for *this* response | per request, on a 401 | provenance only. **Never** a startup coordinate, never affects availability. `record_version` is `expected_version + 1` on every vault `commit_refresh` (`store.rs:1931-1951`); an earlier draft that compared it at startup would have made an account unavailable every time the vault did its job | +| **IDENTITY** | vault `account_id` from `credential.get` **vs** the row's persisted `anthropicAccountUuid` | startup reconcile and each custody tick, only when `vault=USABLE` | **both present and unequal → `MISMATCH`** (refuse serve, no writes, surfaced). **Either absent → `UNLABELLED`**: serve; absence is not difference. The request-time bootstrap of the served token performs the same comparison per request and is the authoritative one (§4.1) | +| **PRE-COMMIT FINGERPRINT** | `sha256(len(access) ‖ access ‖ len(refresh) ‖ refresh)` of each account's local material as read inside the barrier's fences | persisted with the mode write; consulted by `RESUME_TAKEOVER` | **crash reconciliation only.** Distinguishes crash-left pre-commit material (fingerprint matches → resume) from material that changed after the barrier read (differs → `NEW_LOCAL_FAMILY_UNDER_CLAUSTRUM`, §5), because content alone cannot tell the two apart and the in-process login record does not survive the restart that the crash case is. **It does not make the host write safe** (§12.1): OpenCode's `Auth.set` sits outside every lock we hold, and a re-read immediately before `client.auth.set` is still check-then-write | + +`credentialId` is **not** a startup comparand: `credential.get` returns `payload`, `expires_at_ms`, +`record_version`, `project_id?`, `account_id?`, `email?`, `org_name?` and **no credential id** +(`claustrum/crates/credentials-module/src/read_surface.rs:272-303`; the Rust source states +`account_id` is neither the credential id nor the handle). `credentialId` is the join key for the +quota feed and operator tooling only. + +### 4.1 Identity provenance (Anthropic-specific; a port must not copy it) + +On the vault side `account_id = account_id_for_adapter(adapter, token).or_else(stored identity)` +(`read_surface.rs:849-858`), and the live parse derives only for `openai`. For Anthropic the vault's +`account_id` is therefore the **operator-asserted** `ck auth set-identity` label, while our +request-time bootstrap of the served token is **provider-asserted**. The startup IDENTITY check +catches a swapped or mislabelled record; only the request-time check catches a label that is itself +wrong. Both yield `MISMATCH`; the request-time one is authoritative. For OpenAI the precedence +inverts (token claim wins; the vault's write sink refuses a contradicting label). + +Fingerprint covers both tokens, length-prefixed. Between the barrier's read and its commit, local +refresh is inert (binding), OpenCode has no Anthropic refresh loop, Anthropic never rotates access +without refresh, and a host login writes a whole new family; so refresh-only and both-tokens are +equivalent in the true-positive direction. They differ on a torn `Auth.set` (new access, old +refresh): both-tokens refuses, refresh-only tombstones an access token that is dead without its +family. Same safety; both-tokens removes an assumption about the atomicity of a file we do not own. + +## 5. `mode = claustrum` + +Per account. `serve` is from the vault where it says `vault`; local material is **never** served in +this mode. Local refresh is inert wherever a binding exists (`VALID` or `INVALID`), independent of +vault reachability: a valid binding means the vault owns that family, and a cold daemon is not +evidence to the contrary. A corrupt binding must not silently re-enable a local refresher on a +vault-owned family. + +| # | binding | local | vault | verdict | serve | local refresh | durable writes | retry | operator | +|---|---|---|---|---|---|---|---|---|---| +| C1 | VALID | INERT | USABLE | `CUSTODY_SERVE` | vault | inert | none | — | none | +| C2 | VALID | REAL, fingerprint **matches** | USABLE | `RESUME_TAKEOVER` | vault, after this account's commit | inert | finish this account's commit under its lock: fallback → drop refresh material; main → `client.auth.set(tombstone)`, awaited | immediate | none | +| C2′ | VALID | REAL, fingerprint **differs or absent** | any | `NEW_LOCAL_FAMILY_UNDER_CLAUSTRUM` | **no** | inert | **none** | none | **unresolved** (§12.2): `ck auth migrate-plugin --replace` then re-enter is consistent with every rule; "exit to `local` and the login stands" is not | +| C3 | VALID | INERT | COLD | `CUSTODY_UNAVAILABLE` | **no** (typed provider-unavailable) | inert | none | bounded custody retry on vault availability | none | +| C3′ | VALID | REAL | COLD | `TAKEOVER_INCOMPLETE_VAULT_UNAVAILABLE` | **no** | inert | **none**: no rollback, no drop. The destructive commit waits for `USABLE` (→ C2) because dropping material without proof the vault holds the family is destruction without evidence | on vault availability | none required; `local` + re-login only to abandon custody | +| C4 | VALID | any | REAUTH | `CUSTODY_CREDENTIAL_LATCHED` | **no** | inert | none | **none**: retry cannot fix a latched record | re-import into the vault; resumes without a mode change | +| C5 | VALID | any | USABLE ∧ IDENTITY mismatch | `CUSTODY_IDENTITY_MISMATCH` | **no** | inert | none | none | `set-identity` or re-bind; a different account may sit behind this handle | +| C6 | ABSENT | REAL | N/A | `NOT_ENROLLED` | **no** | **no** (this mode has no local refreshers) | none | none | `ck auth bind` after import, or `local` | +| C7 | ABSENT | INERT | N/A | `ORPHAN_TOMBSTONE` (main) / `ORPHAN_INERT` (fallback) | **no** | nothing to refresh | none | none | `bind`, or `local` + re-login | +| C8 | INVALID | any | N/A | `CORRUPT_BINDING` | **no** | **inert** | **none**: never auto-repair a manifest entry | none | `ck auth bind --replace`, or `local` | +| C9 | VALID | GONE (main) | USABLE · COLD · REAUTH | `RESTORE_TOMBSTONE` → re-classify as C1 / C3 / C4 | per re-class | inert | `auth.json` ← WRITE set, **awaited in the factory before the loader pass**. **BLOCKED under §13.1** (same host-write race as the install). Write failure → `FAIL_CLOSED`, typed `main unavailable: slot unrestorable`. (An unparseable slot cannot be distinguished from an absent one through the SDK, so no separate warn is possible; the host itself drops such an entry on its next write) | next boot | none on success | +| C10 | VALID | GONE (fallback = `ROW_UNPARSEABLE`) | any | `CORRUPT_ROW` | **no** | inert | **none**; state secrets retained | next reconcile | repair the row, or remove + re-discover | + +Invariants pinning the combinations not rowed: + +- `vault = N/A ⇔ binding ∈ {ABSENT, INVALID}`; a `VALID` binding always resolves to one of + `USABLE | COLD | REAUTH`. Test: every `VALID` fixture produces a connector call; no `ABSENT`/`INVALID` + fixture does. +- `binding = ABSENT ∧ local = GONE` for main is not a custody state: with no binding there is nothing + to restore, so the slot stays as found and OpenCode's own not-logged-in applies (no install without + a binding: that would fabricate custody). +- The recognise-set (§2.2) makes "partial tombstone write" a tombstone for every verdict; there is + no separate local-axis value for it. +- C9 installs on **REAUTH** and **COLD** as well as **USABLE**: the tombstone grants nothing, so + installing never transfers authority; what it buys is that the loader runs and a typed verdict can + exist. Same reasoning for a `MISMATCH` discovered after restore (C9 → C5). This is a deliberate + divergence from the openai-auth table, which does not install on mismatch: their identity is + provider-asserted and a mismatch there is strong evidence the *binding* is wrong; ours is + operator-labelled (§4.1), and a typed `MISMATCH` beats the host's generic not-logged-in. + +## 6. `mode = local` + +No vault call is made in this mode (test: zero connector invocations under `mode=local`). The +`vault` axis is not consulted. + +| # | binding | local | verdict | serve | local refresh | durable writes | operator | +|---|---|---|---|---|---|---|---| +| L1 | ABSENT | REAL | `LOCAL_SERVE` | local | yes | none | none | +| L2 | ABSENT | GONE | `DARK_PENDING_LOGIN` | no | no | none (`SLOT_UNPARSEABLE` logs the fact) | `/login` | +| L3 | ABSENT | INERT | `AWAITING_LOGIN` | no | nothing to refresh | none | `/login`. **Expected**: this is the post-`/claude-account local` state before re-login | +| L4 | VALID | INERT · GONE | `AWAITING_LOGIN` with a lingering binding (exit ran; the clear did not land or was never reached) | no | inert (binding) | none | `/login`; the verified login clears the binding (§7) | +| L5 | VALID | REAL | `DARK_PENDING_VERIFIED_LOGIN` | **no** | inert (binding) | none | `/login` through our own path | +| L6 | INVALID | any | `CORRUPT_BINDING` | no | **no** | none | repair or remove the entry | + +**L5 is the row the verified-login ruling creates.** Real material alongside a live binding means +material appeared without a login through our path: a restored backup, a hand-edit, a copied file. +"Material exists" is satisfiable by a restore and so cannot be the clearing signal; the binding keeps +refresh inert and the account stays dark until a real login clears it. + +## 7. The takeover barrier (`/claude-account claustrum`) + +An **all-accounts readiness barrier**, not an atomic commit: the writes span `auth.json`, the sidecar, +and the manifest, and cannot be made kill-atomic. The barrier makes every crash-visible intermediate +a state with a named verdict (§5) and a resume path. + +0. Acquire, in this fixed total order: config write lock → cross-tenant manifest lock → per-account + refresh locks (main, then fallbacks by sorted id). Hold all through step 4. Deadlock-free by total + order; the manifest lock's TTL/renewal covers the awaited host write. +1. **Inside** the locks: capture each account's custody generation; re-read the manifest, the raw + account rows, and the raw auth slot; compute each account's PRE-COMMIT FINGERPRINT. Any preflight + computed before this point is advisory and discarded (stale by construction under concurrency). +2. Classify every enabled OAuth account as C1 or C2-eligible (VALID binding, USABLE vault, IDENTITY + not mismatched) while fenced. Any other class → release, **zero writes**, typed refusal naming the + first failing account and its class. +3. Persist `mode=claustrum` **and** the per-account fingerprints in one config write (config lock + held). This is the barrier's durable marker and the only global write. Mode-first: a tombstone + never coexists with `mode=local` during a normal commit, so observing that pair is evidence of + tampering rather than an expected intermediate, and it is what makes `RESUME_TAKEOVER` possible + at all (under mode-last every intermediate is indistinguishable from a hand-written tombstone). +4. Idempotent per-account commits, fallbacks then main. Fallback → drop local refresh material + (no-op if absent); fallback rows live under our own locks, so this half is fenced. Main → + `client.auth.set(WRITE set)`, awaited (no-op if the slot already satisfies the recognise-set). + **The main write is not fenced** against the host (§12.1). A fingerprint re-read immediately + before it narrows the window; it does not close it. +5. Any failure after step 3: retain the mode, keep **all** local refresh inert (the binding alone + inerts it, mode-independent), release, surface. The next reconcile resumes **only** incomplete + accounts (C2), under their own locks; it never re-runs a transition for accounts already in C1. + +Against other processes: enable/disable and **our** login path take the config lock, so they +serialise with steps 0–4; other tenants' manifest writes take the cross-tenant lock, so they +serialise too; a generation bump observed at step 4 aborts **that** account's commit only, the others +proceed, and resume covers it. The host's `Auth.set` serialises with nothing we hold (§12.1). + +Serving is per-account and independent of the barrier: main may serve from the vault while a +fallback sits in C3, and the reverse. Nothing about serving account A depends on account B, so no +aggregate state exists. + +## 8. Operation transitions + +| operation | mode | precondition | effect | fence | +|---|---|---|---|---| +| `/claude-account claustrum` | local | barrier §7 | mode + fingerprints, then per-account commits | §7 | +| `/claude-account local` | claustrum | — | `mode=local` only. **No material writes, no manifest writes.** Bindings stay; every bound account becomes L4/L5 and stays dark until a verified login clears it. A transient inability to prove vault state never transfers refresh authority back to local; abandoning custody is this explicit command plus re-login | config lock | +| local login (`Claude Pro/Max` authorize) | claustrum | — | **refused before the browser opens or anything is written**: `Exit Claustrum mode first: /claude-account local` | none needed | +| verified login | local | login completed through **our** OAuth path (in-process record) **∧** real material observed via the live `getAuth` re-read | commit the new family, **then** clear that account's binding, both under config + manifest locks in one fence; bump the generation → L1 | config + manifest locks | +| enable account | claustrum | binding VALID ∧ vault USABLE ∧ IDENTITY not mismatched (COLD is a typed refusal, not a wait) | `enabled=true` | config lock | +| enable account | local | — | `enabled=true` | config lock | +| disable | any | — | `enabled=false`; binding unchanged; vault material untouched | config lock | +| remove account | claustrum | — | row removed; **its** binding removed under the manifest lock; vault material **retained** (`ck auth` owns vault removal; the plugin never writes the vault) | config + manifest locks | +| add new OAuth account | claustrum | vault-side tooling created the credential **and** the binding in our provider block | reconcile **discovers** a VALID binding with no row → creates `{id: label, enabled: false, no refresh material, no identity}`, appended to the fallback order. INERT from birth: there is never a moment where a row exists, is enabled, and lacks a usable vault binding. **Discovery writes no identity**; the row binds `anthropicAccountUuid` from the first served token's bootstrap, never from the vault's operator-asserted label and never from a placeholder (a placeholder would `MISMATCH` the real claim forever) | config lock, manifest re-read inside it | +| manifest change (other tenant) racing the barrier | — | — | serialised by the cross-tenant lock; if it lands between steps 1 and 4 → generation bump → that account's commit aborts → resume | manifest lock + generation | +| enable/disable racing the barrier | — | — | serialised by the config lock | config lock | + +**Adding a new account today.** No verb on Claustrum master writes our provider block +(`mint-handle` prints a handle; `migrate-opencode` writes an OpenCode-shaped entry even under +`--serve-by anthropic-auth`; `migrate-plugin --serve anthropic-auth` writes our block but takes a +plugin-**exported** file, so it migrates accounts we already hold). Two paths: + +- direct, once it lands: `ck auth bind --serve anthropic-auth --label