From 73766c80cdf37c22ad54ca3c90ff8a3fff41af68 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:17:17 +0200 Subject: [PATCH 01/19] fix(custody): key scoped main quota by the roster primary account Under scoped custody, core's state fence keeps a main quota only when its accountIdentity equals claustrum.primaryAccount.accountId. The OpenCode plugin keyed main quota by the local mainAccountId slot, so every header harvest and usage poll for main was dropped at save. A migrated seat, whose slot id differs from the provider account id, therefore never persisted main quota: every process polled /api/oauth/usage on its own and hit 429 backoff. Resolve the main quota key to the scoped primary account id when scoped custody is active; keep the local slot key otherwise. --- packages/opencode/src/index.ts | 12 +- .../scoped-main-quota-persistence.test.ts | 173 ++++++++++++++++++ 2 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/tests/scoped-main-quota-persistence.test.ts diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ea62df8f8..99126d6de 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1388,10 +1388,16 @@ const anthropicAuthPlugin = async ( model, credentialAccountUuid ?? mainSlotQuotaKey, ) - // 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 = mainSlotQuotaKey const currentStorage = await loadAccounts(accountStoragePath) + // Scoped custody binds main runtime state to the roster's primary + // account id: core's state fence drops any main quota keyed otherwise, + // so a slot-keyed quota would never persist. Outside scoped custody, + // non-oat adapters keep their local slot for quota fencing; it is not a + // provider identity and must never reach a provider-facing field. + const quotaKey = + (isScopedCustodyActive(currentStorage) + ? currentStorage?.claustrum?.primaryAccount?.accountId + : undefined) ?? mainSlotQuotaKey const persistedProviderAccountUuid = currentStorage?.main?.profile?.providerAccountUuid ?? (currentStorage?.main?.profile?.accountIdentity as diff --git a/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts new file mode 100644 index 000000000..7f06631ee --- /dev/null +++ b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts @@ -0,0 +1,173 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type AccountStorage, + type ClaustrumScopedClient, + getAccountStatePath, +} from '@cortexkit/anthropic-auth-core' +import type { ScopedInventoryRow } from '@cortexkit/claustrum-client' +import { AnthropicAuthPlugin } from '../index.ts' +import { drainSidebarWrites } from '../sidebar-state.ts' + +const testDirs: string[] = [] +const originalFetch = globalThis.fetch + +afterEach(async () => { + globalThis.fetch = originalFetch + for (const d of testDirs.splice(0)) { + await rm(d, { recursive: true, force: true }) + } + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + delete process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE +}) + +const PRIMARY_ACCOUNT_ID = 'e84ca8b4-bd13-41e9-98e4-13f7b6690b7e' +const SLOT_ID = '874a76c0-309a-4ccb-9199-9106db83f521' + +const mainRow: ScopedInventoryRow = { + id: 'oauth:anthropic', + accountId: PRIMARY_ACCOUNT_ID, + categories: ['anthropic-native'], + serves: ['anthropic'], + credentialType: 'oauth', + refreshAdapter: 'anthropic', + operations: ['read'], + state: 'active', + recordVersion: 10, + createdAtMs: null, +} + +async function readMainQuota(storagePath: string) { + const state = JSON.parse( + await readFile(getAccountStatePath(storagePath), 'utf8'), + ) as { main?: { quota?: { accountIdentity?: string } } } + return state.main?.quota +} + +test('scoped custody persists main quota under the roster primary account id', async () => { + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-main-quota-')) + testDirs.push(root) + const storagePath = join(root, 'anthropic-auth.json') + const tokenPath = join(root, 'opencode-enrollment.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = storagePath + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = tokenPath + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( + root, + 'sidebar.json', + ) + await writeFile( + tokenPath, + JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + // A migrated seat keeps its pre-scoped local slot id, which differs from + // the provider account id the scoped roster binds main state to. + const initialStorage: AccountStorage = { + version: 1, + mainAccountId: SLOT_ID, + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: mainRow.id, + accountId: PRIMARY_ACCOUNT_ID as any, + state: 'active', + }, + }, + accounts: [], + } + await writeFile(storagePath, JSON.stringify(initialStorage, null, 2), { + mode: 0o600, + }) + + const scopedClient: ClaustrumScopedClient = { + listScoped: async () => ({ view: 'view-1', rows: [mainRow] }), + getScoped: async (input) => ({ + credentialId: input.credentialId, + accountId: PRIMARY_ACCOUNT_ID, + material: 'scoped-access-main', + recordVersion: 10, + expiresAtMs: Date.now() + 3_600_000, + }), + reportAuthFailureScoped: async () => {}, + close: () => {}, + } + + const resetSeconds = Math.floor(Date.now() / 1000) + 3_600 + globalThis.fetch = (async (input: unknown) => { + const url = String(input) + if (url.includes('/api/oauth/usage')) { + return Response.json({ + five_hour: { utilization: 40 }, + seven_day: { utilization: 13 }, + }) + } + if (url.includes('/v1/messages')) { + return new Response( + '{"id":"msg_1","type":"message","content":[{"type":"text","text":"ok"}]}', + { + status: 200, + headers: { + 'content-type': 'application/json', + 'anthropic-ratelimit-unified-5h-utilization': '0.4', + 'anthropic-ratelimit-unified-5h-reset': String(resetSeconds), + 'anthropic-ratelimit-unified-7d-utilization': '0.13', + 'anthropic-ratelimit-unified-7d-reset': String( + resetSeconds + 86_400, + ), + }, + }, + ) + } + return new Response('not found', { status: 404 }) + }) as typeof fetch + + const plugin = await AnthropicAuthPlugin( + { directory: root } as any, + { + claustrumScopedConnect: async () => scopedClient, + } as any, + ) + try { + const result = await (plugin as any).auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: '', + refresh: 'claustrum-tombstone:v1:anthropic', + expires: 0, + }), + { models: {} } as any, + ) + const response = await result.fetch( + 'https://api.anthropic.com/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-5', + max_tokens: 10, + messages: [{ role: 'user', content: 'hello' }], + }), + }, + ) + expect(response.status).toBe(200) + await response.text() + + let quota = await readMainQuota(storagePath) + for (let i = 0; i < 60 && !quota; i++) { + await Bun.sleep(50) + quota = await readMainQuota(storagePath) + } + await drainSidebarWrites() + expect(quota).toBeDefined() + expect(quota?.accountIdentity).toBe(PRIMARY_ACCOUNT_ID) + } finally { + await plugin.dispose?.() + } +}) From a456ebd9600e425f1aebc3e15f393dc3361a9c48 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:53:00 +0200 Subject: [PATCH 02/19] fix(custody): poll main quota under scoped custody Two gaps left main without its model-scoped (e.g. Fable weekly) limits under scoped custody, where the host auth slot is an empty-access tombstone: - /claude-quota required a non-empty host access token, so it never polled main. It now authorizes main through the scoped runtime when scoped custody is active. - Model-scoped windows come only from the usage poll and main has no background poll. Header harvests keep five_hour/seven_day fresh, so a main entry that had only seen headers never became stale and never polled. A header-sourced main entry without scoped data is now due for a poll; an empty scoped array is a real poll result and does not qualify. --- packages/core/src/quota-manager.ts | 9 +- packages/opencode/src/index.ts | 24 +++- .../opencode/src/tests/quota-manager.test.ts | 17 +++ .../scoped-main-quota-persistence.test.ts | 123 +++++++++++++++++- 4 files changed, 167 insertions(+), 6 deletions(-) diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts index ee929550f..0add1e33b 100644 --- a/packages/core/src/quota-manager.ts +++ b/packages/core/src/quota-manager.ts @@ -541,7 +541,14 @@ export class QuotaManager { } return ( this.now() >= this.main.refreshAfter || - this.scopedWindowIsStale(this.main, modelId) + this.scopedWindowIsStale(this.main, modelId) || + // Model-scoped windows come only from the usage poll, and main has no + // background poll: header harvests keep five_hour/seven_day fresh and + // carry a polled `scoped` forward, so a main entry that has only ever + // seen headers would never learn its scoped (e.g. Fable weekly) limits. + // An empty array is a real poll result and does not qualify. + (this.main.quota.source === 'headers' && + this.main.quota.scoped === undefined) ) } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 99126d6de..85be27478 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -3246,15 +3246,31 @@ const anthropicAuthPlugin = async ( if (latestGetAuth) { try { const auth = await latestGetAuth() - if (auth.type === 'oauth' && auth.access) { - mainAccessToken = mainServedAccessToken ?? auth.access - await resolveMainQuotaAccountIdentity(mainAccessToken) + // Under scoped custody the host slot is a tombstone with empty access; + // main's credential comes from the vault per attempt instead. + const scopedMainCredential = + auth.type === 'oauth' && + isScopedCustodyActive(await loadAccounts(accountStoragePath)) + ? await getCurrentMainCredential() + : undefined + const commandAccessToken = scopedMainCredential + ? scopedMainCredential.accessToken + : auth.access + ? (mainServedAccessToken ?? auth.access) + : undefined + if (auth.type === 'oauth' && commandAccessToken) { + mainAccessToken = commandAccessToken + await resolveMainQuotaAccountIdentity( + commandAccessToken, + undefined, + scopedMainCredential?.credentialAccountId, + ) // /claude-quota is a manual action: force a real fetch instead of // returning the cache. refreshMain still respects 429 backoff — it // returns the last cached snapshot when the API is backed off. const quota = await quotaManager.refreshMain( mainQuotaAccountId, - mainAccessToken, + commandAccessToken, ) accounts.push({ name: 'OpenCode anthropic', diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts index 4b9355437..276b3f628 100644 --- a/packages/opencode/src/tests/quota-manager.test.ts +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -1992,6 +1992,23 @@ describe('QuotaManager', () => { expect(pushed.quota.scoped).toEqual([]) }) + test('a header-only main entry stays due for its first usage poll', () => { + const qm = createQM() + qm.pushMainFromHeaders('token', headerSnapshot()) + + // Headers keep five_hour/seven_day fresh but never carry scoped windows, + // so without a poll the entry would never learn model-scoped limits. + expect(qm.isMainStale()).toBe(true) + + qm.setMain('token', { + quota: { scoped: [], checkedAt: now, source: 'poll' }, + checkedAt: now, + refreshAfter: now + 60_000, + }) + qm.pushMainFromHeaders('token', headerSnapshot()) + expect(qm.isMainStale()).toBe(false) + }) + test('header push never introduces poll-owned scoped data', () => { const qm = createQM() const incoming = { diff --git a/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts index 7f06631ee..e16ca30d2 100644 --- a/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts +++ b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts @@ -44,10 +44,131 @@ const mainRow: ScopedInventoryRow = { async function readMainQuota(storagePath: string) { const state = JSON.parse( await readFile(getAccountStatePath(storagePath), 'utf8'), - ) as { main?: { quota?: { accountIdentity?: string } } } + ) as { + main?: { + quota?: { + accountIdentity?: string + scoped?: Array<{ id?: string }> + } + } + } return state.main?.quota } +async function setupScopedSeat(root: string) { + const storagePath = join(root, 'anthropic-auth.json') + const tokenPath = join(root, 'opencode-enrollment.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = storagePath + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = tokenPath + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( + root, + 'sidebar.json', + ) + await writeFile( + tokenPath, + JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const initialStorage: AccountStorage = { + version: 1, + mainAccountId: SLOT_ID, + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: mainRow.id, + accountId: PRIMARY_ACCOUNT_ID as any, + state: 'active', + }, + }, + accounts: [], + } + await writeFile(storagePath, JSON.stringify(initialStorage, null, 2), { + mode: 0o600, + }) + return storagePath +} + +const scopedClient: ClaustrumScopedClient = { + listScoped: async () => ({ view: 'view-1', rows: [mainRow] }), + getScoped: async (input) => ({ + credentialId: input.credentialId, + accountId: PRIMARY_ACCOUNT_ID, + material: 'scoped-access-main', + recordVersion: 10, + expiresAtMs: Date.now() + 3_600_000, + }), + reportAuthFailureScoped: async () => {}, + close: () => {}, +} + +const TOMBSTONE_AUTH = () => + Promise.resolve({ + type: 'oauth', + access: '', + refresh: 'claustrum-tombstone:v1:anthropic', + expires: 0, + }) + +test('/claude-quota polls main through scoped custody and persists its scoped windows', async () => { + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-quota-cmd-')) + testDirs.push(root) + const storagePath = await setupScopedSeat(root) + const usageAuthorizations: string[] = [] + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = String(input) + if (url.includes('/api/oauth/usage')) { + usageAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Response.json({ + five_hour: { utilization: 40, resets_at: null }, + seven_day: { utilization: 13, resets_at: null }, + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 22, + scope: { + model: { id: 'claude-fable-5-1', display_name: 'Fable' }, + }, + }, + ], + }) + } + return new Response('not found', { status: 404 }) + }) as typeof fetch + + const plugin = await AnthropicAuthPlugin( + { directory: root, client: { session: {} } } as any, + { claustrumScopedConnect: async () => scopedClient } as any, + ) + try { + await (plugin as any).auth.loader(TOMBSTONE_AUTH, { models: {} } as any) + await (plugin as any) + ['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'session-1', + }) + .catch(() => {}) + await drainSidebarWrites() + expect(usageAuthorizations).toContain('Bearer scoped-access-main') + let quota = await readMainQuota(storagePath) + for (let i = 0; i < 60 && !quota?.scoped; i++) { + await Bun.sleep(50) + quota = await readMainQuota(storagePath) + } + expect(quota?.accountIdentity).toBe(PRIMARY_ACCOUNT_ID) + expect(quota?.scoped?.map((window) => window.id)).toEqual([ + 'claude-weekly-scoped-claude-fable-5-1', + ]) + } finally { + await plugin.dispose?.() + } +}) + test('scoped custody persists main quota under the roster primary account id', async () => { process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-main-quota-')) From 0677fd0c007a163bd05ce8b43c126e4e3ff25aae Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:37:33 +0200 Subject: [PATCH 03/19] fix(custody): fence scoped main quota identity and bound header-only polls Review follow-ups for scoped main quota: - A scoped receipt now binds quota only to the account it was served for. resolveMainQuotaAccountIdentity fails closed (stale, no global identity change) when scoped custody is active and the receipt's account differs from the current roster primary, or when the custody mode changed after the receipt. Prime passes its receipt account and honours the fence, as does /claude-quota. - The header-only staleness rule now fires at most once per quota interval per process: a usage 403 deliberately arms no quota backoff, so a failing first poll must not repeat on every request. --- packages/core/src/quota-manager.ts | 11 ++- packages/opencode/src/index.ts | 43 +++++++-- .../opencode/src/tests/quota-manager.test.ts | 23 +++++ .../scoped-main-quota-persistence.test.ts | 93 +++++++++++++++++++ 4 files changed, 163 insertions(+), 7 deletions(-) diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts index 0add1e33b..64773f2c4 100644 --- a/packages/core/src/quota-manager.ts +++ b/packages/core/src/quota-manager.ts @@ -135,6 +135,11 @@ export class QuotaManager { // --- Inflight deduplication --- private inflightMain: Promise | null = null + // When this process last started a main usage poll, whatever its outcome. + // Bounds the header-only staleness rule below: a usage 403 deliberately does + // not arm quota backoff, so without this a failing poll would repeat on + // every request that checks main staleness. + private lastMainPollAttemptAt: number | undefined private inflightMainAccountId: string | undefined private inflightMainAccessToken: string | undefined private inflightMainToken = 0 @@ -548,7 +553,10 @@ export class QuotaManager { // seen headers would never learn its scoped (e.g. Fable weekly) limits. // An empty array is a real poll result and does not qualify. (this.main.quota.source === 'headers' && - this.main.quota.scoped === undefined) + this.main.quota.scoped === undefined && + (this.lastMainPollAttemptAt === undefined || + this.now() - this.lastMainPollAttemptAt >= + getQuotaCheckIntervalMs(this.storage))) ) } @@ -928,6 +936,7 @@ export class QuotaManager { generation: number, inflightToken: number, ): Promise { + this.lastMainPollAttemptAt = this.now() return this._enqueueApiFetch(async () => { try { // Re-check backoff inside gate — may have been set by diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 85be27478..4f24e8a86 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1389,15 +1389,34 @@ const anthropicAuthPlugin = async ( credentialAccountUuid ?? mainSlotQuotaKey, ) const currentStorage = await loadAccounts(accountStoragePath) + const scopedCustodyActive = isScopedCustodyActive(currentStorage) + const rosterPrimaryAccountId = scopedCustodyActive + ? currentStorage?.claustrum?.primaryAccount?.accountId + : undefined + // A scoped receipt may only bind quota to the account it was served + // for. If the roster primary or the custody mode changed after the + // receipt was issued, the receipt's quota must not land on the new + // account: fail closed without touching the global main identity. + if ( + (scopedCustodyActive && + (credentialAccountUuid === undefined || + credentialAccountUuid !== rosterPrimaryAccountId)) || + (!scopedCustodyActive && credentialAccountUuid !== undefined) + ) { + return { + quotaKey: undefined, + providerAccountUuid: undefined, + generation: quotaManager.getMainQuotaIdentityGeneration(), + stale: true, + state: 'on-identity-mismatch' as const, + } + } // Scoped custody binds main runtime state to the roster's primary // account id: core's state fence drops any main quota keyed otherwise, // so a slot-keyed quota would never persist. Outside scoped custody, // non-oat adapters keep their local slot for quota fencing; it is not a // provider identity and must never reach a provider-facing field. - const quotaKey = - (isScopedCustodyActive(currentStorage) - ? currentStorage?.claustrum?.primaryAccount?.accountId - : undefined) ?? mainSlotQuotaKey + const quotaKey = rosterPrimaryAccountId ?? mainSlotQuotaKey const persistedProviderAccountUuid = currentStorage?.main?.profile?.providerAccountUuid ?? (currentStorage?.main?.profile?.accountIdentity as @@ -2429,7 +2448,14 @@ const anthropicAuthPlugin = async ( async function refreshPrimeMainQuota(): Promise { const credential = await getCurrentMainCredential() - await resolveMainQuotaAccountIdentity(credential.accessToken) + const resolution = await resolveMainQuotaAccountIdentity( + credential.accessToken, + undefined, + credential.credentialAccountId, + ) + if (resolution.stale && credential.credentialAccountId !== undefined) { + throw new Error('Main account identity changed before the prime refresh') + } // The quota transport obtains its own per-dispatch scoped receipt and // owns any 401 report. Reporting the earlier identity-preflight receipt // here would misattribute a rotation and potentially invalidate it. @@ -3260,11 +3286,16 @@ const anthropicAuthPlugin = async ( : undefined if (auth.type === 'oauth' && commandAccessToken) { mainAccessToken = commandAccessToken - await resolveMainQuotaAccountIdentity( + const resolution = await resolveMainQuotaAccountIdentity( commandAccessToken, undefined, scopedMainCredential?.credentialAccountId, ) + if (resolution.stale && scopedMainCredential) { + throw new Error( + 'Main account identity changed while checking quota; try again', + ) + } // /claude-quota is a manual action: force a real fetch instead of // returning the cache. refreshMain still respects 429 backoff — it // returns the last cached snapshot when the API is backed off. diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts index 276b3f628..2b7653225 100644 --- a/packages/opencode/src/tests/quota-manager.test.ts +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -2009,6 +2009,29 @@ describe('QuotaManager', () => { expect(qm.isMainStale()).toBe(false) }) + test('a failed first usage poll does not re-arm header-only staleness until the next interval', async () => { + let usageCalls = 0 + const qm = createQM((async () => { + usageCalls += 1 + return new Response('{"error":{"type":"permission_error"}}', { + status: 403, + }) + }) as unknown as typeof fetch) + qm.pushMainFromHeaders('token', headerSnapshot()) + expect(qm.isMainStale()).toBe(true) + + await qm.refreshMain('token', 'access-token').catch(() => {}) + expect(usageCalls).toBe(1) + // A usage 403 arms no quota backoff; the header-only rule must not + // turn every subsequent request into another poll. + qm.pushMainFromHeaders('token', headerSnapshot()) + expect(qm.isMainStale()).toBe(false) + + now += 5 * 60_000 + qm.pushMainFromHeaders('token', headerSnapshot()) + expect(qm.isMainStale()).toBe(true) + }) + test('header push never introduces poll-owned scoped data', () => { const qm = createQM() const incoming = { diff --git a/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts index e16ca30d2..48a023614 100644 --- a/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts +++ b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts @@ -292,3 +292,96 @@ test('scoped custody persists main quota under the roster primary account id', a await plugin.dispose?.() } }) + +test('a scoped main receipt never binds its quota to a roster primary that changed mid-request', async () => { + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-primary-race-')) + testDirs.push(root) + const storagePath = await setupScopedSeat(root) + const NEW_PRIMARY = '11111111-2222-4333-8444-555555555555' + + let getScopedCalls = 0 + const racingClient: ClaustrumScopedClient = { + listScoped: async () => ({ view: 'view-1', rows: [mainRow] }), + getScoped: async (input) => { + getScopedCalls += 1 + // The roster primary moves to B after the plugin authorized main but + // before the receipt for A is used to bind quota. + if (getScopedCalls === 1) { + const stored = JSON.parse(await readFile(storagePath, 'utf8')) + stored.claustrum.primaryAccount.accountId = NEW_PRIMARY + await writeFile(storagePath, JSON.stringify(stored, null, 2), { + mode: 0o600, + }) + } + return { + credentialId: input.credentialId, + accountId: PRIMARY_ACCOUNT_ID, + material: 'scoped-access-main', + recordVersion: 10, + expiresAtMs: Date.now() + 3_600_000, + } + }, + reportAuthFailureScoped: async () => {}, + close: () => {}, + } + + const resetSeconds = Math.floor(Date.now() / 1000) + 3_600 + const messageAuthorizations: string[] = [] + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = String(input) + if (url.includes('/v1/messages')) { + messageAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return new Response( + '{"id":"msg_1","type":"message","content":[{"type":"text","text":"ok"}]}', + { + status: 200, + headers: { + 'content-type': 'application/json', + 'anthropic-ratelimit-unified-5h-utilization': '0.4', + 'anthropic-ratelimit-unified-5h-reset': String(resetSeconds), + 'anthropic-ratelimit-unified-7d-utilization': '0.13', + 'anthropic-ratelimit-unified-7d-reset': String( + resetSeconds + 86_400, + ), + }, + }, + ) + } + return new Response('not found', { status: 404 }) + }) as typeof fetch + + const plugin = await AnthropicAuthPlugin( + { directory: root } as any, + { + claustrumScopedConnect: async () => racingClient, + } as any, + ) + try { + const result = await (plugin as any).auth.loader(TOMBSTONE_AUTH, { + models: {}, + } as any) + const response = await result + .fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-5', + max_tokens: 10, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + .catch(() => undefined) + await response?.text().catch(() => {}) + await Bun.sleep(300) + await drainSidebarWrites() + + const quota = await readMainQuota(storagePath) + // Whatever happened to the request, A's quota must never be bound to B. + expect(quota?.accountIdentity).not.toBe(NEW_PRIMARY) + } finally { + await plugin.dispose?.() + } +}) From dc6e69ec0b04b4e3437c2e21c1a6e6d6b2370422 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:38 +0200 Subject: [PATCH 04/19] fix(pi): port transcript replay so Oh My Pi can load the extension v1.23.0 imports collapseSystemMessages, normalizeContext, getCurrentSystemPrompt and getCurrentTools from @earendil-works/pi-ai, but Oh My Pi serves that specifier from its bundled @oh-my-pi/pi-ai fork plus legacy-pi-ai-shim.ts, which publishes none of them. The extension failed to load with "Export named 'collapseSystemMessages' not found in module 'omp-legacy-pi-bundled:@oh-my-pi/pi-ai'", so no request was ever built. Port the four pure replay helpers to packages/pi/src/transcript.ts, and hold the two host runtime imports that must stay host-supplied (calculateCost, createAssistantMessageEventStream) to an allowlist in scripts/check-pi-dist-imports.ts, run in CI and in `bun run test`. Co-authored-by: randomvariable --- .github/workflows/ci.yml | 1 + package.json | 3 +- packages/pi/src/convert.ts | 9 +- packages/pi/src/tests/convert.test.ts | 8 +- packages/pi/src/tests/stream.test.ts | 3 +- packages/pi/src/transcript.ts | 144 ++++++++++++++++++++++++++ scripts/check-pi-dist-imports.ts | 133 ++++++++++++++++++++++++ 7 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 packages/pi/src/transcript.ts create mode 100644 scripts/check-pi-dist-imports.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcf351207..dec864d9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: - run: bun run check:workspace-lock - run: bun run types - run: bun run build + - run: bun run check:pi-dist-imports - run: bun run check:claustrum-golden env: GITHUB_TOKEN: ${{ github.token }} diff --git a/package.json b/package.json index d00769881..34f0b1653 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,10 @@ "dev:clean": "bun scripts/dev-clean.ts", "extract": "bun scripts/extract-system-prompt.ts", "check:claustrum-golden": "bun scripts/check-claustrum-golden.ts", + "check:pi-dist-imports": "bun scripts/check-pi-dist-imports.ts", "check:workspace-lock": "node scripts/version-sync.mjs --check-lock", "analyze:cache": "node scripts/analyze-cache-usage.mjs", - "test": "bun run build && cd packages/core && bun test src/tests && cd ../opencode && bun run test && cd ../pi && bun run test && cd ../.. && bun run test:pi-host", + "test": "bun run build && bun run check:pi-dist-imports && cd packages/core && bun test src/tests && cd ../opencode && bun run test && cd ../pi && bun run test && cd ../.. && bun run test:pi-host", "test:pi-host": "bun scripts/check-pi-tool-mapping.ts", "test:e2e": "cd packages/core && bun run build && cd ../.. && bun run --cwd packages/e2e-tests test", "typecheck": "cd packages/core && bun run build && bun run typecheck && cd ../opencode && bun run typecheck && cd ../pi && bun run typecheck && cd ../e2e-tests && bun run typecheck && cd ../.. && tsc -p tsconfig.scripts.json", diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index 534e89053..17acc84b8 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -38,7 +38,7 @@ import { getCurrentSystemPrompt, getCurrentTools, normalizeContext, -} from '@earendil-works/pi-ai' +} from './transcript.ts' // Anchor identifying Pi's documentation paragraph — the only part of the prompt // that Anthropic currently rejects in system[]. Unknown prompt shapes take the @@ -567,9 +567,10 @@ export async function buildAnthropicRequest( bodyText: string hostTools: Tool[] }> { - // Pi 0.86 passes a normalized transcript to providers. Resolve instructions, - // named sections and tool changes through its own replay helpers; raw host - // prompts (including ordered OMP blocks) still enter through normalization. + // Pi 0.86 passes a normalized transcript to providers, and later system + // messages can change the prompt, its named sections and the tool set. Resolve + // them through the local replay port; raw host prompts (including ordered OMP + // blocks) still enter through normalization. const transcript = collapseSystemMessages( normalizeContext({ ...context, diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index 7d12a6eb8..d451d28ef 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -4,13 +4,9 @@ import { computeCcVersionSuffix, type ProviderAccountUuid, } from '@cortexkit/anthropic-auth-core' -import { - type Context, - type Message, - normalizeContext, - Type, -} from '@earendil-works/pi-ai' +import { type Context, type Message, Type } from '@earendil-works/pi-ai' import { buildAnthropicRequest } from '../convert' +import { normalizeContext } from '../transcript.ts' function userMsg(text: string): Message { return { role: 'user', content: text, timestamp: 0 } diff --git a/packages/pi/src/tests/stream.test.ts b/packages/pi/src/tests/stream.test.ts index 9ce16c28c..bddf06186 100644 --- a/packages/pi/src/tests/stream.test.ts +++ b/packages/pi/src/tests/stream.test.ts @@ -3,8 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { loadAccounts, saveAccounts } from '@cortexkit/anthropic-auth-core' -import { normalizeContext } from '@earendil-works/pi-ai' - import { buildExplicitBaseMessagesUrl, configureApiRouteHeaders, @@ -12,6 +10,7 @@ import { primaryResponseAllowsApiFallback, streamCortexKitAnthropic, } from '../stream.ts' +import { normalizeContext } from '../transcript.ts' let tempDir: string | undefined const originalFetch = globalThis.fetch diff --git a/packages/pi/src/transcript.ts b/packages/pi/src/transcript.ts new file mode 100644 index 000000000..3e0a5de05 --- /dev/null +++ b/packages/pi/src/transcript.ts @@ -0,0 +1,144 @@ +import type { + Context, + Message, + SystemMessage, + Tool, +} from '@earendil-works/pi-ai' + +/** + * A transcript whose prompt and tool set are carried by its leading system + * message, which is the shape Pi 0.86 hands to providers. + */ +export type Transcript = { + messages: Message[] +} + +/** + * Replay a transcript's system messages into one leading prompt and tool set. + * + * Ported from pi-ai's `utils/transcript.ts`. Pi 0.86 exports these helpers from + * its package root; Oh My Pi serves every legacy pi-scope import — including + * this package's `@earendil-works/pi-ai` — from its own bundled + * `@oh-my-pi/pi-ai` fork, which has no system-message replay at all. Importing + * them there threw `Export named 'collapseSystemMessages' not found in module + * 'omp-legacy-pi-bundled:@oh-my-pi/pi-ai'` while the extension loaded, so no + * request was ever built. Host types are used for build-time shape only: nothing + * here imports the host at runtime (see scripts/check-pi-dist-imports.ts). + * + * Only `system` messages are read, so a host whose transcript has none — an + * OMP transcript puts the prompt in `Context.systemPrompt` and has no system + * role — still resolves to the message built from that prompt and + * `Context.tools`, which is what that host's own providers send. + */ +function contentText(content: SystemMessage['content']): string { + if (typeof content === 'string') return content + return content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join('\n') +} + +/** Render a system message as a complete prompt: its content followed by its sections. */ +function systemMessageText(message: SystemMessage): string { + const parts = [contentText(message.content)] + for (const text of Object.values(message.sections ?? {})) { + if (text !== null) parts.push(text) + } + return parts.filter((part) => part.length > 0).join('\n\n') +} + +function isSystemMessage(message: Message): message is SystemMessage { + return message.role === 'system' +} + +/** + * Build the leading system message for a prompt and tool set. Returns undefined when + * both are empty, so an empty transcript stays empty. + */ +function createInitialSystemMessage( + systemPrompt: string | undefined, + tools: Tool[] | undefined, +): SystemMessage | undefined { + const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0 + const hasTools = tools !== undefined && tools.length > 0 + if (!hasSystemPrompt && !hasTools) return undefined + return { + role: 'system', + content: systemPrompt ?? '', + ...(hasTools ? { toolsAdded: tools } : {}), + timestamp: 0, + } +} + +/** Fold `Context.systemPrompt` and `Context.tools` into a leading system message. */ +export function normalizeContext(context: Context): Transcript { + const initialMessage = createInitialSystemMessage( + context.systemPrompt, + context.tools, + ) + const messages = initialMessage + ? [initialMessage, ...context.messages] + : context.messages + return { messages } +} + +/** Resolve the tools available after applying every transcript delta in order. */ +export function getCurrentTools(messages: readonly Message[]): Tool[] { + const tools = new Map() + for (const message of messages) { + if (!isSystemMessage(message)) continue + for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name) + for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool) + } + return [...tools.values()] +} + +/** + * Replay every system message into one leading system message holding the current + * prompt and tools. Later `content` is appended to the base prompt, `sections` are + * patched by name, and tools are resolved with {@link getCurrentTools}. + */ +function getCurrentSystemMessage( + messages: readonly Message[], +): SystemMessage | undefined { + const content: string[] = [] + const sections = new Map() + let timestamp: number | undefined + for (const message of messages) { + if (!isSystemMessage(message)) continue + timestamp ??= message.timestamp + const text = contentText(message.content) + if (text.length > 0) content.push(text) + for (const [name, value] of Object.entries(message.sections ?? {})) { + if (value === null) sections.delete(name) + else sections.set(name, value) + } + } + const tools = getCurrentTools(messages) + if (timestamp === undefined && tools.length === 0) return undefined + return { + role: 'system', + content: content.join('\n\n'), + ...(sections.size > 0 ? { sections: Object.fromEntries(sections) } : {}), + ...(tools.length > 0 ? { toolsAdded: tools } : {}), + timestamp: timestamp ?? 0, + } +} + +/** Render the current system prompt text after replaying every system message. */ +export function getCurrentSystemPrompt(messages: readonly Message[]): string { + const message = getCurrentSystemMessage(messages) + return message ? systemMessageText(message) : '' +} + +/** + * Rebuild the transcript for APIs without mid-conversation system messages: the + * replayed system message leads, and every later system message is dropped. + */ +export function collapseSystemMessages(transcript: Transcript): Transcript { + const head = getCurrentSystemMessage(transcript.messages) + const messages = transcript.messages.filter( + (message) => message.role !== 'system', + ) + return { messages: head ? [head, ...messages] : messages } +} diff --git a/scripts/check-pi-dist-imports.ts b/scripts/check-pi-dist-imports.ts new file mode 100644 index 000000000..c14ae7ed1 --- /dev/null +++ b/scripts/check-pi-dist-imports.ts @@ -0,0 +1,133 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join, relative } from 'node:path' + +/** + * Host SDK packages whose bare specifier a Pi host rewrites. + * + * A pi extension is loaded through the host's extension loader, which serves + * these specifiers from the host's own bundled compatibility surface rather than + * the `@earendil-works/*` version the extension was built against. Oh My Pi + * answers them with its `@oh-my-pi/pi-ai` fork plus `legacy-pi-ai-shim.ts`, and + * that fork has no transcript-replay helpers, so importing them at runtime threw + * `Export named 'collapseSystemMessages' not found in module + * 'omp-legacy-pi-bundled:@oh-my-pi/pi-ai'` while the extension loaded and no + * request was ever built (v1.23.0). + * + * Type-only imports are safe, because TypeScript erases them, so this checks the + * shipped JavaScript. Anything a host might not publish is ported locally + * instead (packages/pi/src/transcript.ts). + */ +const HOST_PI_PACKAGE = /^@(?:earendil-works|oh-my-pi|mariozechner)\/(.+)$/ + +/** + * Host runtime imports this extension relies on, and why each cannot be ported: + * the object must be the host's own, or must match the host's own accounting. + * Reviewed against the host's legacy compat surface, not against + * `@earendil-works/pi-ai` — the two have already diverged once. + */ +const ALLOWED_HOST_RUNTIME_IMPORTS: Record = { + '@oh-my-pi/pi-ai': [ + // The stream handed back to the host must be the host's event-stream class. + 'createAssistantMessageEventStream', + // Costs must use the host's pricing so its usage reporting agrees. + 'calculateCost', + ], +} + +/** Names in one emitted `import { … }` clause, ignoring re-aliasing. */ +function clauseNames(clause: string): string[] { + return clause + .split(',') + .map((entry) => entry.trim().replace(/^type\s+/, '')) + .filter(Boolean) + .map((entry) => entry.split(/\s+as\s+/)[0]?.trim() ?? '') +} + +/** + * Names one host specifier is imported for, or `undefined` when the statement is + * not a named-import clause. The specifier comes from Bun's scanner, so comment + * and string text can never reach here. + */ +function hostImportNames( + source: string, + specifier: string, +): string[] | undefined { + const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const named = new RegExp( + `import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${escaped}['"]`, + ).exec(source) + if (named?.[1] !== undefined) return clauseNames(named[1]) + return new RegExp(`import\\s+[^'";]+from\\s*['"]${escaped}['"]`).test(source) + ? undefined + : [] +} + +export async function verifyPiDistRuntimeImports(): Promise { + const distRoot = join(import.meta.dir, '..', 'packages', 'pi', 'dist') + let files: string[] + try { + files = (await readdir(distRoot, { recursive: true })) + .filter((entry) => entry.endsWith('.js')) + .map((entry) => join(distRoot, entry)) + } catch { + throw new Error( + `Missing ${relative(process.cwd(), distRoot)}: run \`bun run build\` first`, + ) + } + + const transpiler = new Bun.Transpiler({ loader: 'js' }) + const findings: string[] = [] + for (const file of files) { + const source = await readFile(file, 'utf8') + const origin = relative(distRoot, file) + for (const { path: specifier } of transpiler.scanImports(source)) { + const matched = HOST_PI_PACKAGE.exec(specifier) + if (!matched) continue + const canonical = `@oh-my-pi/${matched[1]}` + const allowed = ALLOWED_HOST_RUNTIME_IMPORTS[canonical] + if (!allowed) { + findings.push( + `${origin}: runtime import from '${specifier}' — port what is needed locally (see packages/pi/src/transcript.ts)`, + ) + continue + } + const names = hostImportNames(source, specifier) + if (!names) { + findings.push( + `${origin}: '${specifier}' must be imported only by name, not as a namespace or default`, + ) + continue + } + for (const name of names) { + if (!allowed.includes(name)) { + findings.push( + `${origin}: '${specifier}' exports '${name}' to this build, but the host's compat surface is not guaranteed to`, + ) + } + } + if (names.length === 0) { + findings.push( + `${origin}: side-effect import of '${specifier}' loads the host's copy`, + ) + } + } + } + + if (findings.length > 0) { + throw new Error( + `Pi extension reaches into host SDK packages at runtime:\n${findings + .map((finding) => ` ${finding}`) + .join('\n')}`, + ) + } + return `packages/pi/dist: ${files.length} files, host runtime imports held to the reviewed compat surface` +} + +if (import.meta.main) { + try { + console.log(await verifyPiDistRuntimeImports()) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} From 1e004668936144cdb65bff6c924c8e8afcc3f1e9 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:38 +0200 Subject: [PATCH 05/19] fix(pi): check every host import clause in the dist import guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hostImportNames read only the first import statement for a specifier, so a second `import { … } from '@earendil-works/pi-ai'` in the same file passed unchecked, as did a bare side-effect import alongside a named one. Read every statement for the specifier, flag any non-named clause, detect side-effect imports even next to named ones, and dedupe findings for repeated statements. --- scripts/check-pi-dist-imports.ts | 40 ++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/scripts/check-pi-dist-imports.ts b/scripts/check-pi-dist-imports.ts index c14ae7ed1..e9b3ad524 100644 --- a/scripts/check-pi-dist-imports.ts +++ b/scripts/check-pi-dist-imports.ts @@ -44,22 +44,30 @@ function clauseNames(clause: string): string[] { } /** - * Names one host specifier is imported for, or `undefined` when the statement is - * not a named-import clause. The specifier comes from Bun's scanner, so comment - * and string text can never reach here. + * Names one host specifier is imported for across every import statement in the + * file, or `undefined` when any statement for it is not a named-import clause + * (namespace or default). A file may import the same specifier more than once, + * so every clause is read, not just the first. The specifier comes from Bun's + * scanner, so comment and string text can never reach here. */ function hostImportNames( source: string, specifier: string, ): string[] | undefined { const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const named = new RegExp( - `import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${escaped}['"]`, - ).exec(source) - if (named?.[1] !== undefined) return clauseNames(named[1]) - return new RegExp(`import\\s+[^'";]+from\\s*['"]${escaped}['"]`).test(source) - ? undefined - : [] + const statements = [ + ...source.matchAll( + new RegExp(`import\\s*([^'";]*?)\\s*from\\s*['"]${escaped}['"]`, 'g'), + ), + ] + const names: string[] = [] + for (const statement of statements) { + const clause = statement[1]?.trim() ?? '' + const named = /^\{([^}]*)\}$/.exec(clause) + if (!named) return undefined + names.push(...clauseNames(named[1] ?? '')) + } + return names } export async function verifyPiDistRuntimeImports(): Promise { @@ -105,7 +113,11 @@ export async function verifyPiDistRuntimeImports(): Promise { ) } } - if (names.length === 0) { + const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + if ( + names.length === 0 || + new RegExp(`import\\s*['"]${escaped}['"]`).test(source) + ) { findings.push( `${origin}: side-effect import of '${specifier}' loads the host's copy`, ) @@ -114,8 +126,12 @@ export async function verifyPiDistRuntimeImports(): Promise { } if (findings.length > 0) { + // Bun's scanner reports each import statement, so a file importing the same + // specifier twice would otherwise repeat its findings. throw new Error( - `Pi extension reaches into host SDK packages at runtime:\n${findings + `Pi extension reaches into host SDK packages at runtime:\n${[ + ...new Set(findings), + ] .map((finding) => ` ${finding}`) .join('\n')}`, ) From 733e19b4e6a1ab77b2149ccb448fad2ad5ce0b4a Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:50:42 +0200 Subject: [PATCH 06/19] fix(custody): bind the main poll-attempt bound to its account Review follow-ups: - The header-only poll bound recorded when this process last polled main but not for which account, so a poll for a previous main account could delay the new account's first usage poll by up to one interval. Record the account with the attempt and only honour it for the same account. - The scoped primary-race test could pass without exercising the race. It now asserts that the receipt was served, that the roster primary moved, that the request failed on the identity fence before sending, and only then that A's quota was never bound to B. --- packages/core/src/quota-manager.ts | 13 ++++++++---- .../opencode/src/tests/quota-manager.test.ts | 20 +++++++++++++++++++ .../scoped-main-quota-persistence.test.ts | 17 ++++++++++++++-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts index 64773f2c4..4dd677023 100644 --- a/packages/core/src/quota-manager.ts +++ b/packages/core/src/quota-manager.ts @@ -139,7 +139,11 @@ export class QuotaManager { // Bounds the header-only staleness rule below: a usage 403 deliberately does // not arm quota backoff, so without this a failing poll would repeat on // every request that checks main staleness. - private lastMainPollAttemptAt: number | undefined + // Bound to the account it polled for, so a poll for a previous main account + // never delays the first poll of a new one. + private lastMainPollAttempt: + | { accountId: string | undefined; at: number } + | undefined private inflightMainAccountId: string | undefined private inflightMainAccessToken: string | undefined private inflightMainToken = 0 @@ -554,8 +558,9 @@ export class QuotaManager { // An empty array is a real poll result and does not qualify. (this.main.quota.source === 'headers' && this.main.quota.scoped === undefined && - (this.lastMainPollAttemptAt === undefined || - this.now() - this.lastMainPollAttemptAt >= + (this.lastMainPollAttempt === undefined || + this.lastMainPollAttempt.accountId !== this.mainAccountId || + this.now() - this.lastMainPollAttempt.at >= getQuotaCheckIntervalMs(this.storage))) ) } @@ -936,7 +941,7 @@ export class QuotaManager { generation: number, inflightToken: number, ): Promise { - this.lastMainPollAttemptAt = this.now() + this.lastMainPollAttempt = { accountId: mainAccountId, at: this.now() } return this._enqueueApiFetch(async () => { try { // Re-check backoff inside gate — may have been set by diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts index 2b7653225..602fdf049 100644 --- a/packages/opencode/src/tests/quota-manager.test.ts +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -2032,6 +2032,26 @@ describe('QuotaManager', () => { expect(qm.isMainStale()).toBe(true) }) + test('a poll attempt for a previous main account does not delay the new account first poll', async () => { + let usageCalls = 0 + const qm = createQM((async () => { + usageCalls += 1 + return new Response('{"error":{"type":"permission_error"}}', { + status: 403, + }) + }) as unknown as typeof fetch) + qm.pushMainFromHeaders('account-a', headerSnapshot()) + await qm.refreshMain('account-a', 'access-a').catch(() => {}) + expect(usageCalls).toBe(1) + qm.pushMainFromHeaders('account-a', headerSnapshot()) + expect(qm.isMainStale()).toBe(false) + + // The main account changes within the same interval: the new account has + // only header data and must still get its first usage poll. + qm.pushMainFromHeaders('account-b', headerSnapshot()) + expect(qm.isMainStale()).toBe(true) + }) + test('header push never introduces poll-owned scoped data', () => { const qm = createQM() const incoming = { diff --git a/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts index 48a023614..835a3faf2 100644 --- a/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts +++ b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts @@ -363,6 +363,7 @@ test('a scoped main receipt never binds its quota to a roster primary that chang const result = await (plugin as any).auth.loader(TOMBSTONE_AUTH, { models: {}, } as any) + let requestError: unknown const response = await result .fetch('https://api.anthropic.com/v1/messages', { method: 'POST', @@ -373,13 +374,25 @@ test('a scoped main receipt never binds its quota to a roster primary that chang messages: [{ role: 'user', content: 'hello' }], }), }) - .catch(() => undefined) + .catch((error: unknown) => { + requestError = error + return undefined + }) await response?.text().catch(() => {}) await Bun.sleep(300) await drainSidebarWrites() + // The race really happened: A's receipt was served and the roster primary + // moved to B while it was in flight. + expect(getScopedCalls).toBeGreaterThan(0) + const stored = JSON.parse(await readFile(storagePath, 'utf8')) + expect(stored.claustrum.primaryAccount.accountId).toBe(NEW_PRIMARY) + // The request failed closed on the identity fence before sending A's + // bearer, instead of failing for an unrelated reason. + expect(String(requestError)).toContain('Main OAuth identity changed') + expect(messageAuthorizations).toEqual([]) + // And A's quota was never bound to B. const quota = await readMainQuota(storagePath) - // Whatever happened to the request, A's quota must never be bound to B. expect(quota?.accountIdentity).not.toBe(NEW_PRIMARY) } finally { await plugin.dispose?.() From ee0bdb5db57c8de7a072e4f9dfc1d37aebf8b7d5 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:16:14 +0200 Subject: [PATCH 07/19] feat(custody): log scoped 401 retry decisions and delivered reports When the vault records a stale 401 report (applied=0), explaining it needs the consumer's side of the timeline: which version the physical request served, which version the re-authorize returned, whether the request retried, and which version was finally reported. The scoped build logged a retry only at four of the five send sites, never logged the no-retry arm, and never logged a delivered report. decideScopedRetryAfter401 replaces the bare rotation predicate at all five sites (model, model-relay, CacheKeep, Prime, quota/profile) and records each decision at debug. reportFailure records a report the vault accepted, with its served record version and reporter source. Neither line carries credential material. --- packages/core/src/claustrum-scoped-runtime.ts | 4 +- packages/core/src/claustrum-scoped.ts | 37 +++++++ .../core/src/tests/claustrum-scoped.test.ts | 101 ++++++++++++++++++ packages/opencode/src/index.ts | 17 ++- 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/packages/core/src/claustrum-scoped-runtime.ts b/packages/core/src/claustrum-scoped-runtime.ts index 994ba5552..25075ddea 100644 --- a/packages/core/src/claustrum-scoped-runtime.ts +++ b/packages/core/src/claustrum-scoped-runtime.ts @@ -14,7 +14,7 @@ import { type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, - isScopedCredentialRotation, + decideScopedRetryAfter401, } from './claustrum-scoped.js' import { type ClaustrumScopedRoster, @@ -293,7 +293,7 @@ export class ClaustrumScopedRuntime { // No verified replacement: retain the response and report the // exact receipt used by this physical request below. } - if (isScopedCredentialRotation(served, current)) { + if (decideScopedRetryAfter401('quota-profile', served, current)) { await response.body?.cancel().catch(() => {}) const headers = new Headers(init?.headers) headers.set('authorization', `Bearer ${current.accessToken}`) diff --git a/packages/core/src/claustrum-scoped.ts b/packages/core/src/claustrum-scoped.ts index d47701793..4621361f0 100644 --- a/packages/core/src/claustrum-scoped.ts +++ b/packages/core/src/claustrum-scoped.ts @@ -7,6 +7,7 @@ import { import { CUSTODY_TOMBSTONE_PREFIX } from './claustrum.js' import { readClaustrumEnrollmentToken } from './claustrum-enrollment.js' import { parseJsonRedacted } from './json.js' +import { logger } from './logger.js' export type ClaustrumScopedClient = Pick< ClaustrumClient, @@ -41,6 +42,37 @@ export function isScopedCredentialRotation( ) } +/** Send classes that retry once after a scoped 401, for diagnostics. */ +export type ScopedRetrySite = + | 'model' + | 'model-relay' + | 'cachekeep' + | 'prime' + | 'quota-profile' + +/** + * Decide whether a request that got a 401 should retry with the freshly + * re-authorized receipt, and record the decision. The vault can refresh a + * credential between dispatch and the 401; this line is the consumer-side + * half of that timeline, pairing with the vault's refresh commit instant when + * a stale report has to be explained. Never logs credential material. + */ +export function decideScopedRetryAfter401( + site: ScopedRetrySite, + served: ClaustrumScopedAttempt, + current: ClaustrumScopedAttempt | undefined, +): current is ClaustrumScopedAttempt { + const retry = isScopedCredentialRotation(served, current) + logger.debug('claustrum', 'scoped 401 re-authorized', { + site, + credentialId: served.credentialId, + servedVersion: served.recordVersion, + currentVersion: current?.recordVersion ?? null, + retry, + }) + return retry +} + const SERVING_MARGIN_MS = 300_000 function accessTokenFromMaterial(material: string): string { @@ -266,6 +298,11 @@ export class ClaustrumScopedCustody { this.#reports.set(attempt, report) try { await report + logger.debug('claustrum', 'scoped 401 reported', { + credentialId: attempt.credentialId, + recordVersion: attempt.recordVersion, + reporterSource, + }) } catch (error) { this.#reports.delete(attempt) throw error diff --git a/packages/core/src/tests/claustrum-scoped.test.ts b/packages/core/src/tests/claustrum-scoped.test.ts index 4c8a87ba7..18bee0ab9 100644 --- a/packages/core/src/tests/claustrum-scoped.test.ts +++ b/packages/core/src/tests/claustrum-scoped.test.ts @@ -7,8 +7,30 @@ import { import { type ClaustrumScopedClient, ClaustrumScopedCustody, + decideScopedRetryAfter401, isScopedCredentialRotation, } from '../claustrum-scoped.ts' +import { + __setLogTestSink, + getLogLevel, + type LogTestRecord, + setLogLevel, +} from '../logger.ts' + +async function captureLogs( + run: () => Promise | T, +): Promise<{ result: T; records: LogTestRecord[] }> { + const records: LogTestRecord[] = [] + const previousLevel = getLogLevel() + setLogLevel('debug') + __setLogTestSink((record) => records.push(record)) + try { + return { result: await run(), records } + } finally { + __setLogTestSink(null) + setLogLevel(previousLevel) + } +} const identity = { credentialId: 'oauth:anthropic:work', @@ -146,6 +168,41 @@ describe('scoped custody dispatch authorization', () => { expect(JSON.stringify(attempt)).not.toContain('01'.repeat(32)) }) + test('logs a delivered 401 report with its served version and no credential material', async () => { + const f = fixture() + const attempt = await f.custody.authorize(identity) + const { records } = await captureLogs(() => + f.custody.reportFailure(attempt, 401, 'direct'), + ) + const reported = records.filter( + (record) => record.message === 'scoped 401 reported', + ) + expect(reported).toHaveLength(1) + expect(reported[0]?.payload).toEqual({ + credentialId: identity.credentialId, + recordVersion: 7, + reporterSource: 'direct', + }) + const serialized = JSON.stringify(records) + expect(serialized).not.toContain('test-access') + expect(serialized).not.toContain('01'.repeat(32)) + }) + + test('does not log a 401 report the vault did not accept', async () => { + const f = fixture({ + reportAuthFailureScoped: async () => { + throw new Error('vault unavailable') + }, + }) + const attempt = await f.custody.authorize(identity) + const { records } = await captureLogs(() => + f.custody.reportFailure(attempt, 401, 'direct').catch(() => {}), + ) + expect( + records.some((record) => record.message === 'scoped 401 reported'), + ).toBe(false) + }) + test('rejects a copied or foreign attempt receipt', async () => { const f = fixture() const attempt = await f.custody.authorize(identity) @@ -416,3 +473,47 @@ test('only a changed record version for the same scoped credential and provider expect(isScopedCredentialRotation(served, candidate)).toBe(false) } }) + +test('records both arms of a scoped 401 retry decision without credential material', async () => { + const served = { + ...identity, + accessToken: 'served-secret', + recordVersion: 7, + expiresAtMs: 1_000_000, + } + const rotated = { ...served, accessToken: 'rotated-secret', recordVersion: 8 } + const { result: retried, records } = await captureLogs(() => [ + decideScopedRetryAfter401('model', served, rotated), + decideScopedRetryAfter401('cachekeep', served, { ...served }), + decideScopedRetryAfter401('prime', served, undefined), + ]) + expect(retried).toEqual([true, false, false]) + expect( + records + .filter((record) => record.message === 'scoped 401 re-authorized') + .map((record) => record.payload), + ).toEqual([ + { + site: 'model', + credentialId: identity.credentialId, + servedVersion: 7, + currentVersion: 8, + retry: true, + }, + { + site: 'cachekeep', + credentialId: identity.credentialId, + servedVersion: 7, + currentVersion: 7, + retry: false, + }, + { + site: 'prime', + credentialId: identity.credentialId, + servedVersion: 7, + currentVersion: null, + retry: false, + }, + ]) + expect(JSON.stringify(records)).not.toContain('secret') +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ea62df8f8..0a8a34d7c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -50,6 +50,7 @@ import { createEmptyStorage, createStickyNoRouteResponse, type DumpHandle, + decideScopedRetryAfter401, decideStickyQuotaFailure, detectClaustrumConnection, dumpDirectRequest, @@ -118,7 +119,6 @@ import { isPermanentRefreshError, isPrimePersistentlyEnabled, isQuotaBearingHeaderFrame, - isScopedCredentialRotation, isValidApiBaseURL, KILLSWITCH_COMMAND_NAME, killswitchPassesPolicy, @@ -2267,7 +2267,8 @@ const anthropicAuthPlugin = async ( } catch { return undefined } - if (!isScopedCredentialRotation(served, current)) return undefined + if (!decideScopedRetryAfter401('cachekeep', served, current)) + return undefined logger.info( 'claustrum', 'retrying CacheKeep after scoped credential rotation', @@ -2576,7 +2577,7 @@ const anthropicAuthPlugin = async ( // The first 401 belongs to the original physical attempt unless // the vault confirms a newer version of this same account. } - if (isScopedCredentialRotation(scopedAttempt, current)) { + if (decideScopedRetryAfter401('prime', scopedAttempt, current)) { await response.body?.cancel().catch(() => {}) logger.info( 'claustrum', @@ -6098,7 +6099,13 @@ const anthropicAuthPlugin = async ( } catch { // Keep the 401 and report the exact relay-served record below. } - if (isScopedCredentialRotation(relay401Attempt, rotated)) { + if ( + decideScopedRetryAfter401( + 'model-relay', + relay401Attempt, + rotated, + ) + ) { await response.body?.cancel().catch(() => {}) logger.info( 'claustrum', @@ -6135,7 +6142,7 @@ const anthropicAuthPlugin = async ( } catch { // Preserve the genuine 401 if no replacement can be verified. } - if (isScopedCredentialRotation(directAttempt, rotated)) { + if (decideScopedRetryAfter401('model', directAttempt, rotated)) { await response.body?.cancel().catch(() => {}) logger.info( 'claustrum', From e9d775aa12e1db3bacbdaa5b59efbf697efa8913 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:27:28 +0200 Subject: [PATCH 08/19] fix(pi): parse the dist import guard's input instead of matching its spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex guard could be bypassed by valid JavaScript it did not model: a quoted export name (`import { "collapseSystemMessages" as c } from …`) beside an allowed clause passed. Read the emitted modules with @babel/parser and check import declarations, re-exports and dynamic imports from the syntax tree. TypeScript 7 has no synchronous parser API (its JS API needs a project snapshot from the native server), so the guard uses @babel/parser, already in the lockfile at 7.29.7 through the TUI build and now pinned as a root dev dependency. Focused tests cover allowed names including quoted and aliased forms, duplicate clauses, quoted unsupported names, side-effect imports, default/namespace/star/dynamic imports, re-exports, other host package roots, the legacy pi-ai scope, text in comments and strings, and an empty dist. They run in CI before the guard. --- .github/workflows/ci.yml | 1 + bun.lock | 1 + package.json | 1 + scripts/check-pi-dist-imports.test.ts | 129 ++++++++++++++ scripts/check-pi-dist-imports.ts | 231 ++++++++++++++++++-------- 5 files changed, 290 insertions(+), 73 deletions(-) create mode 100644 scripts/check-pi-dist-imports.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec864d9a..4f40fde78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: - run: bun run check:workspace-lock - run: bun run types - run: bun run build + - run: bun test scripts/check-pi-dist-imports.test.ts - run: bun run check:pi-dist-imports - run: bun run check:claustrum-golden env: diff --git a/bun.lock b/bun.lock index 078fccb60..db42d45c3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@cortexkit/anthropic-auth", "devDependencies": { + "@babel/parser": "7.29.7", "@biomejs/biome": "2.5.14", "@earendil-works/pi-ai": "0.86.1", "@earendil-works/pi-coding-agent": "0.86.1", diff --git a/package.json b/package.json index 34f0b1653..4655a5bbf 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { + "@babel/parser": "7.29.7", "@biomejs/biome": "2.5.14", "@earendil-works/pi-ai": "0.86.1", "@earendil-works/pi-coding-agent": "0.86.1", diff --git a/scripts/check-pi-dist-imports.test.ts b/scripts/check-pi-dist-imports.test.ts new file mode 100644 index 000000000..7ffcc6fc4 --- /dev/null +++ b/scripts/check-pi-dist-imports.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + findHostImportViolations, + verifyPiDistRuntimeImports, +} from './check-pi-dist-imports.ts' + +const ALLOWED = `import { calculateCost, createAssistantMessageEventStream } from '@earendil-works/pi-ai'` + +function violations(source: string) { + return findHostImportViolations(source, 'stream.js') +} + +describe('Pi dist host-import guard', () => { + test('accepts the allowed names, including quoted and aliased forms', () => { + expect(violations(ALLOWED)).toEqual([]) + expect( + violations( + `import { "calculateCost" as cost } from '@earendil-works/pi-ai'`, + ), + ).toEqual([]) + expect( + violations(`import { calculateCost as c } from '@oh-my-pi/pi-ai'`), + ).toEqual([]) + }) + + test('rejects a disallowed name in a second clause for the same specifier', () => { + expect( + violations( + `${ALLOWED}\nimport { collapseSystemMessages } from '@earendil-works/pi-ai'`, + ), + ).toEqual([ + "stream.js: '@earendil-works/pi-ai' exports 'collapseSystemMessages' to this build, but the host's compat surface is not guaranteed to", + ]) + }) + + test('rejects a quoted unsupported name beside allowed imports', () => { + expect( + violations( + `${ALLOWED}\nimport { "collapseSystemMessages" as collapse } from '@earendil-works/pi-ai'`, + ), + ).toEqual([ + "stream.js: '@earendil-works/pi-ai' exports 'collapseSystemMessages' to this build, but the host's compat surface is not guaranteed to", + ]) + }) + + test('rejects side-effect imports, even beside named ones', () => { + expect(violations(`${ALLOWED}\nimport '@earendil-works/pi-ai'`)).toEqual([ + "stream.js: side-effect import of '@earendil-works/pi-ai' loads the host's copy", + ]) + }) + + test('rejects default, namespace, star re-export and dynamic imports', () => { + for (const source of [ + `import piAi from '@earendil-works/pi-ai'`, + `import * as piAi from '@earendil-works/pi-ai'`, + `import piAi, { calculateCost } from '@earendil-works/pi-ai'`, + `export * from '@earendil-works/pi-ai'`, + `export * as piAi from '@earendil-works/pi-ai'`, + `export async function load() { return import('@earendil-works/pi-ai') }`, + ]) { + expect(violations(source)).toContain( + "stream.js: '@earendil-works/pi-ai' must be imported only by name, not as a namespace, default or dynamic import", + ) + } + }) + + test('rejects a disallowed name re-exported from the host', () => { + expect( + violations(`export { normalizeContext } from '@earendil-works/pi-ai'`), + ).toEqual([ + "stream.js: '@earendil-works/pi-ai' exports 'normalizeContext' to this build, but the host's compat surface is not guaranteed to", + ]) + }) + + test('rejects any runtime import from another host package root', () => { + for (const specifier of [ + '@earendil-works/pi-tui', + '@earendil-works/pi-coding-agent', + '@mariozechner/pi-tui', + ]) { + expect(violations(`import { x } from '${specifier}'`)).toEqual([ + `stream.js: runtime import from '${specifier}' — port what is needed locally (see packages/pi/src/transcript.ts)`, + ]) + } + }) + + test('holds the legacy pi-ai scope to the same allowlist', () => { + expect( + violations(`import { calculateCost } from '@mariozechner/pi-ai'`), + ).toEqual([]) + expect( + violations(`import { normalizeContext } from '@mariozechner/pi-ai'`), + ).toEqual([ + "stream.js: '@mariozechner/pi-ai' exports 'normalizeContext' to this build, but the host's compat surface is not guaranteed to", + ]) + }) + + // The guard reads emitted JavaScript, where TypeScript has already erased + // type-only imports, so they never reach it. + test('ignores non-host packages and import text in comments or strings', () => { + expect( + violations( + [ + `import { loadAccounts } from '@cortexkit/anthropic-auth-core'`, + `// import { collapseSystemMessages } from '@earendil-works/pi-ai'`, + `const text = "import { normalizeContext } from '@earendil-works/pi-ai'"`, + ].join('\n'), + ), + ).toEqual([]) + }) + + test('fails when the dist directory holds no JavaScript', async () => { + const empty = await mkdtemp(join(tmpdir(), 'pi-dist-guard-')) + try { + await expect(verifyPiDistRuntimeImports(empty)).rejects.toThrow( + 'No JavaScript', + ) + await writeFile(join(empty, 'index.js'), ALLOWED) + await expect(verifyPiDistRuntimeImports(empty)).resolves.toContain( + '1 files', + ) + } finally { + await rm(empty, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/check-pi-dist-imports.ts b/scripts/check-pi-dist-imports.ts index e9b3ad524..33b978dc3 100644 --- a/scripts/check-pi-dist-imports.ts +++ b/scripts/check-pi-dist-imports.ts @@ -1,5 +1,6 @@ import { readdir, readFile } from 'node:fs/promises' import { join, relative } from 'node:path' +import { parse } from '@babel/parser' /** * Host SDK packages whose bare specifier a Pi host rewrites. @@ -34,44 +35,159 @@ const ALLOWED_HOST_RUNTIME_IMPORTS: Record = { ], } -/** Names in one emitted `import { … }` clause, ignoring re-aliasing. */ -function clauseNames(clause: string): string[] { - return clause - .split(',') - .map((entry) => entry.trim().replace(/^type\s+/, '')) - .filter(Boolean) - .map((entry) => entry.split(/\s+as\s+/)[0]?.trim() ?? '') +type AstNode = { type: string; [key: string]: unknown } + +function isNode(value: unknown): value is AstNode { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string' + ) +} + +/** Name of an import/export binding, which may be an identifier or a string. */ +function moduleExportName(node: unknown): string | undefined { + if (!isNode(node)) return undefined + if (node.type === 'Identifier' && typeof node.name === 'string') + return node.name + if (node.type === 'StringLiteral' && typeof node.value === 'string') + return node.value + return undefined +} + +function stringValue(node: unknown): string | undefined { + return isNode(node) && + node.type === 'StringLiteral' && + typeof node.value === 'string' + ? node.value + : undefined +} + +/** Every node in the program, for dynamic imports that can appear anywhere. */ +function* walk(root: AstNode): Generator { + const stack: AstNode[] = [root] + while (stack.length > 0) { + const node = stack.pop() as AstNode + yield node + for (const [key, value] of Object.entries(node)) { + if (key === 'loc' || key === 'extra') continue + if (Array.isArray(value)) { + for (const item of value) if (isNode(item)) stack.push(item) + } else if (isNode(value)) { + stack.push(value) + } + } + } } /** - * Names one host specifier is imported for across every import statement in the - * file, or `undefined` when any statement for it is not a named-import clause - * (namespace or default). A file may import the same specifier more than once, - * so every clause is read, not just the first. The specifier comes from Bun's - * scanner, so comment and string text can never reach here. + * Violations in one emitted module, found from its syntax tree rather than from + * its import spelling, so quoted export names, repeated statements, re-exports + * and dynamic imports are all seen. Imports inside comments and strings are not + * statements and are ignored. */ -function hostImportNames( +export function findHostImportViolations( source: string, - specifier: string, -): string[] | undefined { - const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const statements = [ - ...source.matchAll( - new RegExp(`import\\s*([^'";]*?)\\s*from\\s*['"]${escaped}['"]`, 'g'), - ), - ] - const names: string[] = [] - for (const statement of statements) { - const clause = statement[1]?.trim() ?? '' - const named = /^\{([^}]*)\}$/.exec(clause) - if (!named) return undefined - names.push(...clauseNames(named[1] ?? '')) + origin: string, +): string[] { + // Walked structurally: only node `type` fields and the few properties read + // below are relied on, so the full @babel/types surface is not needed. + const program: unknown = parse(source, { sourceType: 'module' }).program + if (!isNode(program) || !Array.isArray(program.body)) { + throw new Error(`${origin}: could not read the module body`) + } + const findings: string[] = [] + const check = (specifier: string, names: string[] | 'opaque' | 'bare') => { + const matched = HOST_PI_PACKAGE.exec(specifier) + if (!matched) return + const allowed = ALLOWED_HOST_RUNTIME_IMPORTS[`@oh-my-pi/${matched[1]}`] + if (!allowed) { + findings.push( + `${origin}: runtime import from '${specifier}' — port what is needed locally (see packages/pi/src/transcript.ts)`, + ) + return + } + if (names === 'bare') { + findings.push( + `${origin}: side-effect import of '${specifier}' loads the host's copy`, + ) + return + } + if (names === 'opaque') { + findings.push( + `${origin}: '${specifier}' must be imported only by name, not as a namespace, default or dynamic import`, + ) + return + } + for (const name of names) { + if (!allowed.includes(name)) { + findings.push( + `${origin}: '${specifier}' exports '${name}' to this build, but the host's compat surface is not guaranteed to`, + ) + } + } + } + + for (const statement of program.body.filter(isNode)) { + if (statement.type === 'ImportDeclaration') { + const specifier = stringValue(statement.source) + if (specifier === undefined || statement.importKind === 'type') continue + const specifiers = (statement.specifiers as AstNode[]) ?? [] + if (specifiers.length === 0) { + check(specifier, 'bare') + continue + } + if (specifiers.some((entry) => entry.type !== 'ImportSpecifier')) { + check(specifier, 'opaque') + } + check( + specifier, + specifiers + .filter( + (entry) => + entry.type === 'ImportSpecifier' && entry.importKind !== 'type', + ) + .map((entry) => moduleExportName(entry.imported) ?? ''), + ) + } else if (statement.type === 'ExportNamedDeclaration') { + const specifier = stringValue(statement.source) + if (specifier === undefined || statement.exportKind === 'type') continue + const specifiers = (statement.specifiers as AstNode[]) ?? [] + if (specifiers.some((entry) => entry.type !== 'ExportSpecifier')) { + check(specifier, 'opaque') + } + check( + specifier, + specifiers + .filter((entry) => entry.type === 'ExportSpecifier') + .map((entry) => moduleExportName(entry.local) ?? ''), + ) + } else if (statement.type === 'ExportAllDeclaration') { + const specifier = stringValue(statement.source) + if (specifier !== undefined) check(specifier, 'opaque') + } } - return names + + for (const node of walk(program)) { + const argument = + node.type === 'ImportExpression' + ? node.source + : node.type === 'CallExpression' && + isNode(node.callee) && + node.callee.type === 'Import' + ? (node.arguments as unknown[])[0] + : undefined + const specifier = stringValue(argument) + if (specifier !== undefined) check(specifier, 'opaque') + } + + // A module can import the same specifier more than once. + return [...new Set(findings)] } -export async function verifyPiDistRuntimeImports(): Promise { - const distRoot = join(import.meta.dir, '..', 'packages', 'pi', 'dist') +export async function verifyPiDistRuntimeImports( + distRoot = join(import.meta.dir, '..', 'packages', 'pi', 'dist'), +): Promise { let files: string[] try { files = (await readdir(distRoot, { recursive: true })) @@ -82,56 +198,25 @@ export async function verifyPiDistRuntimeImports(): Promise { `Missing ${relative(process.cwd(), distRoot)}: run \`bun run build\` first`, ) } + if (files.length === 0) { + throw new Error( + `No JavaScript in ${relative(process.cwd(), distRoot)}: run \`bun run build\` first`, + ) + } - const transpiler = new Bun.Transpiler({ loader: 'js' }) const findings: string[] = [] for (const file of files) { - const source = await readFile(file, 'utf8') - const origin = relative(distRoot, file) - for (const { path: specifier } of transpiler.scanImports(source)) { - const matched = HOST_PI_PACKAGE.exec(specifier) - if (!matched) continue - const canonical = `@oh-my-pi/${matched[1]}` - const allowed = ALLOWED_HOST_RUNTIME_IMPORTS[canonical] - if (!allowed) { - findings.push( - `${origin}: runtime import from '${specifier}' — port what is needed locally (see packages/pi/src/transcript.ts)`, - ) - continue - } - const names = hostImportNames(source, specifier) - if (!names) { - findings.push( - `${origin}: '${specifier}' must be imported only by name, not as a namespace or default`, - ) - continue - } - for (const name of names) { - if (!allowed.includes(name)) { - findings.push( - `${origin}: '${specifier}' exports '${name}' to this build, but the host's compat surface is not guaranteed to`, - ) - } - } - const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - if ( - names.length === 0 || - new RegExp(`import\\s*['"]${escaped}['"]`).test(source) - ) { - findings.push( - `${origin}: side-effect import of '${specifier}' loads the host's copy`, - ) - } - } + findings.push( + ...findHostImportViolations( + await readFile(file, 'utf8'), + relative(distRoot, file), + ), + ) } if (findings.length > 0) { - // Bun's scanner reports each import statement, so a file importing the same - // specifier twice would otherwise repeat its findings. throw new Error( - `Pi extension reaches into host SDK packages at runtime:\n${[ - ...new Set(findings), - ] + `Pi extension reaches into host SDK packages at runtime:\n${findings .map((finding) => ` ${finding}`) .join('\n')}`, ) From 0dda779e920e7dae581e0e8eed3f1357288dc244 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:27:52 +0200 Subject: [PATCH 09/19] fix(custody): count only physical usage polls toward the header-only bound lastMainPollAttempt was set before the queued fetch acquired the cross-process opencode-main-quota-refresh lock. A contender that lost the lock returned its header cache without calling /api/oauth/usage, yet the header-only entry was then treated as polled for a full interval. Record the attempt inside the acquired lock, immediately before the usage request, so backed-off, superseded and lock-losing contenders do not defer the poll. --- packages/core/src/quota-manager.ts | 8 ++++- .../opencode/src/tests/quota-manager.test.ts | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts index 4dd677023..7574d9465 100644 --- a/packages/core/src/quota-manager.ts +++ b/packages/core/src/quota-manager.ts @@ -941,7 +941,6 @@ export class QuotaManager { generation: number, inflightToken: number, ): Promise { - this.lastMainPollAttempt = { accountId: mainAccountId, at: this.now() } return this._enqueueApiFetch(async () => { try { // Re-check backoff inside gate — may have been set by @@ -979,6 +978,13 @@ export class QuotaManager { } try { const fetchStartedAt = this.now() + // Record only a physical usage request. Backed-off, superseded or + // lock-losing contenders return above without polling and must not + // defer the header-only poll this process still owes. + this.lastMainPollAttempt = { + accountId: mainAccountId, + at: fetchStartedAt, + } const quota = this.fetchQuotaSnapshot ? await this.fetchQuotaSnapshot({ kind: 'main', diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts index 602fdf049..7b3ef2cab 100644 --- a/packages/opencode/src/tests/quota-manager.test.ts +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -2032,6 +2032,36 @@ describe('QuotaManager', () => { expect(qm.isMainStale()).toBe(true) }) + test('losing the cross-process quota lock does not count as a header-only poll attempt', async () => { + let usageCalls = 0 + const qm = createQM((async () => { + usageCalls += 1 + return Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }) + }) as unknown as typeof fetch) + qm.pushMainFromHeaders('token', headerSnapshot()) + expect(qm.isMainStale()).toBe(true) + + // Another process owns the main quota refresh: this contender returns + // its fresh header cache without sending a usage request. + const lock = await acquireRefreshFileLock({ + name: 'opencode-main-quota-refresh', + ttlMs: 30_000, + }) + expect(lock).not.toBeNull() + try { + await qm.refreshMain('token', 'access-token').catch(() => {}) + } finally { + await lock?.release() + } + + expect(usageCalls).toBe(0) + // No physical poll happened, so the header-only entry is still due. + expect(qm.isMainStale()).toBe(true) + }) + test('a poll attempt for a previous main account does not delay the new account first poll', async () => { let usageCalls = 0 const qm = createQM((async () => { From 9a1a49081797d3a16d53c221658327d1028f156e Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:30:07 +0200 Subject: [PATCH 10/19] feat(custody): log Pi scoped 401 decisions and why a retry was skipped Review follow-ups: - Pi's CacheKeep, direct-model and relay retry sites still called isScopedCredentialRotation directly, so Pi decisions never reached the diagnostics line. Route them through decideScopedRetryAfter401 with pi-cachekeep, pi-model and pi-model-relay site labels. - A decision that did not retry now carries a non-secret reason: reauthorize-failed, credential-changed, account-changed or version-unchanged (rotated when it did retry), so a re-authorize that returned an advanced version for another account or credential is distinguishable from an unchanged version. --- packages/core/src/claustrum-scoped.ts | 23 ++++++++++++ .../core/src/tests/claustrum-scoped.test.ts | 29 ++++++++++++++- packages/pi/src/stream.ts | 9 ++--- packages/pi/src/tests/scoped-stream.test.ts | 36 +++++++++++++++++-- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/core/src/claustrum-scoped.ts b/packages/core/src/claustrum-scoped.ts index 4621361f0..abf65ebf2 100644 --- a/packages/core/src/claustrum-scoped.ts +++ b/packages/core/src/claustrum-scoped.ts @@ -49,6 +49,28 @@ export type ScopedRetrySite = | 'cachekeep' | 'prime' | 'quota-profile' + | 'pi-model' + | 'pi-model-relay' + | 'pi-cachekeep' + +/** Why a scoped 401 did or did not retry. Never carries credential material. */ +export type ScopedRetryReason = + | 'rotated' + | 'reauthorize-failed' + | 'credential-changed' + | 'account-changed' + | 'version-unchanged' + +function scopedRetryReason( + served: ClaustrumScopedAttempt, + current: ClaustrumScopedAttempt | undefined, +): ScopedRetryReason { + if (current === undefined) return 'reauthorize-failed' + if (current.credentialId !== served.credentialId) return 'credential-changed' + if (current.accountId !== served.accountId) return 'account-changed' + if (current.recordVersion === served.recordVersion) return 'version-unchanged' + return 'rotated' +} /** * Decide whether a request that got a 401 should retry with the freshly @@ -69,6 +91,7 @@ export function decideScopedRetryAfter401( servedVersion: served.recordVersion, currentVersion: current?.recordVersion ?? null, retry, + reason: scopedRetryReason(served, current), }) return retry } diff --git a/packages/core/src/tests/claustrum-scoped.test.ts b/packages/core/src/tests/claustrum-scoped.test.ts index 18bee0ab9..b5ed29ced 100644 --- a/packages/core/src/tests/claustrum-scoped.test.ts +++ b/packages/core/src/tests/claustrum-scoped.test.ts @@ -486,8 +486,16 @@ test('records both arms of a scoped 401 retry decision without credential materi decideScopedRetryAfter401('model', served, rotated), decideScopedRetryAfter401('cachekeep', served, { ...served }), decideScopedRetryAfter401('prime', served, undefined), + decideScopedRetryAfter401('pi-model', served, { + ...rotated, + accountId: 'other-account', + }), + decideScopedRetryAfter401('pi-model-relay', served, { + ...rotated, + credentialId: 'oauth:anthropic:other', + }), ]) - expect(retried).toEqual([true, false, false]) + expect(retried).toEqual([true, false, false, false, false]) expect( records .filter((record) => record.message === 'scoped 401 re-authorized') @@ -499,6 +507,7 @@ test('records both arms of a scoped 401 retry decision without credential materi servedVersion: 7, currentVersion: 8, retry: true, + reason: 'rotated', }, { site: 'cachekeep', @@ -506,6 +515,7 @@ test('records both arms of a scoped 401 retry decision without credential materi servedVersion: 7, currentVersion: 7, retry: false, + reason: 'version-unchanged', }, { site: 'prime', @@ -513,6 +523,23 @@ test('records both arms of a scoped 401 retry decision without credential materi servedVersion: 7, currentVersion: null, retry: false, + reason: 'reauthorize-failed', + }, + { + site: 'pi-model', + credentialId: identity.credentialId, + servedVersion: 7, + currentVersion: 8, + retry: false, + reason: 'account-changed', + }, + { + site: 'pi-model-relay', + credentialId: identity.credentialId, + servedVersion: 7, + currentVersion: 8, + retry: false, + reason: 'credential-changed', }, ]) expect(JSON.stringify(records)).not.toContain('secret') diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index 82fee74c1..d0cff3b11 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -10,6 +10,7 @@ import { type ClaustrumScopedRuntimeOptions, connectClaustrumScopedClient, createStickyNoRouteResponse, + decideScopedRetryAfter401, decideStickyQuotaFailure, dumpDirectRequest, FAST_MODE_BETA, @@ -37,7 +38,6 @@ import { isKillswitchEnabled, isOAuthAccount, isPermanentRefreshError, - isScopedCredentialRotation, isValidApiBaseURL, killswitchPassesPolicy, loadAccounts, @@ -364,7 +364,8 @@ const cacheKeepManager = new CacheKeepManager({ } catch { return undefined } - if (!isScopedCredentialRotation(entry.receipt, current)) return undefined + if (!decideScopedRetryAfter401('pi-cachekeep', entry.receipt, current)) + return undefined logger.info( 'claustrum', 'retrying Pi CacheKeep after scoped credential rotation', @@ -700,7 +701,7 @@ async function sendAnthropicRequest(options: { // A failed lookup is not proof of a replacement: report the token // that received the genuine 401 below, without using local material. } - if (isScopedCredentialRotation(attempt, rotated)) { + if (decideScopedRetryAfter401('pi-model', attempt, rotated)) { await dumpDirectRequest({ affinity: relayAffinity, route: options.route ?? 'oauth', @@ -806,7 +807,7 @@ async function sendAnthropicRequest(options: { } catch { // Report the actual rejected record below if no replacement can be read. } - if (isScopedCredentialRotation(relay401Attempt, rotated)) { + if (decideScopedRetryAfter401('pi-model-relay', relay401Attempt, rotated)) { await response.body?.cancel().catch(() => {}) logger.info('claustrum', 'retrying after scoped credential rotation', { accountId: routeId, diff --git a/packages/pi/src/tests/scoped-stream.test.ts b/packages/pi/src/tests/scoped-stream.test.ts index 67c71e0cb..c2c88e3bb 100644 --- a/packages/pi/src/tests/scoped-stream.test.ts +++ b/packages/pi/src/tests/scoped-stream.test.ts @@ -3,11 +3,15 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { + __setLogTestSink, type ClaustrumScopedClient, + getLogLevel, + type LogTestRecord, loadAccounts, saveAccounts, setAccountEnabledPersistent, setClaustrumModePersistent, + setLogLevel, } from '@cortexkit/anthropic-auth-core' import { ClaustrumCredentialError, @@ -570,15 +574,41 @@ test.each([200, 401])( }, { preconnect: originalFetch.preconnect }, ) - const result = await streamCortexKitAnthropic(model, context, { - sessionId: 'pi-rotation', - }).result() + const records: LogTestRecord[] = [] + const previousLevel = getLogLevel() + setLogLevel('debug') + __setLogTestSink((record) => records.push(record)) + let result: Awaited< + ReturnType['result']> + > + try { + result = await streamCortexKitAnthropic(model, context, { + sessionId: 'pi-rotation', + }).result() + } finally { + __setLogTestSink(null) + setLogLevel(previousLevel) + } expect(result.stopReason).toBe(finalStatus === 401 ? 'error' : 'stop') expect(sent).toEqual(['Bearer rotated-pi-1', 'Bearer rotated-pi-2']) expect(gets).toEqual([1, 2]) expect(f.reports.map((report) => report.recordVersion)).toEqual( finalStatus === 401 ? [2] : [], ) + // Pi's direct-model retry decision reaches the shared diagnostics line. + expect( + records + .filter((record) => record.message === 'scoped 401 re-authorized') + .map((record) => record.payload), + ).toContainEqual( + expect.objectContaining({ + site: 'pi-model', + servedVersion: 1, + currentVersion: 2, + retry: true, + reason: 'rotated', + }), + ) }, ) From 540eb11c5fb257fcf398383097295f721a985457 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:43:12 +0200 Subject: [PATCH 11/19] fix(custody): record reauthorize-failed at the CacheKeep 401 retry sites OpenCode and Pi CacheKeep returned early when re-authorization threw, before the retry decision was logged. The original 401 was still reported, but the diagnostic timeline did not say why no retry happened. Fall through to decideScopedRetryAfter401 with no current receipt, which records reauthorize-failed and declines the retry as before. The other five decision sites already fell through. --- packages/opencode/src/index.ts | 3 +- .../tests/fallback-token-use-sites.test.ts | 117 ++++++++++++++++++ packages/pi/src/stream.ts | 3 +- 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 0a8a34d7c..e4cff7b48 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -2265,7 +2265,8 @@ const anthropicAuthPlugin = async ( ctx.directory, ).authorize(target.oauthAccountId ?? 'main', attempt.signal) } catch { - return undefined + // No verified replacement: fall through so the decision records + // reauthorize-failed, then keep the original 401. } if (!decideScopedRetryAfter401('cachekeep', served, current)) return undefined diff --git a/packages/opencode/src/tests/fallback-token-use-sites.test.ts b/packages/opencode/src/tests/fallback-token-use-sites.test.ts index 71ef1c947..945affd01 100644 --- a/packages/opencode/src/tests/fallback-token-use-sites.test.ts +++ b/packages/opencode/src/tests/fallback-token-use-sites.test.ts @@ -3,14 +3,18 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { + __setLogTestSink, type AccountStorage, CACHE_KEEP_TICK_MS, type ClaustrumScopedClient, custodyTombstoneOAuth, + getLogLevel, + type LogTestRecord, resetCache1hState, resetClaudeCodeIdentityCachesForTest, saveAccountState, saveAccounts, + setLogLevel, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin } from '../index' @@ -807,6 +811,119 @@ describe('scoped maintenance rotation recovery', () => { () => checkCacheKeepRotation(401), ) + test.serial( + 'CacheKeep records reauthorize-failed and reports the served version when re-authorization throws', + async () => { + const now = 1_000 + const fixture = await createFixture('main-cachekeep', { + now, + cachekeep: true, + captureIntervals: true, + mainFirst: true, + }) + await ( + await fixture.result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'cachekeep-reauthorize-failed' }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + system: [ + { + type: 'text', + text: 'stable', + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [{ role: 'user', content: 'hello' }], + }), + }) + ).text() + resetClaudeCodeIdentityCachesForTest() + const reports: number[] = [] + const sent: string[] = [] + let failReauthorize = false + const originalGet = fixture.scopedClient.getScoped.bind( + fixture.scopedClient, + ) + fixture.scopedClient.getScoped = async (input) => { + if (failReauthorize && input.credentialId === 'oauth:anthropic') { + throw new Error('vault unavailable') + } + const receipt = await originalGet(input) + return input.credentialId === 'oauth:anthropic' + ? { + ...receipt, + material: 'scoped-cachekeep-v103', + recordVersion: 103, + } + : receipt + } + fixture.scopedClient.reportAuthFailureScoped = async ({ + recordVersion, + }) => { + reports.push(recordVersion) + } + const originalRequest = globalThis.fetch + globalThis.fetch = mock((input: unknown, init?: RequestInit) => { + const requestBody = typeof init?.body === 'string' ? init.body : '' + if ( + extractUrl(input as string | URL | Request).includes( + '/v1/messages', + ) && + (JSON.parse(requestBody) as { max_tokens?: number }).max_tokens === 0 + ) { + sent.push(new Headers(init?.headers).get('authorization') ?? '') + failReauthorize = true + return Promise.resolve( + new Response('token rejected', { status: 401 }), + ) + } + return originalRequest(input as Parameters[0], init) + }) as unknown as typeof fetch + + const records: LogTestRecord[] = [] + const previousLevel = getLogLevel() + setLogLevel('debug') + __setLogTestSink((record) => records.push(record)) + try { + ;( + fixture.intervals as IntervalRecord[] & { + clock: (now: number) => void + } + ).clock(now + 55 * 60_000) + const interval = fixture.intervals.find( + (candidate) => candidate.ms === CACHE_KEEP_TICK_MS, + ) + if (!interval) throw new Error('CacheKeep interval missing') + interval.callback() + await waitFor( + () => reports.length >= 1, + 'CacheKeep 401 was not reported after re-authorization failed', + ) + } finally { + __setLogTestSink(null) + setLogLevel(previousLevel) + } + + expect(sent).toEqual(['Bearer scoped-cachekeep-v103']) + expect(reports).toEqual([103]) + expect( + records + .filter((record) => record.message === 'scoped 401 re-authorized') + .map((record) => record.payload), + ).toContainEqual( + expect.objectContaining({ + site: 'cachekeep', + servedVersion: 103, + currentVersion: null, + retry: false, + reason: 'reauthorize-failed', + }), + ) + }, + ) + test.serial( 'Prime fires only once after in-flight rotation and reports the final rejected version', async () => { diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index d0cff3b11..f5f785bf2 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -362,7 +362,8 @@ const cacheKeepManager = new CacheKeepManager({ attempt.signal, ) } catch { - return undefined + // No verified replacement: fall through so the decision records + // reauthorize-failed, then keep the original 401. } if (!decideScopedRetryAfter401('pi-cachekeep', entry.receipt, current)) return undefined From eed580439f1b20d9189ed04e1c00d78adef5b23e Mon Sep 17 00:00:00 2001 From: ualtinok Date: Thu, 24 Sep 2026 18:55:53 +0200 Subject: [PATCH 12/19] build(core): upgrade Claustrum client to 0.4.0 --- CHANGELOG.md | 1 + bun.lock | 6 +++--- packages/core/package.json | 2 +- packages/opencode/CHANGELOG.md | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df96c532..7eee17005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Patch Changes +- Update the shared Claustrum client to 0.4.0 (with Subc client 0.16.x) for scoped custody. The client no longer reconnects on a terminal `unknown_module` response from a daemon without the Claustrum module. - Show an approved Claustrum enrollment as active in `/claude-account` and the account modal when the main account is vault-served; stop telling already configured users to rerun setup or request a grant. Clarify that `enrollment-reset` only clears terminal state. - Fix packed OpenCode CLI startup under Node: keep `jsonc-parser` external so its CommonJS `./impl/*` modules resolve from the installed dependency, and run the packed CLI's `--help` path alongside the TUI smoke gate (#257). - Stop unsolicited Claustrum enrollment on OpenCode boot and account-status views: only explicit offline setup proposes and polls, while `/claude-account enrollment-reset` clears terminal state under lock without starting another request. Setup resumes crash-persisted secrets and replaces one daemon-confirmed dead request; all producer-permanent refusal codes stop polling even when older client transports mislabel them retryable. This removes the per-process enrollment poll loop (#255) without changing scoped account-discovery polling. diff --git a/bun.lock b/bun.lock index 078fccb60..63d7b9dec 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ "name": "@cortexkit/anthropic-auth-core", "version": "1.23.0", "dependencies": { - "@cortexkit/claustrum-client": "0.3.0", + "@cortexkit/claustrum-client": "0.4.0", "xxhash-wasm": "^1.1.0", }, }, @@ -211,7 +211,7 @@ "@cortexkit/anthropic-auth-e2e": ["@cortexkit/anthropic-auth-e2e@workspace:packages/e2e-tests"], - "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.3.0", "", { "dependencies": { "@cortexkit/subc-client": "^0.8.1" } }, "sha512-vX28AlhG6vsk2PDNIQMkMmcP2gg+tcODuuz/xppVyLnveNL8aVZlZll1TkT1T9UMKeLDk/vvfOkeMSAC9ZaKGA=="], + "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.4.0", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-xjUXf/lB3T8R7oaOu9T1Obnn47bAdN5UbyaTpywyiY4wdvTbqHBnB3qX0KpDFTeZ7m35tRPDevcWvOzJIgUhMw=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], @@ -829,7 +829,7 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@cortexkit/claustrum-client/@cortexkit/subc-client": ["@cortexkit/subc-client@0.8.1", "", {}, "sha512-8U9w3AnSff0QYlLVzcKOuTULakRtVpcGJrquGHxOQQlWZGzXZdCs8nroLMeoM2xhFGwxIh8xFk832w1KG6akAA=="], + "@cortexkit/claustrum-client/@cortexkit/subc-client": ["@cortexkit/subc-client@0.16.1", "", {}, "sha512-TJqOcfMVxhCfZMANmEH/9w+L+zYxFSy5p5Zl/shDErtGSeUF6HFiwZ7OR/P2qFvpSg/yxCbsdenZ7rs5SE6fhA=="], "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], diff --git a/packages/core/package.json b/packages/core/package.json index 536c320db..2b248a7d5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,7 +28,7 @@ "prepublishOnly": "bun run build" }, "dependencies": { - "@cortexkit/claustrum-client": "0.3.0", + "@cortexkit/claustrum-client": "0.4.0", "xxhash-wasm": "^1.1.0" } } diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 8ebdacfad..ee9fdc922 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -10,6 +10,7 @@ This package is a CortexKit-maintained fork of the original `@ex-machina/opencod ### Patch Changes +- Update scoped Claustrum transport through the shared 0.4.0 client; a daemon that does not serve the Claustrum module now fails without a futile reconnect. - Correct approved enrollment status in `/claude-account` and the TUI account dialog when the main account is vault-served; do not prompt configured users to rerun setup, and describe `enrollment-reset` as a state clear rather than an automatic retry. - Fix the packed CLI failing before `setup` under Node by externalizing `jsonc-parser` from the split bundle and checking a fresh packed CLI install in the smoke gate (#257). - Make Claustrum enrollment explicit: OpenCode boot and `/claude-account` no longer start proposals or per-process polling; status reads local state, and reset only clears terminal state. The setup wizard resumes interrupted enrollment and replaces a daemon-proven dead request at most once. Permanently refused requests now stop even if the client labels them retryable (#255). From af39724c183d17f94e13403d6189a9c93439156a Mon Sep 17 00:00:00 2001 From: ualtinok Date: Thu, 24 Sep 2026 19:07:40 +0200 Subject: [PATCH 13/19] ci(pi): guard all emitted host imports in release gates --- .github/workflows/release.yaml | 6 +++++ ARCHITECTURE.md | 8 +++--- CHANGELOG.md | 3 +++ STRUCTURE.md | 5 ++-- packages/opencode/CHANGELOG.md | 2 ++ scripts/check-pi-dist-imports.test.ts | 38 ++++++++++++++++++++++++++- scripts/check-pi-dist-imports.ts | 12 ++++++++- 7 files changed, 66 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a3dcaf452..98a119174 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -54,6 +54,12 @@ jobs: - name: Build run: bun run build + - name: Pi host-import guard tests + run: bun test scripts/check-pi-dist-imports.test.ts + + - name: Pi host-import guard + run: bun run check:pi-dist-imports + - name: Packed TUI smoke test run: bun run --cwd packages/opencode smoke:tui env: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 57a6f768b..4a4849afa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,7 +30,7 @@ **@cortexkit/pi-anthropic-auth (Pi Extension):** - Purpose: Pi package that registers a CortexKit Anthropic provider override under Pi's built-in `anthropic` provider ID - Location: `packages/pi/src/` -- Contains: Extension entry point (`index.ts`), command registration (`commands.ts`), request body conversion (`convert.ts`), compaction-aware Fable 5.1 effort history (`effort-history.ts`), Pi-specific path resolution (`paths.ts`), streaming provider implementation (`stream.ts`) +- Contains: Extension entry point (`index.ts`), command registration (`commands.ts`), request body conversion (`convert.ts`), host-independent transcript replay (`transcript.ts`), compaction-aware Fable 5.1 effort history (`effort-history.ts`), Pi-specific path resolution (`paths.ts`), streaming provider implementation (`stream.ts`) - Depends on: `@cortexkit/anthropic-auth-core`, `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui` (all peer) - Used by: Pi agent (loaded as Pi package) @@ -48,7 +48,7 @@ 3. **Command registration** — Plugin registers `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-start`, `/claude-quota`, `/claude-dump`, `/claude-fast`, `/claude-routing`, `/claude-killswitch`, `/claude-account`, `/claude-logging` — `packages/opencode/src/index.ts` (config hook) 4. **Request interception** — OpenCode's messages hook observes messages to mark effort transitions and assign user-turn prompt UUIDs, `chat.headers` injects internal correlation headers (`x-cortexkit-effort-plan`, `x-cortexkit-billing-lineage`), and OpenCode's fetch wrapper intercepts Anthropic fetch calls, resolving and stripping internal headers before dispatch — `packages/opencode/src/index.ts`, `packages/opencode/src/billing-lineage.ts` (experimental fetch wrapping) 5. **URL rewrite** — `rewriteUrl()` adds `?beta=true` to `/v1/messages`, normalizes versioned base paths without duplicating version segments, and overrides base URL when `ANTHROPIC_BASE_URL` or a proxy route `baseURL` is set — `packages/opencode/src/transform.ts` -6. **Request body rewrite** — `rewriteRequestBody()` strips trailing assistant messages and trailing whitespace after tool_use, normalizes Fable/Mythos 5/5.1, Sonnet 5, Opus 5, and Opus 5.5 adaptive thinking (enforcing always-on adaptive thinking on Opus 5.5 and normalizing `[1m]` context-window qualifiers via `normalizeAnthropicModelId`), injects the Claude Code 2.1.258 billing header with a bounded process-local suffix pin derived from the initial visible user text and optional request lineage (`cc_prev_req` and `cc_prompt_id`), sanitizes the system prompt (removes OpenCode identity), prepends Claude Code identity, applies cache strategy (explicit/automatic/hybrid), adds fast mode, remaps model names on proxy routes using `ANTHROPIC_DEFAULT_*_MODEL` tier-specific or `ANTHROPIC_MODEL` generic overrides, prefixes tool names with `mcp_`, opts eligible OAuth Fable 5/5.1, Opus 5, and Opus 5.5 requests into Anthropic server-side safety fallback, restores stored fallback boundary markers, conditionally applies the configured Fable 5.1 thinking-prefix behavior only when signed or redacted thinking remains in the outgoing history, keeps top-level Fable 5.1 effort anchored to the earliest retained turn while inserting later effort changes as empty system markers, applies cache diagnostics opt-in (`diagnostics.previous_message_id`), shapes a correlated OAuth lane-start request to one output token without thinking, strips request lineage before reusing bodies in source cache warming or prewarming, and creates `cch` over the final serialized body — `packages/opencode/src/transform.ts`, `packages/opencode/src/server-fallback.ts`, `packages/core/src/thinking-binding.ts`, `packages/core/src/model-remap.ts`, `packages/opencode/src/cache-diagnostics.ts`, `packages/core/src/cch.ts` +6. **Request body rewrite** — `rewriteRequestBody()` strips trailing assistant messages and trailing whitespace after tool_use, normalizes Fable/Mythos 5/5.1, Sonnet 5, Opus 5, and Opus 5.5 adaptive thinking (enforcing always-on adaptive thinking on Opus 5.5 and normalizing `[1m]` context-window qualifiers via `normalizeAnthropicModelId`), injects the Claude Code 2.1.280 billing header with a bounded process-local suffix pin derived from the initial visible user text and optional request lineage (`cc_prev_req` and `cc_prompt_id`), sanitizes the system prompt (removes OpenCode identity), prepends Claude Code identity, applies cache strategy (explicit/automatic/hybrid), adds fast mode, remaps model names on proxy routes using `ANTHROPIC_DEFAULT_*_MODEL` tier-specific or `ANTHROPIC_MODEL` generic overrides, prefixes tool names with `mcp_`, opts eligible OAuth Fable 5/5.1, Opus 5, and Opus 5.5 requests into Anthropic server-side safety fallback, restores stored fallback boundary markers, conditionally applies the configured Fable 5.1 thinking-prefix behavior only when signed or redacted thinking remains in the outgoing history, keeps top-level Fable 5.1 effort anchored to the earliest retained turn while inserting later effort changes as empty system markers, applies cache diagnostics opt-in (`diagnostics.previous_message_id`), shapes a correlated OAuth lane-start request to one output token without thinking, strips request lineage before reusing bodies in source cache warming or prewarming, and creates `cch` over the final serialized body — `packages/opencode/src/transform.ts`, `packages/opencode/src/server-fallback.ts`, `packages/core/src/thinking-binding.ts`, `packages/core/src/model-remap.ts`, `packages/opencode/src/cache-diagnostics.ts`, `packages/core/src/cch.ts` 7. **Routing** — `shouldFallbackStatus()` checks if response should trigger fallback; `FallbackAccountManager` iterates accounts in ordered modes, while `StickySessionRouter` assigns cold sessions by reset-normalized spendable OAuth quota and weighted initial-prompt deficit, then persists hashed session affinity across processes/restarts. Sticky routes retain transient failures, including DNS-related refresh backoffs on a fixed five-minute retry interval while the fallback's current access token remains valid, hold confirmed 5h exhaustion when reset is within 15 minutes, and migrate for longer confirmed exhaustion/permanent account failure. All modes respect model-scoped quotas and killswitch thresholds (including per-model scoped thresholds). API-key routes remain excluded until fresh general OAuth exhaustion is confirmed; model-scoped exhaustion can move only among OAuth accounts. A complete sticky pool with no eligible route returns an explicit 401 for a permanent main-account authentication failure, an explicit 401 naming fallback OAuth accounts that require re-login, or a quota-policy 429 rather than falling through to an excluded account; incomplete pools fail retryably. Killswitch blocks are classified as scoped-driven (matching a specific model's weekly limit) or account-level (5h/7d limits) with a model-specific or generic retry hint — `packages/core/src/routing.ts`, `packages/core/src/sticky-routing.ts`, `packages/core/src/accounts.ts`, `packages/opencode/src/index.ts` 8. **Relay** — `sendViaRelay()` sends full or patched body to Cloudflare Worker, which streams Anthropic response back. HTTP relay responses and WebSocket `response_start` control messages deliver genuine upstream headers to the OpenCode account-bound quota harvester and billing lineage tracker (committing upstream `req_*` request IDs for subsequent session turns); synthetic optimistic response headers are never harvest input, and relay-to-direct fallback stays owned by the direct path — `packages/core/src/relay.ts`, `packages/opencode/src/index.ts` 9. **SSE stream and content-filter safety fallback** — By default, OAuth Fable 5/5.1 and Opus 5 requests send `fallbacks: "default"` with Anthropic's `server-side-fallback-2026-07-01` beta. The stream wrapper detects initial `fallback` blocks, sticky `fallback_message` usage iterations, and restoration to the requested model. Because OpenCode does not natively preserve `fallback` content blocks, the wrapper rewrites each block into a hidden signed thinking marker for storage, then the next eligible request restores the exact Anthropic fallback boundary before signing and sending. If an eligible OAuth response still terminates in refusal, the deterministic 10-successful-response Opus 4.8 recovery activates as a client-side backstop. Source-model prewarms explicitly remove `fallbacks` so they cannot be sticky-routed back to a fallback model. If a served fallback completes a `tool_use` before its terminal refusal, the wrapper preserves that completed call, changes the terminal finish to `tool_use`, and lets OpenCode continue on Opus 4.8 with the existing `tool_result` instead of replaying the tool. The active Anthropic-selected target and later restoration are written per session to the TUI sidebar state; when no matching TUI is connected, OpenCode Desktop receives an ignored/no-reply `promptAsync` notice that the plugin schedules delivery as soon as `session.idle` is observed instead of waiting for a later `session.updated` event, which OpenCode 1.18 no longer emits after idle; delivery still escapes the awaited event handler with `setImmediate` because OpenCode evaluates the run-loop exit condition only after awaiting event handlers. Intermediate `tool-calls` completions do not flush notices. When a refusal arms client recovery, the Desktop switch notice is not queued until the first downgraded Opus response completes successfully, so a transient idle state between the refused source response and OpenCode's internal retry cannot insert a user message or consume the retry response. The plugin probes `session.status()` outside that critical section and revalidates its idle-delivery lease after both the asynchronous status lookup and prompt-context lookup so a new prompt cannot adopt the ignored notice as its retry parent. A newly created user-message event revokes that lease before OpenCode's later busy-status event, while repeated updates for the user message that produced the current idle event do not suppress delivery; if multiple fallback state changes overtake delivery, only the newest notice is retained. Delivered notice message IDs are tracked per session (bounded to four IDs across at most 128 recently used sessions) and excluded from user-message lease revocation so the plugin's own notice cannot cancel its successor; delivery additionally requires the notice still head its queue, with a superseded queue looping to the newest instead of dropping it. Inconclusive probes re-arm for at most four attempts, with attempt 0 on `setImmediate` and later attempts delayed by `25` ms multiplied by the attempt number, and requires message-ID placement before the active assistant; consecutive notices use strictly increasing IDs bounded by that assistant, taking both host history and the latest locally prepared notice into account. If no safe ID can be allocated, delivery stays queued rather than letting the host mint an untracked ID. IDs are registered before dispatch so the notice's own user-message event cannot revoke its delivery lease. This removes the unsafe host-assigned-ID fallback when placement fails. Custom API-key routes strip the beta and `fallbacks` field. Setting `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` bypasses the server policy and uses the same deterministic Opus 4.8 recovery from the first refusal, keyed by session and source-model family with account-bound source-model prewarming and standby cache bridges beyond Anthropic's 20-block lookback — `packages/opencode/src/transform.ts`, `packages/opencode/src/server-fallback.ts`, `packages/opencode/src/fable-fallback.ts`, `packages/opencode/src/prompt-context.ts`, `packages/opencode/src/index.ts` @@ -58,14 +58,14 @@ 1. **Extension load** — Pi loads `@cortexkit/pi-anthropic-auth` package, which calls `registerCommands()` and `pi.registerProvider("anthropic", ...)`, and hooks `turn_start` to derive Fable 5.1 effort history directly from the session branch (deriving compaction trims locally when `buildContextEntries()` is absent) — `packages/pi/src/index.ts`, `packages/pi/src/effort-history.ts` 2. **Provider registration** — Provider defines OAuth login/refresh functions (delegating to core's `authorize`/`exchange`/`refreshClaudeOAuthToken`), exposes the CortexKit model catalog including Fable 5.1, limited-access Mythos 5.1, Opus 5, and Opus 5.5, and registers a `streamSimple` function — `packages/pi/src/index.ts` -3. **Request conversion** — `buildAnthropicRequest()` builds the Claude Code-compatible body and signature. Flattens host system prompts whether provided as strings or ordered prompt blocks (e.g. Oh My Pi string/text block arrays) on blank lines so the paragraph split classifies identical text. The recognized Pi documentation paragraph is kept out of top-level `system[]`—where its path-enumeration/cross-reference text can cause OAuth billing rejection—and is prepended as a separately cached block before the first user's text. If Pi changes the documentation heading or prompt layout, the converter moves the complete host prompt to that safe cached block rather than restoring the rejected top-level shape. Signed thinking is replayed only when the stored assistant message came from the same Anthropic API and model as the current request, matching Pi's built-in origin check; foreign signatures are stripped before Anthropic validation while visible reasoning is retained as ordinary text. Pi redacted reasoning maps losslessly to Anthropic `redacted_thinking`. The converter pins the first-user billing suffix per Pi session, places cache breakpoints without exceeding 4 blocks by walking cache control holders and keeping automatic and hybrid placement separate, and an OAuth Fable 5.1 continuation with replayed signed or redacted thinking adds `thinking.block_binding.prefix_mismatch_behavior = "drop_block"` plus the matching beta so compaction drops stale thinking instead of rejecting the request; first turns and API-key routes receive neither control. — `packages/pi/src/convert.ts`, `packages/core/src/thinking-binding.ts` +3. **Request conversion** — `buildAnthropicRequest()` builds the Claude Code-compatible body and signature. Pi's transcript-replay helpers live in `packages/pi/src/transcript.ts` rather than being imported from the host SDK at runtime, because Oh My Pi's legacy Pi SDK shim does not export them; the emitted Pi bundle's host imports are syntax-checked in CI and release. Flattens host system prompts whether provided as strings or ordered prompt blocks (e.g. Oh My Pi string/text block arrays) on blank lines so the paragraph split classifies identical text. The recognized Pi documentation paragraph is kept out of top-level `system[]`—where its path-enumeration/cross-reference text can cause OAuth billing rejection—and is prepended as a separately cached block before the first user's text. If Pi changes the documentation heading or prompt layout, the converter moves the complete host prompt to that safe cached block rather than restoring the rejected top-level shape. Signed thinking is replayed only when the stored assistant message came from the same Anthropic API and model as the current request, matching Pi's built-in origin check; foreign signatures are stripped before Anthropic validation while visible reasoning is retained as ordinary text. Pi redacted reasoning maps losslessly to Anthropic `redacted_thinking`. The converter pins the first-user billing suffix per Pi session, places cache breakpoints without exceeding 4 blocks by walking cache control holders and keeping automatic and hybrid placement separate, and an OAuth Fable 5.1 continuation with replayed signed or redacted thinking adds `thinking.block_binding.prefix_mismatch_behavior = "drop_block"` plus the matching beta so compaction drops stale thinking instead of rejecting the request; first turns and API-key routes receive neither control. — `packages/pi/src/convert.ts`, `packages/core/src/thinking-binding.ts` 4. **Stream implementation** — `streamCortexKitAnthropic()` in `packages/pi/src/stream.ts` sends via relay or direct, handles ordered or persistent sticky-balanced routing (including model-scoped OAuth routing and the confirmed-general-exhaustion gate for API-key routes), configures version-normalized base paths (`buildExplicitBaseMessagesUrl`) and safe proxy custom headers (`applyCustomHeaders`) for API-key routes, manages cache keepalive, maps returned Claude Code tool names against the same normalized host tool set used to build the outgoing request (Pi ≥0.86 puts tools in transcript system messages rather than `context.tools`), and converts streamed `redacted_thinking` blocks into Pi's replayable redacted reasoning representation 5. **Slash commands** — `/claude-*` commands registered in `packages/pi/src/commands.ts` reuse core command execution functions **Quota Refresh & Header Harvest Flow:** 1. Background timer fires at `checkIntervalMinutes` (default 5) — `packages/core/src/quota-manager.ts` 2. `QuotaManager.refreshMain()` fetches `https://api.anthropic.com/api/oauth/usage` (including standard five-hour/seven-day windows and weekly scoped model limits) with the access token, but keys the result and its backoff to the stable account identity rather than the rotating token -3. On success: persists the account-identity-bound quota snapshot (including a top-level `checkedAt` freshness timestamp to support merge resolution of windowless empty-scoped quotas) to the sidecar state file and updates sidebar state — `packages/opencode/src/index.ts` (onMainQuotaFetched callback) +3. On success: persists the account-identity-bound quota snapshot (including a top-level `checkedAt` freshness timestamp to support merge resolution of windowless empty-scoped quotas) to the sidecar state file and updates sidebar state. Scoped custody keys main by the discovered primary account UUID rather than the local host slot; an authorized receipt is rejected if the roster primary changes before identity binding. When main has only header-sourced 5h/7d values, it stays due for a first usage poll to learn model-scoped limits. Failed physical polls are spaced per account; lock-losing contenders do not consume that interval — `packages/opencode/src/index.ts`, `packages/core/src/quota-manager.ts` 4. On 429: records backoff with `nextRetryAt` timestamp — prevents further refreshes during backoff — `packages/core/src/accounts.ts` 5. Quota-endpoint 403 policy/auth failures bypass quota and refresh backoff rather than being misclassified as rate limits — `packages/core/src/accounts.ts`, `packages/core/src/quota-manager.ts` 6. Fallback accounts: `FallbackAccountManager` refreshes per-account quotas in background, persists to state, and notifies the sidebar. Persisted fallback quota cache seeds use the freshest timestamp across top-level, standard, and model-scoped windows, preventing an unnecessary usage fetch when only fresh scoped quota data is available. Concurrent refresh-lock waiters preserve the original classified refresh failure rather than replacing a permanent `invalid_grant` with an unclassified wrapper error — `packages/core/src/accounts.ts`, `packages/core/src/quota-manager.ts`, `packages/opencode/src/index.ts` diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eee17005..37d5de119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Patch Changes +- Persist scoped main quota under the discovered primary account rather than the local host slot, and let `/claude-quota` poll with a freshly authorized scoped credential. A header-only main quota remains due for its first usage poll; failed polls are spaced by account without treating usage 403 as rate-limit backoff or counting requests that lose the cross-process refresh lock. +- Load the Pi extension through Oh My Pi's legacy SDK compatibility layer by keeping transcript system-prompt and tool replay in the extension instead of importing helpers absent from the host. Check built Pi imports with a syntax-aware allowlist in CI and release workflows. +- Add debug diagnostics for scoped 401 retry decisions and delivered failure reports across OpenCode and Pi, recording served and current record versions and a non-secret decision reason without logging bearer material. - Update the shared Claustrum client to 0.4.0 (with Subc client 0.16.x) for scoped custody. The client no longer reconnects on a terminal `unknown_module` response from a daemon without the Claustrum module. - Show an approved Claustrum enrollment as active in `/claude-account` and the account modal when the main account is vault-served; stop telling already configured users to rerun setup or request a grant. Clarify that `enrollment-reset` only clears terminal state. - Fix packed OpenCode CLI startup under Node: keep `jsonc-parser` external so its CommonJS `./impl/*` modules resolve from the installed dependency, and run the packed CLI's `--help` path alongside the TUI smoke gate (#257). diff --git a/STRUCTURE.md b/STRUCTURE.md index 87c3d0d88..3d9c9503f 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -60,7 +60,7 @@ anthropic-auth/ **`packages/pi/src/`:** - Purpose: Pi extension — registers CortexKit Anthropic provider override - Contains: Extension entry point, command registration, request building, streaming provider -- Key files: `index.ts` (provider and model-catalog registration), `stream.ts` (streaming request handling including redacted-thinking preservation), `commands.ts` (slash command registration), `convert.ts` (Claude Code-compatible request conversion, bounded process-local billing-suffix pinning, origin-aware thinking-signature filtering, configurable Fable 5.1 prefix behavior, mid-conversation effort markers, redacted-thinking replay, Pi documentation-prompt relocation, and cache breakpoint placement), `effort-history.ts` (compaction-aware Pi thinking-level timeline), `paths.ts` (Pi-specific path resolution) +- Key files: `index.ts` (provider and model-catalog registration), `stream.ts` (streaming request handling including redacted-thinking preservation), `commands.ts` (slash command registration), `convert.ts` (Claude Code-compatible request conversion, bounded process-local billing-suffix pinning, origin-aware thinking-signature filtering, configurable Fable 5.1 prefix behavior, mid-conversation effort markers, redacted-thinking replay, Pi documentation-prompt relocation, and cache breakpoint placement), `transcript.ts` (host-independent system-prompt and tool replay for Pi and Oh My Pi), `effort-history.ts` (compaction-aware Pi thinking-level timeline), `paths.ts` (Pi-specific path resolution) **`packages/e2e-tests/`:** - Purpose: Integration tests with mock Anthropic, relay, and Claustrum servers @@ -68,7 +68,7 @@ anthropic-auth/ **`scripts/`:** - Purpose: Development, release, and analysis utilities -- Contains: `dev.ts` / `dev-clean.ts` (local dev workflow with symlinks), `release.sh` / `wait-release.sh` (tag-driven npm release), `analyze-cache-usage.mjs` (OpenCode SQLite cache analyzer), `check-claustrum-golden.ts` (pins canonical Claustrum tombstone ancestry and exact fixture bytes), `check-pi-tool-mapping.ts` (runs an isolated real Pi host tool round-trip against deterministic SSE), `check-test-count-floors.ts` (runs unit suites once; compares floors against the merge target in PR CI and the previous version tag at release), `extract-system-prompt.ts` (prompt capture extraction), `capture-with-mitmproxy.sh` (HTTPS capture setup), `version-sync.mjs` / `workspace-lock.mjs` (cross-package versions, surgical Bun lockfile synchronization, and manifest-to-lock validation) +- Contains: `dev.ts` / `dev-clean.ts` (local dev workflow with symlinks), `release.sh` / `wait-release.sh` (tag-driven npm release), `analyze-cache-usage.mjs` (OpenCode SQLite cache analyzer), `check-claustrum-golden.ts` (pins canonical Claustrum tombstone ancestry and exact fixture bytes), `check-pi-tool-mapping.ts` (runs an isolated real Pi host tool round-trip against deterministic SSE), `check-pi-dist-imports.ts` (AST-validates emitted Pi host-SDK imports, rejecting unsupported or unverifiable dynamic imports in CI and release), `check-test-count-floors.ts` (runs unit suites once; compares floors against the merge target in PR CI and the previous version tag at release), `extract-system-prompt.ts` (prompt capture extraction), `capture-with-mitmproxy.sh` (HTTPS capture setup), `version-sync.mjs` / `workspace-lock.mjs` (cross-package versions, surgical Bun lockfile synchronization, and manifest-to-lock validation) ## Key File Locations @@ -148,6 +148,7 @@ anthropic-auth/ - `packages/pi/src/commands.ts`: Pi slash command registration (`/claude-*`) and persistent setting handlers - `packages/pi/src/paths.ts`: Pi-specific storage and config path resolution - `packages/pi/src/convert.ts`: Pi-to-Anthropic request conversion, including ordered system prompt block flattening, session-stable Claude Code billing suffixes, same-origin thinking-signature replay, configurable Fable 5.1 compaction behavior, mid-conversation effort markers, redacted_thinking mapping, Pi documentation-prompt relocation, and four-slot cache breakpoint placement +- `packages/pi/src/transcript.ts`: Locally replays host system messages and tool changes rather than importing helpers absent from Oh My Pi's legacy SDK shim - `packages/pi/src/effort-history.ts`: Rebuilds Fable 5.1 effort transitions from Pi's active branch and compaction-aware context entries (locally derived when buildContextEntries is absent) - `packages/pi/src/stream.ts`: Pi provider streaming implementation, including preservation of Anthropic redacted-thinking blocks for later replay, request-resolved host tool names for SSE tool-call mapping, API-key route versioned URL composition, and safe custom header application diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index ee9fdc922..6a0cb932c 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -10,6 +10,8 @@ This package is a CortexKit-maintained fork of the original `@ex-machina/opencod ### Patch Changes +- Persist scoped main quotas with the vault's primary account identity, restore `/claude-quota` polling with scoped credentials, and discover model-scoped limits from usage polls even when response headers keep general windows fresh. A failed usage poll is bounded per account, without counting lock-losing contenders. +- Log scoped OAuth 401 re-authorization decisions and delivered failure reports at debug level with record-version provenance but no bearer tokens. - Update scoped Claustrum transport through the shared 0.4.0 client; a daemon that does not serve the Claustrum module now fails without a futile reconnect. - Correct approved enrollment status in `/claude-account` and the TUI account dialog when the main account is vault-served; do not prompt configured users to rerun setup, and describe `enrollment-reset` as a state clear rather than an automatic retry. - Fix the packed CLI failing before `setup` under Node by externalizing `jsonc-parser` from the split bundle and checking a fresh packed CLI install in the smoke gate (#257). diff --git a/scripts/check-pi-dist-imports.test.ts b/scripts/check-pi-dist-imports.test.ts index 7ffcc6fc4..ba5689eac 100644 --- a/scripts/check-pi-dist-imports.test.ts +++ b/scripts/check-pi-dist-imports.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -67,6 +67,21 @@ describe('Pi dist host-import guard', () => { } }) + test('rejects template-literal and computed dynamic import specifiers that evade static host checks', () => { + expect( + violations( + 'export async function load() { return import(`@earendil-works/pi-ai`) }', + ), + ).toContain( + 'stream.js: runtime dynamic import cannot be verified against the host SDK allowlist', + ) + expect( + violations('export async function load(name) { return import(name) }'), + ).toContain( + 'stream.js: runtime dynamic import cannot be verified against the host SDK allowlist', + ) + }) + test('rejects a disallowed name re-exported from the host', () => { expect( violations(`export { normalizeContext } from '@earendil-works/pi-ai'`), @@ -112,6 +127,27 @@ describe('Pi dist host-import guard', () => { ).toEqual([]) }) + test('CI and release verify the built Pi import surface before publication', async () => { + const root = join(import.meta.dir, '..') + for (const workflow of [ + '.github/workflows/ci.yml', + '.github/workflows/release.yaml', + ]) { + const source = await readFile(join(root, workflow), 'utf8') + const build = source.indexOf('bun run build') + const test = source.indexOf( + 'bun test scripts/check-pi-dist-imports.test.ts', + ) + const guard = source.indexOf('bun run check:pi-dist-imports') + expect(build).toBeGreaterThan(-1) + expect(test).toBeGreaterThan(build) + expect(guard).toBeGreaterThan(test) + if (workflow.endsWith('release.yaml')) { + expect(guard).toBeLessThan(source.indexOf('publish-npm:')) + } + } + }) + test('fails when the dist directory holds no JavaScript', async () => { const empty = await mkdtemp(join(tmpdir(), 'pi-dist-guard-')) try { diff --git a/scripts/check-pi-dist-imports.ts b/scripts/check-pi-dist-imports.ts index 33b978dc3..02b6a8416 100644 --- a/scripts/check-pi-dist-imports.ts +++ b/scripts/check-pi-dist-imports.ts @@ -177,8 +177,18 @@ export function findHostImportViolations( node.callee.type === 'Import' ? (node.arguments as unknown[])[0] : undefined + if (argument === undefined) continue const specifier = stringValue(argument) - if (specifier !== undefined) check(specifier, 'opaque') + if (specifier !== undefined) { + check(specifier, 'opaque') + } else { + // Template literals and computed specifiers may resolve to a host SDK + // at runtime. We cannot prove their targets from emitted JavaScript, so + // reject them rather than silently letting an unsupported import ship. + findings.push( + `${origin}: runtime dynamic import cannot be verified against the host SDK allowlist`, + ) + } } // A module can import the same specifier more than once. From 13ec4013a4f47ea88185a3a87081bc04e734d108 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Thu, 24 Sep 2026 19:24:08 +0200 Subject: [PATCH 14/19] ci(test): raise Core and OpenCode floors from clean CI --- .ci/test-count-floors.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.ci/test-count-floors.json b/.ci/test-count-floors.json index d6008f1a9..31648432a 100644 --- a/.ci/test-count-floors.json +++ b/.ci/test-count-floors.json @@ -1,17 +1,17 @@ { "floors": { - "core": 290, - "opencode": 1677, + "core": 293, + "opencode": 1685, "pi": 140 }, "measurement": { - "head": "e86aa0b79fa5e5fae164ddb47fddcf6e74f4802b", + "head": "af39724c183d17f94e13403d6189a9c93439156a", "dirtyPaths": 0 }, "evidence": { "_note": "Pinned Bun 1.3.14 runner totals after the scoped-only cutover. The measurement head precedes this floor-file update and was clean. Runner counts, not source declaration counts, are the authority at the gate.", - "core": "290 passing on clean CI run 35985845771 at commit e86aa0b7", - "opencode": "1677 passing on clean CI run 35985845771 at commit e86aa0b7", - "pi": "140 passing on clean CI run 35985845771 at commit e86aa0b7" + "core": "293 passing on clean CI run 36032206137 at commit af39724c", + "opencode": "1685 passing on clean CI run 36032206137 at commit af39724c", + "pi": "140 passing on clean CI run 36032206137 at commit af39724c" } } From ca624260dec6475a072d5307a7d09bf993ff2303 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Thu, 24 Sep 2026 19:26:33 +0200 Subject: [PATCH 15/19] docs(release): prepare v2.0.0 migration notes and install examples --- CHANGELOG.md | 2 ++ README.md | 6 +++--- packages/opencode/CHANGELOG.md | 2 ++ packages/opencode/README.md | 4 ++-- packages/pi/README.md | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d5de119..d19828eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ## Unreleased +## 2.0.0 + ### Breaking Changes - Remove the handle-based Claustrum serving path, manifest bindings and per-account gates. OpenCode and Pi now require enrolled, zero-bind scoped custody; an older Claustrum configuration without a scoped roster refuses serving until `setup` completes. Remove Core's process-shared enrollment-registry export; host path resolution remains available through the Core enrollment module. Local OAuth and API-key routes are unaffected. diff --git a/README.md b/README.md index 23ce9f497..9f424cd83 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Pinning is strongly recommended for any OpenCode plugin: ```json { - "plugin": ["@cortexkit/opencode-anthropic-auth@1.0.0"] + "plugin": ["@cortexkit/opencode-anthropic-auth@2.0.0"] } ``` @@ -102,7 +102,7 @@ After changing plugin config, restart OpenCode. Install the Pi package with Pi's package manager: ```bash -pi install npm:@cortexkit/pi-anthropic-auth@1.0.0 +pi install npm:@cortexkit/pi-anthropic-auth@2.0.0 ``` For an unpinned install: @@ -380,7 +380,7 @@ Pinning is recommended, matching the main plugin entry: ```json { - "plugin": ["@cortexkit/opencode-anthropic-auth@1.0.0"] + "plugin": ["@cortexkit/opencode-anthropic-auth@2.0.0"] } ``` diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 6a0cb932c..603fc0403 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -4,6 +4,8 @@ This package is a CortexKit-maintained fork of the original `@ex-machina/opencod ## Unreleased +## 2.0.0 + ### Breaking Changes - Remove legacy capability-handle and manifest-based Claustrum custody. The plugin serves OAuth only via enrolled scoped credentials; old Claustrum configurations without `scopedRoster: true` fail closed until the offline `setup` wizard completes. diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 4d5147e86..bdd72b5de 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -81,7 +81,7 @@ Pinning is strongly recommended for any OpenCode plugin: ```json { - "plugin": ["@cortexkit/opencode-anthropic-auth@1.0.0"] + "plugin": ["@cortexkit/opencode-anthropic-auth@2.0.0"] } ``` @@ -95,7 +95,7 @@ After changing plugin config, restart OpenCode. Install the Pi package with Pi's package manager: ```bash -pi install npm:@cortexkit/pi-anthropic-auth@1.0.0 +pi install npm:@cortexkit/pi-anthropic-auth@2.0.0 ``` For an unpinned install: diff --git a/packages/pi/README.md b/packages/pi/README.md index 25474a872..87415019f 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -21,7 +21,7 @@ bunx @cortexkit/opencode-anthropic-auth setup Install with Pi's package manager: ```bash -pi install npm:@cortexkit/pi-anthropic-auth@1.0.0 +pi install npm:@cortexkit/pi-anthropic-auth@2.0.0 ``` For an unpinned install: From 52a69b5fbc9508bb936f1993fb81ce227d42de90 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 25 Sep 2026 20:44:40 +0200 Subject: [PATCH 16/19] test(opencode): isolate host paths before Core imports --- packages/opencode/bunfig.toml | 2 +- packages/opencode/src/setup/detect.ts | 2 +- .../opencode/src/tests/host-isolation.test.ts | 63 +++++++++++++++++++ .../opencode/src/tests/preload-sandbox.ts | 57 +++++++++++++++++ .../opencode/src/tests/setup-command.test.ts | 28 +++++++++ .../src/tests/setup-detect-isolation.test.ts | 21 +++++++ packages/opencode/src/tests/setup.ts | 33 ++-------- 7 files changed, 175 insertions(+), 31 deletions(-) create mode 100644 packages/opencode/src/tests/host-isolation.test.ts create mode 100644 packages/opencode/src/tests/preload-sandbox.ts create mode 100644 packages/opencode/src/tests/setup-detect-isolation.test.ts diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index 1c8dc619d..810495c06 100644 --- a/packages/opencode/bunfig.toml +++ b/packages/opencode/bunfig.toml @@ -1,2 +1,2 @@ [test] -preload = ["./src/tests/setup.ts"] +preload = ["./src/tests/preload-sandbox.ts", "./src/tests/setup.ts"] diff --git a/packages/opencode/src/setup/detect.ts b/packages/opencode/src/setup/detect.ts index 54209fc29..158977a69 100644 --- a/packages/opencode/src/setup/detect.ts +++ b/packages/opencode/src/setup/detect.ts @@ -100,7 +100,7 @@ export async function detectClaustrum( let daemonRunning = false try { - connectionPath = resolveClaustrumConnectionPath() + connectionPath = resolveClaustrumConnectionPath(undefined, env) if (connectionPath && existsSync(connectionPath)) { // Prove the daemon is actually running with a real handshake const client = await connectClaustrumEnrollmentClient({ diff --git a/packages/opencode/src/tests/host-isolation.test.ts b/packages/opencode/src/tests/host-isolation.test.ts new file mode 100644 index 000000000..fa6f06514 --- /dev/null +++ b/packages/opencode/src/tests/host-isolation.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { + getAccountStoragePath, + getDefaultCacheKeepRegistryDirectory, + getDefaultQuotaHeaderFeedDirectory, + getDumpDirectory, + getLogFilePath, + resolveClaustrumConnectionPath, +} from '@cortexkit/anthropic-auth-core' +import { getRpcDir } from '../rpc/rpc-dir.ts' +import { getOpenCodeAuthPath, getPiAuthPath } from '../setup/paths.ts' +import { + DEFAULT_SIDEBAR_STATE, + getSidebarStateFile, + setSidebarState, +} from '../sidebar-state.ts' + +const FEATURE_OVERRIDES = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'OPENCODE_ANTHROPIC_AUTH_DUMP_DIR', +] as const + +test('feature-path overrides deleted by a test cannot resolve outside the preload sandbox', async () => { + const root = process.env.OPENCODE_ANTHROPIC_AUTH_TEST_DIR + const connectionFile = + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + if (!root || !connectionFile) + throw new Error('OpenCode test preload is missing') + const saved = FEATURE_OVERRIDES.map( + (name) => [name, process.env[name]] as const, + ) + try { + for (const name of FEATURE_OVERRIDES) delete process.env[name] + for (const path of [ + getAccountStoragePath(), + getOpenCodeAuthPath(), + getPiAuthPath(), + getSidebarStateFile(), + getDefaultCacheKeepRegistryDirectory('opencode'), + getDefaultQuotaHeaderFeedDirectory(), + getRpcDir('/tmp/hostile-project'), + getDumpDirectory(), + getLogFilePath(), + ]) { + expect(path.startsWith(`${root}/`), path).toBe(true) + } + expect(resolveClaustrumConnectionPath()).toBe(connectionFile) + const lastUpdated = Date.now() + await setSidebarState({ ...DEFAULT_SIDEBAR_STATE, lastUpdated }) + const persisted = JSON.parse(await readFile(getSidebarStateFile(), 'utf8')) + expect(persisted.lastUpdated).toBe(lastUpdated) + } finally { + for (const [name, value] of saved) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + } +}) diff --git a/packages/opencode/src/tests/preload-sandbox.ts b/packages/opencode/src/tests/preload-sandbox.ts new file mode 100644 index 000000000..cd52bece8 --- /dev/null +++ b/packages/opencode/src/tests/preload-sandbox.ts @@ -0,0 +1,57 @@ +import { afterAll } from 'bun:test' +import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// This must run before setup.ts imports Core. Core's logger and dump modules +// capture default paths at import time; setting TMPDIR in setup.ts is too late. +const testDir = mkdtempSync(join(tmpdir(), 'anthropic-auth-opencode-test-')) + +// Feature-specific overrides may be deleted by a test's teardown. The lower +// level defaults must remain inside this sandbox too: Bun caches os.homedir() +// at startup, so setting HOME alone after preload cannot protect auth/config. +const safetyPaths = { + OPENCODE_ANTHROPIC_AUTH_TEST_DIR: testDir, + OPENCODE_CONFIG_DIR: join(testDir, 'config'), + XDG_CONFIG_HOME: join(testDir, 'xdg-config'), + XDG_DATA_HOME: join(testDir, 'data'), + XDG_CACHE_HOME: join(testDir, 'cache'), + XDG_STATE_HOME: join(testDir, 'state'), + PI_CODING_AGENT_DIR: join(testDir, 'pi-agent'), + XDG_RUNTIME_DIR: join(testDir, 'runtime'), + HOME: testDir, + TMPDIR: testDir, + OPENCODE_ANTHROPIC_AUTH_FILE: join(testDir, 'anthropic-auth.json'), + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + testDir, + 'opencode-enrollment.json', + ), + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + testDir, + 'unavailable-subc-connection.json', + ), + OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE: join( + testDir, + 'sidebar-state.json', + ), + OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR: join( + testDir, + 'cachekeep-registry', + ), + OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR: join(testDir, 'quota-header-feed'), + OPENCODE_ANTHROPIC_AUTH_RPC_DIR: join(testDir, 'rpc'), + OPENCODE_ANTHROPIC_AUTH_DUMP_DIR: join(testDir, 'dumps'), +} as const + +export function restoreTestSafetyPaths() { + for (const [key, value] of Object.entries(safetyPaths)) { + process.env[key] = value + } +} + +restoreTestSafetyPaths() + +afterAll(async () => { + await rm(testDir, { recursive: true, force: true }).catch(() => {}) +}) diff --git a/packages/opencode/src/tests/setup-command.test.ts b/packages/opencode/src/tests/setup-command.test.ts index d858ad9ff..91b7951cf 100644 --- a/packages/opencode/src/tests/setup-command.test.ts +++ b/packages/opencode/src/tests/setup-command.test.ts @@ -60,6 +60,10 @@ describe('setup wizard command', () => { const env: NodeJS.ProcessEnv = { HOME: root, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + root, + 'unavailable-subc-connection.json', + ), XDG_CONFIG_HOME: join(root, '.config'), XDG_DATA_HOME: join(root, '.local', 'share'), PI_CODING_AGENT_DIR: join(root, '.pi', 'agent'), @@ -94,6 +98,10 @@ describe('setup wizard command', () => { const env: NodeJS.ProcessEnv = { HOME: root, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + root, + 'unavailable-subc-connection.json', + ), XDG_CONFIG_HOME: join(root, '.config'), XDG_DATA_HOME: join(root, '.local', 'share'), PI_CODING_AGENT_DIR: join(root, 'nonexistent-pi'), @@ -116,6 +124,10 @@ describe('setup wizard command', () => { const env: NodeJS.ProcessEnv = { HOME: root, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + root, + 'unavailable-subc-connection.json', + ), XDG_CONFIG_HOME: join(root, '.config'), XDG_DATA_HOME: join(root, '.local', 'share'), PI_CODING_AGENT_DIR: join(root, 'nonexistent-pi'), @@ -160,6 +172,10 @@ test('executes end-to-end Claustrum setup for OpenCode with mock ck and daemon', const env: NodeJS.ProcessEnv = { HOME: root, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + root, + 'unavailable-subc-connection.json', + ), XDG_CONFIG_HOME: join(root, '.config'), XDG_DATA_HOME: join(root, '.local', 'share'), XDG_STATE_HOME: join(root, '.local', 'state'), @@ -279,6 +295,10 @@ test('approved enrollment never opens a live daemon connection', async () => { const env: NodeJS.ProcessEnv = { HOME: root, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + root, + 'unavailable-subc-connection.json', + ), XDG_CONFIG_HOME: join(root, '.config'), XDG_DATA_HOME: join(root, '.local', 'share'), XDG_STATE_HOME: join(root, '.local', 'state'), @@ -374,6 +394,10 @@ test('executes Pi Claustrum setup: removes local OAuth and commits scoped roster const env: NodeJS.ProcessEnv = { HOME: root, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + root, + 'unavailable-subc-connection.json', + ), PI_CODING_AGENT_DIR: agentDir, } @@ -472,6 +496,10 @@ async function enrollmentSetupFixture( testDirs.push(root) const env: NodeJS.ProcessEnv = { HOME: root, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + root, + 'unavailable-subc-connection.json', + ), XDG_CONFIG_HOME: join(root, 'config'), XDG_DATA_HOME: join(root, 'data'), XDG_STATE_HOME: join(root, 'state'), diff --git a/packages/opencode/src/tests/setup-detect-isolation.test.ts b/packages/opencode/src/tests/setup-detect-isolation.test.ts new file mode 100644 index 000000000..71830bc83 --- /dev/null +++ b/packages/opencode/src/tests/setup-detect-isolation.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test' +import { join } from 'node:path' +import { detectClaustrum } from '../setup/detect.ts' +import type { CommandRunner } from '../setup/types.ts' + +test('Claustrum detection resolves the supplied environment instead of the host process environment', async () => { + const root = process.env.OPENCODE_ANTHROPIC_AUTH_TEST_DIR + if (!root) throw new Error('OpenCode test preload is missing') + const explicit = join(root, 'isolated-detection-absent.json') + const env: NodeJS.ProcessEnv = { + HOME: root, + XDG_RUNTIME_DIR: join(root, 'isolated-runtime'), + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: explicit, + } + const runner = { + run: async () => ({ exitCode: 1, stdout: '', stderr: '' }), + } as CommandRunner + const result = await detectClaustrum(env, runner) + expect(result.connectionPath).toBe(explicit) + expect(result.daemonRunning).toBe(false) +}) diff --git a/packages/opencode/src/tests/setup.ts b/packages/opencode/src/tests/setup.ts index fb733afaf..347989f1e 100644 --- a/packages/opencode/src/tests/setup.ts +++ b/packages/opencode/src/tests/setup.ts @@ -1,13 +1,10 @@ -import { afterAll, afterEach } from 'bun:test' -import { mkdtempSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { afterEach, beforeEach } from 'bun:test' import { assertLoopback, assertPreconnectUrl, fetchUrl, } from './network-guard-utils' +import { restoreTestSafetyPaths } from './preload-sandbox' import { DEFAULT_FETCH_MOCK } from './test-fetch' const nativeFetch = globalThis.fetch @@ -160,30 +157,8 @@ afterEach(() => { // the captured guarded implementation afterward. globalThis.fetch = guardedFetch -const testDir = mkdtempSync(join(tmpdir(), 'anthropic-auth-opencode-test-')) - -afterAll(async () => { - await rm(testDir, { recursive: true, force: true }).catch(() => {}) -}) - -process.env.OPENCODE_ANTHROPIC_AUTH_TEST_DIR = testDir -process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join(testDir, 'anthropic-auth.json') -process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( - testDir, - 'opencode-enrollment.json', -) -process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( - testDir, - 'sidebar-state.json', -) -process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( - testDir, - 'cachekeep-registry', -) -process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( - testDir, - 'quota-header-feed', -) +beforeEach(restoreTestSafetyPaths) +afterEach(restoreTestSafetyPaths) // User-level Anthropic overrides are valid runtime configuration, but they make // request-transform tests depend on the developer machine. Tests set these // explicitly when they exercise override behavior. From eae65918efe35b93900380a119c23e1abe4fce14 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 25 Sep 2026 20:44:54 +0200 Subject: [PATCH 17/19] fix(models): reject unsupported Opus fast and forced tool modes --- README.md | 4 +- packages/core/src/constants.ts | 5 +- packages/core/src/fast.ts | 2 +- packages/core/src/tests/fast.test.ts | 18 +++ packages/e2e-tests/src/harness.ts | 3 + .../tests/opus55-structured-output.test.ts | 67 ++++++++++++ packages/opencode/README.md | 4 +- packages/opencode/src/tests/fast.test.ts | 2 +- packages/opencode/src/tests/index.test.ts | 103 +++++++++--------- packages/opencode/src/tests/transform.test.ts | 97 +++++++++++++++++ packages/opencode/src/transform.ts | 30 +++-- packages/pi/README.md | 2 +- packages/pi/src/tests/convert.test.ts | 19 ++++ 13 files changed, 288 insertions(+), 68 deletions(-) create mode 100644 packages/core/src/tests/fast.test.ts create mode 100644 packages/e2e-tests/tests/opus55-structured-output.test.ts diff --git a/README.md b/README.md index 9f424cd83..c61c3a11c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. - **Quota window priming**: opt in with `/claude-prime on` to start each 5-hour quota window about one minute after it resets instead of waiting for the next normal prompt. - **Lane start (OpenCode only)**: use `/claude-start` to fire one synthetic, one-token turn through the current session's normal model, agent, variant, quota, routing, cache, and request pipeline. -- **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, and `claude-opus-5-5`). +- **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models (`claude-opus-4-8`, `claude-opus-5`, and `claude-opus-5-5`). - **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5/5.1, Mythos 5/5.1, Opus 5, and Opus 5.5. OpenCode exposes native `low`, `medium`, `high`, `xhigh`, and `max` effort variants for Fable 5.1, Opus 5, and Opus 5.5. On Opus 5.5, thinking is always on (adaptive summarized) per the model specification. OAuth Fable 5.1 sessions can change effort between turns without rewriting the cached prefix. - **Fable/Opus 5 and 5.5 safety fallback (OpenCode)**: eligible OAuth requests try Anthropic's server-side safety fallback first. The plugin preserves Anthropic's fallback conversation boundary across OpenCode history and automatically starts its deterministic 10-response Opus 4.8 recovery if the response still ends in refusal. The TUI sidebar and OpenCode Desktop report the active target model and restoration. Set `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` to bypass the server policy and use client-side recovery exclusively. - **Live quota visibility**: use `/claude-quota` to see main and fallback quota state, reset times, and refresh errors. @@ -587,7 +587,7 @@ Both OpenCode and Pi packages can persistently request Anthropic fast mode for s /claude-fast off ``` -When enabled, supported requests add `speed: "fast"` to the Anthropic JSON body and include the `fast-mode-2026-02-01` beta header. Unsupported models are left at standard speed. Anthropic currently documents fast mode for `claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, and `claude-opus-5`; Claude Fable and Mythos 5/5.1 are not fast-mode models. +When enabled, supported requests add `speed: "fast"` to the Anthropic JSON body and include the `fast-mode-2026-02-01` beta header. Unsupported models are left at standard speed. Anthropic supports fast mode for `claude-opus-4-8`, `claude-opus-5`, and `claude-opus-5-5`. Opus 4.7 rejects `speed: "fast"`, while Opus 4.6 runs at standard speed; Claude Fable and Mythos 5/5.1 are not fast-mode models. Fast and standard speeds do not share prompt-cache prefixes, so switching this setting can cause cache misses. diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 013be08f6..d84ce6647 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -47,10 +47,7 @@ export function mergeAnthropicBetas( export function isFastModeSupportedModel(model: unknown) { return ( typeof model === 'string' && - (model.startsWith('claude-opus-4-6') || - model.startsWith('claude-opus-4-7') || - model.startsWith('claude-opus-4-8') || - model.startsWith('claude-opus-5')) + (model.startsWith('claude-opus-4-8') || model.startsWith('claude-opus-5')) ) } diff --git a/packages/core/src/fast.ts b/packages/core/src/fast.ts index 039b41508..d42a0d0c2 100644 --- a/packages/core/src/fast.ts +++ b/packages/core/src/fast.ts @@ -47,7 +47,7 @@ export function buildFastModeStatusSummary(input?: { enabled?: boolean }) { `- Enabled: ${enabled ? 'enabled' : 'disabled'}`, '- Persisted: ~/.config/opencode/anthropic-auth.json', '- Scope: adds Anthropic fast mode to supported Opus requests', - '- Supported models: claude-opus-4-6, claude-opus-4-7, claude-opus-4-8, claude-opus-5, and claude-opus-5-5', + '- Supported models: claude-opus-4-8, claude-opus-5, and claude-opus-5-5', '- Request changes: adds `speed: "fast"` and the `fast-mode-2026-02-01` beta header', '- Note: fast and standard speeds do not share prompt-cache prefixes', ].join('\n') diff --git a/packages/core/src/tests/fast.test.ts b/packages/core/src/tests/fast.test.ts new file mode 100644 index 000000000..ac36e6361 --- /dev/null +++ b/packages/core/src/tests/fast.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'bun:test' +import { isFastModeSupportedModel } from '../constants' + +for (const [model, expected] of [ + ['claude-opus-4-6', false], + ['claude-opus-4-7', false], + ['claude-opus-4-7[1m]', false], + ['claude-opus-4-8', true], + ['claude-opus-4-8-20260901', true], + ['claude-opus-5', true], + ['claude-opus-5-5', true], + ['claude-opus-5-5[1m]', true], + ['claude-sonnet-5', false], +] as const) { + test(`fast mode eligibility for ${model} is ${expected}`, () => { + expect(isFastModeSupportedModel(model)).toBe(expected) + }) +} diff --git a/packages/e2e-tests/src/harness.ts b/packages/e2e-tests/src/harness.ts index 145f27f26..6ace93526 100644 --- a/packages/e2e-tests/src/harness.ts +++ b/packages/e2e-tests/src/harness.ts @@ -19,6 +19,7 @@ type SdkClient = { model: { providerID: string; modelID: string } parts: Array<{ type: 'text'; text: string }> variant?: string + format?: { type: 'json_schema'; schema: Record } } }) => Promise<{ data?: unknown }> promptAsync: (options: { @@ -136,6 +137,7 @@ export class E2EHarness { timeoutMs = 45_000, modelID = 'claude-sonnet-4-5', variant?: string, + format?: { type: 'json_schema'; schema: Record }, ) { const result = await this.withTimeout( this.client.session.prompt({ @@ -144,6 +146,7 @@ export class E2EHarness { model: { providerID: 'anthropic', modelID }, parts: [{ type: 'text', text }], variant, + ...(format ? { format } : {}), }, }), timeoutMs, diff --git a/packages/e2e-tests/tests/opus55-structured-output.test.ts b/packages/e2e-tests/tests/opus55-structured-output.test.ts new file mode 100644 index 000000000..e8bafcbca --- /dev/null +++ b/packages/e2e-tests/tests/opus55-structured-output.test.ts @@ -0,0 +1,67 @@ +import { afterEach, expect, test } from 'bun:test' +import { E2EHarness } from '../src/harness.ts' + +let harness: E2EHarness | null = null + +afterEach(async () => { + const finished = harness + harness = null + await finished?.dispose() +}) + +const format = { + type: 'json_schema' as const, + schema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + additionalProperties: false, + }, +} + +type PromptResult = { + info?: { + structured?: unknown + error?: { name?: string; message?: string } + } +} + +test('Opus 5.5 structured output keeps the tool available and fails closed if it is not called', async () => { + harness = await E2EHarness.create() + harness.script([{ type: 'tool_use', name: 'mcp_StructuredOutput', input: { answer: 'ok' } }]) + const successId = await harness.createSession() + const success = await harness.sendPrompt( + successId, + 'Return a JSON object with answer ok', + 45_000, + 'claude-opus-5-5', + undefined, + format, + ) + const successInfo = (success.data as PromptResult | undefined)?.info + expect(successInfo?.structured).toEqual({ answer: 'ok' }) + expect(successInfo?.error).toBeUndefined() + + const first = harness.anthropic.requests().find((request) => + request.body.model === 'claude-opus-5-5' && + Array.isArray(request.body.tools) && + request.body.tools.some((tool: { name?: string }) => tool.name === 'mcp_StructuredOutput'), + ) + expect(first).toBeDefined() + expect(first?.body.tool_choice).toBeUndefined() + expect(first?.body.thinking).toMatchObject({ type: 'adaptive' }) + + harness.script([{ type: 'text', text: 'A plain-text answer, not structured output.' }]) + const failureId = await harness.createSession() + const failure = await harness.sendPrompt( + failureId, + 'Return a JSON object with answer ok', + 45_000, + 'claude-opus-5-5', + undefined, + format, + ) + const failureInfo = (failure.data as PromptResult | undefined)?.info + expect(failureInfo?.structured).toBeUndefined() + expect(JSON.stringify(failureInfo?.error)).toContain('Model did not produce structured output') +}, 90_000) diff --git a/packages/opencode/README.md b/packages/opencode/README.md index bdd72b5de..1d8ec65ac 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -32,7 +32,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. - **Quota window priming**: opt in with `/claude-prime on` to start each 5-hour quota window about one minute after it resets instead of waiting for the next normal prompt. - **Lane start (OpenCode only)**: use `/claude-start` to fire one synthetic, one-token turn through the current session's normal model, agent, variant, quota, routing, cache, and request pipeline. -- **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, and `claude-opus-5-5`). +- **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models (`claude-opus-4-8`, `claude-opus-5`, and `claude-opus-5-5`). - **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5/5.1, Mythos 5/5.1, Opus 5, and Opus 5.5. OpenCode exposes native `low`, `medium`, `high`, `xhigh`, and `max` effort variants for Fable 5.1, Opus 5, and Opus 5.5. On Opus 5.5, thinking is always on (adaptive summarized) per the model specification. OAuth Fable 5.1 sessions can change effort between turns without rewriting the cached prefix. - **Fable/Opus 5 and 5.5 safety fallback**: eligible OAuth requests try Anthropic's server-side safety fallback first. The plugin preserves Anthropic's fallback conversation boundary across OpenCode history and automatically starts its deterministic 10-response Opus 4.8 recovery if the response still ends in refusal. The TUI sidebar and OpenCode Desktop report the active target model and restoration. Set `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` to bypass the server policy and use client-side recovery exclusively. - **Live quota visibility**: use `/claude-quota` to see main and fallback quota state, reset times, and refresh errors. @@ -550,7 +550,7 @@ Both OpenCode and Pi packages can persistently request Anthropic fast mode for s /claude-fast off ``` -When enabled, supported requests add `speed: "fast"` to the Anthropic JSON body and include the `fast-mode-2026-02-01` beta header. Unsupported models are left at standard speed. Anthropic currently documents fast mode for `claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, and `claude-opus-5`; Claude Fable and Mythos 5/5.1 are not fast-mode models. +When enabled, supported requests add `speed: "fast"` to the Anthropic JSON body and include the `fast-mode-2026-02-01` beta header. Unsupported models are left at standard speed. Anthropic supports fast mode for `claude-opus-4-8`, `claude-opus-5`, and `claude-opus-5-5`. Opus 4.7 rejects `speed: "fast"`, while Opus 4.6 runs at standard speed; Claude Fable and Mythos 5/5.1 are not fast-mode models. Fast and standard speeds do not share prompt-cache prefixes, so switching this setting can cause cache misses. diff --git a/packages/opencode/src/tests/fast.test.ts b/packages/opencode/src/tests/fast.test.ts index d96bbd400..c7afdc6a8 100644 --- a/packages/opencode/src/tests/fast.test.ts +++ b/packages/opencode/src/tests/fast.test.ts @@ -26,7 +26,7 @@ describe('claude-fast command state', () => { expect(summary).toContain('## Claude Fast Mode Status') expect(parseEnabled(summary)).toBe(false) expect(summary).toContain( - 'claude-opus-4-6, claude-opus-4-7, claude-opus-4-8, claude-opus-5, and claude-opus-5-5', + 'claude-opus-4-8, claude-opus-5, and claude-opus-5-5', ) }) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index aa0b6b387..feba602f9 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -9226,7 +9226,7 @@ describe('auth.loader', () => { await result.fetch(MESSAGES_URL, { method: 'POST', body: JSON.stringify({ - model: 'claude-opus-4-7', + model: 'claude-opus-4-8', messages: [{ role: 'user', content: 'hello' }], }), }) @@ -9237,59 +9237,62 @@ describe('auth.loader', () => { expect(JSON.parse(capturedBody!).speed).toBe('fast') }) - test('persistent claudeFast setting skips unsupported models', async () => { - await useTempAccountFile( - createFallbackStorage({ - accounts: [], - claudeFast: { enabled: true }, - }), - ) + test.each(['claude-opus-4-6', 'claude-opus-4-7', 'claude-sonnet-4-5'])( + 'persistent claudeFast setting skips unsupported %s', + async (model) => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + claudeFast: { enabled: true }, + }), + ) - let capturedHeaders: Headers | undefined - let capturedBody: string | undefined - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), - ) - } - capturedHeaders = init?.headers - capturedBody = init?.body - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + let capturedHeaders: Headers | undefined + let capturedBody: string | undefined + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedHeaders = init?.headers + capturedBody = init?.body + return Promise.resolve(new Response(null, { status: 200 })) + }) as unknown as typeof fetch - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'token', - refresh: 'refresh', - expires: Date.now() + 100000, - }), - { models: {} }, - ) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'token', + refresh: 'refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) - await result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ - model: 'claude-sonnet-4-5', - messages: [{ role: 'user', content: 'hello' }], - }), - }) + await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: 'hello' }], + }), + }) - expect(capturedHeaders?.get('anthropic-beta')).not.toContain( - 'fast-mode-2026-02-01', - ) - expect(JSON.parse(capturedBody!).speed).toBeUndefined() - }) + expect(capturedHeaders?.get('anthropic-beta')).not.toContain( + 'fast-mode-2026-02-01', + ) + expect(JSON.parse(capturedBody!).speed).toBeUndefined() + }, + ) test('/claude-cache on makes fetch wrapper set ttl on existing cache controls', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index 9c7e0370a..d985e4d59 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -98,6 +98,37 @@ describe('mergeHeaders', () => { }) }) +describe('fast mode model eligibility', () => { + test.each([ + ['claude-opus-4-6', false], + ['claude-opus-4-7', false], + ['claude-opus-4-7[1m]', false], + ['claude-opus-4-8', true], + ['claude-opus-5', true], + ['claude-opus-5-5', true], + ])( + 'only eligible models receive speed and its beta on %s', + async (model, enabled) => { + const body = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ + model, + max_tokens: 10, + speed: 'fast', + messages: [{ role: 'user', content: 'hello' }], + }), + { fastModeEnabled: true }, + ), + ) + const headers = new Headers() + setOAuthHeaders(headers, 'fixture-token', { body }) + expect(body.speed).toBe(enabled ? 'fast' : undefined) + const betas = headers.get('anthropic-beta')?.split(',') ?? [] + expect(betas.includes(FAST_MODE_BETA)).toBe(enabled) + }, + ) +}) + describe('lane start request shaping', () => { const laneStartBody = ( model: string, @@ -796,6 +827,21 @@ describe('prefixToolNames', () => { expect(result.tools[1].name).toBe('mcp_Write_file') }) + test('prefixes a named tool choice consistently with its tool definition', () => { + const result = JSON.parse( + prefixToolNames({ + model: 'claude-opus-5', + tools: [{ name: 'get_weather', input_schema: { type: 'object' } }], + tool_choice: { type: 'tool', name: 'get_weather' }, + }), + ) + expect(result.tools[0].name).toBe('mcp_Get_weather') + expect(result.tool_choice).toEqual({ + type: 'tool', + name: 'mcp_Get_weather', + }) + }) + test('prefixes tool_use block names in messages', () => { const body = { messages: [ @@ -4257,6 +4303,57 @@ describe('sanitizeSystemText – realistic prompt', () => { }) describe('Claude Opus 5.5 request transform', () => { + test.each([ + ['claude-opus-5-5', { type: 'any' }], + ['claude-opus-5-5[1m]', { type: 'any' }], + ['claude-opus-5-5-20260918', { type: 'tool', name: 'StructuredOutput' }], + ])( + 'removes forced tool choice rejected by %s without dropping tools', + async (model, toolChoice) => { + const body = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ + model, + thinking: { type: 'disabled' }, + tool_choice: toolChoice, + tools: [ + { name: 'StructuredOutput', input_schema: { type: 'object' } }, + ], + messages: [{ role: 'user', content: 'return structured output' }], + }), + ), + ) + expect(body.tool_choice).toBeUndefined() + expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(body.tools[0].name).toBe('mcp_StructuredOutput') + }, + ) + + test('preserves optional and unforced tool choices on Opus 5.5', async () => { + for (const type of ['auto', 'none']) { + const body = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ + model: 'claude-opus-5-5', + tool_choice: { type }, + messages: [{ role: 'user', content: 'hello' }], + }), + ), + ) + expect(body.tool_choice).toEqual({ type }) + } + const opus5 = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ + model: 'claude-opus-5', + tool_choice: { type: 'any' }, + messages: [{ role: 'user', content: 'hello' }], + }), + ), + ) + expect(opus5.tool_choice).toEqual({ type: 'any' }) + }) + test('rewrites disabled thinking to adaptive summarized on Opus 5.5', async () => { const raw = JSON.stringify({ model: 'claude-opus-5-5', diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index c12adbbda..f5fc87f32 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -207,6 +207,15 @@ export function prefixToolNames(parsed: Record): string { ) } + const toolChoice = parsed.tool_choice + if ( + isRecord(toolChoice) && + toolChoice.type === 'tool' && + typeof toolChoice.name === 'string' + ) { + parsed.tool_choice = { ...toolChoice, name: prefixName(toolChoice.name) } + } + if (parsed.messages && Array.isArray(parsed.messages)) { parsed.messages = parsed.messages.map( (msg: { @@ -1004,6 +1013,16 @@ function stripNonAnthropicThinkingBlocks(parsed: Record) { return removed } +function removeUnsupportedForcedToolChoice(parsed: Record) { + const toolChoice = parsed.tool_choice + if ( + isRecord(toolChoice) && + (toolChoice.type === 'any' || toolChoice.type === 'tool') + ) { + delete parsed.tool_choice + } +} + function normalizeFableMythosRequest( parsed: Record, ): { replacedExisting: boolean } | null { @@ -1011,13 +1030,7 @@ function normalizeFableMythosRequest( const hadThinking = Object.hasOwn(parsed, 'thinking') parsed.thinking = { ...CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING } if (isClaudeFableOrMythos51Model(parsed.model)) { - const toolChoice = parsed.tool_choice - if ( - isRecord(toolChoice) && - (toolChoice.type === 'any' || toolChoice.type === 'tool') - ) { - delete parsed.tool_choice - } + removeUnsupportedForcedToolChoice(parsed) } return { replacedExisting: hadThinking } } @@ -1062,6 +1075,9 @@ function normalizeOpus5Request( parsed: Record, ): { replacedExisting: boolean; display: 'summarized' | 'disabled' } | null { if (isClaudeOpus55Model(parsed.model)) { + // Opus 5.5 rejects forced tool choice, including OpenCode's + // `required` setting for its StructuredOutput tool. + removeUnsupportedForcedToolChoice(parsed) // Opus 5.5 has adaptive thinking ALWAYS ON: setting `type: "disabled"` or manual // `budget_tokens` returns a 400 invalid_request_error. Rewrite to adaptive summarized. const hadThinking = Object.hasOwn(parsed, 'thinking') diff --git a/packages/pi/README.md b/packages/pi/README.md index 87415019f..460b55df7 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -93,7 +93,7 @@ The sidecar uses the same JSON shape as the OpenCode package, including `routing /claude-quota ``` -`/claude-account reset-backoff` clears the main OAuth refresh backoff and its matching quota backoff. Persisted refresh backoffs are bound to the refresh token that produced them, so replacing a rejected credential immediately escapes its stale latch. `/claude-quota` reports sidecar OAuth fallback quota state from `~/.pi/agent/anthropic-auth.json`. `/claude-routing fallback-first` prefers usable OAuth fallback accounts before the main account; `/claude-routing main-first` restores the default. `/claude-routing sticky-balanced` assigns each Pi session to an OAuth account according to current 5-hour, 7-day, and matching model-scoped quota headroom, then persists that assignment across transient failures and process restarts. Changing the session model discards the old assignment before quota-based reselection. `/claude-routing reset` clears the current Pi session's assignment. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted. API-key routes use the same sidecar shape as OpenCode and are sent directly to their configured Anthropic-compatible base URL, such as Kie's `https://api.kie.ai/claude`, but Pi only uses them after the main OAuth model response reports HTTP 429 or a streaming rate-limit error and a live quota check confirms 0% remaining. `/claude-cachekeep always` keeps active hybrid caches warm while Pi remains open; `/claude-cachekeep HH-HH` limits prewarms to a local time window. Both send `max_tokens: 0` pre-warm requests about five minutes before the 1-hour TTL expires. Running `/claude-cachekeep` without arguments lists live tracked sessions across Pi processes through a temporary lease registry that stores only session IDs and cache timing. `/claude-fast on` adds Anthropic `speed: "fast"` plus the `fast-mode-2026-02-01` beta header for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, and `claude-opus-5-5`). +`/claude-account reset-backoff` clears the main OAuth refresh backoff and its matching quota backoff. Persisted refresh backoffs are bound to the refresh token that produced them, so replacing a rejected credential immediately escapes its stale latch. `/claude-quota` reports sidecar OAuth fallback quota state from `~/.pi/agent/anthropic-auth.json`. `/claude-routing fallback-first` prefers usable OAuth fallback accounts before the main account; `/claude-routing main-first` restores the default. `/claude-routing sticky-balanced` assigns each Pi session to an OAuth account according to current 5-hour, 7-day, and matching model-scoped quota headroom, then persists that assignment across transient failures and process restarts. Changing the session model discards the old assignment before quota-based reselection. `/claude-routing reset` clears the current Pi session's assignment. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted. API-key routes use the same sidecar shape as OpenCode and are sent directly to their configured Anthropic-compatible base URL, such as Kie's `https://api.kie.ai/claude`, but Pi only uses them after the main OAuth model response reports HTTP 429 or a streaming rate-limit error and a live quota check confirms 0% remaining. `/claude-cachekeep always` keeps active hybrid caches warm while Pi remains open; `/claude-cachekeep HH-HH` limits prewarms to a local time window. Both send `max_tokens: 0` pre-warm requests about five minutes before the 1-hour TTL expires. Running `/claude-cachekeep` without arguments lists live tracked sessions across Pi processes through a temporary lease registry that stores only session IDs and cache timing. `/claude-fast on` adds Anthropic `speed: "fast"` plus the `fast-mode-2026-02-01` beta header for supported Opus models (`claude-opus-4-8`, `claude-opus-5`, and `claude-opus-5-5`). ### Claude quota window priming diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index d451d28ef..5bffed800 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -75,6 +75,25 @@ async function buildMessages( return body.messages } +describe('fast mode model eligibility', () => { + test.each([ + ['claude-opus-4-6', false], + ['claude-opus-4-7', false], + ['claude-opus-4-8', true], + ['claude-opus-5', true], + ['claude-opus-5-5', true], + ])('Pi sends speed only for eligible %s', async (model, enabled) => { + const { body } = await buildAnthropicRequest( + model, + { messages: [userMsg('hello')], tools: [] } as Context, + undefined, + defaultCache, + true, + ) + expect(body.speed).toBe(enabled ? 'fast' : undefined) + }) +}) + describe('buildAnthropicRequest — prefill stripping', () => { test('strips single trailing assistant message', async () => { const messages = await buildMessages([ From c0052410b5a58dc1116b1b53512f0ddacea4306e Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 25 Sep 2026 20:45:07 +0200 Subject: [PATCH 18/19] docs: record v2 Opus request and test isolation fixes --- ARCHITECTURE.md | 4 +++- AUDITOR.md | 1 + CHANGELOG.md | 3 +++ STRUCTURE.md | 4 ++-- packages/opencode/CHANGELOG.md | 3 +++ 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a4849afa..42ff866b0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -48,7 +48,7 @@ 3. **Command registration** — Plugin registers `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-start`, `/claude-quota`, `/claude-dump`, `/claude-fast`, `/claude-routing`, `/claude-killswitch`, `/claude-account`, `/claude-logging` — `packages/opencode/src/index.ts` (config hook) 4. **Request interception** — OpenCode's messages hook observes messages to mark effort transitions and assign user-turn prompt UUIDs, `chat.headers` injects internal correlation headers (`x-cortexkit-effort-plan`, `x-cortexkit-billing-lineage`), and OpenCode's fetch wrapper intercepts Anthropic fetch calls, resolving and stripping internal headers before dispatch — `packages/opencode/src/index.ts`, `packages/opencode/src/billing-lineage.ts` (experimental fetch wrapping) 5. **URL rewrite** — `rewriteUrl()` adds `?beta=true` to `/v1/messages`, normalizes versioned base paths without duplicating version segments, and overrides base URL when `ANTHROPIC_BASE_URL` or a proxy route `baseURL` is set — `packages/opencode/src/transform.ts` -6. **Request body rewrite** — `rewriteRequestBody()` strips trailing assistant messages and trailing whitespace after tool_use, normalizes Fable/Mythos 5/5.1, Sonnet 5, Opus 5, and Opus 5.5 adaptive thinking (enforcing always-on adaptive thinking on Opus 5.5 and normalizing `[1m]` context-window qualifiers via `normalizeAnthropicModelId`), injects the Claude Code 2.1.280 billing header with a bounded process-local suffix pin derived from the initial visible user text and optional request lineage (`cc_prev_req` and `cc_prompt_id`), sanitizes the system prompt (removes OpenCode identity), prepends Claude Code identity, applies cache strategy (explicit/automatic/hybrid), adds fast mode, remaps model names on proxy routes using `ANTHROPIC_DEFAULT_*_MODEL` tier-specific or `ANTHROPIC_MODEL` generic overrides, prefixes tool names with `mcp_`, opts eligible OAuth Fable 5/5.1, Opus 5, and Opus 5.5 requests into Anthropic server-side safety fallback, restores stored fallback boundary markers, conditionally applies the configured Fable 5.1 thinking-prefix behavior only when signed or redacted thinking remains in the outgoing history, keeps top-level Fable 5.1 effort anchored to the earliest retained turn while inserting later effort changes as empty system markers, applies cache diagnostics opt-in (`diagnostics.previous_message_id`), shapes a correlated OAuth lane-start request to one output token without thinking, strips request lineage before reusing bodies in source cache warming or prewarming, and creates `cch` over the final serialized body — `packages/opencode/src/transform.ts`, `packages/opencode/src/server-fallback.ts`, `packages/core/src/thinking-binding.ts`, `packages/core/src/model-remap.ts`, `packages/opencode/src/cache-diagnostics.ts`, `packages/core/src/cch.ts` +6. **Request body rewrite** — `rewriteRequestBody()` strips trailing assistant messages and trailing whitespace after tool_use, normalizes Fable/Mythos 5/5.1, Sonnet 5, Opus 5, and Opus 5.5 adaptive thinking (enforcing always-on adaptive thinking on Opus 5.5 and normalizing `[1m]` context-window qualifiers via `normalizeAnthropicModelId`), injects the Claude Code 2.1.280 billing header with a bounded process-local suffix pin derived from the initial visible user text and optional request lineage (`cc_prev_req` and `cc_prompt_id`), sanitizes the system prompt (removes OpenCode identity), prepends Claude Code identity, applies cache strategy (explicit/automatic/hybrid), adds fast mode only to Opus 4.8/5/5.5, remaps model names on proxy routes using `ANTHROPIC_DEFAULT_*_MODEL` tier-specific or `ANTHROPIC_MODEL` generic overrides, prefixes tool names and named-tool choices with `mcp_`, removes forced `tool_choice` on Opus 5.5 and Fable/Mythos 5.1 (OpenCode still rejects missing structured output), opts eligible OAuth Fable 5/5.1, Opus 5, and Opus 5.5 requests into Anthropic server-side safety fallback, restores stored fallback boundary markers, conditionally applies the configured Fable 5.1 thinking-prefix behavior only when signed or redacted thinking remains in the outgoing history, keeps top-level Fable 5.1 effort anchored to the earliest retained turn while inserting later effort changes as empty system markers, applies cache diagnostics opt-in (`diagnostics.previous_message_id`), shapes a correlated OAuth lane-start request to one output token without thinking, strips request lineage before reusing bodies in source cache warming or prewarming, and creates `cch` over the final serialized body — `packages/opencode/src/transform.ts`, `packages/opencode/src/server-fallback.ts`, `packages/core/src/thinking-binding.ts`, `packages/core/src/model-remap.ts`, `packages/opencode/src/cache-diagnostics.ts`, `packages/core/src/cch.ts` 7. **Routing** — `shouldFallbackStatus()` checks if response should trigger fallback; `FallbackAccountManager` iterates accounts in ordered modes, while `StickySessionRouter` assigns cold sessions by reset-normalized spendable OAuth quota and weighted initial-prompt deficit, then persists hashed session affinity across processes/restarts. Sticky routes retain transient failures, including DNS-related refresh backoffs on a fixed five-minute retry interval while the fallback's current access token remains valid, hold confirmed 5h exhaustion when reset is within 15 minutes, and migrate for longer confirmed exhaustion/permanent account failure. All modes respect model-scoped quotas and killswitch thresholds (including per-model scoped thresholds). API-key routes remain excluded until fresh general OAuth exhaustion is confirmed; model-scoped exhaustion can move only among OAuth accounts. A complete sticky pool with no eligible route returns an explicit 401 for a permanent main-account authentication failure, an explicit 401 naming fallback OAuth accounts that require re-login, or a quota-policy 429 rather than falling through to an excluded account; incomplete pools fail retryably. Killswitch blocks are classified as scoped-driven (matching a specific model's weekly limit) or account-level (5h/7d limits) with a model-specific or generic retry hint — `packages/core/src/routing.ts`, `packages/core/src/sticky-routing.ts`, `packages/core/src/accounts.ts`, `packages/opencode/src/index.ts` 8. **Relay** — `sendViaRelay()` sends full or patched body to Cloudflare Worker, which streams Anthropic response back. HTTP relay responses and WebSocket `response_start` control messages deliver genuine upstream headers to the OpenCode account-bound quota harvester and billing lineage tracker (committing upstream `req_*` request IDs for subsequent session turns); synthetic optimistic response headers are never harvest input, and relay-to-direct fallback stays owned by the direct path — `packages/core/src/relay.ts`, `packages/opencode/src/index.ts` 9. **SSE stream and content-filter safety fallback** — By default, OAuth Fable 5/5.1 and Opus 5 requests send `fallbacks: "default"` with Anthropic's `server-side-fallback-2026-07-01` beta. The stream wrapper detects initial `fallback` blocks, sticky `fallback_message` usage iterations, and restoration to the requested model. Because OpenCode does not natively preserve `fallback` content blocks, the wrapper rewrites each block into a hidden signed thinking marker for storage, then the next eligible request restores the exact Anthropic fallback boundary before signing and sending. If an eligible OAuth response still terminates in refusal, the deterministic 10-successful-response Opus 4.8 recovery activates as a client-side backstop. Source-model prewarms explicitly remove `fallbacks` so they cannot be sticky-routed back to a fallback model. If a served fallback completes a `tool_use` before its terminal refusal, the wrapper preserves that completed call, changes the terminal finish to `tool_use`, and lets OpenCode continue on Opus 4.8 with the existing `tool_result` instead of replaying the tool. The active Anthropic-selected target and later restoration are written per session to the TUI sidebar state; when no matching TUI is connected, OpenCode Desktop receives an ignored/no-reply `promptAsync` notice that the plugin schedules delivery as soon as `session.idle` is observed instead of waiting for a later `session.updated` event, which OpenCode 1.18 no longer emits after idle; delivery still escapes the awaited event handler with `setImmediate` because OpenCode evaluates the run-loop exit condition only after awaiting event handlers. Intermediate `tool-calls` completions do not flush notices. When a refusal arms client recovery, the Desktop switch notice is not queued until the first downgraded Opus response completes successfully, so a transient idle state between the refused source response and OpenCode's internal retry cannot insert a user message or consume the retry response. The plugin probes `session.status()` outside that critical section and revalidates its idle-delivery lease after both the asynchronous status lookup and prompt-context lookup so a new prompt cannot adopt the ignored notice as its retry parent. A newly created user-message event revokes that lease before OpenCode's later busy-status event, while repeated updates for the user message that produced the current idle event do not suppress delivery; if multiple fallback state changes overtake delivery, only the newest notice is retained. Delivered notice message IDs are tracked per session (bounded to four IDs across at most 128 recently used sessions) and excluded from user-message lease revocation so the plugin's own notice cannot cancel its successor; delivery additionally requires the notice still head its queue, with a superseded queue looping to the newest instead of dropping it. Inconclusive probes re-arm for at most four attempts, with attempt 0 on `setImmediate` and later attempts delayed by `25` ms multiplied by the attempt number, and requires message-ID placement before the active assistant; consecutive notices use strictly increasing IDs bounded by that assistant, taking both host history and the latest locally prepared notice into account. If no safe ID can be allocated, delivery stays queued rather than letting the host mint an untracked ID. IDs are registered before dispatch so the notice's own user-message event cannot revoke its delivery lease. This removes the unsafe host-assigned-ID fallback when placement fails. Custom API-key routes strip the beta and `fallbacks` field. Setting `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` bypasses the server policy and uses the same deterministic Opus 4.8 recovery from the first refusal, keyed by session and source-model family with account-bound source-model prewarming and standby cache bridges beyond Anthropic's 20-block lookback — `packages/opencode/src/transform.ts`, `packages/opencode/src/server-fallback.ts`, `packages/opencode/src/fable-fallback.ts`, `packages/opencode/src/prompt-context.ts`, `packages/opencode/src/index.ts` @@ -243,6 +243,8 @@ ## Cross-Cutting Concerns +**Test isolation:** OpenCode loads `preload-sandbox.ts` before `setup.ts` so Core's module-level logger/dump defaults capture an isolated TMPDIR before any application imports. The preload confines account, sidebar, CacheKeep, quota-feed, RPC, dump, and Claustrum connection paths to one disposable sandbox. Its HOME/XDG/TMPDIR and config defaults remain sandboxed even when an individual test removes a feature-specific override; setup detection resolves the injected environment, not the developer's process environment. A regression physically writes a sidebar frame after removing the override and verifies the sandbox destination. Process E2E spawns separate children with isolated HOME and XDG paths. + **Logging:** Simple structured logger (`packages/core/src/logger.ts`) writing to `$TMPDIR/opencode-anthropic-auth.log`; relay diagnostics and performance tracing behind `OPENCODE_ANTHROPIC_AUTH_PERF=1` env flag; cache diagnostics records (`MC-CACHE-DIAG `) and deduplicated beta mappings (`MC-CACHE-DIAG-BETAS`) emitted at debug level **Caching:** In-memory quota cache (`QuotaManager`) with staleness-based refresh logic; memoized system prompt sanitization (`sanitize-memo.ts`) with 8MB max; 1-hour Anthropic prompt cache managed via cache strategy (`cache1h.ts`, `cachekeep.ts`) diff --git a/AUDITOR.md b/AUDITOR.md index 329cf649a..d9eb87f7e 100644 --- a/AUDITOR.md +++ b/AUDITOR.md @@ -15,6 +15,7 @@ Guidance for future audits of intentional tradeoffs in this repository. - **CCH/signing and cache rewrites parse/stringify request bodies.** This is expected because signing must cover the final serialized body and cache markers are JSON-structural. Optimizations should preserve exact wire-body semantics and be backed by benchmarks. - **Relay patch creation is linear in body size.** This is currently acceptable relative to JSON serialization/signing and network upload costs. Replacing it should be justified by measurements and must preserve hash-gated reconstruction. - **Total loss of Fable 5.1 effort markers and the current-user anchor remains fail-closed (#237/#242).** A resolved request-plan header alone cannot prove that the retained wire history is an exact suffix; a retained earlier user/assistant pair with all marker text stripped is a concrete counterexample. An anchored full prefix trim already folds safely. Missing-all refusals log only bounded shape and hashed plan metadata, never prompt text or marker bytes; accepting an unanchored trim needs independent proof of the surviving boundary, not a count check. +- **Opus 5.5 structured output cannot be forced with `tool_choice: any/tool`.** Anthropic rejects those values on Opus 5.5, including OpenCode's `required` setting for JSON-schema output. The request keeps the StructuredOutput tool and system instruction but removes only the unsupported forced choice; OpenCode validates a returned tool call against the schema and reports `StructuredOutputError` when the model completes without it. This avoids a universal 400 without claiming that every request is guaranteed to produce structured output. A real isolated OpenCode tool round-trip and plain-text negative test cover both outcomes. ## Quota and fallback policy diff --git a/CHANGELOG.md b/CHANGELOG.md index d19828eb7..e6047341d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Patch Changes +- Stop sending `speed: "fast"` to Opus 4.7, where it returns 400, and Opus 4.6, where it has no effect. Fast mode now applies only to Opus 4.8, Opus 5, and Opus 5.5 in OpenCode and Pi (#267). +- Remove Opus 5.5's unsupported forced `tool_choice` for OpenCode structured-output requests while retaining the schema tool. OpenCode still records validated structured output and reports an error if the model does not call it. Keep named `tool_choice` references aligned with prefixed tool definitions on other models (#268). +- Isolate OpenCode tests from live account files, sidebar state, CacheKeep leases, RPC directories, and Claustrum sockets even when a test clears a feature-specific override. Setup detection now uses the supplied environment for its connection file (#264, #265). - Persist scoped main quota under the discovered primary account rather than the local host slot, and let `/claude-quota` poll with a freshly authorized scoped credential. A header-only main quota remains due for its first usage poll; failed polls are spaced by account without treating usage 403 as rate-limit backoff or counting requests that lose the cross-process refresh lock. - Load the Pi extension through Oh My Pi's legacy SDK compatibility layer by keeping transcript system-prompt and tool replay in the extension instead of importing helpers absent from the host. Check built Pi imports with a syntax-aware allowlist in CI and release workflows. - Add debug diagnostics for scoped 401 retry decisions and delivered failure reports across OpenCode and Pi, recording served and current record versions and a non-secret decision reason without logging bearer material. diff --git a/STRUCTURE.md b/STRUCTURE.md index 3d9c9503f..c12bb891e 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -154,9 +154,9 @@ anthropic-auth/ **Tests:** - `packages/core/src/tests/`: Core-only unit tests (dump, killswitch, models, prime, quota surfaces, accounts persistence, custom headers, model remap, claustrum) -- `packages/opencode/src/tests/`: One test file per module (50+ test files covering core + opencode), including Claustrum client/custody, credential-handle blindness, billing lineage tracking, and fail-closed network-guard suites +- `packages/opencode/src/tests/`: OpenCode unit and integration tests, including scoped Claustrum custody, billing lineage, a fail-closed network guard, and test-host filesystem/daemon isolation. `preload-sandbox.ts` initializes a disposable sandbox before `setup.ts` imports Core (whose logger/dump defaults are fixed at module load), then `setup.ts` restores account, sidebar, cache, RPC, dump, and daemon paths before/after tests so clearing a feature override cannot reach live state. - `packages/pi/src/tests/`: Pi-specific tests (commands, convert, effort-history, index, stream) -- `packages/e2e-tests/tests/`: Integration tests (tool prefix, quota header relay, temp directory hygiene, custody mode, mock claustrum) +- `packages/e2e-tests/tests/`: Process integration tests (tool prefix, quota header relay, temp directory hygiene, scoped custody, and Opus 5.5 structured output with and without the schema tool call) ## Naming Conventions diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 603fc0403..1d09fd530 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -12,6 +12,9 @@ This package is a CortexKit-maintained fork of the original `@ex-machina/opencod ### Patch Changes +- Exclude Opus 4.6 and 4.7 from fast mode: only Opus 4.8, Opus 5, and Opus 5.5 receive the `speed` field and beta header (#267). +- On Opus 5.5, remove unsupported forced tool choice without removing the StructuredOutput tool; OpenCode accepts its validated schema result and fails closed when the model instead completes without it. Prefix named tool-choice references alongside tool definitions for other models (#268). +- Keep the entire OpenCode unit-test environment under a disposable root, including fallback account/sidebar/cache/RPC paths and a nonexistent Claustrum connection. Setup detection respects its injected environment rather than consulting the operator's daemon (#264, #265). - Persist scoped main quotas with the vault's primary account identity, restore `/claude-quota` polling with scoped credentials, and discover model-scoped limits from usage polls even when response headers keep general windows fresh. A failed usage poll is bounded per account, without counting lock-losing contenders. - Log scoped OAuth 401 re-authorization decisions and delivered failure reports at debug level with record-version provenance but no bearer tokens. - Update scoped Claustrum transport through the shared 0.4.0 client; a daemon that does not serve the Claustrum module now fails without a futile reconnect. From b504bc843c561e911bf6e405b7efecf82ec62886 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 25 Sep 2026 20:54:45 +0200 Subject: [PATCH 19/19] ci: ratchet v2 test floors to clean-runner counts --- .ci/test-count-floors.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.ci/test-count-floors.json b/.ci/test-count-floors.json index 31648432a..990d6f12e 100644 --- a/.ci/test-count-floors.json +++ b/.ci/test-count-floors.json @@ -1,17 +1,17 @@ { "floors": { - "core": 293, - "opencode": 1685, - "pi": 140 + "core": 302, + "opencode": 1700, + "pi": 145 }, "measurement": { - "head": "af39724c183d17f94e13403d6189a9c93439156a", + "head": "c0052410b5a58dc1116b1b53512f0ddacea4306e", "dirtyPaths": 0 }, "evidence": { "_note": "Pinned Bun 1.3.14 runner totals after the scoped-only cutover. The measurement head precedes this floor-file update and was clean. Runner counts, not source declaration counts, are the authority at the gate.", - "core": "293 passing on clean CI run 36032206137 at commit af39724c", - "opencode": "1685 passing on clean CI run 36032206137 at commit af39724c", - "pi": "140 passing on clean CI run 36032206137 at commit af39724c" + "core": "302 passing on clean CI run 36175474561 at commit c0052410", + "opencode": "1700 passing on clean CI run 36175474561 at commit c0052410", + "pi": "145 passing on clean CI run 36175474561 at commit c0052410" } }