From 9f6ea949380af6dd4e671532fe901c148370c138 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 2 Oct 2026 10:17:47 +0200 Subject: [PATCH 001/169] feat(storage): isolate pool namespace from legacy account files --- packages/core/src/pool-paths.ts | 109 +++++++++++++++++++++ packages/core/src/tests/pool-paths.test.ts | 89 +++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 packages/core/src/pool-paths.ts create mode 100644 packages/core/src/tests/pool-paths.test.ts diff --git a/packages/core/src/pool-paths.ts b/packages/core/src/pool-paths.ts new file mode 100644 index 000000000..9e7281754 --- /dev/null +++ b/packages/core/src/pool-paths.ts @@ -0,0 +1,109 @@ +import { createHash } from 'node:crypto' +import { realpath } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' + +import { getAccountStatePath, getAccountStoragePath } from './accounts.ts' + +export interface NativePoolPaths { + legacyConfig: string + legacyState: string + config: string + state: string + runtime: string + journal: string + storageId: string +} + +/** Resolve existing parent aliases without creating any migration files. */ +async function canonicalPath(path: string): Promise { + const absolute = resolve(path) + try { + return await realpath(absolute) + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error + } + } + const parent = dirname(absolute) + if (parent === absolute) return absolute + return join(await canonicalPath(parent), basename(absolute)) +} + +function sibling( + path: string, + legacyName: string, + name: string, + suffix: string, +): string { + return basename(path) === legacyName + ? join(dirname(path), name) + : `${path}.${suffix}.json` +} + +/** + * Separate runtime authority from import inputs so a stale legacy writer cannot + * rewrite a stamped credential. Both independently configured paths matter. + */ +export async function resolveNativePoolPaths( + legacyConfig = getAccountStoragePath(), + legacyState = getAccountStatePath(legacyConfig), +): Promise { + const [sourceConfig, sourceState] = await Promise.all([ + canonicalPath(legacyConfig), + canonicalPath(legacyState), + ]) + const config = await canonicalPath( + sibling( + sourceConfig, + 'anthropic-auth.json', + 'anthropic-auth-pool.json', + 'pool', + ), + ) + const state = await canonicalPath( + sibling( + sourceState, + 'anthropic-auth-state.json', + 'anthropic-auth-pool-state.json', + 'pool', + ), + ) + const runtime = await canonicalPath( + sibling( + sourceState, + 'anthropic-auth-state.json', + 'anthropic-auth-native-state.json', + 'native', + ), + ) + const journal = await canonicalPath( + sibling( + sourceState, + 'anthropic-auth-state.json', + 'anthropic-auth-migration.json', + 'migration', + ), + ) + const paths = [sourceConfig, sourceState, config, state, runtime, journal] + if (new Set(paths).size !== paths.length) { + throw new Error( + 'Anthropic account storage paths overlap; migration requires separate files', + ) + } + const storageId = createHash('sha256') + .update(JSON.stringify(['anthropic', config, state])) + .digest('hex') + return { + legacyConfig: sourceConfig, + legacyState: sourceState, + config, + state, + runtime, + journal, + storageId, + } +} diff --git a/packages/core/src/tests/pool-paths.test.ts b/packages/core/src/tests/pool-paths.test.ts new file mode 100644 index 000000000..58e82b321 --- /dev/null +++ b/packages/core/src/tests/pool-paths.test.ts @@ -0,0 +1,89 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtemp, readdir, realpath, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resolveNativePoolPaths } from '../pool-paths.ts' + +const roots: string[] = [] + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'anthropic-pool-paths-')) + roots.push(root) + return realpath(root) +} + +afterEach(async () => { + const owned = roots.splice(0) + await Promise.all( + owned.map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +test('derives an isolated namespace while preserving independent state directories', async () => { + const root = await fixture() + const config = join(root, 'config', 'anthropic-auth.json') + const state = join(root, 'secrets', 'anthropic-auth-state.json') + const paths = await resolveNativePoolPaths(config, state) + expect(paths).toMatchObject({ + legacyConfig: config, + legacyState: state, + config: join(root, 'config', 'anthropic-auth-pool.json'), + state: join(root, 'secrets', 'anthropic-auth-pool-state.json'), + runtime: join(root, 'secrets', 'anthropic-auth-native-state.json'), + journal: join(root, 'secrets', 'anthropic-auth-migration.json'), + }) + expect(paths.storageId).toMatch(/^[a-f0-9]{64}$/) + expect(await readdir(root)).toEqual([]) +}) + +test('custom config and state names retain their respective namespaces', async () => { + const root = await fixture() + const paths = await resolveNativePoolPaths( + join(root, 'project.json'), + join(root, 'private', 'token.json'), + ) + expect(paths.config).toBe(join(root, 'project.json.pool.json')) + expect(paths.state).toBe(join(root, 'private', 'token.json.pool.json')) + expect(paths.runtime).toBe(join(root, 'private', 'token.json.native.json')) + expect(paths.journal).toBe(join(root, 'private', 'token.json.migration.json')) +}) + +test('storage identity includes both paths and normalizes directory aliases', async () => { + const root = await fixture() + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + const changedConfig = await resolveNativePoolPaths( + join(root, 'other.json'), + paths.legacyState, + ) + const changedState = await resolveNativePoolPaths( + paths.legacyConfig, + join(root, 'other-state.json'), + ) + expect(changedConfig.storageId).not.toBe(paths.storageId) + expect(changedState.storageId).not.toBe(paths.storageId) + + const aliasRoot = await fixture() + const alias = join(aliasRoot, 'link') + await symlink(root, alias, 'dir') + const same = await resolveNativePoolPaths( + join(alias, 'anthropic-auth.json'), + join(alias, 'anthropic-auth-state.json'), + ) + expect(same).toEqual(paths) +}) + +test('refuses source or derived file collisions without creating a file', async () => { + const root = await fixture() + const config = join(root, 'anthropic-auth.json') + await expect(resolveNativePoolPaths(config, config)).rejects.toThrow( + 'paths overlap', + ) + await expect( + resolveNativePoolPaths(config, join(root, 'anthropic-auth-pool.json')), + ).rejects.toThrow('paths overlap') + expect(await readdir(root)).toEqual([]) +}) From fb4a66f422035554dfcfc1f05be5246039092a60 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 2 Oct 2026 11:22:45 +0200 Subject: [PATCH 002/169] feat(core): bundle strict shared account storage machinery --- bun.lock | 5 + packages/core/package.json | 5 +- packages/core/scripts/build.ts | 31 ++++ packages/core/src/pool-store.ts | 41 +++++ packages/core/src/tests/build-core.test.ts | 60 ++++++ packages/core/src/tests/pool-store.test.ts | 202 +++++++++++++++++++++ 6 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 packages/core/scripts/build.ts create mode 100644 packages/core/src/pool-store.ts create mode 100644 packages/core/src/tests/build-core.test.ts create mode 100644 packages/core/src/tests/pool-store.test.ts diff --git a/bun.lock b/bun.lock index edc2d6176..e938d446b 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,9 @@ "@cortexkit/claustrum-client": "0.4.0", "xxhash-wasm": "^1.1.0", }, + "devDependencies": { + "@cortexkit/common-auth": "^0.4.6", + }, }, "packages/e2e-tests": { "name": "@cortexkit/anthropic-auth-e2e", @@ -214,6 +217,8 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.4.0", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-xjUXf/lB3T8R7oaOu9T1Obnn47bAdN5UbyaTpywyiY4wdvTbqHBnB3qX0KpDFTeZ7m35tRPDevcWvOzJIgUhMw=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.4.6", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.5.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.12", "@opentui/solid": ">=0.5.12", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-/FFqtu6huWBU632GOAORQk/olkvI/1Tw3H0+zxA8cLWOfwiNc7kIptb3+3cQYlYBpH7lG4SAqhnFGFkJLo0lWQ=="], + "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], "@cortexkit/pi-anthropic-auth": ["@cortexkit/pi-anthropic-auth@workspace:packages/pi"], diff --git a/packages/core/package.json b/packages/core/package.json index 2b248a7d5..42c59588b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,12 +21,15 @@ "LICENSE" ], "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", + "build": "rm -rf dist && tsc -p tsconfig.build.json --emitDeclarationOnly && bun scripts/build.ts", "test": "bun run build && bun test src/tests", "typecheck": "tsc", "types": "tsc", "prepublishOnly": "bun run build" }, + "devDependencies": { + "@cortexkit/common-auth": "^0.4.6" + }, "dependencies": { "@cortexkit/claustrum-client": "0.4.0", "xxhash-wasm": "^1.1.0" diff --git a/packages/core/scripts/build.ts b/packages/core/scripts/build.ts new file mode 100644 index 000000000..4d8a47f66 --- /dev/null +++ b/packages/core/scripts/build.ts @@ -0,0 +1,31 @@ +import { basename, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +interface CoreBundleOptions { + entrypoint: string + outfile: string +} + +/** Runtime dependencies retain their package-owned resource and IPC loaders. */ +const RUNTIME_EXTERNALS = ['xxhash-wasm', '@cortexkit/claustrum-client'] + +export async function bundleCore(options: CoreBundleOptions): Promise { + const result = await Bun.build({ + entrypoints: [options.entrypoint], + outdir: dirname(options.outfile), + naming: basename(options.outfile), + target: 'node', + format: 'esm', + external: RUNTIME_EXTERNALS, + }) + if (!result.success) { + throw new AggregateError(result.logs, 'Core bundling failed') + } +} + +if (import.meta.main) { + await bundleCore({ + entrypoint: fileURLToPath(new URL('../src/index.ts', import.meta.url)), + outfile: fileURLToPath(new URL('../dist/index.js', import.meta.url)), + }) +} diff --git a/packages/core/src/pool-store.ts b/packages/core/src/pool-store.ts new file mode 100644 index 000000000..8cfc3b366 --- /dev/null +++ b/packages/core/src/pool-store.ts @@ -0,0 +1,41 @@ +import { openPoolStore } from '@cortexkit/common-auth/store' + +import type { NativePoolPaths } from './pool-paths.ts' + +interface NativeQuotaCodec { + validate(value: unknown): boolean + merge(stored: unknown | undefined, observation: unknown): unknown +} + +interface NativePoolStoreOptions { + paths: NativePoolPaths + quota: NativeQuotaCodec + now?: () => number +} + +/** + * Pool and native-runtime writers share these locks when they reconcile an + * account binding. The new namespace has no legacy credential writer. + */ +export function nativePoolStoreLocks(paths: NativePoolPaths) { + return [ + { path: paths.config, name: 'pool-config' }, + { path: paths.state, name: 'pool-state' }, + ] as const +} + +/** + * Internal storage machinery, not a startup or migration authorization. Native + * dispatch also requires the offline migration's committed authority journal. + */ +export function createNativePoolStore(options: NativePoolStoreOptions) { + return openPoolStore({ + provider: 'anthropic', + configPath: options.paths.config, + statePath: options.paths.state, + storeLocks: nativePoolStoreLocks(options.paths), + requireCredentialStamps: true, + quota: options.quota, + now: options.now, + }) +} diff --git a/packages/core/src/tests/build-core.test.ts b/packages/core/src/tests/build-core.test.ts new file mode 100644 index 000000000..562ad6887 --- /dev/null +++ b/packages/core/src/tests/build-core.test.ts @@ -0,0 +1,60 @@ +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 { fileURLToPath } from 'node:url' + +import { bundleCore } from '../../scripts/build.ts' + +const roots: string[] = [] + +afterEach(async () => { + const owned = roots.splice(0) + await Promise.all( + owned.map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +test('bundles shared store and file primitives into an independent Node entrypoint', async () => { + const sourceRoot = await mkdtemp( + fileURLToPath(new URL('../../.bundle-test-', import.meta.url)), + ) + const outputRoot = await mkdtemp(join(tmpdir(), 'anthropic-core-bundle-')) + roots.push(sourceRoot, outputRoot) + const source = join(sourceRoot, 'entry with spaces.ts') + const outfile = join(outputRoot, 'entry with spaces.mjs') + await writeFile( + source, + ` + import { openPoolStore } from '@cortexkit/common-auth/store' + import { writeJsonAtomic } from '@cortexkit/common-auth/fs' + if (typeof openPoolStore !== 'function' || typeof writeJsonAtomic !== 'function') throw new Error('missing functions') + const root = process.argv[2] + const store = openPoolStore({ + provider: 'anthropic', configPath: root + '/pool.json', statePath: root + '/pool-state.json', + requireCredentialStamps: true, + quota: { validate: x => typeof x === 'number', merge: (_, x) => x }, + }) + await store.add({ id: 'a', credential: { type: 'oauth', access: 'synthetic-access', refresh: 'synthetic-refresh', expires: 4000000000000 } }) + const loaded = await store.read() + if (loaded.status !== 'ready' || loaded.rows.length !== 1 || loaded.rows[0].stamp !== 'bound') throw new Error('store did not bind') + await writeJsonAtomic(root + '/result.json', { bound: true }) + `, + ) + await bundleCore({ entrypoint: source, outfile }) + const emitted = await readFile(outfile, 'utf8') + expect(emitted).not.toMatch( + /\b(?:from\s*|import\s*\(|require\s*\()\s*['"]@cortexkit\/common-auth/, + ) + const child = Bun.spawn(['node', outfile, outputRoot], { + cwd: outputRoot, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, NODE_PATH: '' }, + }) + const stderr = await new Response(child.stderr).text() + expect(await child.exited, stderr).toBe(0) + expect( + JSON.parse(await readFile(join(outputRoot, 'result.json'), 'utf8')), + ).toEqual({ bound: true }) +}) diff --git a/packages/core/src/tests/pool-store.test.ts b/packages/core/src/tests/pool-store.test.ts new file mode 100644 index 000000000..f9da2a20c --- /dev/null +++ b/packages/core/src/tests/pool-store.test.ts @@ -0,0 +1,202 @@ +import { afterEach, expect, test } from 'bun:test' +import { + mkdtemp, + readFile, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore, nativePoolStoreLocks } from '../pool-store.ts' + +const roots: string[] = [] +const quota = { + validate: (value: unknown) => + typeof value === 'number' && Number.isFinite(value), + merge: (_stored: unknown, observation: unknown) => observation, +} + +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'anthropic-native-pool-')), + ) + roots.push(root) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + return { root, paths, store: createNativePoolStore({ paths, quota }) } +} + +function credential(suffix: string) { + return { + type: 'oauth' as const, + access: `access-${suffix}`, + refresh: `refresh-${suffix}`, + expires: 4_000_000_000_000, + } +} + +async function requiredRow(store: ReturnType) { + const loaded = await store.read() + if (loaded.status !== 'ready' || loaded.rows.length !== 1) + throw new Error('Expected one ready fixture row') + const row = loaded.rows[0] + if (!row) throw new Error('Expected a fixture row') + return row +} + +async function bytes( + paths: Awaited>, +) { + return Promise.all([ + readFile(paths.config, 'utf8'), + readFile(paths.state, 'utf8'), + ]) +} + +afterEach(async () => { + const owned = roots.splice(0) + await Promise.all( + owned.map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +test('opens only the new namespace and never falls back to legacy credentials', async () => { + const { paths, store } = await fixture() + const legacyConfig = + '{"version":1,"accounts":[{"id":"legacy","type":"oauth"}]}' + const legacyState = + '{"version":1,"accounts":{"legacy":{"access":"legacy-access","refresh":"legacy-refresh","expires":4000000000000}}}' + await writeFile(paths.legacyConfig, legacyConfig) + await writeFile(paths.legacyState, legacyState) + expect(await store.read()).toMatchObject({ status: 'ready', rows: [] }) + await expect(stat(paths.config)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(stat(paths.state)).rejects.toMatchObject({ code: 'ENOENT' }) + await store.initialize() + expect(await store.read()).toMatchObject({ status: 'ready', rows: [] }) + expect(await readFile(paths.legacyConfig, 'utf8')).toBe(legacyConfig) + expect(await readFile(paths.legacyState, 'utf8')).toBe(legacyState) +}) + +test('uses explicit shared locks and writes new credentials only through the stamped store', async () => { + const { paths, store } = await fixture() + expect(nativePoolStoreLocks(paths)).toEqual([ + { path: paths.config, name: 'pool-config' }, + { path: paths.state, name: 'pool-state' }, + ]) + await store.initialize() + await store.add({ + id: 'a', + identity: 'account-a', + credential: credential('a'), + }) + expect(await requiredRow(store)).toMatchObject({ + candidate: true, + stamp: 'bound', + credentialEpoch: 1, + identity: 'account-a', + }) + const [config, state] = await bytes(paths) + expect(config).not.toContain('access-a') + expect(config).not.toContain('refresh-a') + expect(state).toContain('access-a') + expect((await stat(paths.config)).mode & 0o777).toBe(0o600) + expect((await stat(paths.state)).mode & 0o777).toBe(0o600) +}) + +test('strict mode cannot be disabled by additional caller options', async () => { + const { paths, store } = await fixture() + await store.initialize() + await store.add({ + id: 'a', + identity: 'account-a', + credential: credential('a'), + }) + const state = JSON.parse(await readFile(paths.state, 'utf8')) + delete state.accounts.a.commonAuthPool + await writeFile(paths.state, JSON.stringify(state)) + const before = await bytes(paths) + const untrustedOptions = { paths, quota, requireCredentialStamps: false } + const strict = createNativePoolStore(untrustedOptions) + expect(await requiredRow(strict)).toMatchObject({ + candidate: false, + stamp: 'missing', + unbound: true, + }) + let providerCalls = 0 + await expect( + strict.refresh('a', async () => { + providerCalls++ + return credential('new') + }), + ).rejects.toMatchObject({ kind: 'unbound-credential' }) + expect(providerCalls).toBe(0) + expect(await bytes(paths)).toEqual(before) +}) + +test('refuses same-epoch access corruption before refresh or observation persistence', async () => { + const { paths, store } = await fixture() + await store.initialize() + await store.add({ + id: 'a', + identity: 'account-a', + credential: credential('a'), + }) + const state = JSON.parse(await readFile(paths.state, 'utf8')) + state.accounts.a.access = 'foreign-access' + await writeFile(paths.state, JSON.stringify(state)) + const before = await bytes(paths) + expect(await requiredRow(store)).toMatchObject({ + candidate: false, + stamp: 'mismatched', + unbound: true, + }) + let providerCalls = 0 + await expect( + store.refresh('a', async () => { + providerCalls++ + return credential('new') + }), + ).rejects.toMatchObject({ kind: 'unbound-credential' }) + await expect( + store.recordQuota('a', { credentialEpoch: 1, identity: 'account-a' }, 40), + ).rejects.toMatchObject({ kind: 'unbound-credential' }) + expect(providerCalls).toBe(0) + expect(await bytes(paths)).toEqual(before) +}) + +test('validated replacement recovers unbound material and drops old observations', async () => { + const { paths, store } = await fixture() + await store.initialize() + await store.add({ + id: 'a', + identity: 'account-a', + credential: credential('a'), + }) + await store.recordQuota( + 'a', + { credentialEpoch: 1, identity: 'account-a' }, + 40, + ) + const state = JSON.parse(await readFile(paths.state, 'utf8')) + state.accounts.a.access = 'foreign-access' + await writeFile(paths.state, JSON.stringify(state)) + await store.replace('a', credential('b'), { identity: 'account-b' }) + const replaced = await requiredRow(store) + expect(replaced).toMatchObject({ + candidate: true, + stamp: 'bound', + credentialEpoch: 2, + identity: 'account-b', + }) + expect(replaced.quota).toBeUndefined() + await expect( + store.recordQuota('a', { credentialEpoch: 1, identity: 'account-a' }, 80), + ).rejects.toMatchObject({ kind: 'attribution' }) + expect((await requiredRow(store)).quota).toBeUndefined() +}) From 2ab61f82146de369415f385c454e09e46879fa69 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 2 Oct 2026 11:22:45 +0200 Subject: [PATCH 003/169] feat(migration): guard pool authority with a forward-only journal --- packages/core/src/pool-authority.ts | 232 +++++++++++++++ .../src/tests/pool-authority-crash-child.ts | 51 ++++ .../core/src/tests/pool-authority.test.ts | 268 ++++++++++++++++++ 3 files changed, 551 insertions(+) create mode 100644 packages/core/src/pool-authority.ts create mode 100644 packages/core/src/tests/pool-authority-crash-child.ts create mode 100644 packages/core/src/tests/pool-authority.test.ts diff --git a/packages/core/src/pool-authority.ts b/packages/core/src/pool-authority.ts new file mode 100644 index 000000000..7bf2135dd --- /dev/null +++ b/packages/core/src/pool-authority.ts @@ -0,0 +1,232 @@ +import { readFile } from 'node:fs/promises' +import { withLock, writeJsonAtomic } from '@cortexkit/common-auth/fs' + +import { parseJsonRedacted } from './json.ts' +import type { NativePoolPaths } from './pool-paths.ts' + +const PHASES = [ + 'building', + 'verified', + 'activation-installed', + 'committed', + 'retired', +] as const +export type NativeMigrationPhase = (typeof PHASES)[number] + +export interface NativeMigrationSources { + config: string | null + state: string | null + hostAuth: string | null +} + +export interface NativeMigrationJournal { + version: 1 + storageId: string + host: 'opencode' | 'pi' + phase: NativeMigrationPhase + sources: NativeMigrationSources +} + +export class NativeAuthorityError extends Error { + constructor( + public readonly code: + | 'migration-required' + | 'migration-incomplete' + | 'invalid-journal' + | 'journal-conflict', + ) { + super( + { + 'migration-required': + 'Anthropic account migration is required; run setup', + 'migration-incomplete': + 'Anthropic account migration is incomplete; resume setup', + 'invalid-journal': 'Anthropic account migration journal is invalid', + 'journal-conflict': 'Anthropic account migration journal changed', + }[code], + ) + this.name = 'NativeAuthorityError' + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isPhase(value: unknown): value is NativeMigrationPhase { + return PHASES.some((phase) => phase === value) +} + +function isDigest(value: unknown): value is string | null { + return ( + value === null || + (typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)) + ) +} + +function isSources(value: unknown): value is NativeMigrationSources { + return ( + isRecord(value) && + Object.keys(value).length === 3 && + isDigest(value.config) && + isDigest(value.state) && + isDigest(value.hostAuth) + ) +} + +function decodeJournal( + value: unknown, + storageId: string, +): NativeMigrationJournal { + if ( + !isRecord(value) || + Object.keys(value).length !== 5 || + value.version !== 1 || + value.storageId !== storageId || + (value.host !== 'opencode' && value.host !== 'pi') || + !isPhase(value.phase) || + !isSources(value.sources) + ) { + throw new NativeAuthorityError('invalid-journal') + } + return { + version: 1, + storageId, + host: value.host, + phase: value.phase, + sources: { + config: value.sources.config, + state: value.sources.state, + hostAuth: value.sources.hostAuth, + }, + } +} + +export async function readNativeMigrationJournal( + paths: NativePoolPaths, +): Promise { + let text: string + try { + text = await readFile(paths.journal, 'utf8') + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return undefined + throw new NativeAuthorityError('invalid-journal') + } + try { + return decodeJournal(parseJsonRedacted(text), paths.storageId) + } catch { + throw new NativeAuthorityError('invalid-journal') + } +} + +/** An empty or readable store is not proof that the offline authority flip completed. */ +export async function requireNativePoolAuthority( + paths: NativePoolPaths, +): Promise { + const journal = await readNativeMigrationJournal(paths) + if (!journal) throw new NativeAuthorityError('migration-required') + if (journal.phase !== 'committed' && journal.phase !== 'retired') { + throw new NativeAuthorityError('migration-incomplete') + } +} + +interface JournalHooks { + lockOptions?: { ttlMs: number; timeoutMs: number } + onWriteStep?: ( + step: 'before-write' | 'after-write', + journal: NativeMigrationJournal, + ) => Promise +} + +async function writeJournal( + paths: NativePoolPaths, + journal: NativeMigrationJournal, + assertOwned: () => Promise, + hooks: JournalHooks, +): Promise { + await writeJsonAtomic(paths.journal, journal, { + beforeRename: async () => { + await hooks.onWriteStep?.('before-write', journal) + await assertOwned() + }, + }) + await hooks.onWriteStep?.('after-write', journal) +} + +/** Existing source baselines cannot be replaced by a new migration attempt. */ +export async function beginNativeMigration( + paths: NativePoolPaths, + input: { host: 'opencode' | 'pi'; sources: NativeMigrationSources }, + hooks: JournalHooks = {}, +): Promise { + const initial = decodeJournal( + { + version: 1, + storageId: paths.storageId, + host: input.host, + phase: 'building', + sources: input.sources, + }, + paths.storageId, + ) + return withLock( + paths.journal, + { + name: 'migration-journal', + ttlMs: 30_000, + timeoutMs: 15_000, + ...hooks.lockOptions, + renew: true, + }, + async (lock) => { + const current = await readNativeMigrationJournal(paths) + if (current) { + if ( + current.host !== initial.host || + JSON.stringify(current.sources) !== JSON.stringify(initial.sources) + ) { + throw new NativeAuthorityError('journal-conflict') + } + return current + } + await writeJournal(paths, initial, () => lock.assertOwned(), hooks) + return initial + }, + ) +} + +/** Only the immediately following phase may be committed; retries are idempotent. */ +export async function advanceNativeMigration( + paths: NativePoolPaths, + expected: NativeMigrationPhase, + next: NativeMigrationPhase, + hooks: JournalHooks = {}, +): Promise { + if ( + !isPhase(expected) || + !isPhase(next) || + PHASES.indexOf(next) !== PHASES.indexOf(expected) + 1 + ) + throw new NativeAuthorityError('journal-conflict') + return withLock( + paths.journal, + { + name: 'migration-journal', + ttlMs: 30_000, + timeoutMs: 15_000, + ...hooks.lockOptions, + renew: true, + }, + async (lock) => { + const current = await readNativeMigrationJournal(paths) + if (!current) throw new NativeAuthorityError('migration-required') + if (current.phase === next) return current + if (current.phase !== expected) + throw new NativeAuthorityError('journal-conflict') + const updated = { ...current, phase: next } + await writeJournal(paths, updated, () => lock.assertOwned(), hooks) + return updated + }, + ) +} diff --git a/packages/core/src/tests/pool-authority-crash-child.ts b/packages/core/src/tests/pool-authority-crash-child.ts new file mode 100644 index 000000000..966da6522 --- /dev/null +++ b/packages/core/src/tests/pool-authority-crash-child.ts @@ -0,0 +1,51 @@ +import { join } from 'node:path' +import { + advanceNativeMigration, + beginNativeMigration, + type NativeMigrationPhase, +} from '../pool-authority.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' + +const root = process.argv[2] +const action = process.argv[3] +const point = process.argv[4] +if (!root || !action || (point !== 'before-write' && point !== 'after-write')) + throw new Error('Invalid fixture arguments') +const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), +) +const hooks = { + lockOptions: { ttlMs: 100, timeoutMs: 3_000 }, + onWriteStep: async (step: 'before-write' | 'after-write') => { + if (step === point) process.exit(19) + }, +} +if (action === 'building') { + await beginNativeMigration( + paths, + { + host: 'opencode', + sources: { + config: 'a'.repeat(64), + state: 'b'.repeat(64), + hostAuth: null, + }, + }, + hooks, + ) +} else { + const transitions = new Map< + string, + [NativeMigrationPhase, NativeMigrationPhase] + >([ + ['verified', ['building', 'verified']], + ['activation-installed', ['verified', 'activation-installed']], + ['committed', ['activation-installed', 'committed']], + ['retired', ['committed', 'retired']], + ]) + const transition = transitions.get(action) + if (!transition) throw new Error('Invalid fixture transition') + await advanceNativeMigration(paths, transition[0], transition[1], hooks) +} +throw new Error('Expected fixture exit') diff --git a/packages/core/src/tests/pool-authority.test.ts b/packages/core/src/tests/pool-authority.test.ts new file mode 100644 index 000000000..22e08088f --- /dev/null +++ b/packages/core/src/tests/pool-authority.test.ts @@ -0,0 +1,268 @@ +import { afterEach, expect, test } from 'bun:test' +import { + mkdtemp, + readFile, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + advanceNativeMigration, + beginNativeMigration, + type NativeMigrationPhase, + readNativeMigrationJournal, + requireNativePoolAuthority, +} from '../pool-authority.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore } from '../pool-store.ts' + +const roots: string[] = [] +const input = { + host: 'opencode' as const, + sources: { config: 'a'.repeat(64), state: 'b'.repeat(64), hostAuth: null }, +} + +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'anthropic-pool-authority-')), + ) + roots.push(root) + return resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) +} + +afterEach(async () => { + const owned = roots.splice(0) + await Promise.all( + owned.map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +test('a readable empty pool without a committed journal cannot authorize dispatch', async () => { + const paths = await fixture() + const store = createNativePoolStore({ + paths, + quota: { validate: () => true, merge: (_, value) => value }, + }) + expect(await store.read()).toMatchObject({ status: 'ready', rows: [] }) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-required', + }) + await expect(stat(paths.journal)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +test('authority is unavailable until the forward-only commit, and survives cleanup', async () => { + const paths = await fixture() + await beginNativeMigration(paths, input) + expect((await stat(paths.journal)).mode & 0o777).toBe(0o600) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + await advanceNativeMigration(paths, 'building', 'verified') + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + await advanceNativeMigration(paths, 'verified', 'activation-installed') + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + await advanceNativeMigration(paths, 'activation-installed', 'committed') + await requireNativePoolAuthority(paths) + await advanceNativeMigration(paths, 'committed', 'retired') + await requireNativePoolAuthority(paths) +}) + +test('exact replay is idempotent, while changed sources and host ownership refuse', async () => { + const paths = await fixture() + const first = await beginNativeMigration(paths, input) + const before = await readFile(paths.journal, 'utf8') + expect(await beginNativeMigration(paths, input)).toEqual(first) + await expect( + beginNativeMigration(paths, { + ...input, + sources: { ...input.sources, state: 'c'.repeat(64) }, + }), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + await expect( + beginNativeMigration(paths, { ...input, host: 'pi' }), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + await advanceNativeMigration(paths, 'building', 'verified') + const verified = await readFile(paths.journal, 'utf8') + await advanceNativeMigration(paths, 'building', 'verified') + expect(await readFile(paths.journal, 'utf8')).toBe(verified) + expect((await beginNativeMigration(paths, input)).phase).toBe('verified') +}) + +test('skipped or backward phases never change the journal', async () => { + const paths = await fixture() + await beginNativeMigration(paths, input) + const before = await readFile(paths.journal, 'utf8') + await expect( + advanceNativeMigration(paths, 'building', 'committed'), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + await expect( + advanceNativeMigration(paths, 'verified', 'activation-installed'), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + await advanceNativeMigration(paths, 'building', 'verified') + const verified = await readFile(paths.journal, 'utf8') + await expect( + advanceNativeMigration(paths, 'verified', 'building'), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(verified) +}) + +test('a copied, malformed or credential-bearing journal refuses without overwriting its bytes', async () => { + const paths = await fixture() + await beginNativeMigration(paths, input) + const journal = await readFile(paths.journal, 'utf8') + const other = await fixture() + await writeFile(other.journal, journal) + await expect(requireNativePoolAuthority(other)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + expect(await readFile(other.journal, 'utf8')).toBe(journal) + const secret = 'synthetic-secret-never-in-error' + for (const text of [ + `{"token":"Bearer ${secret}`, + JSON.stringify({ ...JSON.parse(journal), credential: secret }), + ]) { + await writeFile(paths.journal, text) + let caught: unknown + try { + await beginNativeMigration(paths, input) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ code: 'invalid-journal' }) + expect( + caught instanceof Error ? caught.message : String(caught), + ).not.toContain(secret) + expect(await readFile(paths.journal, 'utf8')).toBe(text) + } +}) + +test('interruption before rename leaves no authority, while interruption after rename resumes forward', async () => { + const paths = await fixture() + const crash = async () => { + throw new Error('simulated interruption') + } + await expect( + beginNativeMigration(paths, input, { + onWriteStep: async (step) => { + if (step === 'before-write') await crash() + }, + }), + ).rejects.toThrow('simulated interruption') + expect(await readNativeMigrationJournal(paths)).toBeUndefined() + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-required', + }) + await expect( + beginNativeMigration(paths, input, { + onWriteStep: async (step) => { + if (step === 'after-write') await crash() + }, + }), + ).rejects.toThrow('simulated interruption') + expect((await beginNativeMigration(paths, input)).phase).toBe('building') + await advanceNativeMigration(paths, 'building', 'verified') + await advanceNativeMigration(paths, 'verified', 'activation-installed') + await expect( + advanceNativeMigration(paths, 'activation-installed', 'committed', { + onWriteStep: async (step) => { + if (step === 'after-write') await crash() + }, + }), + ).rejects.toThrow('simulated interruption') + await requireNativePoolAuthority(paths) + await advanceNativeMigration(paths, 'activation-installed', 'committed') + await advanceNativeMigration(paths, 'committed', 'retired') + await requireNativePoolAuthority(paths) +}) + +test('concurrent starts publish one journal without rewriting its baselines', async () => { + const paths = await fixture() + let writes = 0 + const hooks = { + onWriteStep: async (step: string) => { + if (step === 'after-write') writes++ + }, + } + const journals = await Promise.all( + Array.from({ length: 4 }, () => beginNativeMigration(paths, input, hooks)), + ) + expect(writes).toBe(1) + expect(journals.every((journal) => journal.phase === 'building')).toBe(true) + expect((await readNativeMigrationJournal(paths))?.sources).toEqual( + input.sources, + ) +}) + +const phases: NativeMigrationPhase[] = [ + 'building', + 'verified', + 'activation-installed', + 'committed', + 'retired', +] + +for (const point of ['before-write', 'after-write'] as const) { + for (const [index, target] of phases.entries()) { + test(`real process exit ${point} at ${target} preserves the authority boundary and resumes forward`, async () => { + const paths = await fixture() + const previous = phases[index - 1] + if (previous) { + await beginNativeMigration(paths, input) + for (let phase = 1; phase < index; phase++) { + const before = phases[phase - 1] + const after = phases[phase] + if (!before || !after) throw new Error('Invalid fixture stage') + await advanceNativeMigration(paths, before, after) + } + } + const child = Bun.spawn( + [ + process.execPath, + fileURLToPath( + new URL('./pool-authority-crash-child.ts', import.meta.url), + ), + dirname(paths.config), + target, + point, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const stderr = await new Response(child.stderr).text() + expect(await child.exited, stderr).toBe(19) + const expected = point === 'after-write' ? target : previous + expect((await readNativeMigrationJournal(paths))?.phase).toBe(expected) + if (expected === 'committed' || expected === 'retired') { + await requireNativePoolAuthority(paths) + } else { + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: expected ? 'migration-incomplete' : 'migration-required', + }) + } + const hooks = { lockOptions: { ttlMs: 100, timeoutMs: 3_000 } } + if (!previous) await beginNativeMigration(paths, input, hooks) + else await advanceNativeMigration(paths, previous, target, hooks) + for (let phase = index + 1; phase < phases.length; phase++) { + const before = phases[phase - 1] + const after = phases[phase] + if (!before || !after) throw new Error('Invalid fixture stage') + await advanceNativeMigration(paths, before, after, hooks) + } + await requireNativePoolAuthority(paths) + expect((await readNativeMigrationJournal(paths))?.phase).toBe('retired') + }) + } +} From 2bf66fb882d6ced9df73a7e1531bee45eed8b6d3 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 2 Oct 2026 12:50:43 +0200 Subject: [PATCH 004/169] feat(core): fence native observations with immutable account bindings --- packages/core/src/pool-binding.ts | 130 ++++++++++++ packages/core/src/tests/pool-binding.test.ts | 211 +++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 packages/core/src/pool-binding.ts create mode 100644 packages/core/src/tests/pool-binding.test.ts diff --git a/packages/core/src/pool-binding.ts b/packages/core/src/pool-binding.ts new file mode 100644 index 000000000..e5db8af21 --- /dev/null +++ b/packages/core/src/pool-binding.ts @@ -0,0 +1,130 @@ +import { createHash } from 'node:crypto' + +import type { NativePoolPaths } from './pool-paths.ts' + +export interface NativeLocalPoolBinding { + readonly kind: 'local' + readonly storageId: string + readonly rowId: string + readonly credentialEpoch: number + readonly identity?: string +} + +interface BindingSourceRow { + id: string + identity?: string + credentialEpoch?: number + candidate: boolean + stamp?: string + invalid?: string + torn?: boolean + unbound?: boolean +} + +export class NativePoolBindingError extends Error { + constructor() { + super('Anthropic account binding is unavailable or changed') + this.name = 'NativePoolBindingError' + } +} + +function nonempty(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.trim() === value +} + +function validEpoch(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + +export function isNativeLocalPoolBinding( + value: unknown, +): value is NativeLocalPoolBinding { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record: Record = value as Record + return ( + record.kind === 'local' && + typeof record.storageId === 'string' && + /^[a-f0-9]{64}$/.test(record.storageId) && + nonempty(record.rowId) && + validEpoch(record.credentialEpoch) && + (record.identity === undefined || nonempty(record.identity)) && + ['kind', 'storageId', 'rowId', 'credentialEpoch'].every((key) => + Object.hasOwn(record, key), + ) && + (record.identity === undefined || Object.hasOwn(record, 'identity')) && + Object.keys(record).every((key) => + ['kind', 'storageId', 'rowId', 'credentialEpoch', 'identity'].includes( + key, + ), + ) + ) +} + +function coherent(row: BindingSourceRow): boolean { + return ( + row.stamp === 'bound' && + !row.invalid && + !row.torn && + !row.unbound && + nonempty(row.id) && + validEpoch(row.credentialEpoch) && + (row.identity === undefined || nonempty(row.identity)) + ) +} + +/** Capture the selected row before asynchronous work, never the newest primary afterward. */ +export function captureNativeLocalPoolBinding( + paths: NativePoolPaths, + row: BindingSourceRow, +): NativeLocalPoolBinding { + const credentialEpoch = row.credentialEpoch + if (!row.candidate || !coherent(row) || !validEpoch(credentialEpoch)) + throw new NativePoolBindingError() + const binding: NativeLocalPoolBinding = { + kind: 'local', + storageId: paths.storageId, + rowId: row.id, + credentialEpoch, + ...(row.identity !== undefined ? { identity: row.identity } : {}), + } + if (!isNativeLocalPoolBinding(binding)) throw new NativePoolBindingError() + return Object.freeze(binding) +} + +/** + * A response still belongs to an account disabled after dispatch. Matching + * this snapshot does not make that account eligible for a new request. + */ +export function nativeLocalPoolBindingMatches( + binding: NativeLocalPoolBinding, + paths: NativePoolPaths, + row: BindingSourceRow, +): boolean { + return ( + isNativeLocalPoolBinding(binding) && + coherent(row) && + binding.storageId === paths.storageId && + binding.rowId === row.id && + binding.credentialEpoch === row.credentialEpoch && + binding.identity === row.identity + ) +} + +/** Unknown row ids and proven account identities occupy different refresh namespaces. */ +export function nativeLocalRefreshJobKey( + binding: NativeLocalPoolBinding, +): string { + if (!isNativeLocalPoolBinding(binding)) throw new NativePoolBindingError() + return createHash('sha256') + .update( + JSON.stringify([ + 'anthropic-local-refresh', + binding.storageId, + binding.credentialEpoch, + binding.identity === undefined + ? ['row', binding.rowId] + : ['account', binding.identity], + ]), + ) + .digest('hex') +} diff --git a/packages/core/src/tests/pool-binding.test.ts b/packages/core/src/tests/pool-binding.test.ts new file mode 100644 index 000000000..19d743b31 --- /dev/null +++ b/packages/core/src/tests/pool-binding.test.ts @@ -0,0 +1,211 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + captureNativeLocalPoolBinding, + isNativeLocalPoolBinding, + nativeLocalPoolBindingMatches, + nativeLocalRefreshJobKey, +} from '../pool-binding.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore } from '../pool-store.ts' + +const roots: string[] = [] + +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'anthropic-pool-binding-')), + ) + roots.push(root) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + const store = createNativePoolStore({ + paths, + quota: { validate: () => true, merge: (_, value) => value }, + }) + await store.add({ + id: 'a', + identity: 'account-a', + credential: { + type: 'oauth', + access: 'synthetic-access', + refresh: 'synthetic-refresh', + expires: 4_000_000_000_000, + }, + }) + return { paths, store } +} + +async function row(store: ReturnType) { + const loaded = await store.read() + if (loaded.status !== 'ready' || !loaded.rows[0]) + throw new Error('Expected fixture row') + return loaded.rows[0] +} + +afterEach(async () => { + const owned = roots.splice(0) + await Promise.all( + owned.map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +test('capture copies and freezes ownership before the selected row changes', async () => { + const { paths, store } = await fixture() + const selected = await row(store) + const binding = captureNativeLocalPoolBinding(paths, selected) + expect(Object.isFrozen(binding)).toBe(true) + expect(binding.identity).toBe('account-a') + selected.identity = 'account-b' + expect(binding.identity).toBe('account-a') + expect(nativeLocalPoolBindingMatches(binding, paths, selected)).toBe(false) + expect(() => Object.assign(binding, { identity: 'account-b' })).toThrow( + TypeError, + ) + expect(Object.keys(binding).sort()).toEqual([ + 'credentialEpoch', + 'identity', + 'kind', + 'rowId', + 'storageId', + ]) +}) + +test('same-account rotation retains ownership while replacement and namespace changes do not', async () => { + const { paths, store } = await fixture() + const binding = captureNativeLocalPoolBinding(paths, await row(store)) + const key = nativeLocalRefreshJobKey(binding) + await store.rotate('a', { + type: 'oauth', + access: 'rotated-access', + refresh: 'rotated-refresh', + expires: 4_000_000_000_001, + }) + expect(nativeLocalPoolBindingMatches(binding, paths, await row(store))).toBe( + true, + ) + expect( + nativeLocalRefreshJobKey( + captureNativeLocalPoolBinding(paths, await row(store)), + ), + ).toBe(key) + const other = await fixture() + expect( + nativeLocalPoolBindingMatches(binding, other.paths, await row(other.store)), + ).toBe(false) + await store.replace( + 'a', + { + type: 'oauth', + access: 'replacement-access', + refresh: 'replacement-refresh', + expires: 4_000_000_000_002, + }, + { identity: 'account-b' }, + ) + expect(nativeLocalPoolBindingMatches(binding, paths, await row(store))).toBe( + false, + ) +}) + +test('a disabled row still owns its completed observation but cannot issue a new serving capture', async () => { + const { paths, store } = await fixture() + const binding = captureNativeLocalPoolBinding(paths, await row(store)) + await store.disable('a', 'user') + const disabled = await row(store) + expect(nativeLocalPoolBindingMatches(binding, paths, disabled)).toBe(true) + expect(() => captureNativeLocalPoolBinding(paths, disabled)).toThrow( + 'binding is unavailable', + ) + for (const change of [ + { stamp: 'missing' }, + { torn: true }, + { unbound: true }, + { invalid: 'entry' }, + { credentialEpoch: 0 }, + { credentialEpoch: Number.MAX_SAFE_INTEGER + 1 }, + ]) { + expect( + nativeLocalPoolBindingMatches(binding, paths, { ...disabled, ...change }), + ).toBe(false) + expect(() => + captureNativeLocalPoolBinding(paths, { + ...disabled, + ...change, + candidate: true, + }), + ).toThrow('binding is unavailable') + } +}) + +test('refresh keys separate unknown row ids from proven identities, storage pairs and epochs', async () => { + const { paths, store } = await fixture() + const known = captureNativeLocalPoolBinding(paths, await row(store)) + const knownKey = nativeLocalRefreshJobKey(known) + const unknown = { + kind: 'local' as const, + storageId: known.storageId, + rowId: 'account-a', + credentialEpoch: known.credentialEpoch, + } + expect(nativeLocalRefreshJobKey(unknown)).not.toBe(knownKey) + expect(nativeLocalRefreshJobKey({ ...known, rowId: 'alias' })).toBe(knownKey) + expect( + nativeLocalPoolBindingMatches( + { ...known, rowId: 'alias' }, + paths, + await row(store), + ), + ).toBe(false) + expect( + nativeLocalRefreshJobKey({ + ...known, + credentialEpoch: known.credentialEpoch + 1, + }), + ).not.toBe(knownKey) + const other = await fixture() + expect( + nativeLocalRefreshJobKey({ ...known, storageId: other.paths.storageId }), + ).not.toBe(knownKey) +}) + +test('runtime binding validation rejects secret fields, malformed epochs and custody shapes', async () => { + const { paths, store } = await fixture() + const binding = captureNativeLocalPoolBinding(paths, await row(store)) + expect(isNativeLocalPoolBinding(binding)).toBe(true) + for (const value of [ + null, + [], + { ...binding, access: 'synthetic-secret' }, + { ...binding, credentialEpoch: 1.5 }, + { ...binding, credentialEpoch: Number.NaN }, + { ...binding, storageId: 'not-a-storage-id' }, + { ...binding, identity: '' }, + { ...binding, kind: 'claustrum' }, + ]) { + expect(isNativeLocalPoolBinding(value)).toBe(false) + } +}) + +test('explicit same-identity replacement still revokes the prior credential epoch', async () => { + const { paths, store } = await fixture() + const binding = captureNativeLocalPoolBinding(paths, await row(store)) + await store.replace( + 'a', + { + type: 'oauth', + access: 'replacement-access', + refresh: 'replacement-refresh', + expires: 4_000_000_000_002, + }, + { identity: 'account-a' }, + ) + const replacement = await row(store) + expect(replacement.identity).toBe(binding.identity) + expect(replacement.credentialEpoch).toBe(binding.credentialEpoch + 1) + expect(nativeLocalPoolBindingMatches(binding, paths, replacement)).toBe(false) +}) From 1107670440b87b8a03875a090bd290084b7cef77 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 2 Oct 2026 13:12:51 +0200 Subject: [PATCH 005/169] feat(migration): capture strict secret-safe source baselines --- packages/core/src/migration-source.ts | 206 +++++++++++++++++ .../core/src/tests/migration-source.test.ts | 215 ++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 packages/core/src/migration-source.ts create mode 100644 packages/core/src/tests/migration-source.test.ts diff --git a/packages/core/src/migration-source.ts b/packages/core/src/migration-source.ts new file mode 100644 index 000000000..248f8454e --- /dev/null +++ b/packages/core/src/migration-source.ts @@ -0,0 +1,206 @@ +import { createHash } from 'node:crypto' +import { type BigIntStats, constants } from 'node:fs' +import { lstat, open } from 'node:fs/promises' + +import { parseJsonRedacted } from './json.ts' + +export type NativeMigrationSourceRole = 'config' | 'state' | 'hostAuth' + +export interface NativeMigrationSourceSnapshot { + readonly role: NativeMigrationSourceRole + readonly path: string + readonly digest: string | null + readonly data: Readonly> | undefined + readonly metadata: + | { + readonly uid: number + readonly mode: number + readonly device: string + readonly inode: string + readonly modified: string + readonly changed: string + } + | undefined +} + +export class NativeMigrationSourceError extends Error { + constructor( + public readonly role: NativeMigrationSourceRole, + public readonly code: + | 'source-io' + | 'unsafe-source' + | 'source-changed' + | 'invalid-source' + | 'source-too-large', + ) { + super(`Anthropic migration ${role} input: ${code}`) + this.name = 'NativeMigrationSourceError' + } +} + +const MAX_SOURCE_BYTES = 4 * 1024 * 1024 + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function freezeJson( + root: Record, +): Readonly> { + const pending: object[] = [root] + while (pending.length) { + const item = pending.pop() + if (!item) continue + for (const value of Object.values(item)) { + if (value !== null && typeof value === 'object') pending.push(value) + } + Object.freeze(item) + } + return root +} + +function sourceSnapshot( + value: NativeMigrationSourceSnapshot, +): NativeMigrationSourceSnapshot { + // Credential-bearing import data must not enter routine snapshot logging. + Object.defineProperty(value, 'data', { enumerable: false }) + return Object.freeze(value) +} + +interface SourceReadHooks { + onOpened?: () => Promise +} + +/** + * Capture exact import bytes without the legacy loader's missing-entry recovery. + * Ownership and legacy schema decisions still belong to the offline importer. + */ +export async function readNativeMigrationSource( + role: NativeMigrationSourceRole, + path: string, + hooks: SourceReadHooks = {}, +): Promise { + const missing = sourceSnapshot({ + role, + path, + digest: null, + data: undefined, + metadata: undefined, + }) + let initial: BigIntStats + try { + initial = await lstat(path, { bigint: true }) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return missing + throw new NativeMigrationSourceError(role, 'source-io') + } + if (!initial.isFile() || initial.isSymbolicLink()) + throw new NativeMigrationSourceError(role, 'unsafe-source') + let handle: Awaited> | undefined + try { + handle = await open( + path, + constants.O_RDONLY | + (constants.O_NOFOLLOW ?? 0) | + (constants.O_NONBLOCK ?? 0), + ) + const before = await handle.stat({ bigint: true }) + if ( + !before.isFile() || + before.dev !== initial.dev || + before.ino !== initial.ino + ) { + throw new NativeMigrationSourceError(role, 'source-changed') + } + if (before.size > MAX_SOURCE_BYTES) + throw new NativeMigrationSourceError(role, 'source-too-large') + await hooks.onOpened?.() + const chunks: Buffer[] = [] + let bytes = 0 + for (;;) { + const chunk = Buffer.alloc(64 * 1024) + const read = await handle.read(chunk) + if (!read.bytesRead) break + bytes += read.bytesRead + if (bytes > MAX_SOURCE_BYTES) + throw new NativeMigrationSourceError(role, 'source-too-large') + chunks.push(chunk.subarray(0, read.bytesRead)) + } + const after = await handle.stat({ bigint: true }) + const current = await lstat(path, { bigint: true }) + if ( + before.uid !== after.uid || + before.gid !== after.gid || + before.mode !== after.mode || + current.uid !== before.uid || + current.gid !== before.gid || + current.mode !== before.mode || + before.dev !== after.dev || + before.ino !== after.ino || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs || + before.size !== after.size || + current.dev !== before.dev || + current.ino !== before.ino || + current.isSymbolicLink() || + current.mtimeNs !== before.mtimeNs || + current.ctimeNs !== before.ctimeNs || + current.size !== before.size || + BigInt(bytes) !== before.size + ) { + throw new NativeMigrationSourceError(role, 'source-changed') + } + const content = Buffer.concat(chunks, bytes) + let data: unknown + try { + data = parseJsonRedacted( + new TextDecoder('utf-8', { fatal: true }).decode(content), + ) + } catch { + throw new NativeMigrationSourceError(role, 'invalid-source') + } + if (!isRecord(data)) + throw new NativeMigrationSourceError(role, 'invalid-source') + return sourceSnapshot({ + role, + path, + digest: createHash('sha256').update(content).digest('hex'), + data: freezeJson(data), + metadata: Object.freeze({ + uid: Number(before.uid), + mode: Number(before.mode), + device: before.dev.toString(), + inode: before.ino.toString(), + modified: before.mtimeNs.toString(), + changed: before.ctimeNs.toString(), + }), + }) + } catch (error) { + if (error instanceof NativeMigrationSourceError) throw error + if (error instanceof Error && 'code' in error) { + if (error.code === 'ENOENT') + throw new NativeMigrationSourceError(role, 'source-changed') + if (error.code === 'ELOOP') + throw new NativeMigrationSourceError(role, 'unsafe-source') + } + if (error instanceof SyntaxError) + throw new NativeMigrationSourceError(role, 'invalid-source') + throw new NativeMigrationSourceError(role, 'source-io') + } finally { + await handle?.close() + } +} + +/** Rewrites, replacements and permission changes require a new import decision. */ +export async function requireUnchangedNativeMigrationSource( + snapshot: NativeMigrationSourceSnapshot, +): Promise { + const current = await readNativeMigrationSource(snapshot.role, snapshot.path) + if ( + current.digest !== snapshot.digest || + JSON.stringify(current.metadata) !== JSON.stringify(snapshot.metadata) + ) { + throw new NativeMigrationSourceError(snapshot.role, 'source-changed') + } +} diff --git a/packages/core/src/tests/migration-source.test.ts b/packages/core/src/tests/migration-source.test.ts new file mode 100644 index 000000000..8f6a44ece --- /dev/null +++ b/packages/core/src/tests/migration-source.test.ts @@ -0,0 +1,215 @@ +import { afterEach, expect, test } from 'bun:test' +import { createHash } from 'node:crypto' +import { + chmod, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { inspect } from 'node:util' + +import { + readNativeMigrationSource, + requireUnchangedNativeMigrationSource, +} from '../migration-source.ts' + +const roots: string[] = [] + +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'anthropic-migration-source-')), + ) + roots.push(root) + return { root, path: join(root, 'source.json') } +} + +afterEach(async () => { + const owned = roots.splice(0) + await Promise.all( + owned.map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +test('captures exact bytes, filesystem ownership information and frozen nested import data', async () => { + const { path } = await fixture() + const text = + ' { "anthropic": { "access": "synthetic-access", "labels": ["café 💻"] } }\n' + await writeFile(path, text, { mode: 0o600 }) + const snapshot = await readNativeMigrationSource('hostAuth', path) + expect(snapshot.digest).toBe(createHash('sha256').update(text).digest('hex')) + expect(snapshot.data).toEqual({ + anthropic: { access: 'synthetic-access', labels: ['café 💻'] }, + }) + expect( + snapshot.metadata?.mode ? snapshot.metadata.mode & 0o777 : undefined, + ).toBe(0o600) + expect(snapshot.metadata?.uid).toBe(process.getuid?.()) + expect(Object.isFrozen(snapshot)).toBe(true) + expect(JSON.stringify(snapshot)).not.toContain('synthetic-access') + expect(inspect(snapshot)).not.toContain('synthetic-access') + const auth = snapshot.data?.anthropic + if (!auth || typeof auth !== 'object') + throw new Error('Expected fixture auth object') + expect(Object.isFrozen(auth)).toBe(true) + expect(() => Object.assign(auth, { access: 'replaced' })).toThrow(TypeError) + await requireUnchangedNativeMigrationSource(snapshot) + expect(await readFile(path, 'utf8')).toBe(text) +}) + +test('records missing inputs distinctly and rejects an input that appears after capture', async () => { + const { path } = await fixture() + const snapshot = await readNativeMigrationSource('state', path) + expect(snapshot).toMatchObject({ + digest: null, + data: undefined, + metadata: undefined, + }) + await requireUnchangedNativeMigrationSource(snapshot) + await writeFile(path, '{}', { mode: 0o600 }) + await expect( + requireUnchangedNativeMigrationSource(snapshot), + ).rejects.toMatchObject({ role: 'state', code: 'source-changed' }) +}) + +test('malformed and non-object inputs never become the missing-entry path', async () => { + const { path } = await fixture() + for (const text of [ + '', + '[]', + 'null', + '"string"', + '{"access":"synthetic-private-bearer"', + ]) { + await writeFile(path, text, { mode: 0o600 }) + let caught: unknown + try { + await readNativeMigrationSource('state', path) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ code: 'invalid-source' }) + expect( + caught instanceof Error ? caught.message : String(caught), + ).not.toContain('synthetic-private-bearer') + expect(await readFile(path, 'utf8')).toBe(text) + } +}) + +test('rejects malformed UTF-8 rather than changing a credential while decoding', async () => { + const { path } = await fixture() + const content = Buffer.concat([ + Buffer.from('{"access":"'), + Buffer.from([0xff]), + Buffer.from('"}'), + ]) + await writeFile(path, content, { mode: 0o600 }) + await expect( + readNativeMigrationSource('hostAuth', path), + ).rejects.toMatchObject({ code: 'invalid-source' }) + expect(await readFile(path)).toEqual(content) +}) + +test('rejects directories and symbolic links before reading any target bytes', async () => { + const { root, path } = await fixture() + await expect(readNativeMigrationSource('config', root)).rejects.toMatchObject( + { code: 'unsafe-source' }, + ) + const target = join(root, 'target.json') + await writeFile(target, '{"access":"synthetic-private-bearer"}', { + mode: 0o600, + }) + await symlink(target, path) + let opened = false + await expect( + readNativeMigrationSource('hostAuth', path, { + onOpened: async () => { + opened = true + }, + }), + ).rejects.toMatchObject({ code: 'unsafe-source' }) + expect(opened).toBe(false) +}) + +test('refuses an atomic replacement after opening the original source', async () => { + const { root, path } = await fixture() + await writeFile(path, '{"account":"a"}', { mode: 0o600 }) + const replacement = join(root, 'replacement.json') + await expect( + readNativeMigrationSource('config', path, { + onOpened: async () => { + await writeFile(replacement, '{"account":"b"}', { mode: 0o600 }) + await rename(replacement, path) + }, + }), + ).rejects.toMatchObject({ code: 'source-changed' }) + expect(await readFile(path, 'utf8')).toBe('{"account":"b"}') +}) + +test('refuses an in-place source rewrite after opening it', async () => { + const { path } = await fixture() + await writeFile(path, '{"account":"a"}', { mode: 0o600 }) + await expect( + readNativeMigrationSource('config', path, { + onOpened: async () => { + await writeFile(path, '{"account":"b"}') + }, + }), + ).rejects.toMatchObject({ code: 'source-changed' }) +}) + +test('refuses deletion during a capture rather than treating it as initially missing', async () => { + const { path } = await fixture() + await writeFile(path, '{}', { mode: 0o600 }) + await expect( + readNativeMigrationSource('state', path, { + onOpened: async () => { + await rm(path) + }, + }), + ).rejects.toMatchObject({ code: 'source-changed' }) +}) + +test('revalidation detects unchanged-byte replacement, permission changes and deletion', async () => { + const { root, path } = await fixture() + await writeFile(path, '{}', { mode: 0o600 }) + const first = await readNativeMigrationSource('hostAuth', path) + const replacement = join(root, 'replacement.json') + await writeFile(replacement, '{}', { mode: 0o600 }) + await rename(replacement, path) + await expect( + requireUnchangedNativeMigrationSource(first), + ).rejects.toMatchObject({ code: 'source-changed' }) + const second = await readNativeMigrationSource('hostAuth', path) + await chmod(path, 0o640) + await expect( + requireUnchangedNativeMigrationSource(second), + ).rejects.toMatchObject({ code: 'source-changed' }) + const third = await readNativeMigrationSource('hostAuth', path) + await rm(path) + await expect( + requireUnchangedNativeMigrationSource(third), + ).rejects.toMatchObject({ code: 'source-changed' }) +}) + +test('bounds both an initially oversized input and growth during reading', async () => { + const { path } = await fixture() + const oversized = JSON.stringify({ padding: 'a'.repeat(4 * 1024 * 1024) }) + await writeFile(path, oversized, { mode: 0o600 }) + await expect(readNativeMigrationSource('config', path)).rejects.toMatchObject( + { code: 'source-too-large' }, + ) + await writeFile(path, '{}') + await expect( + readNativeMigrationSource('config', path, { + onOpened: async () => { + await writeFile(path, oversized) + }, + }), + ).rejects.toMatchObject({ code: 'source-too-large' }) +}) From 84a09f2c652775c8e1f36d5049087b18357db6c5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:15:43 +0200 Subject: [PATCH 006/169] mason: verify Claustrum client 0.5 compatibility --- bun.lock | 4 +- packages/core/package.json | 2 +- .../claustrum-client-compatibility.test.ts | 404 ++++++++++++++++++ 3 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/tests/claustrum-client-compatibility.test.ts diff --git a/bun.lock b/bun.lock index e938d446b..21f1fe986 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ "name": "@cortexkit/anthropic-auth-core", "version": "1.23.0", "dependencies": { - "@cortexkit/claustrum-client": "0.4.0", + "@cortexkit/claustrum-client": "0.5.0", "xxhash-wasm": "^1.1.0", }, "devDependencies": { @@ -215,7 +215,7 @@ "@cortexkit/anthropic-auth-e2e": ["@cortexkit/anthropic-auth-e2e@workspace:packages/e2e-tests"], - "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.4.0", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-xjUXf/lB3T8R7oaOu9T1Obnn47bAdN5UbyaTpywyiY4wdvTbqHBnB3qX0KpDFTeZ7m35tRPDevcWvOzJIgUhMw=="], + "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.5.0", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-+mRG/h3YZufrEs1p12W+Y+NSnWIUiBcpppMTgFA5tAQ7t8WIWoMEmn0Mf7Cp8251SKChW8dN3xOZqEM7N6gyAg=="], "@cortexkit/common-auth": ["@cortexkit/common-auth@0.4.6", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.5.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.12", "@opentui/solid": ">=0.5.12", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-/FFqtu6huWBU632GOAORQk/olkvI/1Tw3H0+zxA8cLWOfwiNc7kIptb3+3cQYlYBpH7lG4SAqhnFGFkJLo0lWQ=="], diff --git a/packages/core/package.json b/packages/core/package.json index 42c59588b..99c1e6305 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,7 +31,7 @@ "@cortexkit/common-auth": "^0.4.6" }, "dependencies": { - "@cortexkit/claustrum-client": "0.4.0", + "@cortexkit/claustrum-client": "0.5.0", "xxhash-wasm": "^1.1.0" } } diff --git a/packages/core/src/tests/claustrum-client-compatibility.test.ts b/packages/core/src/tests/claustrum-client-compatibility.test.ts new file mode 100644 index 000000000..4bea39187 --- /dev/null +++ b/packages/core/src/tests/claustrum-client-compatibility.test.ts @@ -0,0 +1,404 @@ +import { expect, test } from 'bun:test' +import { + ClaustrumClient, + type ClaustrumConnector, + type ClaustrumReporterSource, + type CredentialStatus, + type EnrollmentPollOutcome, + type ScopedInventory, + type ScopedInventoryRow, + type ServedCredential, +} from '@cortexkit/claustrum-client' +import type { + ClaustrumEnrollmentClient as CommonAuthEnrollmentClient, + ClaustrumScopedClient as CommonAuthScopedClient, +} from '@cortexkit/common-auth/claustrum' +import type { ClaustrumEnrollmentClient } from '../claustrum-enrollment.ts' +import type { + ClaustrumScopedAttempt, + ClaustrumScopedClient, +} from '../claustrum-scoped.ts' + +type Assert = T +type Equal = [Actual] extends [Expected] + ? [Expected] extends [Actual] + ? true + : false + : false +type RequiredKeys = [Pick] extends [ + Required>, +] + ? true + : false + +type ConnectOptions = NonNullable[0]> +type ScopedGetInput = Parameters[0] +type ScopedReportInput = Parameters< + ClaustrumClient['reportAuthFailureScoped'] +>[0] +type EnrollmentProposeInput = Parameters[0] +type EnrollmentPollInput = Parameters[0] +type EnrollmentRotateInput = Parameters[0] +type StatusCredentialResult = Awaited< + ReturnType +> +type ApprovedPoll = Extract + +// These declarations make a peer API shape change fail Core's typecheck instead +// of silently compiling against a subset of the published producer contract. +const producerContract: [ + Assert>, + Assert< + Equal, Promise> + >, + Assert< + RequiredKeys + >, + Assert< + Equal< + Pick, + { material: string; recordVersion: number; expiresAtMs: number | null } + > + >, + Assert< + Equal< + Pick, + { credentialId?: string; accountId?: string } + > + >, + Assert>, + Assert< + RequiredKeys< + ScopedInventoryRow, + 'id' | 'recordVersion' | 'createdAtMs' | 'operations' + > + >, + Assert< + Equal< + Pick< + ClaustrumScopedAttempt, + 'credentialId' | 'accountId' | 'recordVersion' | 'expiresAtMs' + >, + { + readonly credentialId: string + readonly accountId: string + readonly recordVersion: number + readonly expiresAtMs: number + } + > + >, + Assert< + Equal< + ScopedGetInput, + { credentialId: string; enrollmentToken?: string; minTtlMs?: number } + > + >, + Assert< + Equal< + ScopedReportInput, + { + credentialId: string + enrollmentToken?: string + providerStatus: number + recordVersion: number + reporterSource: ClaustrumReporterSource + } + > + >, + Assert< + Equal + >, + Assert< + Equal + >, + Assert< + Equal< + Pick, + { + readonly status: 'approved' + readonly name: string + readonly token: string + readonly tokenGeneration: number + } + > + >, + Assert< + Equal< + EnrollmentRotateInput, + { token: string; expectedTokenGeneration: number } + > + >, + Assert< + Equal< + Pick, + { ready: boolean; lastErrorCode: string | null; leaseHeld: boolean } + > + >, + Assert< + Equal< + Pick, + { + credentialId?: string + recordVersion?: number + stalePending?: boolean + } + > + >, + Assert< + Equal< + Pick, + { credentialId?: string; recordVersion?: number } + > + >, + Assert>, + Assert< + Equal< + Pick, + CommonAuthEnrollmentClient + > + >, + Assert>, +] = [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, +] + +test('published Claustrum client decodes scoped and enrollment replies', async () => { + const credentialId = 'oauth:anthropic:work' + const accountId = 'provider-account-1' + const enrollmentToken = '01'.repeat(32) + const accessToken = 'synthetic-access-token' + const calls: Array<{ moduleId: string; method: string; params?: unknown }> = + [] + let closes = 0 + const responses: Record = { + 'credential.list_scoped': { + result: { + credentials: [ + { + id: credentialId, + account_id: accountId, + type: 'oauth', + categories: ['anthropic-native'], + serves: ['anthropic'], + refresh_adapter: 'anthropic', + operations: ['read'], + state: 'active', + record_version: 7, + created_at_ms: null, + }, + ], + view: 'synthetic-view-1', + }, + }, + 'credential.status': { + result: { + ready: false, + last_error_code: 'not_found', + lease_held: false, + }, + }, + 'credential.get_scoped': { + result: { + payload: Array.from(new TextEncoder().encode(accessToken)), + credential_id: credentialId, + account_id: accountId, + record_version: 7, + expires_at_ms: 9_000_000, + }, + }, + 'credential.report_auth_failure': { result: { accepted: true } }, + 'auth.enroll_propose': { result: { request_id: 'synthetic-request-1' } }, + 'auth.enroll_poll': { + result: { + status: 'approved', + name: 'anthropic-auth-test', + token: 'ab'.repeat(32), + token_generation: 4, + }, + }, + 'auth.enroll_rotate': { + result: { token: 'cd'.repeat(32), token_generation: 5 }, + }, + } + const connector: ClaustrumConnector = async () => + ({ + call: async (moduleId: string, method: string, params?: unknown) => { + calls.push({ moduleId, method, params }) + const response = responses[method] + if (response === undefined) { + throw new Error(`Unexpected synthetic Claustrum method: ${method}`) + } + return response + }, + close: () => { + closes++ + }, + }) as unknown as Awaited> + + const client = await ClaustrumClient.connect({ + connectionFile: '/synthetic-test/no-daemon-connection.json', + identity: { + project_root: '/synthetic-test/project', + harness: 'compatibility-test', + session: 'synthetic-session', + }, + connector, + logger: () => {}, + }) + + const inventory = await client.listScoped(enrollmentToken) + expect(inventory).toEqual({ + rows: [ + { + id: credentialId, + accountId, + categories: ['anthropic-native'], + credentialType: 'oauth', + serves: ['anthropic'], + refreshAdapter: 'anthropic', + state: 'active', + recordVersion: 7, + operations: ['read'], + createdAtMs: null, + email: undefined, + orgName: undefined, + }, + ], + view: 'synthetic-view-1', + }) + + const status = await client.statusCredential(credentialId) + expect(status).toEqual({ + ready: false, + lastErrorCode: 'not_found', + leaseHeld: false, + }) + expect('credentialId' in status).toBe(false) + expect('recordVersion' in status).toBe(false) + + const served = await client.getScoped({ + credentialId, + enrollmentToken, + minTtlMs: 300_000, + }) + expect(served).toEqual({ + material: accessToken, + credentialId, + accountId, + recordVersion: 7, + expiresAtMs: 9_000_000, + projectId: undefined, + email: undefined, + orgName: undefined, + }) + + await client.reportAuthFailureScoped({ + credentialId, + enrollmentToken, + providerStatus: 401, + recordVersion: served.recordVersion, + reporterSource: 'relay_status_field', + }) + expect( + await client.enrollPropose({ + name: 'anthropic-auth-test', + requestSecretHash: 'ef'.repeat(32), + }), + ).toEqual({ requestId: 'synthetic-request-1' }) + expect( + await client.enrollPoll({ + requestId: 'synthetic-request-1', + requestSecret: '12'.repeat(32), + }), + ).toEqual({ + status: 'approved', + name: 'anthropic-auth-test', + token: 'ab'.repeat(32), + tokenGeneration: 4, + }) + expect( + await client.enrollRotate({ + token: enrollmentToken, + expectedTokenGeneration: 4, + }), + ).toEqual({ token: 'cd'.repeat(32), tokenGeneration: 5 }) + + expect(calls).toEqual([ + { + moduleId: 'claustrum', + method: 'credential.list_scoped', + params: { enrollment_token: enrollmentToken }, + }, + { + moduleId: 'claustrum', + method: 'credential.status', + params: { handle: credentialId }, + }, + { + moduleId: 'claustrum', + method: 'credential.get_scoped', + params: { + credential_id: credentialId, + enrollment_token: enrollmentToken, + min_ttl_ms: 300_000, + }, + }, + { + moduleId: 'claustrum', + method: 'credential.report_auth_failure', + params: { + credential_id: credentialId, + enrollment_token: enrollmentToken, + provider_status: 401, + record_version: 7, + reporter_source: 'relay_status_field', + }, + }, + { + moduleId: 'claustrum', + method: 'auth.enroll_propose', + params: { + proposed_name: 'anthropic-auth-test', + request_secret_hash: 'ef'.repeat(32), + }, + }, + { + moduleId: 'claustrum', + method: 'auth.enroll_poll', + params: { + request_id: 'synthetic-request-1', + request_secret: '12'.repeat(32), + }, + }, + { + moduleId: 'claustrum', + method: 'auth.enroll_rotate', + params: { + token: enrollmentToken, + expected_token_generation: 4, + }, + }, + ]) + expect(producerContract.every(Boolean)).toBe(true) + client.close() + expect(closes).toBe(1) +}) From 1477de45d09c5310df128f479bef6df9a293f9a5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:56:18 +0200 Subject: [PATCH 007/169] mason: update common-auth foundation dependency --- bun.lock | 4 ++-- packages/core/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 21f1fe986..2eb62977f 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "^0.4.6", + "@cortexkit/common-auth": "^0.9.0", }, }, "packages/e2e-tests": { @@ -217,7 +217,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.5.0", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-+mRG/h3YZufrEs1p12W+Y+NSnWIUiBcpppMTgFA5tAQ7t8WIWoMEmn0Mf7Cp8251SKChW8dN3xOZqEM7N6gyAg=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.4.6", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.5.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.12", "@opentui/solid": ">=0.5.12", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-/FFqtu6huWBU632GOAORQk/olkvI/1Tw3H0+zxA8cLWOfwiNc7kIptb3+3cQYlYBpH7lG4SAqhnFGFkJLo0lWQ=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.9.0", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.4.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-FiolaobIdq8feMJyGaiQm1skefsxixsSyasB+vrKVOkU/HFxkuJGHDo7cUSFrmfNZRs31fWFJbVLcfVGoAfIVQ=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], diff --git a/packages/core/package.json b/packages/core/package.json index 99c1e6305..f9a6f0f86 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,7 +28,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "^0.4.6" + "@cortexkit/common-auth": "^0.9.0" }, "dependencies": { "@cortexkit/claustrum-client": "0.5.0", From ee15aa673844f0c0f3e4c583afcfbaa2174b572c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:35:00 +0200 Subject: [PATCH 008/169] mason: close native storage facade and packed declarations Forward the published store seams without relaxing credential stamps or enabling offline quota pulls. Add the sibling custody roster path and overlap coverage. Package the complete published declaration dependency graph with its license, keep common-auth dev-only, and declare/externalize the existing jsonc-parser version. Verified Core 374 tests, six script tests, production-only packed consumer tsc (329 prefix-local files) and Node runtime assertions, Core/workspace/script types, scoped Biome and lock consistency. Strict-stamp and declaration-fence mutations each reddened exactly their named regression while a companion test passed; both restored before final gates. The existing strict-mode test retains its claim and now exercises every forwarded seam. Parent Sidekick comment review approved the exact ten-file staged diff. --- bun.lock | 1 + packages/core/package.json | 3 + packages/core/scripts/build.ts | 22 +- .../scripts/check-native-type-closure.test.ts | 315 ++++++++++++++++++ .../core/scripts/check-native-type-closure.ts | 306 +++++++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/pool-paths.ts | 20 +- packages/core/src/pool-store.ts | 17 +- packages/core/src/tests/pool-paths.test.ts | 53 ++- packages/core/src/tests/pool-store.test.ts | 132 +++++++- 10 files changed, 862 insertions(+), 9 deletions(-) create mode 100644 packages/core/scripts/check-native-type-closure.test.ts create mode 100644 packages/core/scripts/check-native-type-closure.ts diff --git a/bun.lock b/bun.lock index 2eb62977f..75cd52f9e 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "version": "1.23.0", "dependencies": { "@cortexkit/claustrum-client": "0.5.0", + "jsonc-parser": "^3.3.1", "xxhash-wasm": "^1.1.0", }, "devDependencies": { diff --git a/packages/core/package.json b/packages/core/package.json index f9a6f0f86..1bb9d4243 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,6 +23,8 @@ "scripts": { "build": "rm -rf dist && tsc -p tsconfig.build.json --emitDeclarationOnly && bun scripts/build.ts", "test": "bun run build && bun test src/tests", + "test:scripts": "bun test scripts", + "check:native-type-closure": "bun scripts/check-native-type-closure.ts", "typecheck": "tsc", "types": "tsc", "prepublishOnly": "bun run build" @@ -32,6 +34,7 @@ }, "dependencies": { "@cortexkit/claustrum-client": "0.5.0", + "jsonc-parser": "^3.3.1", "xxhash-wasm": "^1.1.0" } } diff --git a/packages/core/scripts/build.ts b/packages/core/scripts/build.ts index 4d8a47f66..aa36c99c1 100644 --- a/packages/core/scripts/build.ts +++ b/packages/core/scripts/build.ts @@ -1,13 +1,22 @@ import { basename, dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import { + closeNativeTypeDeclarations, + verifyNativeTypeClosure, +} from './check-native-type-closure.ts' + interface CoreBundleOptions { entrypoint: string outfile: string } /** Runtime dependencies retain their package-owned resource and IPC loaders. */ -const RUNTIME_EXTERNALS = ['xxhash-wasm', '@cortexkit/claustrum-client'] +const RUNTIME_EXTERNALS = [ + 'xxhash-wasm', + '@cortexkit/claustrum-client', + 'jsonc-parser', +] export async function bundleCore(options: CoreBundleOptions): Promise { const result = await Bun.build({ @@ -28,4 +37,15 @@ if (import.meta.main) { entrypoint: fileURLToPath(new URL('../src/index.ts', import.meta.url)), outfile: fileURLToPath(new URL('../dist/index.js', import.meta.url)), }) + const dist = fileURLToPath(new URL('../dist', import.meta.url)) + const producer = dirname( + dirname( + dirname( + fileURLToPath(import.meta.resolve('@cortexkit/common-auth/store')), + ), + ), + ) + const copied = await closeNativeTypeDeclarations(dist, producer) + console.log(`Core build: copied ${copied} published declaration assets`) + console.log(await verifyNativeTypeClosure(dist)) } diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts new file mode 100644 index 000000000..8f3887e11 --- /dev/null +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -0,0 +1,315 @@ +import { afterEach, expect, test } from 'bun:test' +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { bundleCore } from './build.ts' +import { + closeNativeTypeDeclarations, + declarationFiles, + moduleReferences, + verifyNativeTypeClosure, +} from './check-native-type-closure.ts' + +const workspace = resolve(import.meta.dir, '../../..') +const core = join(workspace, 'packages/core') +const roots: string[] = [] + +async function fixture() { + // Consumer builds and package-manager writes stay in the isolated checkout. + const parent = join(workspace, 'node_modules/.cache/native-closure') + await mkdir(parent, { recursive: true }) + const root = await mkdtemp(join(parent, 'consumer-')) + roots.push(root) + return root +} + +async function put(root: string, path: string, source: string) { + const destination = join(root, path) + await mkdir(resolve(destination, '..'), { recursive: true }) + await writeFile(destination, source) +} + +async function run(command: string[], cwd: string): Promise { + const result = Bun.spawnSync(command, { cwd, stdout: 'pipe', stderr: 'pipe' }) + const output = result.stdout.toString() + result.stderr.toString() + if (result.exitCode !== 0) + throw new Error( + `${command.join(' ')} exited ${result.exitCode}:\n${output}`, + ) + return output +} + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +test('recognizes import, export, inline import types and reference edges without matching prose', () => { + const source = ` + /// + /// + import type { A } from './a.js'; + export type { B } from './b.js'; + export * from './c.js'; + type D = import('./d.js').D; + import E = require('./e.js'); + // import('./not-an-import.js') + type Prose = "@cortexkit/common-auth/store"; + ` + expect( + moduleReferences(source).map((edge) => [edge.kind, edge.specifier]), + ).toEqual([ + ['module', './a.js'], + ['module', './b.js'], + ['module', './c.js'], + ['module', './d.js'], + ['module', './e.js'], + ['path', './ambient.d.ts'], + ['types', 'node'], + ]) +}) + +test('copies the complete published relative declaration graph including cycles and directives', async () => { + const root = await fixture() + const dist = join(root, 'dist') + const producer = join(root, 'producer') + await put( + producer, + 'package.json', + JSON.stringify({ + version: '0.9.0', + exports: { './store': { types: './dist/store/index.d.ts' } }, + }), + ) + await put(producer, 'LICENSE', 'Synthetic test license') + await put(producer, 'dist/store/index.d.ts', `export * from './pool.js';`) + await put( + producer, + 'dist/store/pool.d.ts', + ` + /// + import type { Lock } from '../fs/lock.js'; + export type Store = Lock & import('./schema.js').Row; + `, + ) + await put( + producer, + 'dist/fs/lock.d.ts', + `export interface Lock { release(): void }`, + ) + await put( + producer, + 'dist/ambient.d.ts', + `export {}; declare global { interface SyntheticAmbient { active: true } }`, + ) + await put( + producer, + 'dist/store/schema.d.ts', + `import type { Store } from './pool.js'; export interface Row { previous?: Store }`, + ) + await put(dist, 'index.js', 'export const ready = true;') + await put(dist, 'index.d.ts', `export * from './facade.js';`) + await put( + dist, + 'facade.d.ts', + `import type { Store } from '@cortexkit/common-auth/store'; export declare function open(): Store;`, + ) + expect(await closeNativeTypeDeclarations(dist, producer)).toBe(5) + expect(await readFile(join(dist, 'internal-types/LICENSE'), 'utf8')).toBe( + 'Synthetic test license', + ) + expect(await declarationFiles(join(dist, 'internal-types'))).toHaveLength(5) + expect(await verifyNativeTypeClosure(dist)).toContain( + '7 relative declaration edges', + ) + await rm(join(dist, 'internal-types/fs/lock.d.ts')) + await expect(verifyNativeTypeClosure(dist)).rejects.toThrow( + 'missing declaration dependency ../fs/lock.js', + ) +}) + +test('closure refuses a producer dependency outside the public barrel and escaped specifiers', async () => { + const root = await fixture() + await put(root, 'index.js', 'export const ready = true;') + await put(root, 'index.d.ts', 'export const ready: true;') + await put( + root, + 'nested/unused.d.ts', + `export type Leaked = import('@cortexkit/common-auth/store').PoolStore;`, + ) + await expect(verifyNativeTypeClosure(root)).rejects.toThrow( + 'nested/unused.d.ts: producer specifier', + ) + await put( + root, + 'nested/unused.d.ts', + String.raw`export type Leaked = import('@cortexkit/comm\u006fn-auth/store').PoolStore;`, + ) + await expect(verifyNativeTypeClosure(root)).rejects.toThrow( + 'producer specifier', + ) +}) + +test('closure refuses declaration dependencies escaping the packed tree', async () => { + const root = await fixture() + await put(root, 'outside.d.ts', 'export interface Outside {}') + await put(root, 'dist/index.js', 'export const ready = true;') + await put(root, 'dist/index.d.ts', `export * from '../outside.js';`) + await expect(verifyNativeTypeClosure(join(root, 'dist'))).rejects.toThrow( + 'Declaration dependency escapes', + ) +}) + +test('Core bundles published preferences but keeps jsonc-parser external', async () => { + const root = await fixture() + const entrypoint = join(root, 'preferences.ts') + const outfile = join(root, 'preferences.js') + await writeFile( + entrypoint, + `export { readTuiPreferences } from '@cortexkit/common-auth/tui-prefs';`, + ) + await bundleCore({ entrypoint, outfile }) + const source = await readFile(outfile, 'utf8') + const specifiers = moduleReferences(source, false).map( + (edge) => edge.specifier, + ) + expect(specifiers).toContain('jsonc-parser') + expect( + specifiers.some((specifier) => + specifier.startsWith('@cortexkit/common-auth'), + ), + ).toBe(false) + expect( + specifiers.some( + (specifier) => + specifier.startsWith('@opentui/') || specifier === 'solid-js', + ), + ).toBe(false) +}) + +test('packed Core types and Node runtime work in a fresh consumer without common-auth', async () => { + const root = await fixture() + console.log(`Packaging probe: Bun ${Bun.version}`) + console.log(await run([process.execPath, 'run', 'build'], core)) + await run([process.execPath, 'pm', 'pack', '--destination', root], core) + const archives = (await readdir(root)).filter((file) => file.endsWith('.tgz')) + expect(archives).toHaveLength(1) + const manifest = JSON.parse( + await readFile(join(core, 'package.json'), 'utf8'), + ) + expect(manifest.dependencies['jsonc-parser']).toBe('^3.3.1') + expect(manifest.dependencies['@cortexkit/common-auth']).toBeUndefined() + const consumer = join(root, 'prefix') + await put( + consumer, + 'package.json', + JSON.stringify({ + private: true, + type: 'module', + dependencies: { + '@cortexkit/anthropic-auth-core': `file:../${archives[0]}`, + '@types/bun': '1.4.2', + typescript: '7.0.2', + }, + }), + ) + console.log( + await run( + [process.execPath, 'install', '--production', '--ignore-scripts'], + consumer, + ), + ) + const installed = join( + consumer, + 'node_modules/@cortexkit/anthropic-auth-core', + ) + const allInstalled = await readdir(join(consumer, 'node_modules'), { + recursive: true, + }) + expect( + allInstalled.some((path) => + /(?:^|\/)@cortexkit\/common-auth(?:\/|$)/.test(path), + ), + ).toBe(false) + console.log(await verifyNativeTypeClosure(join(installed, 'dist'))) + // Parse every published declaration, including ones not re-exported yet. + const declarations = await declarationFiles(join(installed, 'dist')) + await put( + consumer, + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ESNext', + allowImportingTsExtensions: true, + types: ['bun'], + }, + files: ['consumer.ts', ...declarations], + }), + ) + await put( + consumer, + 'consumer.ts', + ` + import { createNativePoolStore, resolveNativePoolPaths, type NativePoolStoreOptions } from '@cortexkit/anthropic-auth-core'; + import * as core from '@cortexkit/anthropic-auth-core'; + const paths = await resolveNativePoolPaths('./account.json', './state.json'); + const options: NativePoolStoreOptions = { + paths, quota: { validate: () => true, merge: (_, value) => value }, + onStep: async (step, info) => { const name: string = step; const id: string | undefined = info.rowId; void [name, id]; }, + hold: (point, id) => { const name: 'refresh-before-provider' | 'pull-before-request' = point; void [name, id]; }, + logger: { warn: (message) => { const text: string = message; void text; } }, + onLockEvent: (event) => { const phase: 'acquired' | 'released' = event.type; void phase; }, + onLockStep: async (lock, step) => { const text: string = lock.path + step; void text; }, + }; + const store = createNativePoolStore(options); + const roster: string = paths.roster; + await store.add({ id: 'synthetic', credential: { type: 'oauth', access: 'synthetic-access', refresh: 'synthetic-refresh', expires: 4000000000000 } }); + const loaded = await store.read(); + if (loaded.status === 'ready') { const candidates: boolean[] = loaded.rows.map(row => row.candidate); void candidates; } + void [core, roster]; + `, + ) + const tsc = join(consumer, 'node_modules/.bin/tsc') + console.log(await run([tsc, '--version'], consumer)) + const typeOutput = await run( + [tsc, '-p', 'tsconfig.json', '--listFiles'], + consumer, + ) + const checked = typeOutput.trim().split('\n') + // This prefix is in the worktree: reject accidental ancestor/hoisted type resolution. + expect(checked.length).toBeGreaterThan(declarations.length) + expect(checked.every((path) => path.startsWith(`${consumer}/`))).toBe(true) + console.log( + `Consumer tsc: ${checked.length} files checked, all within the fresh prefix`, + ) + await put( + consumer, + 'runtime.mjs', + ` + import { createNativePoolStore, resolveNativePoolPaths } from '@cortexkit/anthropic-auth-core'; + import assert from 'node:assert/strict'; + const paths = await resolveNativePoolPaths('./account.json', './state.json'); + assert.equal(paths.roster.endsWith('state.json.roster.json'), true); + const store = createNativePoolStore({ paths, quota: { validate: () => true, merge: (_, observation) => observation } }); + await store.add({ id: 'synthetic', credential: { type: 'oauth', access: 'synthetic-access', refresh: 'synthetic-refresh', expires: 4000000000000 } }); + const loaded = await store.read(); + assert.equal(loaded.status, 'ready'); + assert.equal(loaded.rows[0].stamp, 'bound'); + assert.equal(loaded.rows[0].credential.access, 'synthetic-access'); + console.log('Node ' + process.version + ': 4 packed runtime assertions passed'); + `, + ) + console.log(await run(['node', 'runtime.mjs'], consumer)) +}, 120_000) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts new file mode 100644 index 000000000..3da7e447b --- /dev/null +++ b/packages/core/scripts/check-native-type-closure.ts @@ -0,0 +1,306 @@ +import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises' +import { dirname, extname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse } from '@babel/parser' + +const PRODUCER = '@cortexkit/common-auth' +const TYPE_ASSETS = 'internal-types' +const declarationPattern = /\.d\.(?:ts|mts|cts)$/ + +type AstNode = { type: string; [key: string]: unknown } +interface ModuleReference { + specifier: string + start: number + end: number + kind: 'module' | 'path' | 'types' +} + +function isNode(value: unknown): value is AstNode { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string' + ) +} + +function* walk(root: unknown): Generator { + if (!isNode(root)) return + yield root + for (const [key, value] of Object.entries(root)) { + if (key === 'loc' || key === 'extra' || key === 'comments') continue + if (Array.isArray(value)) { + for (const child of value) yield* walk(child) + } else if (isNode(value)) yield* walk(value) + } +} + +/** Inspect real import syntax, including inline types and reference directives. */ +export function moduleReferences( + source: string, + declaration = true, +): ModuleReference[] { + const ast = parse(source, { + sourceType: 'module', + plugins: declaration ? [['typescript', { dts: true }]] : [], + }) + const references: ModuleReference[] = [] + for (const node of walk(ast.program)) { + const value = [ + 'ImportDeclaration', + 'ExportNamedDeclaration', + 'ExportAllDeclaration', + 'ImportExpression', + ].includes(node.type) + ? node.source + : node.type === 'TSImportType' + ? node.argument + : node.type === 'TSExternalModuleReference' + ? node.expression + : node.type === 'CallExpression' && + isNode(node.callee) && + (node.callee.type === 'Import' || + (node.callee.type === 'Identifier' && + node.callee.name === 'require')) && + Array.isArray(node.arguments) + ? node.arguments[0] + : undefined + if ( + isNode(value) && + value.type === 'StringLiteral' && + typeof value.value === 'string' && + typeof value.start === 'number' && + typeof value.end === 'number' + ) { + references.push({ + specifier: value.value, + start: value.start, + end: value.end, + kind: 'module', + }) + } + } + // Babel preserves directives as comments, not AST import nodes. + for (const comment of ast.comments ?? []) { + if (comment.type !== 'CommentLine' || comment.start == null) continue + const match = /^\/\s* { + return (await readdir(root, { recursive: true })) + .filter((path) => declarationPattern.test(path)) + .sort() + .map((path) => join(root, path)) +} + +/** Resolve the type file behind a relative JS/type edge; never copy runtime code. */ +export async function resolveDeclaration( + origin: string, + reference: ModuleReference, +): Promise { + const target = resolve(dirname(origin), reference.specifier) + const candidates = + reference.kind === 'path' || declarationPattern.test(target) + ? [target] + : /\.(?:js|mjs|cjs|ts|mts|cts)$/.test(target) + ? [ + target.replace( + /\.(js|mjs|cjs|ts|mts|cts)$/, + (_, extension: string) => + ['js', 'ts'].includes(extension) + ? '.d.ts' + : ['mjs', 'mts'].includes(extension) + ? '.d.mts' + : '.d.cts', + ), + ] + : [ + `${target}.d.ts`, + `${target}.d.mts`, + `${target}.d.cts`, + join(target, 'index.d.ts'), + ] + for (const candidate of candidates) { + try { + if ( + (await stat(candidate)).isFile() && + declarationPattern.test(candidate) + ) + return candidate + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) + throw error + } + } + throw new Error( + `${origin}: missing declaration dependency ${reference.specifier}`, + ) +} + +/** + * Preserve the published declarations verbatim except for package import edges. + * Following every relative edge (including cycles and reference directives) + * keeps inferred store types precise without making the dev-only producer a + * consumer dependency. Only declarations and their license are packaged. + */ +export async function closeNativeTypeDeclarations( + distRoot: string, + producerRoot: string, +): Promise { + const manifest = JSON.parse( + await readFile(join(producerRoot, 'package.json'), 'utf8'), + ) as { + version: string + exports: Record + } + if (manifest.version !== '0.9.0') + throw new Error( + `Expected published common-auth 0.9.0, got ${manifest.version}`, + ) + const producerDist = join(producerRoot, 'dist') + const assets = join(distRoot, TYPE_ASSETS) + const copied = new Set() + async function copy(path: string): Promise { + const destination = join(assets, contained(producerDist, path)) + if (copied.has(path)) return destination + copied.add(path) + const source = await readFile(path, 'utf8') + const output = await rewrite(source, path, destination) + await mkdir(dirname(destination), { recursive: true }) + await writeFile(destination, output) + return destination + } + async function rewrite( + source: string, + origin: string, + destination: string, + ): Promise { + const changes: { start: number; end: number; value: string }[] = [] + for (const reference of moduleReferences(source)) { + if (isProducer(reference.specifier)) { + if (reference.kind !== 'module') + throw new Error(`Unsupported producer reference directive: ${origin}`) + const subpath = `.${reference.specifier.slice(PRODUCER.length)}` + const types = manifest.exports[subpath]?.types + if (!types) + throw new Error(`No published types for ${reference.specifier}`) + const target = resolve(producerRoot, types) + const imported = await copy(target) + // A declaration may use value-style imports (including classes), so + // spell the edge as JS just as the published declarations do. TypeScript + // resolves it to the adjacent declaration without a runtime type asset. + let specifier = relative(dirname(destination), imported) + .split(sep) + .join('/') + .replace(/\.d\.(ts|mts|cts)$/, (_, extension: string) => + extension === 'ts' ? '.js' : extension === 'mts' ? '.mjs' : '.cjs', + ) + if (!specifier.startsWith('.')) specifier = `./${specifier}` + changes.push({ + start: reference.start, + end: reference.end, + value: JSON.stringify(specifier), + }) + } else if ( + reference.kind === 'path' || + reference.specifier.startsWith('.') + ) { + const target = await resolveDeclaration(origin, reference) + if (origin.startsWith(`${producerDist}${sep}`)) await copy(target) + else contained(distRoot, target) + } + } + for (const change of changes.sort((a, b) => b.start - a.start)) { + source = + source.slice(0, change.start) + change.value + source.slice(change.end) + } + return source + } + for (const path of await declarationFiles(distRoot)) { + await writeFile( + path, + await rewrite(await readFile(path, 'utf8'), path, path), + ) + } + if (copied.size > 0) { + await writeFile( + join(assets, 'LICENSE'), + await readFile(join(producerRoot, 'LICENSE'), 'utf8'), + ) + } + return copied.size +} + +/** Check every emitted module and every declaration edge, not just the barrel. */ +export async function verifyNativeTypeClosure( + distRoot: string, +): Promise { + const files = (await readdir(distRoot, { recursive: true })).filter((path) => + /\.(?:ts|mts|cts|js|mjs|cjs)$/.test(path), + ) + if (!files.includes('index.d.ts') || !files.includes('index.js')) + throw new Error(`Missing Core build in ${distRoot}`) + let edges = 0 + for (const local of files) { + const path = join(distRoot, local) + const declaration = declarationPattern.test(local) + const source = await readFile(path, 'utf8') + // Bundler source-label comments are not dependencies. Syntax inspection also + // catches escaped spellings that a text search would miss. + for (const reference of moduleReferences( + source, + declaration || extname(local) === '.ts', + )) { + if (isProducer(reference.specifier)) + throw new Error(`${local}: producer specifier ${reference.specifier}`) + if ( + declaration && + (reference.kind === 'path' || reference.specifier.startsWith('.')) + ) { + contained(distRoot, await resolveDeclaration(path, reference)) + edges++ + } + } + } + return `Core closure: ${files.length} modules, ${edges} relative declaration edges, no producer dependency` +} + +if (import.meta.main) { + console.log( + await verifyNativeTypeClosure( + fileURLToPath(new URL('../dist', import.meta.url)), + ), + ) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e1b4f52e0..5315fa266 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,6 +24,8 @@ export * from './model-remap.ts' export * from './models.ts' export * from './oauth-profile.ts' export * from './pkce.ts' +export * from './pool-paths.ts' +export * from './pool-store.ts' export * from './prime.ts' export * from './provider.ts' export * from './quota-header-feed.ts' diff --git a/packages/core/src/pool-paths.ts b/packages/core/src/pool-paths.ts index 9e7281754..a67b8d95a 100644 --- a/packages/core/src/pool-paths.ts +++ b/packages/core/src/pool-paths.ts @@ -11,6 +11,7 @@ export interface NativePoolPaths { state: string runtime: string journal: string + roster: string storageId: string } @@ -88,7 +89,23 @@ export async function resolveNativePoolPaths( 'migration', ), ) - const paths = [sourceConfig, sourceState, config, state, runtime, journal] + const roster = await canonicalPath( + sibling( + sourceState, + 'anthropic-auth-state.json', + 'anthropic-auth-custody-roster.json', + 'roster', + ), + ) + const paths = [ + sourceConfig, + sourceState, + config, + state, + runtime, + journal, + roster, + ] if (new Set(paths).size !== paths.length) { throw new Error( 'Anthropic account storage paths overlap; migration requires separate files', @@ -104,6 +121,7 @@ export async function resolveNativePoolPaths( state, runtime, journal, + roster, storageId, } } diff --git a/packages/core/src/pool-store.ts b/packages/core/src/pool-store.ts index 8cfc3b366..e599a5567 100644 --- a/packages/core/src/pool-store.ts +++ b/packages/core/src/pool-store.ts @@ -1,4 +1,7 @@ -import { openPoolStore } from '@cortexkit/common-auth/store' +import { + type OpenPoolStoreOptions, + openPoolStore, +} from '@cortexkit/common-auth/store' import type { NativePoolPaths } from './pool-paths.ts' @@ -7,10 +10,13 @@ interface NativeQuotaCodec { merge(stored: unknown | undefined, observation: unknown): unknown } -interface NativePoolStoreOptions { +export interface NativePoolStoreOptions + extends Pick< + OpenPoolStoreOptions, + 'now' | 'onStep' | 'hold' | 'logger' | 'onLockEvent' | 'onLockStep' + > { paths: NativePoolPaths quota: NativeQuotaCodec - now?: () => number } /** @@ -37,5 +43,10 @@ export function createNativePoolStore(options: NativePoolStoreOptions) { requireCredentialStamps: true, quota: options.quota, now: options.now, + onStep: options.onStep, + hold: options.hold, + logger: options.logger, + onLockEvent: options.onLockEvent, + onLockStep: options.onLockStep, }) } diff --git a/packages/core/src/tests/pool-paths.test.ts b/packages/core/src/tests/pool-paths.test.ts index 58e82b321..6735756fb 100644 --- a/packages/core/src/tests/pool-paths.test.ts +++ b/packages/core/src/tests/pool-paths.test.ts @@ -1,5 +1,12 @@ import { afterEach, expect, test } from 'bun:test' -import { mkdtemp, readdir, realpath, rm, symlink } from 'node:fs/promises' +import { + mkdtemp, + readdir, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -32,6 +39,7 @@ test('derives an isolated namespace while preserving independent state directori state: join(root, 'secrets', 'anthropic-auth-pool-state.json'), runtime: join(root, 'secrets', 'anthropic-auth-native-state.json'), journal: join(root, 'secrets', 'anthropic-auth-migration.json'), + roster: join(root, 'secrets', 'anthropic-auth-custody-roster.json'), }) expect(paths.storageId).toMatch(/^[a-f0-9]{64}$/) expect(await readdir(root)).toEqual([]) @@ -47,6 +55,7 @@ test('custom config and state names retain their respective namespaces', async ( expect(paths.state).toBe(join(root, 'private', 'token.json.pool.json')) expect(paths.runtime).toBe(join(root, 'private', 'token.json.native.json')) expect(paths.journal).toBe(join(root, 'private', 'token.json.migration.json')) + expect(paths.roster).toBe(join(root, 'private', 'token.json.roster.json')) }) test('storage identity includes both paths and normalizes directory aliases', async () => { @@ -87,3 +96,45 @@ test('refuses source or derived file collisions without creating a file', async ).rejects.toThrow('paths overlap') expect(await readdir(root)).toEqual([]) }) + +test('refuses a legacy config colliding with the derived roster without creating files', async () => { + const root = await fixture() + await expect( + resolveNativePoolPaths( + join(root, 'anthropic-auth-custody-roster.json'), + join(root, 'anthropic-auth-state.json'), + ), + ).rejects.toThrow('paths overlap') + await expect( + resolveNativePoolPaths( + join(root, 'token.json.roster.json'), + join(root, 'token.json'), + ), + ).rejects.toThrow('paths overlap') + expect(await readdir(root)).toEqual([]) +}) + +test('refuses roster aliases of every source and native file', async () => { + const root = await fixture() + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + for (const target of [ + paths.legacyConfig, + paths.legacyState, + paths.config, + paths.state, + paths.runtime, + paths.journal, + ]) { + await writeFile(target, '{}') + await symlink(target, paths.roster) + await expect( + resolveNativePoolPaths(paths.legacyConfig, paths.legacyState), + ).rejects.toThrow('paths overlap') + await rm(paths.roster) + await rm(target) + } + expect(await readdir(root)).toEqual([]) +}) diff --git a/packages/core/src/tests/pool-store.test.ts b/packages/core/src/tests/pool-store.test.ts index f9da2a20c..15d3b05b1 100644 --- a/packages/core/src/tests/pool-store.test.ts +++ b/packages/core/src/tests/pool-store.test.ts @@ -11,7 +11,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { resolveNativePoolPaths } from '../pool-paths.ts' -import { createNativePoolStore, nativePoolStoreLocks } from '../pool-store.ts' +import { + createNativePoolStore, + type NativePoolStoreOptions, + nativePoolStoreLocks, +} from '../pool-store.ts' const roots: string[] = [] const quota = { @@ -109,7 +113,7 @@ test('uses explicit shared locks and writes new credentials only through the sta expect((await stat(paths.state)).mode & 0o777).toBe(0o600) }) -test('strict mode cannot be disabled by additional caller options', async () => { +test('strict mode cannot be disabled by additional caller options alongside every forwarded seam', async () => { const { paths, store } = await fixture() await store.initialize() await store.add({ @@ -121,7 +125,22 @@ test('strict mode cannot be disabled by additional caller options', async () => delete state.accounts.a.commonAuthPool await writeFile(paths.state, JSON.stringify(state)) const before = await bytes(paths) - const untrustedOptions = { paths, quota, requireCredentialStamps: false } + const seams: Pick< + NativePoolStoreOptions, + 'onStep' | 'hold' | 'logger' | 'onLockEvent' | 'onLockStep' + > = { + onStep: () => {}, + hold: () => {}, + logger: { warn: () => {} }, + onLockEvent: () => {}, + onLockStep: () => {}, + } + const untrustedOptions = { + paths, + quota, + ...seams, + requireCredentialStamps: false, + } const strict = createNativePoolStore(untrustedOptions) expect(await requiredRow(strict)).toMatchObject({ candidate: false, @@ -139,6 +158,113 @@ test('strict mode cannot be disabled by additional caller options', async () => expect(await bytes(paths)).toEqual(before) }) +test('forwards published write, hold, logger and lock seams to real store operations', async () => { + const { paths } = await fixture() + const writes: unknown[] = [] + const holds: unknown[] = [] + const warnings: unknown[] = [] + const locks: unknown[] = [] + const lockSteps: unknown[] = [] + const store = createNativePoolStore({ + paths, + quota, + onStep: async (step, info) => { + writes.push({ step, ...info }) + }, + hold: async (point, rowId) => { + holds.push({ point, rowId }) + }, + logger: { + warn: (message, data) => { + warnings.push({ message, data }) + }, + }, + onLockEvent: (event) => { + locks.push(event) + }, + onLockStep: async (lock, step) => { + lockSteps.push({ ...lock, step }) + }, + }) + await store.initialize() + await store.add({ id: 'a', credential: credential('a') }) + expect(writes).toEqual([ + { step: 'before-state-write', operation: 'add', rowId: 'a' }, + { step: 'after-state-write', operation: 'add', rowId: 'a' }, + { step: 'before-config-write', operation: 'add', rowId: 'a' }, + { step: 'after-config-write', operation: 'add', rowId: 'a' }, + ]) + await expect( + store.refresh('a', async () => credential('b')), + ).resolves.toMatchObject({ status: 'rotated' }) + expect(holds).toEqual([{ point: 'refresh-before-provider', rowId: 'a' }]) + await expect( + store.refresh( + 'a', + async () => { + throw new Error('synthetic provider failure') + }, + { + onFailure: async () => { + throw new Error('synthetic hook failure') + }, + }, + ), + ).rejects.toMatchObject({ kind: 'provider' }) + expect(warnings).toEqual([ + { + message: 'store failure hook threw; the original failure stands', + data: { + operation: 'refresh', + rowId: 'a', + error: 'synthetic hook failure', + }, + }, + ]) + for (const lock of nativePoolStoreLocks(paths)) { + expect(locks).toContainEqual({ type: 'acquired', ...lock }) + expect(locks).toContainEqual({ type: 'released', ...lock }) + expect(lockSteps).toContainEqual({ + ...lock, + step: 'release-owner-confirmed', + }) + } +}) + +test('construction, pure reads and migration additions never install a quota pull hook', async () => { + const { paths } = await fixture() + let pulls = 0 + let pullFailures = 0 + // Even wider caller objects cannot opt the offline facade into network pulls. + const untrustedOptions = { + paths, + quota, + pull: async () => { + pulls++ + throw new Error('unexpected pull') + }, + onPullFailure: () => { + pullFailures++ + }, + } + const store = createNativePoolStore(untrustedOptions) + await store.pullsSettled() + expect(pulls).toBe(0) + await store.read() + await store.initialize() + await store.add({ id: 'imported', credential: credential('legacy') }) + await store.read() + await store.load() + store.requestReading('imported') + await store.pullsSettled() + expect(pulls).toBe(0) + expect(pullFailures).toBe(0) + expect(await requiredRow(store)).toMatchObject({ + stamp: 'bound', + needsFirstReading: true, + }) +}) + test('refuses same-epoch access corruption before refresh or observation persistence', async () => { const { paths, store } = await fixture() await store.initialize() From 2c4312502a9393bebbde09bab1b989a4512075bd Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:20:56 +0200 Subject: [PATCH 009/169] mason: add closed native runtime storage and lossless quota codec Persist account-bound runtime observations separately from credentials and settings under the native-runtime lease, with closed parsing, owner-only atomic writes and before/after replacement seams. Preserve quota window clocks, scoped ownership, raw percentages, provenance and monetary metadata through the published 0.9.0 QuotaMap with a closed Anthropic extension. Add full-field fixtures, captured provider poll/header vectors, concurrency/crash/clear-fence tests and three restored mutation controls. Parent-provided Sidekick comment review sk_395353493e39424e9c62b7373ec49e0d approved all four files. Core: 393 tests pass; focused: 23 tests pass; types and scoped Biome pass. Root lint is blocked by read-only nested .cortexkit Biome configurations. --- packages/core/src/native-quota-codec.ts | 439 +++++++++++++ packages/core/src/native-runtime.ts | 584 +++++++++++++++++ .../core/src/tests/native-quota-codec.test.ts | 457 ++++++++++++++ .../core/src/tests/native-runtime.test.ts | 588 ++++++++++++++++++ 4 files changed, 2068 insertions(+) create mode 100644 packages/core/src/native-quota-codec.ts create mode 100644 packages/core/src/native-runtime.ts create mode 100644 packages/core/src/tests/native-quota-codec.test.ts create mode 100644 packages/core/src/tests/native-runtime.test.ts diff --git a/packages/core/src/native-quota-codec.ts b/packages/core/src/native-quota-codec.ts new file mode 100644 index 000000000..b0ba09773 --- /dev/null +++ b/packages/core/src/native-quota-codec.ts @@ -0,0 +1,439 @@ +import { + isQuotaMap, + type QuotaCodec, + type QuotaMap, + type QuotaReadingEntry, +} from '@cortexkit/common-auth/quota' + +import { + mergeHeaderQuotaForPersistence, + type OAuthExtraUsageSnapshot, + type OAuthQuotaSnapshot, + QUOTA_FIELD_NAMES, + type QuotaFieldSources, + type QuotaMoney, + quotaSnapshotCheckedAt, +} from './accounts.ts' + +type ScopedMetadata = { + id: string + title: string + modelId?: string + modelName: string + remainingPercent: number +} + +/** Snapshot checkedAt, remaining percentages, scoped labels/ownership, account/source provenance and monetary extra usage absent from QuotaMap. */ +export type NativeQuotaMetadata = Omit< + OAuthQuotaSnapshot, + 'five_hour' | 'seven_day' | 'scoped' +> & { + version: 1 + remainingPercent: { five_hour?: number; seven_day?: number } + scoped?: ScopedMetadata[] +} + +export interface NativeQuotaMap extends QuotaMap { + anthropic: NativeQuotaMetadata +} + +export class NativeQuotaCodecError extends Error { + constructor() { + super('Anthropic quota observation is invalid') + this.name = 'NativeQuotaCodecError' + } +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function keys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + return ( + required.every((key) => Object.hasOwn(value, key)) && + Object.keys(value).every( + (key) => required.includes(key) || optional.includes(key), + ) + ) +} + +function finite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function time(value: unknown): value is number { + return finite(value) && value >= 0 +} + +function text(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.trim() === value +} + +function optional( + value: Record, + key: string, + validate: (value: unknown) => boolean, +): boolean { + return value[key] === undefined || validate(value[key]) +} + +function source(value: unknown): boolean { + return value === 'poll' || value === 'headers' +} + +function money(value: unknown): value is QuotaMoney { + return ( + record(value) && + keys(value, ['amountMinor', 'currency', 'exponent']) && + finite(value.amountMinor) && + text(value.currency) && + /^[A-Za-z]{3}$/.test(value.currency) && + finite(value.exponent) && + Number.isInteger(value.exponent) && + value.exponent >= 0 && + value.exponent <= 20 + ) +} + +function extraUsage(value: unknown): value is OAuthExtraUsageSnapshot { + return ( + record(value) && + keys( + value, + ['used', 'limit', 'exhausted'], + ['utilizationPercent', 'severity'], + ) && + money(value.used) && + money(value.limit) && + typeof value.exhausted === 'boolean' && + optional(value, 'utilizationPercent', finite) && + optional(value, 'severity', (entry) => typeof entry === 'string') + ) +} + +function fieldSources(value: unknown): value is QuotaFieldSources { + return ( + record(value) && + keys(value, [], QUOTA_FIELD_NAMES) && + Object.values(value).every(source) + ) +} + +const metadataKeys: readonly string[] = [ + 'checkedAt', + 'accountIdentity', + 'extraUsage', + 'bindingWindow', + 'bindingWindowSource', + 'fieldSources', + 'fallbackAdvised', + 'source', +] + +function metadataFields(value: Record): boolean { + return ( + optional(value, 'checkedAt', time) && + optional(value, 'accountIdentity', text) && + optional(value, 'extraUsage', extraUsage) && + optional(value, 'bindingWindow', text) && + optional(value, 'bindingWindowSource', source) && + optional(value, 'fieldSources', fieldSources) && + optional(value, 'fallbackAdvised', (entry) => typeof entry === 'boolean') && + optional(value, 'source', source) + ) +} + +function window(value: unknown, scoped: boolean): boolean { + if ( + !record(value) || + !keys( + value, + scoped + ? [ + 'usedPercent', + 'remainingPercent', + 'checkedAt', + 'id', + 'title', + 'modelName', + ] + : ['usedPercent', 'remainingPercent', 'checkedAt'], + scoped ? ['resetsAt', 'modelId'] : ['resetsAt'], + ) + ) + return false + return ( + finite(value.usedPercent) && + finite(value.remainingPercent) && + time(value.checkedAt) && + optional(value, 'resetsAt', (entry) => typeof entry === 'string') && + (!scoped || + (text(value.id) && + text(value.title) && + text(value.modelName) && + optional(value, 'modelId', text))) + ) +} + +export function isNativeOAuthQuotaSnapshot( + value: unknown, +): value is OAuthQuotaSnapshot { + return ( + record(value) && + keys(value, [], ['five_hour', 'seven_day', 'scoped', ...metadataKeys]) && + metadataFields(value) && + optional(value, 'five_hour', (entry) => window(entry, false)) && + optional(value, 'seven_day', (entry) => window(entry, false)) && + optional( + value, + 'scoped', + (entry) => + Array.isArray(entry) && + entry.every((item) => window(item, true)) && + new Set(entry.map((item) => item.id)).size === entry.length, + ) + ) +} + +function scopedMetadata(value: unknown): value is ScopedMetadata { + return ( + record(value) && + keys( + value, + ['id', 'title', 'modelName', 'remainingPercent'], + ['modelId'], + ) && + text(value.id) && + text(value.title) && + text(value.modelName) && + optional(value, 'modelId', text) && + finite(value.remainingPercent) + ) +} + +function nativeMetadata(value: unknown): value is NativeQuotaMetadata { + return ( + record(value) && + keys(value, ['version', 'remainingPercent'], ['scoped', ...metadataKeys]) && + value.version === 1 && + metadataFields(value) && + record(value.remainingPercent) && + keys(value.remainingPercent, [], ['five_hour', 'seven_day']) && + Object.values(value.remainingPercent).every(finite) && + optional( + value, + 'scoped', + (entry) => Array.isArray(entry) && entry.every(scopedMetadata), + ) + ) +} + +export function nativeQuotaModelScope(model: string): string { + const normalized = model.toLowerCase().replace(/[^a-z0-9]/g, '') + if (normalized.includes('fable')) return 'fable' + if (normalized.includes('mythos')) return 'mythos' + return `model:${normalized}` +} + +function scopedPair(entry: ScopedMetadata): { scope: string; label: string } { + const family = nativeQuotaModelScope( + [entry.modelId, entry.modelName, entry.title].join(' '), + ) + return { + scope: + family === 'fable' || family === 'mythos' + ? family + : nativeQuotaModelScope(entry.modelId ?? entry.modelName), + label: `seven_day:${entry.id}`, + } +} + +/** Conversion is arithmetic-free: preserve independent clocks and raw percentages. */ +export function toNativeQuotaMap(snapshot: OAuthQuotaSnapshot): NativeQuotaMap { + if (!isNativeOAuthQuotaSnapshot(snapshot)) throw new NativeQuotaCodecError() + const json: unknown = JSON.parse(JSON.stringify(snapshot)) + if (!isNativeOAuthQuotaSnapshot(json)) throw new NativeQuotaCodecError() + const { five_hour, seven_day, scoped, ...metadata } = json + const limits: QuotaReadingEntry[] = [] + const remainingPercent: NativeQuotaMetadata['remainingPercent'] = {} + if (five_hour) { + const { remainingPercent: remaining, ...reading } = five_hour + remainingPercent.five_hour = remaining + limits.push({ + scope: 'all', + label: 'five_hour', + kind: 'reading', + ...reading, + }) + } + if (seven_day) { + const { remainingPercent: remaining, ...reading } = seven_day + remainingPercent.seven_day = remaining + limits.push({ + scope: 'all', + label: 'seven_day', + kind: 'reading', + ...reading, + }) + } + const scopedMetadataEntries = scoped?.map((entry) => { + const { usedPercent, checkedAt, resetsAt, ...metadata } = entry + limits.push({ + ...scopedPair(metadata), + kind: 'reading', + usedPercent, + checkedAt, + ...(resetsAt !== undefined ? { resetsAt } : {}), + }) + return metadata + }) + const result: NativeQuotaMap = { + limits, + anthropic: { + version: 1, + remainingPercent, + ...metadata, + ...(scopedMetadataEntries !== undefined + ? { scoped: scopedMetadataEntries } + : {}), + }, + } + // Extra usage has no independent clock in OAuthQuotaSnapshot. Without a + // snapshot stamp keep it in the extension, rather than invent budget freshness. + if (metadata.extraUsage && metadata.checkedAt !== undefined) { + result.budget = { + kind: 'reading', + checkedAt: metadata.checkedAt, + reached: metadata.extraUsage.exhausted, + ...(metadata.extraUsage.utilizationPercent !== undefined + ? { usedPercent: metadata.extraUsage.utilizationPercent } + : {}), + } + } + if (!isQuotaMap(result)) throw new NativeQuotaCodecError() + return result +} + +function reading(value: unknown): value is QuotaReadingEntry { + return ( + record(value) && + keys( + value, + ['scope', 'label', 'kind', 'checkedAt', 'usedPercent'], + ['resetsAt'], + ) && + text(value.scope) && + text(value.label) && + value.kind === 'reading' && + time(value.checkedAt) && + finite(value.usedPercent) && + optional(value, 'resetsAt', (entry) => typeof entry === 'string') + ) +} + +function equalJson(a: unknown, b: unknown): boolean { + if (Array.isArray(a) && Array.isArray(b)) + return ( + a.length === b.length && a.every((value, i) => equalJson(value, b[i])) + ) + if (record(a) && record(b)) + return ( + Object.keys(a).length === Object.keys(b).length && + Object.keys(a).every( + (key) => Object.hasOwn(b, key) && equalJson(a[key], b[key]), + ) + ) + return a === b +} + +/** Reject missing Anthropic metadata, extra keys, duplicate/unowned limits, absent/retired entries and inconsistent budgets. */ +export function fromNativeQuotaMap(value: unknown): OAuthQuotaSnapshot { + if ( + !record(value) || + !keys(value, ['limits', 'anthropic'], ['budget']) || + !isQuotaMap(value) || + !value.limits.every(reading) || + !nativeMetadata(value.anthropic) + ) + throw new NativeQuotaCodecError() + const { + version: _version, + remainingPercent, + scoped, + ...metadata + } = value.anthropic + const snapshot: OAuthQuotaSnapshot = structuredClone(metadata) + for (const label of ['five_hour', 'seven_day']) { + const entry = value.limits.find( + (entry) => entry.scope === 'all' && entry.label === label, + ) + const remaining = + label === 'five_hour' + ? remainingPercent.five_hour + : remainingPercent.seven_day + if (entry?.kind !== 'reading' || remaining === undefined) continue + const nativeWindow = { + usedPercent: entry.usedPercent, + remainingPercent: remaining, + checkedAt: entry.checkedAt, + ...(entry.resetsAt !== undefined ? { resetsAt: entry.resetsAt } : {}), + } + if (label === 'five_hour') snapshot.five_hour = nativeWindow + else snapshot.seven_day = nativeWindow + } + if (scoped !== undefined) { + snapshot.scoped = scoped.map((metadata) => { + const pair = scopedPair(metadata) + const entry = value.limits.find( + (entry) => entry.scope === pair.scope && entry.label === pair.label, + ) + if (entry?.kind !== 'reading') throw new NativeQuotaCodecError() + return { + ...structuredClone(metadata), + usedPercent: entry.usedPercent, + checkedAt: entry.checkedAt, + ...(entry.resetsAt !== undefined ? { resetsAt: entry.resetsAt } : {}), + } + }) + } + // This also rejects extension/map disagreement, unowned limits and budgets. + if (!equalJson(toNativeQuotaMap(snapshot), value)) + throw new NativeQuotaCodecError() + return snapshot +} + +export const nativeQuotaCodec: QuotaCodec = { + validate(value) { + try { + fromNativeQuotaMap(value) + return true + } catch { + return false + } + }, + merge(stored, observation) { + const incoming = fromNativeQuotaMap(observation) + if (stored === undefined) return toNativeQuotaMap(incoming) + const previous = fromNativeQuotaMap(stored) + if (previous.accountIdentity !== incoming.accountIdentity) + throw new NativeQuotaCodecError() + if (incoming.source === 'headers') + return toNativeQuotaMap( + mergeHeaderQuotaForPersistence(previous, incoming), + ) + const previousTime = quotaSnapshotCheckedAt(previous) + const incomingTime = quotaSnapshotCheckedAt(incoming) + return toNativeQuotaMap( + previousTime > incomingTime || + (previousTime === incomingTime && + previous.source === 'poll' && + incoming.source !== 'poll') + ? previous + : incoming, + ) + }, +} diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts new file mode 100644 index 000000000..c499574db --- /dev/null +++ b/packages/core/src/native-runtime.ts @@ -0,0 +1,584 @@ +import { randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { lstat, mkdir, open, rename, rm } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join } from 'node:path' +import { withLock } from '@cortexkit/common-auth/fs' + +import type { + AccountOperationError, + OAuthAccountProfile, + PrimeUsageCounters, +} from './accounts.ts' +import { quotaSnapshotCheckedAt } from './accounts.ts' +import { parseJsonRedacted } from './json.ts' +import { + fromNativeQuotaMap, + type NativeQuotaMap, +} from './native-quota-codec.ts' +import { + isNativeLocalPoolBinding, + type NativeLocalPoolBinding, +} from './pool-binding.ts' + +/** Identifies the vault credential and Claude account that supplied an observation. This binding contains no access token. */ +export interface NativeCustodyRuntimeBinding { + kind: 'custody' + storageId: string + routeId: string + credentialId: string + accountIdentity: string + recordVersion: number +} + +export type NativeRuntimeBinding = + | NativeLocalPoolBinding + | NativeCustodyRuntimeBinding + +/** + * Per-account retry errors, profiles, quota-priming usage and observation + * identifiers stored separately from credentials. Vault quota is stored here; + * local quota stays in the pool. + */ +export interface NativeRuntimeEntry { + binding: NativeRuntimeBinding + lastUsed?: number + lastRefreshedAt?: number + lastRefreshError?: AccountOperationError + refreshErrorClearedAt?: number + refreshLeaseId?: string + refreshLeaseUntil?: number + refreshLeaseTokenHash?: string + lastQuotaRefreshError?: AccountOperationError + quotaErrorGeneration?: number + quotaErrorClearedAt?: number + quota?: NativeQuotaMap + quotaCheckedAt?: number + quotaToken?: string + profile?: OAuthAccountProfile + profileToken?: string + prime?: PrimeUsageCounters + authLineageId?: string + primeAuthLineageRefreshTokenFingerprint?: string +} + +export interface NativeRuntimeState { + version: 1 + storageId: string + accounts: Record + relay?: { token: string } +} + +export type NativeRuntimeRead = + | { status: 'missing' } + | { status: 'ready'; state: NativeRuntimeState } + +export class NativeRuntimeError extends Error { + constructor( + public readonly code: + | 'invalid-runtime' + | 'unsafe-runtime' + | 'runtime-io' + | 'runtime-conflict' + | 'publication-refused', + ) { + super( + { + 'invalid-runtime': 'Anthropic runtime state is invalid', + 'unsafe-runtime': + 'Anthropic runtime file is not owner-only regular storage', + 'runtime-io': 'Anthropic runtime storage is unavailable', + 'runtime-conflict': + 'Anthropic runtime observation is older than persisted state', + 'publication-refused': 'Anthropic runtime publication was refused', + }[code], + ) + this.name = 'NativeRuntimeError' + } +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function keys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + return ( + required.every((key) => Object.hasOwn(value, key)) && + Object.keys(value).every( + (key) => required.includes(key) || optional.includes(key), + ) + ) +} + +function text(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.trim() === value +} + +function time(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +function counter(value: unknown): value is number { + return time(value) && Number.isSafeInteger(value) +} + +function optional( + value: Record, + key: string, + check: (entry: unknown) => boolean, +): boolean { + return !Object.hasOwn(value, key) || check(value[key]) +} + +function digest(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} + +function binding(value: unknown): value is NativeRuntimeBinding { + return ( + isNativeLocalPoolBinding(value) || + (record(value) && + keys(value, [ + 'kind', + 'storageId', + 'routeId', + 'credentialId', + 'accountIdentity', + 'recordVersion', + ]) && + value.kind === 'custody' && + digest(value.storageId) && + text(value.routeId) && + text(value.credentialId) && + text(value.accountIdentity) && + counter(value.recordVersion)) + ) +} + +function operationError(value: unknown): value is AccountOperationError { + return ( + record(value) && + keys( + value, + ['message', 'checkedAt'], + [ + 'nextRetryAt', + 'retryCount', + 'accountIdentity', + 'tokenHash', + 'refreshTokenFingerprint', + 'status', + 'permanent', + ], + ) && + typeof value.message === 'string' && + time(value.checkedAt) && + optional(value, 'nextRetryAt', time) && + optional(value, 'retryCount', counter) && + optional(value, 'accountIdentity', text) && + optional(value, 'tokenHash', text) && + optional(value, 'refreshTokenFingerprint', text) && + optional( + value, + 'status', + (entry) => counter(entry) && entry >= 100 && entry <= 599, + ) && + optional(value, 'permanent', (entry) => typeof entry === 'boolean') + ) +} + +function profile(value: unknown): value is OAuthAccountProfile { + return ( + record(value) && + keys( + value, + ['tier', 'orgType', 'checkedAt'], + ['accountIdentity', 'providerAccountUuid', 'tokenFingerprint'], + ) && + text(value.tier) && + text(value.orgType) && + time(value.checkedAt) && + optional(value, 'accountIdentity', text) && + optional(value, 'providerAccountUuid', text) && + optional(value, 'tokenFingerprint', text) + ) +} + +function prime(value: unknown): value is PrimeUsageCounters { + return ( + record(value) && + keys(value, ['count', 'inputTokens', 'outputTokens', 'since']) && + counter(value.count) && + counter(value.inputTokens) && + counter(value.outputTokens) && + time(value.since) + ) +} + +const entryKeys: readonly string[] = [ + 'lastUsed', + 'lastRefreshedAt', + 'lastRefreshError', + 'refreshErrorClearedAt', + 'refreshLeaseId', + 'refreshLeaseUntil', + 'refreshLeaseTokenHash', + 'lastQuotaRefreshError', + 'quotaErrorGeneration', + 'quotaErrorClearedAt', + 'quota', + 'quotaCheckedAt', + 'quotaToken', + 'profile', + 'profileToken', + 'prime', + 'authLineageId', + 'primeAuthLineageRefreshTokenFingerprint', +] + +function entry( + value: unknown, + id: string, + storageId: string, +): value is NativeRuntimeEntry { + if ( + !record(value) || + !keys(value, ['binding'], entryKeys) || + !binding(value.binding) || + value.binding.storageId !== storageId || + (value.binding.kind === 'local' + ? value.binding.rowId + : value.binding.routeId) !== id + ) + return false + for (const key of [ + 'lastUsed', + 'lastRefreshedAt', + 'refreshErrorClearedAt', + 'refreshLeaseUntil', + 'quotaErrorClearedAt', + 'quotaCheckedAt', + ]) { + if (!optional(value, key, time)) return false + } + for (const key of [ + 'refreshLeaseId', + 'refreshLeaseTokenHash', + 'quotaToken', + 'profileToken', + 'authLineageId', + 'primeAuthLineageRefreshTokenFingerprint', + ]) { + if (!optional(value, key, text)) return false + } + if ( + !optional(value, 'quotaErrorGeneration', counter) || + !optional(value, 'profile', profile) || + !optional(value, 'prime', prime) || + !optional(value, 'lastRefreshError', operationError) || + !optional(value, 'lastQuotaRefreshError', operationError) + ) + return false + const identity = + value.binding.kind === 'local' + ? value.binding.identity + : value.binding.accountIdentity + for (const observation of [ + value.profile, + value.lastRefreshError, + value.lastQuotaRefreshError, + ]) { + if ( + record(observation) && + ((observation.accountIdentity !== undefined && + observation.accountIdentity !== identity) || + (observation.providerAccountUuid !== undefined && + observation.providerAccountUuid !== identity)) + ) + return false + } + if (Object.hasOwn(value, 'quota')) { + if (value.binding.kind !== 'custody') return false + try { + if (fromNativeQuotaMap(value.quota).accountIdentity !== identity) + return false + } catch { + return false + } + } + // Reject errors observed at or before the clear so a read cannot restore cleared backoff. + const errorFences: Array<[string, string]> = [ + ['lastRefreshError', 'refreshErrorClearedAt'], + ['lastQuotaRefreshError', 'quotaErrorClearedAt'], + ] + for (const [errorKey, clearKey] of errorFences) { + const error = value[errorKey] + const clear = value[clearKey] + if (operationError(error) && time(clear) && error.checkedAt <= clear) + return false + } + return true +} + +/** Validate structure and internal identifier consistency. The publisher separately checks these identifiers against the current pool or vault roster. */ +export function decodeNativeRuntime( + value: unknown, + storageId: string, +): NativeRuntimeState { + if ( + !record(value) || + !keys(value, ['version', 'storageId', 'accounts'], ['relay']) || + value.version !== 1 || + !digest(storageId) || + value.storageId !== storageId || + !record(value.accounts) || + !optional( + value, + 'relay', + (entry) => + record(entry) && + keys(entry, ['token']) && + typeof entry.token === 'string', + ) + ) + throw new NativeRuntimeError('invalid-runtime') + const accounts: Record = Object.create(null) + for (const [id, item] of Object.entries(value.accounts)) { + if (!text(id) || !entry(item, id, storageId)) + throw new NativeRuntimeError('invalid-runtime') + accounts[id] = structuredClone(item) + } + return { + version: 1, + storageId, + accounts, + ...(record(value.relay) && typeof value.relay.token === 'string' + ? { relay: { token: value.relay.token } } + : {}), + } +} + +function missing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +function checkPath(path: string): void { + if (!isAbsolute(path)) throw new NativeRuntimeError('unsafe-runtime') +} + +export async function readNativeRuntime( + path: string, + storageId: string, +): Promise { + checkPath(path) + if (!digest(storageId)) throw new NativeRuntimeError('invalid-runtime') + try { + const info = await lstat(path) + if ( + !info.isFile() || + (info.mode & 0o777) !== 0o600 || + (typeof process.getuid === 'function' && info.uid !== process.getuid()) + ) + throw new NativeRuntimeError('unsafe-runtime') + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) + try { + const opened = await file.stat() + if ( + opened.dev !== info.dev || + opened.ino !== info.ino || + !opened.isFile() || + (opened.mode & 0o777) !== 0o600 || + (typeof process.getuid === 'function' && + opened.uid !== process.getuid()) + ) + throw new NativeRuntimeError('unsafe-runtime') + let contents: string + try { + contents = new TextDecoder('utf-8', { fatal: true }).decode( + await file.readFile(), + ) + } catch (error) { + if (error instanceof TypeError) + throw new NativeRuntimeError('invalid-runtime') + throw error + } + return { + status: 'ready', + state: decodeNativeRuntime(parseJsonRedacted(contents), storageId), + } + } finally { + await file.close() + } + } catch (error) { + if (missing(error)) return { status: 'missing' } + if (error instanceof NativeRuntimeError) throw error + if (error instanceof SyntaxError) + throw new NativeRuntimeError('invalid-runtime') + throw new NativeRuntimeError('runtime-io') + } +} + +export interface NativeRuntimeWriteHooks { + /** Verify that the observed account still matches the current pool record or vault roster and that the caller still owns the locks protecting those records. */ + beforeRename?: () => Promise + /** Runs after the new file replaces the old one. Failure does not undo that replacement. */ + afterRename?: () => Promise +} + +/** This writer alone uses `.native-runtime..partial`. */ +export function isNativeRuntimeStagingName( + path: string, + name: string, +): boolean { + const prefix = `${basename(path)}.native-runtime.` + return ( + name === basename(name) && + name.startsWith(prefix) && + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.partial$/.test( + name.slice(prefix.length), + ) + ) +} + +function assertTransitions( + previous: NativeRuntimeState, + next: NativeRuntimeState, +): void { + for (const [id, incoming] of Object.entries(next.accounts)) { + const old = previous.accounts[id] + if (!old) continue + if (old.binding.kind !== incoming.binding.kind) continue + if (old.binding.kind === 'local' && incoming.binding.kind === 'local') { + if (incoming.binding.credentialEpoch < old.binding.credentialEpoch) + throw new NativeRuntimeError('runtime-conflict') + if (incoming.binding.credentialEpoch > old.binding.credentialEpoch) + continue + if (old.binding.identity !== incoming.binding.identity) + throw new NativeRuntimeError('runtime-conflict') + } else if ( + old.binding.kind === 'custody' && + incoming.binding.kind === 'custody' + ) { + if ( + old.binding.credentialId !== incoming.binding.credentialId || + old.binding.accountIdentity !== incoming.binding.accountIdentity + ) + continue + if (incoming.binding.recordVersion < old.binding.recordVersion) + throw new NativeRuntimeError('runtime-conflict') + } + const fenceKeys: Array< + 'quotaErrorGeneration' | 'quotaErrorClearedAt' | 'refreshErrorClearedAt' + > = ['quotaErrorGeneration', 'quotaErrorClearedAt', 'refreshErrorClearedAt'] + for (const key of fenceKeys) { + const older = old[key] + const newer = incoming[key] + if (older !== undefined && (newer === undefined || newer < older)) + throw new NativeRuntimeError('runtime-conflict') + } + if ( + old.profile && + incoming.profile && + incoming.profile.checkedAt < old.profile.checkedAt + ) + throw new NativeRuntimeError('runtime-conflict') + if ( + old.quota && + incoming.quota && + quotaSnapshotCheckedAt(fromNativeQuotaMap(incoming.quota)) < + quotaSnapshotCheckedAt(fromNativeQuotaMap(old.quota)) + ) + throw new NativeRuntimeError('runtime-conflict') + for (const [older, newer, clear] of [ + [ + old.lastRefreshError, + incoming.lastRefreshError, + incoming.refreshErrorClearedAt, + ], + [ + old.lastQuotaRefreshError, + incoming.lastQuotaRefreshError, + incoming.quotaErrorClearedAt, + ], + ]) { + if ( + operationError(older) && + (!operationError(newer) + ? !time(clear) || clear < older.checkedAt + : newer.checkedAt < older.checkedAt) + ) + throw new NativeRuntimeError('runtime-conflict') + } + } +} + +/** + * This file has its own update lock. Account publishers also hold the locks + * protecting the pool and, for vault accounts, the roster. They verify those + * locks immediately before replacement. A process paused beyond a lock expiry + * can overlap a new owner even with normal renewal. + */ +export async function updateNativeRuntime( + path: string, + storageId: string, + change: ( + current: NativeRuntimeState, + ) => NativeRuntimeState | Promise, + hooks: NativeRuntimeWriteHooks = {}, +): Promise { + checkPath(path) + try { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + return await withLock( + path, + { name: 'native-runtime', ttlMs: 10_000, timeoutMs: 15_000, renew: true }, + async (lock) => { + const read = await readNativeRuntime(path, storageId) + const current: NativeRuntimeState = + read.status === 'ready' + ? read.state + : { version: 1, storageId, accounts: {} } + const next = decodeNativeRuntime( + await change(structuredClone(current)), + storageId, + ) + assertTransitions(current, next) + const bytes = `${JSON.stringify(next, null, 2)}\n` + const stage = join( + dirname(path), + `${basename(path)}.native-runtime.${randomUUID()}.partial`, + ) + try { + const file = await open(stage, 'wx', 0o600) + try { + await file.writeFile(bytes, 'utf8') + await file.sync() + } finally { + await file.close() + } + try { + await hooks.beforeRename?.() + } catch { + throw new NativeRuntimeError('publication-refused') + } + await lock.assertOwned() + await rename(stage, path) + try { + await hooks.afterRename?.() + } catch { + throw new NativeRuntimeError('publication-refused') + } + return next + } finally { + await rm(stage, { force: true }) + } + }, + ) + } catch (error) { + if (error instanceof NativeRuntimeError) throw error + throw new NativeRuntimeError('runtime-io') + } +} diff --git a/packages/core/src/tests/native-quota-codec.test.ts b/packages/core/src/tests/native-quota-codec.test.ts new file mode 100644 index 000000000..8ad04567d --- /dev/null +++ b/packages/core/src/tests/native-quota-codec.test.ts @@ -0,0 +1,457 @@ +import { expect, test } from 'bun:test' +import { isQuotaMap } from '@cortexkit/common-auth/quota' + +import { + fetchOAuthQuotaSnapshot, + getScopedQuotaWindowForModel, + mergeHeaderQuotaForPersistence, + type OAuthQuotaSnapshot, + quotaSnapshotCheckedAt, +} from '../accounts.ts' +import { + fromNativeQuotaMap, + nativeQuotaCodec, + nativeQuotaModelScope, + toNativeQuotaMap, +} from '../native-quota-codec.ts' +import { normalizeQuotaHeaders } from '../quota-headers.ts' + +// Different timestamps expose accidental timestamp reuse between quota windows. +const full: OAuthQuotaSnapshot = { + accountIdentity: 'synthetic-account-A', + checkedAt: 1234, + five_hour: { + usedPercent: 99.96, + remainingPercent: 0.04, + checkedAt: 1100, + resetsAt: '2030-01-01T05:00:00.000Z', + }, + seven_day: { + usedPercent: 43.125, + remainingPercent: 56.875, + checkedAt: 900, + resetsAt: '2030-01-07T00:00:00.000Z', + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelId: 'claude-fable-5', + modelName: 'Claude Fable 5', + usedPercent: 100, + remainingPercent: 0, + checkedAt: 1000, + resetsAt: '2030-01-06T00:00:00.000Z', + }, + { + id: 'claude-weekly-scoped-mythos', + title: 'Mythos only', + modelName: 'Mythos', + usedPercent: 0.125, + remainingPercent: 99.875, + checkedAt: 800, + }, + ], + extraUsage: { + used: { amountMinor: 192.25, currency: 'USD', exponent: 2 }, + limit: { amountMinor: 1000, currency: 'USD', exponent: 2 }, + utilizationPercent: 19.225, + severity: 'normal', + exhausted: false, + }, + bindingWindow: 'seven_day', + bindingWindowSource: 'poll', + fallbackAdvised: false, + source: 'headers', + fieldSources: { + five_hour: 'headers', + seven_day: 'poll', + scoped: 'poll', + extraUsage: 'poll', + bindingWindow: 'poll', + fallbackAdvised: 'headers', + }, +} + +test('quota full fixture preserves independent timestamps, scope, provenance and extra usage', () => { + const map = toNativeQuotaMap(full) + expect(isQuotaMap(map)).toBe(true) + expect(map.limits).toEqual([ + { + scope: 'all', + label: 'five_hour', + kind: 'reading', + usedPercent: 99.96, + checkedAt: 1100, + resetsAt: '2030-01-01T05:00:00.000Z', + }, + { + scope: 'all', + label: 'seven_day', + kind: 'reading', + usedPercent: 43.125, + checkedAt: 900, + resetsAt: '2030-01-07T00:00:00.000Z', + }, + { + scope: 'fable', + label: 'seven_day:claude-weekly-scoped-fable', + kind: 'reading', + usedPercent: 100, + checkedAt: 1000, + resetsAt: '2030-01-06T00:00:00.000Z', + }, + { + scope: 'mythos', + label: 'seven_day:claude-weekly-scoped-mythos', + kind: 'reading', + usedPercent: 0.125, + checkedAt: 800, + }, + ]) + expect(map.budget).toEqual({ + kind: 'reading', + checkedAt: 1234, + reached: false, + usedPercent: 19.225, + }) + expect(fromNativeQuotaMap(map)).toEqual(full) + expect(fromNativeQuotaMap(JSON.parse(JSON.stringify(map)))).toEqual(full) + expect(quotaSnapshotCheckedAt(fromNativeQuotaMap(map))).toBe(1234) + expect( + getScopedQuotaWindowForModel( + fromNativeQuotaMap(map), + 'anthropic/claude-fable-5@fast', + )?.remainingPercent, + ).toBe(0) +}) + +test('quota timestamp metadata control preserves a windowless empty scoped observation', () => { + const empty: OAuthQuotaSnapshot = { + accountIdentity: 'synthetic-account-A', + checkedAt: 2000, + scoped: [], + source: 'poll', + fieldSources: { scoped: 'poll' }, + } + const map = toNativeQuotaMap(empty) + expect(map.limits).toEqual([]) + expect(map.anthropic.checkedAt).toBe(2000) + expect(map.anthropic.scoped).toEqual([]) + expect(fromNativeQuotaMap(map)).toEqual(empty) + expect(quotaSnapshotCheckedAt(fromNativeQuotaMap(map))).toBe(2000) +}) + +test('quota scoped metadata control preserves scoped-only ownership versus missing', () => { + const scoped: OAuthQuotaSnapshot = { + scoped: [ + { + id: 'weekly-custom', + title: 'Custom only', + modelId: 'claude-custom-1', + modelName: 'Custom', + usedPercent: 22.125, + remainingPercent: 77.875, + checkedAt: 456, + resetsAt: '2030-02-01T00:00:00Z', + }, + ], + } + const map = toNativeQuotaMap(scoped) + expect(map.limits).toEqual([ + { + scope: 'model:claudecustom1', + label: 'seven_day:weekly-custom', + kind: 'reading', + usedPercent: 22.125, + checkedAt: 456, + resetsAt: '2030-02-01T00:00:00Z', + }, + ]) + expect(fromNativeQuotaMap(map)).toEqual(scoped) + expect(fromNativeQuotaMap(toNativeQuotaMap({}))).toEqual({}) + expect( + Object.hasOwn(fromNativeQuotaMap(toNativeQuotaMap({})), 'scoped'), + ).toBe(false) + expect(fromNativeQuotaMap(toNativeQuotaMap({ scoped: [] }))).toEqual({ + scoped: [], + }) +}) + +test('extra usage without a clock stays lossless without fabricated budget freshness', () => { + const quota: OAuthQuotaSnapshot = { + extraUsage: { + used: { amountMinor: 11, currency: 'EUR', exponent: 3 }, + limit: { amountMinor: 10, currency: 'EUR', exponent: 3 }, + exhausted: true, + }, + } + const map = toNativeQuotaMap(quota) + expect(Object.hasOwn(map, 'budget')).toBe(false) + expect(fromNativeQuotaMap(map)).toEqual(quota) + expect( + toNativeQuotaMap({ + five_hour: { + usedPercent: 3.14, + remainingPercent: 96.86, + checkedAt: 0, + resetsAt: undefined, + }, + }).limits, + ).toEqual([ + { + scope: 'all', + label: 'five_hour', + kind: 'reading', + usedPercent: 3.14, + checkedAt: 0, + }, + ]) + expect(nativeQuotaModelScope('ALL')).toBe('model:all') +}) + +test('scoped family attribution matches native model names even with an opaque provider model id', () => { + const quota: OAuthQuotaSnapshot = { + scoped: [ + { + id: 'weekly-opaque', + title: 'Fable only', + modelId: 'provider-model-123', + modelName: 'Fable 5', + usedPercent: 100, + remainingPercent: 0, + checkedAt: 1, + }, + ], + } + expect(getScopedQuotaWindowForModel(quota, 'claude-fable-5@fast')?.id).toBe( + 'weekly-opaque', + ) + expect(toNativeQuotaMap(quota).limits).toEqual([ + { + scope: 'fable', + label: 'seven_day:weekly-opaque', + kind: 'reading', + usedPercent: 100, + checkedAt: 1, + }, + ]) +}) + +test('quota extension is closed and cannot hide foreign or contradictory shared data', () => { + const map = toNativeQuotaMap(full) + const malformed: unknown[] = [ + { ...map, secret: 'synthetic-secret' }, + { ...map, anthropic: { ...map.anthropic, version: 2 } }, + { ...map, anthropic: { ...map.anthropic, secret: 'synthetic-secret' } }, + { + ...map, + limits: [ + ...map.limits, + { + scope: 'all', + label: 'unowned', + kind: 'reading', + usedPercent: 7, + checkedAt: 8, + }, + ], + }, + { + ...map, + limits: map.limits.map((entry) => ({ ...entry, windowMinutes: 300 })), + }, + { + ...map, + budget: { + kind: 'reading', + checkedAt: 1234, + reached: true, + usedPercent: 19.225, + }, + }, + { + ...map, + anthropic: { ...map.anthropic, remainingPercent: { five_hour: 0.04 } }, + }, + { + ...map, + limits: map.limits.map((entry) => ({ + ...entry, + kind: 'retired', + retiredAt: 333, + })), + }, + { limits: [] }, + ] + for (const input of malformed) { + expect(nativeQuotaCodec.validate(input)).toBe(false) + expect(() => fromNativeQuotaMap(input)).toThrow( + 'Anthropic quota observation is invalid', + ) + } + expect(() => + toNativeQuotaMap({ + scoped: [ + { + id: 'same', + title: 'A', + modelName: 'A', + checkedAt: 1, + usedPercent: 1, + remainingPercent: 99, + }, + { + id: 'same', + title: 'B', + modelName: 'B', + checkedAt: 1, + usedPercent: 2, + remainingPercent: 98, + }, + ], + }), + ).toThrow() +}) + +test('store codec follows native header/poll merge and refuses cross-account provenance', () => { + const poll: OAuthQuotaSnapshot = { ...full, source: 'poll' } + const headers: OAuthQuotaSnapshot = { + accountIdentity: 'synthetic-account-A', + source: 'headers', + checkedAt: 1400, + five_hour: { usedPercent: 12.5, remainingPercent: 87.5, checkedAt: 1400 }, + fieldSources: { five_hour: 'headers' }, + } + const merged = fromNativeQuotaMap( + nativeQuotaCodec.merge(toNativeQuotaMap(poll), toNativeQuotaMap(headers)), + ) + expect(merged).toEqual( + JSON.parse(JSON.stringify(mergeHeaderQuotaForPersistence(poll, headers))), + ) + expect(merged.scoped).toEqual(full.scoped) + expect(merged.seven_day?.checkedAt).toBe(900) + expect(merged.five_hour?.checkedAt).toBe(1400) + expect( + fromNativeQuotaMap( + nativeQuotaCodec.merge( + toNativeQuotaMap(poll), + toNativeQuotaMap({ + accountIdentity: 'synthetic-account-A', + checkedAt: 1, + source: 'poll', + }), + ), + ), + ).toEqual(poll) + expect( + fromNativeQuotaMap( + nativeQuotaCodec.merge(undefined, toNativeQuotaMap(headers)), + ), + ).toEqual(headers) + expect(() => + nativeQuotaCodec.merge( + toNativeQuotaMap(poll), + toNativeQuotaMap({ ...headers, accountIdentity: 'synthetic-account-B' }), + ), + ).toThrow() +}) + +test('captured Team poll and response headers survive the production provider parsers and codec', async () => { + const capture = { + five_hour: { utilization: 77 }, + seven_day: { utilization: 40 }, + limits: [ + { kind: 'session', group: 'session', percent: 77, is_active: true }, + { kind: 'weekly_all', group: 'weekly', percent: 40, is_active: false }, + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 51, + is_active: false, + scope: { model: { id: null, display_name: 'Fable' } }, + }, + ], + extra_usage: { + is_enabled: true, + monthly_limit: 10000, + used_credits: 10035, + utilization: 100, + }, + spend: { + severity: 'critical', + limit: { amount_minor: 10000, currency: 'USD', exponent: 2 }, + can_purchase_credits: false, + }, + } + const fetchImpl: typeof fetch = Object.assign( + async () => Response.json(capture), + { preconnect: fetch.preconnect }, + ) + const snapshot = await fetchOAuthQuotaSnapshot({ + accessToken: 'synthetic-fixture-token', + now: () => 1700000000000, + fetchImpl, + }) + const map = toNativeQuotaMap(snapshot) + expect(map.limits).toEqual([ + { + scope: 'all', + label: 'five_hour', + kind: 'reading', + usedPercent: 77, + checkedAt: 1700000000000, + }, + { + scope: 'all', + label: 'seven_day', + kind: 'reading', + usedPercent: 40, + checkedAt: 1700000000000, + }, + { + scope: 'fable', + label: 'seven_day:claude-weekly-scoped-fable', + kind: 'reading', + usedPercent: 51, + checkedAt: 1700000000000, + }, + ]) + expect(map.budget).toEqual({ + kind: 'reading', + checkedAt: 1700000000000, + reached: true, + usedPercent: 100, + }) + expect(fromNativeQuotaMap(map)).toEqual(JSON.parse(JSON.stringify(snapshot))) + const headers = normalizeQuotaHeaders( + new Headers({ + 'anthropic-ratelimit-unified-5h-utilization': '0.78', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + 'anthropic-ratelimit-unified-representative-claim': 'five_hour', + 'anthropic-ratelimit-unified-fallback': 'available', + }), + 1700000000001, + ) + expect(toNativeQuotaMap(headers).limits).toEqual([ + { + scope: 'all', + label: 'five_hour', + kind: 'reading', + usedPercent: 78, + checkedAt: 1700000000001, + resetsAt: '2026-07-17T00:00:00.000Z', + }, + { + scope: 'all', + label: 'seven_day', + kind: 'reading', + usedPercent: 40, + checkedAt: 1700000000001, + resetsAt: '2026-07-21T10:00:00.000Z', + }, + ]) + expect(fromNativeQuotaMap(toNativeQuotaMap(headers))).toEqual(headers) +}) diff --git a/packages/core/src/tests/native-runtime.test.ts b/packages/core/src/tests/native-runtime.test.ts new file mode 100644 index 000000000..6ba561b03 --- /dev/null +++ b/packages/core/src/tests/native-runtime.test.ts @@ -0,0 +1,588 @@ +import { afterEach, expect, test } from 'bun:test' +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { toNativeQuotaMap } from '../native-quota-codec.ts' +import { + decodeNativeRuntime, + isNativeRuntimeStagingName, + type NativeRuntimeState, + readNativeRuntime, + updateNativeRuntime, +} from '../native-runtime.ts' + +const storageId = 'a'.repeat(64) +const roots: string[] = [] + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'anthropic-native-runtime-')) + roots.push(root) + return join(root, 'private', 'native-state.json') +} + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +// The fixture includes both local and vaulted account metadata without OAuth tokens. +const full = { + version: 1, + storageId, + relay: { token: 'synthetic-relay-secret' }, + accounts: { + 'local-row': { + binding: { + kind: 'local', + storageId, + rowId: 'local-row', + credentialEpoch: 4, + identity: 'synthetic-account-A', + }, + lastUsed: 111, + lastRefreshedAt: 222, + lastRefreshError: { + message: 'synthetic transient failure', + checkedAt: 333, + nextRetryAt: 666, + retryCount: 2, + accountIdentity: 'synthetic-account-A', + tokenHash: 'access-hash', + refreshTokenFingerprint: 'refresh-fingerprint', + status: 503, + permanent: false, + }, + refreshErrorClearedAt: 100, + refreshLeaseId: 'lease-id', + refreshLeaseUntil: 777, + refreshLeaseTokenHash: 'lease-token-hash', + lastQuotaRefreshError: { + message: 'synthetic quota failure', + checkedAt: 555, + nextRetryAt: 888, + retryCount: 1, + accountIdentity: 'synthetic-account-A', + tokenHash: 'quota-token-hash', + refreshTokenFingerprint: 'refresh-fingerprint', + status: 429, + permanent: false, + }, + quotaErrorGeneration: 3, + quotaErrorClearedAt: 444, + quotaCheckedAt: 234, + quotaToken: 'quota-observation-fingerprint', + profile: { + tier: 'max', + orgType: 'individual', + checkedAt: 123, + accountIdentity: 'synthetic-account-A', + providerAccountUuid: 'synthetic-account-A', + tokenFingerprint: 'profile-fingerprint', + }, + profileToken: 'profile-observation-fingerprint', + prime: { count: 5, inputTokens: 12345, outputTokens: 678, since: 90 }, + authLineageId: 'prime-lineage', + primeAuthLineageRefreshTokenFingerprint: 'prime-refresh-fingerprint', + }, + 'vault-route': { + binding: { + kind: 'custody', + storageId, + routeId: 'vault-route', + credentialId: 'credential-A', + accountIdentity: 'synthetic-vault-A', + recordVersion: 7, + }, + quota: toNativeQuotaMap({ + accountIdentity: 'synthetic-vault-A', + checkedAt: 999, + scoped: [], + source: 'headers', + fieldSources: { scoped: 'poll' }, + }), + profile: { + tier: 'max', + orgType: 'team', + checkedAt: 998, + accountIdentity: 'synthetic-vault-A', + }, + }, + }, +} + +function state(): NativeRuntimeState { + return decodeNativeRuntime(structuredClone(full), storageId) +} + +test('runtime full-field fixture round-trips and accounts for every owned field', async () => { + const path = await fixture() + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'missing', + }) + expect( + await updateNativeRuntime(path, storageId, () => state()), + ).toEqual(full) + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'ready', + state: full, + }) + expect(Object.keys(full.accounts['local-row']).sort()).toEqual( + [ + 'binding', + 'lastUsed', + 'lastRefreshedAt', + 'lastRefreshError', + 'refreshErrorClearedAt', + 'refreshLeaseId', + 'refreshLeaseUntil', + 'refreshLeaseTokenHash', + 'lastQuotaRefreshError', + 'quotaErrorGeneration', + 'quotaErrorClearedAt', + 'quotaCheckedAt', + 'quotaToken', + 'profile', + 'profileToken', + 'prime', + 'authLineageId', + 'primeAuthLineageRefreshTokenFingerprint', + ].sort(), + ) + expect(Object.keys(full.accounts['vault-route']).sort()).toEqual([ + 'binding', + 'profile', + 'quota', + ]) + const text = await readFile(path, 'utf8') + for (const forbidden of [ + '"access"', + '"refresh"', + '"apiKey"', + '"expires"', + '"enabled"', + '"claustrumScopedState"', + ]) + expect(text).not.toContain(forbidden) + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(dirname(path))).mode & 0o777).toBe(0o700) +}) + +test('runtime extra-key control rejects unknown root and nested fields', () => { + const local = full.accounts['local-row'] + const mutations: unknown[] = [ + { ...full, extra: 'synthetic-secret' }, + { + ...full, + relay: { token: 'synthetic-relay-secret', url: 'https://invalid' }, + }, + { + ...full, + accounts: { 'local-row': { ...local, access: 'synthetic-secret' } }, + }, + { + ...full, + accounts: { + 'local-row': { + ...local, + binding: { ...local.binding, credential: 'synthetic-secret' }, + }, + }, + }, + { + ...full, + accounts: { + 'local-row': { ...local, profile: { ...local.profile, extra: 1 } }, + }, + }, + { + ...full, + accounts: { + 'local-row': { + ...local, + lastRefreshError: { ...local.lastRefreshError, extra: 1 }, + }, + }, + }, + { + ...full, + accounts: { + 'local-row': { ...local, prime: { ...local.prime, enabled: true } }, + }, + }, + ] + for (const mutation of mutations) + expect(() => decodeNativeRuntime(mutation, storageId)).toThrow( + 'Anthropic runtime state is invalid', + ) +}) + +test('runtime versions, missing fields, invalid bindings and attribution fail closed', () => { + const local = full.accounts['local-row'] + const custody = full.accounts['vault-route'] + const mutations: unknown[] = [ + null, + [], + { ...full, version: 2 }, + { version: 1, storageId }, + { ...full, storageId: 'b'.repeat(64) }, + { ...full, accounts: { wrong: local } }, + { + ...full, + accounts: { + 'local-row': { + ...local, + binding: { ...local.binding, credentialEpoch: 0 }, + }, + }, + }, + { + ...full, + accounts: { + 'local-row': { + ...local, + binding: { ...local.binding, storageId: 'b'.repeat(64) }, + }, + }, + }, + { + ...full, + accounts: { + 'local-row': { + ...local, + profile: { + ...local.profile, + providerAccountUuid: 'synthetic-account-B', + }, + }, + }, + }, + { + ...full, + accounts: { + 'local-row': { + ...local, + lastRefreshError: { + ...local.lastRefreshError, + accountIdentity: 'synthetic-account-B', + }, + }, + }, + }, + { + ...full, + accounts: { 'local-row': { ...local, refreshErrorClearedAt: 333 } }, + }, + { + ...full, + accounts: { + 'local-row': { ...local, prime: { ...local.prime, inputTokens: -1 } }, + }, + }, + { + ...full, + accounts: { + 'vault-route': { + ...custody, + binding: { ...custody.binding, recordVersion: -1 }, + }, + }, + }, + { + ...full, + accounts: { + 'vault-route': { + ...custody, + binding: { ...custody.binding, routeId: 'other' }, + }, + }, + }, + { + ...full, + accounts: { + 'vault-route': { + ...custody, + quota: toNativeQuotaMap({ accountIdentity: 'synthetic-vault-B' }), + }, + }, + }, + { ...full, accounts: { 'local-row': { ...local, quota: custody.quota } } }, + ] + for (const mutation of mutations) + expect(() => decodeNativeRuntime(mutation, storageId)).toThrow() +}) + +test('runtime malformed secret-bearing input has redacted diagnostics and remains untouched', async () => { + const path = await fixture() + await mkdir(dirname(path), { mode: 0o700 }) + for (const bytes of [ + '{"relay":{"token":"synthetic-parser-secret', + JSON.stringify({ ...full, secret: 'synthetic-parser-secret' }), + JSON.stringify({ ...full, version: 0 }), + ]) { + await writeFile(path, bytes, { mode: 0o600 }) + await expect(readNativeRuntime(path, storageId)).rejects.toMatchObject({ + code: 'invalid-runtime', + message: 'Anthropic runtime state is invalid', + }) + await expect( + updateNativeRuntime(path, storageId, () => state()), + ).rejects.toMatchObject({ code: 'invalid-runtime' }) + expect(await readFile(path, 'utf8')).toBe(bytes) + } +}) + +test('runtime accepts the published custody receipt initial record version zero', () => { + const zero = structuredClone(full) + zero.accounts['vault-route'].binding.recordVersion = 0 + expect(decodeNativeRuntime(zero, storageId)).toEqual(zero) +}) + +test('runtime refuses non-owner modes, symlinks, directories and relative paths', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + await chmod(path, 0o644) + await expect(readNativeRuntime(path, storageId)).rejects.toMatchObject({ + code: 'unsafe-runtime', + }) + await expect( + updateNativeRuntime(path, storageId, () => state()), + ).rejects.toMatchObject({ code: 'unsafe-runtime' }) + await chmod(path, 0o600) + const link = join(dirname(path), 'link') + await symlink(path, link) + await expect(readNativeRuntime(link, storageId)).rejects.toMatchObject({ + code: 'unsafe-runtime', + }) + await expect( + readNativeRuntime(dirname(path), storageId), + ).rejects.toMatchObject({ code: 'unsafe-runtime' }) + await expect( + readNativeRuntime('relative.json', storageId), + ).rejects.toMatchObject({ code: 'unsafe-runtime' }) +}) + +test('runtime malformed UTF-8 cannot silently change a persisted relay secret', async () => { + const path = await fixture() + await mkdir(dirname(path), { mode: 0o700 }) + const bytes = Buffer.concat([ + Buffer.from( + `{"version":1,"storageId":"${storageId}","accounts":{},"relay":{"token":"synthetic-`, + ), + Buffer.from([0xff]), + Buffer.from('"}}'), + ]) + await writeFile(path, bytes, { mode: 0o600 }) + await expect(readNativeRuntime(path, storageId)).rejects.toMatchObject({ + code: 'invalid-runtime', + }) + expect(await readFile(path)).toEqual(bytes) +}) + +test('runtime concurrent updates read under its own lock without lost counters', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + await Promise.all( + Array.from({ length: 12 }, () => + updateNativeRuntime(path, storageId, (current) => { + const local = current.accounts['local-row'] + if (!local?.prime) throw new Error('fixture prime missing') + local.prime.count += 1 + return current + }), + ), + ) + const read = await readNativeRuntime(path, storageId) + expect(read.status).toBe('ready') + if (read.status !== 'ready') throw new Error('runtime missing') + expect(read.state.accounts['local-row']?.prime?.count).toBe(17) +}) + +test('runtime hook refusal before rename preserves exact old bytes and removes staging', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + const before = await readFile(path, 'utf8') + await expect( + updateNativeRuntime( + path, + storageId, + (current) => ({ + ...current, + relay: { token: 'synthetic-new-relay-secret' }, + }), + { + beforeRename: async () => { + throw new Error('synthetic-secret-binding-refusal') + }, + }, + ), + ).rejects.toMatchObject({ + code: 'publication-refused', + message: 'Anthropic runtime publication was refused', + }) + expect(await readFile(path, 'utf8')).toBe(before) + expect( + (await readdir(dirname(path))).filter((name) => + isNativeRuntimeStagingName(path, name), + ), + ).toEqual([]) +}) + +test('runtime afterRename failure reports refusal but never rolls committed new bytes back', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + await expect( + updateNativeRuntime( + path, + storageId, + (current) => ({ + ...current, + relay: { token: 'synthetic-new-relay-secret' }, + }), + { + afterRename: async () => { + expect((await readNativeRuntime(path, storageId)).status).toBe( + 'ready', + ) + throw new Error('synthetic-crash') + }, + }, + ), + ).rejects.toMatchObject({ code: 'publication-refused' }) + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'ready', + state: { ...full, relay: { token: 'synthetic-new-relay-secret' } }, + }) +}) + +test('runtime clear fences and newer epochs refuse stale writes without disk changes', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + await updateNativeRuntime(path, storageId, (current) => { + const local = current.accounts['local-row'] + if (!local) throw new Error('fixture row missing') + delete local.lastQuotaRefreshError + delete local.lastRefreshError + local.quotaErrorClearedAt = 600 + local.quotaErrorGeneration = 4 + local.refreshErrorClearedAt = 600 + return current + }) + const cleared = await readFile(path, 'utf8') + await expect( + updateNativeRuntime(path, storageId, () => state()), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) + expect(await readFile(path, 'utf8')).toBe(cleared) + await updateNativeRuntime(path, storageId, (current) => { + current.accounts['local-row'] = { + binding: { + kind: 'local', + storageId, + rowId: 'local-row', + credentialEpoch: 5, + identity: 'synthetic-account-B', + }, + } + return current + }) + const replaced = await readFile(path, 'utf8') + await expect( + updateNativeRuntime(path, storageId, () => state()), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) + expect(await readFile(path, 'utf8')).toBe(replaced) +}) + +test('runtime staging predicate matches only its own exact partial filename shape', () => { + const path = '/isolated/native-state.json' + const own = + 'native-state.json.native-runtime.aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.partial' + expect(isNativeRuntimeStagingName(path, own)).toBe(true) + for (const name of [ + own.replace('4ccc', '1ccc'), + own.replace('8ddd', '7ddd'), + own.replace('.partial', '.tmp'), + `${own}.bak`, + `../${own}`, + `/tmp/${own}`, + own.replace('native-state.json', 'other.json'), + own.replace('native-runtime.', ''), + 'native-state.json.native-runtime.short.partial', + 'native-state.json.native-runtime.lock', + '.DS_Store', + ]) + expect(isNativeRuntimeStagingName(path, name)).toBe(false) +}) + +test('runtime custody versions and quota/profile observation clocks cannot regress', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + const before = await readFile(path, 'utf8') + for (const field of ['version', 'quota', 'profile']) { + await expect( + updateNativeRuntime(path, storageId, (current) => { + const custody = current.accounts['vault-route'] + if (custody?.binding.kind !== 'custody') + throw new Error('fixture custody missing') + if (field === 'version') custody.binding.recordVersion = 6 + if (field === 'quota') + custody.quota = toNativeQuotaMap({ + accountIdentity: 'synthetic-vault-A', + checkedAt: 998, + scoped: [], + source: 'poll', + }) + if (field === 'profile' && custody.profile) + custody.profile.checkedAt = 997 + return current + }), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) + expect(await readFile(path, 'utf8')).toBe(before) + } +}) + +for (const seam of ['beforeRename', 'afterRename']) { + test(`runtime child crash at ${seam} leaves only old or complete new authority`, async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + const before = await readFile(path, 'utf8') + const module = new URL('../native-runtime.ts', import.meta.url).href + const child = Bun.spawn( + [ + process.execPath, + '-e', + `import { updateNativeRuntime } from ${JSON.stringify(module)}; + await updateNativeRuntime(${JSON.stringify(path)}, ${JSON.stringify(storageId)}, current => ({ ...current, relay: { token: 'synthetic-child-relay' } }), { ${seam}: async () => process.exit(19) });`, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + expect(await child.exited).toBe(19) + expect(await new Response(child.stderr).text()).toBe('') + if (seam === 'beforeRename') + expect(await readFile(path, 'utf8')).toBe(before) + else + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'ready', + state: { ...full, relay: { token: 'synthetic-child-relay' } }, + }) + const stages = (await readdir(dirname(path))).filter((name) => + isNativeRuntimeStagingName(path, name), + ) + expect(stages.length).toBe(seam === 'beforeRename' ? 1 : 0) + for (const name of stages) { + expect((await lstat(join(dirname(path), name))).mode & 0o777).toBe(0o600) + await rm(join(dirname(path), name)) + } + }) +} From 106005c5751a9bf7399662c79e3b607de9cba01b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:51:57 +0200 Subject: [PATCH 010/169] mason: harden native runtime stage ownership and metadata Clean a staging name only after successful exclusive creation, and relinquish it after rename. Check effective rather than real UID before and after opening owner-only runtime storage. Validate credential-derived fields using established 16-hex tokenFingerprint, 64-hex hashRefreshToken and bound vault-account UUID forms. Reject profileToken values because the pinned source has no supported writer or established identifier form; retain the recognized key and expose its unsupported status in the type. Update the original roundtrip fixture to real helper-produced metadata rather than invented strings, preserve the demonstrated legacy Prime fingerprint, and add seven targeted regression tests. Pinned Bun 1.3.14: focused 30/30, rebuilt Core 400/400, workspace types and scoped Biome pass. Exact parent probe: four bearer acceptances false, collision sentinel preserved, foreign effective UID refused, HTTP 0. Three restored mutation controls redden their named stage/metadata/euid tests. Fresh parent Sidekick comment review sk_de2c4e4103d640159f3b49cfc5d8265d approved this exact staged revision. Original commit 2c431250 remains unchanged. --- packages/core/src/native-runtime.ts | 55 +++- .../core/src/tests/native-runtime.test.ts | 302 +++++++++++++++++- 2 files changed, 327 insertions(+), 30 deletions(-) diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index c499574db..da3ed9485 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -55,7 +55,8 @@ export interface NativeRuntimeEntry { quotaCheckedAt?: number quotaToken?: string profile?: OAuthAccountProfile - profileToken?: string + /** No supported writer supplies this field, so its value cannot safely be interpreted as an identifier. */ + profileToken?: never prime?: PrimeUsageCounters authLineageId?: string primeAuthLineageRefreshTokenFingerprint?: string @@ -137,6 +138,19 @@ function digest(value: unknown): value is string { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) } +function tokenFingerprintValue(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{16}$/.test(value) +} + +function accountUuid(value: unknown): value is string { + return ( + typeof value === 'string' && + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test( + value, + ) + ) +} + function binding(value: unknown): value is NativeRuntimeBinding { return ( isNativeLocalPoolBinding(value) || @@ -179,8 +193,8 @@ function operationError(value: unknown): value is AccountOperationError { optional(value, 'nextRetryAt', time) && optional(value, 'retryCount', counter) && optional(value, 'accountIdentity', text) && - optional(value, 'tokenHash', text) && - optional(value, 'refreshTokenFingerprint', text) && + optional(value, 'tokenHash', digest) && + optional(value, 'refreshTokenFingerprint', tokenFingerprintValue) && optional( value, 'status', @@ -203,7 +217,7 @@ function profile(value: unknown): value is OAuthAccountProfile { time(value.checkedAt) && optional(value, 'accountIdentity', text) && optional(value, 'providerAccountUuid', text) && - optional(value, 'tokenFingerprint', text) + optional(value, 'tokenFingerprint', tokenFingerprintValue) ) } @@ -254,6 +268,7 @@ function entry( : value.binding.routeId) !== id ) return false + const observedBinding = value.binding for (const key of [ 'lastUsed', 'lastRefreshedAt', @@ -264,17 +279,22 @@ function entry( ]) { if (!optional(value, key, time)) return false } - for (const key of [ - 'refreshLeaseId', - 'refreshLeaseTokenHash', - 'quotaToken', - 'profileToken', - 'authLineageId', - 'primeAuthLineageRefreshTokenFingerprint', - ]) { + for (const key of ['refreshLeaseId', 'authLineageId']) { if (!optional(value, key, text)) return false } if ( + Object.hasOwn(value, 'profileToken') || + !optional(value, 'refreshLeaseTokenHash', digest) || + !optional( + value, + 'primeAuthLineageRefreshTokenFingerprint', + tokenFingerprintValue, + ) || + !optional(value, 'quotaToken', (token) => + observedBinding.kind === 'local' + ? tokenFingerprintValue(token) + : accountUuid(token) && token === observedBinding.accountIdentity, + ) || !optional(value, 'quotaErrorGeneration', counter) || !optional(value, 'profile', profile) || !optional(value, 'prime', prime) || @@ -380,7 +400,7 @@ export async function readNativeRuntime( if ( !info.isFile() || (info.mode & 0o777) !== 0o600 || - (typeof process.getuid === 'function' && info.uid !== process.getuid()) + (typeof process.geteuid === 'function' && info.uid !== process.geteuid()) ) throw new NativeRuntimeError('unsafe-runtime') const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) @@ -391,8 +411,8 @@ export async function readNativeRuntime( opened.ino !== info.ino || !opened.isFile() || (opened.mode & 0o777) !== 0o600 || - (typeof process.getuid === 'function' && - opened.uid !== process.getuid()) + (typeof process.geteuid === 'function' && + opened.uid !== process.geteuid()) ) throw new NativeRuntimeError('unsafe-runtime') let contents: string @@ -551,8 +571,10 @@ export async function updateNativeRuntime( dirname(path), `${basename(path)}.native-runtime.${randomUUID()}.partial`, ) + let ownsStage = false try { const file = await open(stage, 'wx', 0o600) + ownsStage = true try { await file.writeFile(bytes, 'utf8') await file.sync() @@ -566,6 +588,7 @@ export async function updateNativeRuntime( } await lock.assertOwned() await rename(stage, path) + ownsStage = false try { await hooks.afterRename?.() } catch { @@ -573,7 +596,7 @@ export async function updateNativeRuntime( } return next } finally { - await rm(stage, { force: true }) + if (ownsStage) await rm(stage, { force: true }) } }, ) diff --git a/packages/core/src/tests/native-runtime.test.ts b/packages/core/src/tests/native-runtime.test.ts index 6ba561b03..090f0af2e 100644 --- a/packages/core/src/tests/native-runtime.test.ts +++ b/packages/core/src/tests/native-runtime.test.ts @@ -14,6 +14,7 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { hashRefreshToken } from '../accounts.ts' import { toNativeQuotaMap } from '../native-quota-codec.ts' import { decodeNativeRuntime, @@ -22,8 +23,10 @@ import { readNativeRuntime, updateNativeRuntime, } from '../native-runtime.ts' +import { tokenFingerprint } from '../token-fingerprint.ts' const storageId = 'a'.repeat(64) +const vaultAccountIdentity = '11111111-2222-4333-8444-555555555555' const roots: string[] = [] async function fixture() { @@ -60,42 +63,42 @@ const full = { nextRetryAt: 666, retryCount: 2, accountIdentity: 'synthetic-account-A', - tokenHash: 'access-hash', - refreshTokenFingerprint: 'refresh-fingerprint', + tokenHash: hashRefreshToken('synthetic-refresh'), + refreshTokenFingerprint: tokenFingerprint('synthetic-refresh'), status: 503, permanent: false, }, refreshErrorClearedAt: 100, refreshLeaseId: 'lease-id', refreshLeaseUntil: 777, - refreshLeaseTokenHash: 'lease-token-hash', + refreshLeaseTokenHash: hashRefreshToken('synthetic-refresh'), lastQuotaRefreshError: { message: 'synthetic quota failure', checkedAt: 555, nextRetryAt: 888, retryCount: 1, accountIdentity: 'synthetic-account-A', - tokenHash: 'quota-token-hash', - refreshTokenFingerprint: 'refresh-fingerprint', + tokenHash: hashRefreshToken('synthetic-refresh'), + refreshTokenFingerprint: tokenFingerprint('synthetic-refresh'), status: 429, permanent: false, }, quotaErrorGeneration: 3, quotaErrorClearedAt: 444, quotaCheckedAt: 234, - quotaToken: 'quota-observation-fingerprint', + quotaToken: tokenFingerprint('synthetic-access'), profile: { tier: 'max', orgType: 'individual', checkedAt: 123, accountIdentity: 'synthetic-account-A', providerAccountUuid: 'synthetic-account-A', - tokenFingerprint: 'profile-fingerprint', + tokenFingerprint: tokenFingerprint('synthetic-access'), }, - profileToken: 'profile-observation-fingerprint', prime: { count: 5, inputTokens: 12345, outputTokens: 678, since: 90 }, authLineageId: 'prime-lineage', - primeAuthLineageRefreshTokenFingerprint: 'prime-refresh-fingerprint', + primeAuthLineageRefreshTokenFingerprint: + tokenFingerprint('synthetic-refresh'), }, 'vault-route': { binding: { @@ -103,11 +106,11 @@ const full = { storageId, routeId: 'vault-route', credentialId: 'credential-A', - accountIdentity: 'synthetic-vault-A', + accountIdentity: vaultAccountIdentity, recordVersion: 7, }, quota: toNativeQuotaMap({ - accountIdentity: 'synthetic-vault-A', + accountIdentity: vaultAccountIdentity, checkedAt: 999, scoped: [], source: 'headers', @@ -117,8 +120,9 @@ const full = { tier: 'max', orgType: 'team', checkedAt: 998, - accountIdentity: 'synthetic-vault-A', + accountIdentity: vaultAccountIdentity, }, + quotaToken: vaultAccountIdentity, }, }, } @@ -155,7 +159,6 @@ test('runtime full-field fixture round-trips and accounts for every owned field' 'quotaCheckedAt', 'quotaToken', 'profile', - 'profileToken', 'prime', 'authLineageId', 'primeAuthLineageRefreshTokenFingerprint', @@ -165,6 +168,7 @@ test('runtime full-field fixture round-trips and accounts for every owned field' 'binding', 'profile', 'quota', + 'quotaToken', ]) const text = await readFile(path, 'utf8') for (const forbidden of [ @@ -180,6 +184,276 @@ test('runtime full-field fixture round-trips and accounts for every owned field' expect((await stat(dirname(path))).mode & 0o777).toBe(0o700) }) +test('runtime credential metadata control rejects bearer and unexplained identifier forms without publication', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + const before = await readFile(path, 'utf8') + const local = full.accounts['local-row'] + const bearer = `sk-ant-oat01-${'b'.repeat(80)}` + const malformed: unknown[] = [] + for (const field of [ + 'quotaToken', + 'refreshLeaseTokenHash', + 'primeAuthLineageRefreshTokenFingerprint', + 'profileToken', + ]) { + for (const token of [bearer, 'unexplained-token-label']) { + malformed.push({ + ...full, + accounts: { 'local-row': { ...local, [field]: token } }, + }) + } + } + for (const errorField of ['lastRefreshError', 'lastQuotaRefreshError']) { + for (const tokenField of ['tokenHash', 'refreshTokenFingerprint']) { + malformed.push({ + ...full, + accounts: { + 'local-row': { + ...local, + [errorField]: { ...local.lastRefreshError, [tokenField]: bearer }, + }, + }, + }) + } + } + malformed.push({ + ...full, + accounts: { + 'local-row': { + ...local, + profile: { ...local.profile, tokenFingerprint: bearer }, + }, + }, + }) + for (const candidate of malformed) { + expect(() => decodeNativeRuntime(candidate, storageId)).toThrow( + 'Anthropic runtime state is invalid', + ) + await expect( + updateNativeRuntime(path, storageId, () => + decodeNativeRuntime(candidate, storageId), + ), + ).rejects.toMatchObject({ + code: 'invalid-runtime', + message: 'Anthropic runtime state is invalid', + }) + expect(await readFile(path, 'utf8')).toBe(before) + } +}) + +test('runtime metadata preserves only the fingerprint width each established writer supplies', () => { + const local = full.accounts['local-row'] + const short = tokenFingerprint('synthetic-access') + const long = hashRefreshToken('synthetic-refresh') + expect(short).toMatch(/^[a-f0-9]{16}$/) + expect(long).toMatch(/^[a-f0-9]{64}$/) + expect(decodeNativeRuntime(full, storageId)).toEqual(full) + const wrongWidths: unknown[] = [ + { ...local, quotaToken: long }, + { ...local, refreshLeaseTokenHash: short }, + { ...local, primeAuthLineageRefreshTokenFingerprint: long }, + { + ...local, + lastRefreshError: { ...local.lastRefreshError, tokenHash: short }, + }, + { + ...local, + lastRefreshError: { + ...local.lastRefreshError, + refreshTokenFingerprint: long, + }, + }, + { + ...local, + lastQuotaRefreshError: { + ...local.lastQuotaRefreshError, + tokenHash: short, + }, + }, + { + ...local, + lastQuotaRefreshError: { + ...local.lastQuotaRefreshError, + refreshTokenFingerprint: long, + }, + }, + { ...local, profile: { ...local.profile, tokenFingerprint: long } }, + ] + for (const candidate of wrongWidths) + expect(() => + decodeNativeRuntime( + { ...full, accounts: { 'local-row': candidate } }, + storageId, + ), + ).toThrow() +}) + +test('runtime vault quota identifiers must be UUIDs equal to the observed account', () => { + const custody = full.accounts['vault-route'] + expect(decodeNativeRuntime(full, storageId)).toEqual(full) + for (const token of [ + tokenFingerprint('synthetic-access'), + 'unexplained-account', + 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + ]) { + expect(() => + decodeNativeRuntime( + { + ...full, + accounts: { 'vault-route': { ...custody, quotaToken: token } }, + }, + storageId, + ), + ).toThrow() + } + const unexplained = { + ...custody, + binding: { ...custody.binding, accountIdentity: 'unexplained-account' }, + quota: toNativeQuotaMap({ accountIdentity: 'unexplained-account' }), + profile: { ...custody.profile, accountIdentity: 'unexplained-account' }, + quotaToken: 'unexplained-account', + } + expect(() => + decodeNativeRuntime( + { ...full, accounts: { 'vault-route': unexplained } }, + storageId, + ), + ).toThrow() +}) + +test('runtime refuses profileToken even when it resembles a known fingerprint', () => { + const local = full.accounts['local-row'] + for (const token of [ + tokenFingerprint('synthetic-access'), + hashRefreshToken('synthetic-refresh'), + vaultAccountIdentity, + 'unexplained-profile-token', + ]) { + expect(() => + decodeNativeRuntime( + { + ...full, + accounts: { 'local-row': { ...local, profileToken: token } }, + }, + storageId, + ), + ).toThrow() + } +}) + +test('runtime stage ownership control preserves an existing collision sentinel', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + const before = await readFile(path, 'utf8') + const uuid = '11111111-1111-4111-8111-111111111111' + const stage = `${path}.native-runtime.${uuid}.partial` + await writeFile(stage, 'another-writer-stage', { mode: 0o600 }) + const module = new URL('../native-runtime.ts', import.meta.url).href + const child = Bun.spawn( + [ + process.execPath, + '-e', + ` + import { mock } from 'bun:test'; + import * as crypto from 'node:crypto'; + let uuidCalls = 0; + let httpAttempts = 0; + globalThis.fetch = async () => { httpAttempts++; throw new Error('HTTP forbidden'); }; + mock.module('node:crypto', () => ({ ...crypto, randomUUID: () => { uuidCalls++; return ${JSON.stringify(uuid)}; } })); + const { updateNativeRuntime } = await import(${JSON.stringify(module)}); + let outcome = 'accepted'; + try { await updateNativeRuntime(${JSON.stringify(path)}, ${JSON.stringify(storageId)}, current => current); } + catch (error) { outcome = error.code; } + console.log(JSON.stringify({ outcome, uuidCalls, httpAttempts })); + `, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + expect(await child.exited).toBe(0) + expect(await new Response(child.stderr).text()).toBe('') + const result: unknown = JSON.parse(await new Response(child.stdout).text()) + expect(result).toMatchObject({ outcome: 'runtime-io', httpAttempts: 0 }) + if ( + !result || + typeof result !== 'object' || + !('uuidCalls' in result) || + typeof result.uuidCalls !== 'number' + ) + throw new Error('UUID substitution evidence missing') + expect(result.uuidCalls).toBeGreaterThan(0) + expect(await readFile(stage, 'utf8')).toBe('another-writer-stage') + expect(await readFile(path, 'utf8')).toBe(before) +}) + +test('runtime relinquishes the stage name after rename instead of deleting a successor file', async () => { + const path = await fixture() + let stage: string | undefined + await expect( + updateNativeRuntime(path, storageId, () => state(), { + beforeRename: async () => { + const names = (await readdir(dirname(path))).filter((name) => + isNativeRuntimeStagingName(path, name), + ) + expect(names.length).toBe(1) + const name = names[0] + if (!name) throw new Error('fixture stage missing') + stage = join(dirname(path), name) + }, + afterRename: async () => { + if (!stage) throw new Error('fixture stage missing') + await writeFile(stage, 'successor-stage', { mode: 0o600, flag: 'wx' }) + throw new Error('synthetic after-rename failure') + }, + }), + ).rejects.toMatchObject({ code: 'publication-refused' }) + if (!stage) throw new Error('fixture stage missing') + expect(await readFile(stage, 'utf8')).toBe('successor-stage') + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'ready', + state: full, + }) +}) + +test('runtime effective-owner control refuses foreign euid before and after opening', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + const before = await readFile(path, 'utf8') + const uid = (await stat(path)).uid + const module = new URL('../native-runtime.ts', import.meta.url).href + const child = Bun.spawn( + [ + process.execPath, + '-e', + ` + const { readNativeRuntime } = await import(${JSON.stringify(module)}); + let httpAttempts = 0; + globalThis.fetch = async () => { httpAttempts++; throw new Error('HTTP forbidden'); }; + Object.defineProperty(process, 'getuid', { configurable: true, value: () => ${uid} }); + async function run(opened) { + let euidCalls = 0; + Object.defineProperty(process, 'geteuid', { configurable: true, value: () => { euidCalls++; return ${uid} + (opened && euidCalls === 1 ? 0 : 1); } }); + let outcome = 'accepted'; + try { await readNativeRuntime(${JSON.stringify(path)}, ${JSON.stringify(storageId)}); } + catch (error) { outcome = error.code; } + return { outcome, euidCalls }; + } + console.log(JSON.stringify({ initial: await run(false), opened: await run(true), httpAttempts })); + `, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + expect(await child.exited).toBe(0) + expect(await new Response(child.stderr).text()).toBe('') + const result: unknown = JSON.parse(await new Response(child.stdout).text()) + expect(result).toEqual({ + initial: { outcome: 'unsafe-runtime', euidCalls: 1 }, + opened: { outcome: 'unsafe-runtime', euidCalls: 2 }, + httpAttempts: 0, + }) + expect(await readFile(path, 'utf8')).toBe(before) +}) + test('runtime extra-key control rejects unknown root and nested fields', () => { const local = full.accounts['local-row'] const mutations: unknown[] = [ @@ -538,7 +812,7 @@ test('runtime custody versions and quota/profile observation clocks cannot regre if (field === 'version') custody.binding.recordVersion = 6 if (field === 'quota') custody.quota = toNativeQuotaMap({ - accountIdentity: 'synthetic-vault-A', + accountIdentity: vaultAccountIdentity, checkedAt: 998, scoped: [], source: 'poll', From d7d1b62eb9a8f489690c2d9ed7aa49ddf3c665a9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:00:08 +0200 Subject: [PATCH 011/169] mason: pin native claustrum client to 0.6.2 Verify the published SDK's provider mapping wire fields and preserve scoped account/version receipts, enrollment generation fences, and secret redaction. The existing inventory assertion now includes providerIds and authMethod because the producer decodes those new fields; no prior assertion is removed or weakened. Keep common-auth locked at accepted 0.9.0. Required readonly providerIds fixture additions in five downstream test files were explicitly authorized by the parent. Parent pre-commit Sidekick sk_f2ec744eb7c7424e856dd28afc1dc023 cleared changed comments; the parent additionally inspected the two literal scoped-runtime fixture additions omitted from that named review list. --- bun.lock | 4 +- packages/core/package.json | 2 +- .../claustrum-client-compatibility.test.ts | 105 ++++++++++++++++-- .../tests/claustrum-scoped-runtime.test.ts | 2 + .../core/src/tests/claustrum-scoped.test.ts | 1 + .../tests/fallback-token-use-sites.test.ts | 2 + .../src/tests/scoped-custody-serving.test.ts | 3 + .../scoped-main-quota-persistence.test.ts | 1 + packages/pi/src/tests/custody.test.ts | 1 + packages/pi/src/tests/scoped-stream.test.ts | 1 + 10 files changed, 112 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 75cd52f9e..c809dfdfe 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ "name": "@cortexkit/anthropic-auth-core", "version": "1.23.0", "dependencies": { - "@cortexkit/claustrum-client": "0.5.0", + "@cortexkit/claustrum-client": "0.6.2", "jsonc-parser": "^3.3.1", "xxhash-wasm": "^1.1.0", }, @@ -216,7 +216,7 @@ "@cortexkit/anthropic-auth-e2e": ["@cortexkit/anthropic-auth-e2e@workspace:packages/e2e-tests"], - "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.5.0", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-+mRG/h3YZufrEs1p12W+Y+NSnWIUiBcpppMTgFA5tAQ7t8WIWoMEmn0Mf7Cp8251SKChW8dN3xOZqEM7N6gyAg=="], + "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], "@cortexkit/common-auth": ["@cortexkit/common-auth@0.9.0", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.4.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-FiolaobIdq8feMJyGaiQm1skefsxixsSyasB+vrKVOkU/HFxkuJGHDo7cUSFrmfNZRs31fWFJbVLcfVGoAfIVQ=="], diff --git a/packages/core/package.json b/packages/core/package.json index 1bb9d4243..6e0de4386 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,7 +33,7 @@ "@cortexkit/common-auth": "^0.9.0" }, "dependencies": { - "@cortexkit/claustrum-client": "0.5.0", + "@cortexkit/claustrum-client": "0.6.2", "jsonc-parser": "^3.3.1", "xxhash-wasm": "^1.1.0" } diff --git a/packages/core/src/tests/claustrum-client-compatibility.test.ts b/packages/core/src/tests/claustrum-client-compatibility.test.ts index 4bea39187..739b02eda 100644 --- a/packages/core/src/tests/claustrum-client-compatibility.test.ts +++ b/packages/core/src/tests/claustrum-client-compatibility.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'bun:test' import { ClaustrumClient, type ClaustrumConnector, + ClaustrumCredentialError, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, @@ -70,7 +71,14 @@ const producerContract: [ Assert< RequiredKeys< ScopedInventoryRow, - 'id' | 'recordVersion' | 'createdAtMs' | 'operations' + 'id' | 'recordVersion' | 'createdAtMs' | 'operations' | 'providerIds' + > + >, + Assert>, + Assert< + Equal< + ScopedInventoryRow['authMethod'], + 'apikey' | 'chatgpt' | 'antigravity' | 'oauth' | undefined > >, Assert< @@ -179,6 +187,8 @@ const producerContract: [ true, true, true, + true, + true, ] test('published Claustrum client decodes scoped and enrollment replies', async () => { @@ -186,6 +196,12 @@ test('published Claustrum client decodes scoped and enrollment replies', async ( const accountId = 'provider-account-1' const enrollmentToken = '01'.repeat(32) const accessToken = 'synthetic-access-token' + const identity = { + project_root: '/synthetic-test/project', + harness: 'compatibility-test', + session: 'synthetic-session', + } + const callOptions: unknown[] = [] const calls: Array<{ moduleId: string; method: string; params?: unknown }> = [] let closes = 0 @@ -241,8 +257,14 @@ test('published Claustrum client decodes scoped and enrollment replies', async ( } const connector: ClaustrumConnector = async () => ({ - call: async (moduleId: string, method: string, params?: unknown) => { + call: async ( + moduleId: string, + method: string, + params?: unknown, + options?: unknown, + ) => { calls.push({ moduleId, method, params }) + callOptions.push(options) const response = responses[method] if (response === undefined) { throw new Error(`Unexpected synthetic Claustrum method: ${method}`) @@ -256,11 +278,7 @@ test('published Claustrum client decodes scoped and enrollment replies', async ( const client = await ClaustrumClient.connect({ connectionFile: '/synthetic-test/no-daemon-connection.json', - identity: { - project_root: '/synthetic-test/project', - harness: 'compatibility-test', - session: 'synthetic-session', - }, + identity, connector, logger: () => {}, }) @@ -274,6 +292,8 @@ test('published Claustrum client decodes scoped and enrollment replies', async ( categories: ['anthropic-native'], credentialType: 'oauth', serves: ['anthropic'], + providerIds: [], + authMethod: undefined, refreshAdapter: 'anthropic', state: 'active', recordVersion: 7, @@ -398,7 +418,78 @@ test('published Claustrum client decodes scoped and enrollment replies', async ( }, }, ]) + // Every request must explicitly suppress transport-inherited consumer identity. + expect(callOptions).toHaveLength(7) + for (const options of callOptions) { + expect(options).toEqual({ identity, consumerIdentity: null }) + } expect(producerContract.every(Boolean)).toBe(true) client.close() expect(closes).toBe(1) }) + +test('published Claustrum client decodes provider mappings and rejects malformed selection fields', async () => { + // Catalog ids are operator-assigned, not inferred from the credential label, + // model vendor, or refresh protocol. Keep those values distinct in the wire row. + const wireRow = { + id: 'oauth:anthropic:work', + type: 'oauth', + categories: ['anthropic-native'], + serves: ['anthropic'], + refresh_adapter: 'anthropic', + provider_ids: ['operator-catalog-primary', 'operator-catalog-secondary'], + auth_method: 'oauth', + operations: ['read'], + state: 'active', + record_version: 7, + } + let row: unknown = wireRow + let calls = 0 + const client = await ClaustrumClient.connect({ + connectionFile: '/synthetic-test/no-daemon-connection.json', + logger: () => {}, + connector: async () => + ({ + call: async (moduleId: string, method: string, params?: unknown) => { + expect({ moduleId, method, params }).toEqual({ + moduleId: 'claustrum', + method: 'credential.list_scoped', + params: {}, + }) + calls++ + return { result: { credentials: [row], view: 'provider-view' } } + }, + close: () => {}, + }) as unknown as Awaited>, + }) + try { + const inventory = await client.listScoped() + expect(inventory.view).toBe('provider-view') + expect(inventory.rows).toHaveLength(1) + expect(inventory.rows[0]?.providerIds).toEqual([ + 'operator-catalog-primary', + 'operator-catalog-secondary', + ]) + expect(inventory.rows[0]?.authMethod).toBe('oauth') + // Omitted provider_ids is covered by the legacy reply above. A present + // malformed value must still refuse, not silently become an unmapped row. + for (const invalid of [ + { provider_ids: null }, + { provider_ids: 'operator-catalog-primary' }, + { provider_ids: ['operator-catalog-primary', 42] }, + { auth_method: 'unsupported-method' }, + ]) { + row = { ...wireRow, ...invalid } + const pending = client.listScoped() + await expect(pending).rejects.toBeInstanceOf(ClaustrumCredentialError) + await expect(pending).rejects.toMatchObject({ + code: 'invalid_response', + class: 'transient', + action: 'retry', + }) + } + expect(calls).toBe(5) + } finally { + client.close() + } +}) diff --git a/packages/core/src/tests/claustrum-scoped-runtime.test.ts b/packages/core/src/tests/claustrum-scoped-runtime.test.ts index 8e6d18fa5..6628db3b4 100644 --- a/packages/core/src/tests/claustrum-scoped-runtime.test.ts +++ b/packages/core/src/tests/claustrum-scoped-runtime.test.ts @@ -56,6 +56,7 @@ async function fixture() { accountId: `provider-${index}`, categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], credentialType: 'oauth', refreshAdapter: 'anthropic', operations: ['read'], @@ -380,6 +381,7 @@ test('onRoster fires only when the discovery view changes', async () => { accountId: 'provider-0', categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], credentialType: 'oauth', refreshAdapter: 'anthropic', operations: ['read'], diff --git a/packages/core/src/tests/claustrum-scoped.test.ts b/packages/core/src/tests/claustrum-scoped.test.ts index b5ed29ced..c7ca31ce6 100644 --- a/packages/core/src/tests/claustrum-scoped.test.ts +++ b/packages/core/src/tests/claustrum-scoped.test.ts @@ -43,6 +43,7 @@ const row: ScopedInventoryRow = { credentialType: 'oauth', refreshAdapter: 'anthropic', serves: ['anthropic'], + providerIds: [], operations: ['read'], state: 'active', recordVersion: 1, 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 945affd01..c74c76e33 100644 --- a/packages/opencode/src/tests/fallback-token-use-sites.test.ts +++ b/packages/opencode/src/tests/fallback-token-use-sites.test.ts @@ -297,6 +297,7 @@ async function createFixture( accountId: mainProviderAccountId, categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], credentialType: 'oauth', refreshAdapter: 'anthropic', operations: ['read'], @@ -309,6 +310,7 @@ async function createFixture( accountId, categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], credentialType: 'oauth', refreshAdapter: 'anthropic', operations: ['read'], diff --git a/packages/opencode/src/tests/scoped-custody-serving.test.ts b/packages/opencode/src/tests/scoped-custody-serving.test.ts index 8b2bff3b2..68ddad920 100644 --- a/packages/opencode/src/tests/scoped-custody-serving.test.ts +++ b/packages/opencode/src/tests/scoped-custody-serving.test.ts @@ -45,6 +45,7 @@ const mainRow: ScopedInventoryRow = { accountId: 'provider-main-uuid', categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], credentialType: 'oauth', refreshAdapter: 'anthropic', operations: ['read'], @@ -58,6 +59,7 @@ const workRow: ScopedInventoryRow = { accountId: 'provider-work-uuid', categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], credentialType: 'oauth', refreshAdapter: 'anthropic', operations: ['read'], @@ -222,6 +224,7 @@ describe('OpenCode scoped custody serving', () => { accountId: 'provider-personal-uuid', categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], credentialType: 'oauth', refreshAdapter: 'anthropic', operations: ['read'], 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 835a3faf2..214238817 100644 --- a/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts +++ b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts @@ -33,6 +33,7 @@ const mainRow: ScopedInventoryRow = { accountId: PRIMARY_ACCOUNT_ID, categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], credentialType: 'oauth', refreshAdapter: 'anthropic', operations: ['read'], diff --git a/packages/pi/src/tests/custody.test.ts b/packages/pi/src/tests/custody.test.ts index e94c3534d..acc1e80ec 100644 --- a/packages/pi/src/tests/custody.test.ts +++ b/packages/pi/src/tests/custody.test.ts @@ -43,6 +43,7 @@ async function fixture() { credentialType: 'oauth', categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], refreshAdapter: 'anthropic', state: 'active', operations: ['read'], diff --git a/packages/pi/src/tests/scoped-stream.test.ts b/packages/pi/src/tests/scoped-stream.test.ts index c2c88e3bb..e1271dbca 100644 --- a/packages/pi/src/tests/scoped-stream.test.ts +++ b/packages/pi/src/tests/scoped-stream.test.ts @@ -80,6 +80,7 @@ const row = (id: string, accountId: string): ScopedInventoryRow => ({ credentialType: 'oauth', categories: ['anthropic-native'], serves: ['anthropic'], + providerIds: [], refreshAdapter: 'anthropic', operations: ['read'], state: 'active', From 20e40807069bdadb0aea4a79323832d62f1d10b5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:18:48 +0200 Subject: [PATCH 012/169] mason: add account-local native refresh coordinator Use the strict published pool facade with process-wide binding-keyed jobs, account provider locks and callback-end identity handoff. Preserve consumed rotations across cancellation, bootstrap failure and duplicate disablement; handle quarantine exhaustively and re-read each requesting row before exposing access. Require caller-owned restriction reads and awaited native reconciliation. Cover real store concurrency, adoption, quarantine, cancellation and bounded classified retries with 36 tests and three restored isolated negative controls. Parent Sidekick comment re-review sk_0105f8597d0b4bdc9d0a1e247a8f94ee accepted the comments and authorized this local commit; integration review remains separate. --- .../core/src/native-refresh-coordinator.ts | 822 +++++++++++ .../tests/native-refresh-coordinator.test.ts | 1295 +++++++++++++++++ 2 files changed, 2117 insertions(+) create mode 100644 packages/core/src/native-refresh-coordinator.ts create mode 100644 packages/core/src/tests/native-refresh-coordinator.test.ts diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts new file mode 100644 index 000000000..ebba521b5 --- /dev/null +++ b/packages/core/src/native-refresh-coordinator.ts @@ -0,0 +1,822 @@ +import { createHash } from 'node:crypto' + +import { + acquireRefreshFileLock, + LockContentionError, + type RefreshFileLock, +} from '@cortexkit/common-auth/fs' +import { + fingerprintOf, + POOL_LOCK_DEFAULTS, + type PoolFailureKind, + type PoolLockSpec, + PoolOperationError, + type PoolRow, + type RefreshOutcome, +} from '@cortexkit/common-auth/store' + +import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from './auth.ts' +import { resolveClaudeCodeIdentity } from './claude-code.ts' +import { isTransientNetworkError } from './network-errors.ts' +import { + isNativeLocalPoolBinding, + type NativeLocalPoolBinding, + nativeLocalPoolBindingMatches, + nativeLocalRefreshJobKey, +} from './pool-binding.ts' +import type { NativePoolPaths } from './pool-paths.ts' +import { + createNativePoolStore, + type NativePoolStoreOptions, +} from './pool-store.ts' + +/** + * Jobs distinguish credential replacements. This lock must still serialize + * all refreshes for the same account. + */ +export function nativeAccountProviderLock( + paths: NativePoolPaths, + identity: string, +): PoolLockSpec { + const key = createHash('sha256') + .update(JSON.stringify([paths.storageId, identity])) + .digest('hex') + return { + path: paths.state, + name: `native-account-provider-${key}`, + ...POOL_LOCK_DEFAULTS, + } +} + +export type NativeRefreshRestriction = + | { status: 'allowed' } + | { + status: 'blocked' + reason: + | 'quota-ineligible' + | 'account-disabled' + | 'local-mode-unavailable' + | 'identity-unproven' + } + | { + status: 'blocked' + reason: 'refresh-backoff' | 'invalid-grant' + /** + * Identifies the stored credential through fingerprintOf; never put a + * bearer token or an access-token hash here. + */ + credentialFingerprint: string + } + +export type NativeIdentityBootstrap = 'resolved' | 'unavailable' | 'failed' +export type NativeRefreshHandoff = + | 'not-needed' + | 'acquired' + | 'skipped-contention' + | 'skipped-loss' + | 'skipped-cancelled' + +export interface NativeRefreshFailure { + kind: PoolFailureKind | 'caller-hook' + classification: 'transient' | 'invalid-grant' | 'permanent' + status?: number + retryAfter?: number +} + +interface ObservationBase { + /** + * Identifies the row, credential version and account before refresh. Compare + * this binding with current store state before writing an observation. + */ + binding: NativeLocalPoolBinding + attempt: number + credentialFingerprint?: string + bootstrap: NativeIdentityBootstrap + handoff: NativeRefreshHandoff +} + +/** Token-free observations. In particular bootstrap exceptions are never forwarded. */ +export type NativeRefreshObservation = ObservationBase & + ( + | { status: 'persisted'; identity?: string; successorFingerprint: string } + | { status: 'adopted'; successorFingerprint: string } + | { status: 'refused'; reason: string; consumed: boolean } + | { + status: 'identity-contradicted' + expectedIdentity: string + returnedIdentity: string + successorFingerprint: string + } + | { status: 'failed'; failure: NativeRefreshFailure; persisted: boolean } + ) + +export interface NativeRefreshHooks { + /** + * Read current local-mode, account-disable, quota and refresh-error + * restrictions from native state. Apply token-bound errors only to their + * matching credential fingerprint. Also report when a previously persisted + * successor still lacks account validation. Do not read host credentials or + * mutate the store. This callback runs outside shared store locks. + */ + readRestrictions( + binding: NativeLocalPoolBinding, + ): Promise + /** + * Persist observations while holding pool-config, pool-state and then + * native-runtime locks. Re-read the bound row and verify its credential + * version, account identity, token fingerprint and error-clear markers + * before writing. A newly discovered identity is valid only on the row that + * received it. Clear obsolete token errors only after persistence or + * adoption; neither a quarantined credential nor a failed observation + * permits serving. If the successor was persisted but its account could not + * be validated, keep that credential marked unvalidated until a later + * identity check succeeds. This callback is awaited before the result + * returns and starts after shared store locks are released. Row and provider + * locks remain held during ordinary persistence reconciliation. Do not + * re-enter row operations or wait for callers joining this same job. + */ + reconcile(observation: NativeRefreshObservation): Promise +} + +export interface NativeRefreshCoordinatorOptions + extends NativePoolStoreOptions, + NativeRefreshHooks { + refreshToken?: typeof refreshClaudeOAuthToken + resolveIdentity?: typeof resolveClaudeCodeIdentity +} + +export interface NativeRefreshRequest { + mode: 'local' | 'claustrum' + binding: NativeLocalPoolBinding + /** A 401's dispatched access token, used only for replacement adoption. */ + rejectedAccessToken?: string + signal?: AbortSignal +} + +export type NativeRefreshResult = + | { + status: 'usable' + source: 'rotated' | 'adopted' + binding: NativeLocalPoolBinding + /** + * Read from the requesting row after the job completes. An alias must + * not receive the credential refreshed for a different row. + */ + access: string + expires: number + } + | { status: 'refused'; reason: string; persisted: boolean } + | { + status: 'identity-contradicted' + expectedIdentity: string + returnedIdentity: string + persisted: true + } + | { + status: 'failed' + error: Error + persisted: boolean + reconciliationFailed: boolean + } + +type JobResult = + | { + status: 'completed' + source: 'rotated' | 'adopted' + identity?: string + proven: boolean + persisted: boolean + } + | Exclude + +interface Job { + keys: Set + promise: Promise +} + +// Shared by every platform/coordinator instance in this process. Deletion is +// compare-and-delete: an old job can never dispose a successor registration. +const jobs = new Map() + +function unregister(job: Job, key: string) { + if (jobs.get(key) === job) jobs.delete(key) + job.keys.delete(key) +} + +function failureOf(error: Error): NativeRefreshFailure { + const cause = error instanceof PoolOperationError ? error.cause : error + const kind = error instanceof PoolOperationError ? error.kind : 'caller-hook' + if (cause instanceof ClaudeOAuthRefreshError) { + const invalidGrant = /\binvalid_grant\b/.test(cause.body) + return { + kind, + classification: invalidGrant + ? 'invalid-grant' + : cause.status >= 500 + ? 'transient' + : 'permanent', + status: cause.status, + ...(cause.retryAfter !== undefined + ? { retryAfter: cause.retryAfter } + : {}), + } + } + return { + kind, + classification: + kind === 'provider' && isTransientNetworkError(cause) + ? 'transient' + : 'permanent', + } +} + +function asError(error: unknown): Error { + return error instanceof Error + ? error + : new Error('Native refresh callback failed', { cause: error }) +} + +class LocalRefreshRefusal extends Error { + constructor(readonly reason: string) { + super('Local refresh policy refused the exchange') + } +} + +function restrictionReason( + restriction: NativeRefreshRestriction, + fingerprint?: string, +) { + if (restriction.status === 'allowed') return undefined + if ( + restriction.reason !== 'refresh-backoff' && + restriction.reason !== 'invalid-grant' + ) + return restriction.reason + return restriction.credentialFingerprint === fingerprint + ? restriction.reason + : undefined +} + +function sameDispatch(left: PoolRow, right: PoolRow): boolean { + const a = left.credential + const b = right.credential + return ( + a?.type === 'oauth' && + b?.type === 'oauth' && + a.access === b.access && + a.refresh === b.refresh && + a.expires === b.expires + ) +} + +function exhaustive(outcome: never): never { + throw new Error(`Unsupported refresh outcome: ${String(outcome)}`) +} + +/** Independent local machinery: construction performs no network or migration. */ +export function createNativeRefreshCoordinator( + options: NativeRefreshCoordinatorOptions, +) { + const store = createNativePoolStore(options) + const now = options.now ?? Date.now + const provider = options.refreshToken ?? refreshClaudeOAuthToken + const bootstrap = options.resolveIdentity ?? resolveClaudeCodeIdentity + + async function rowFor(binding: NativeLocalPoolBinding) { + const read = await store.read() + return read.status === 'ready' + ? read.rows.find((row) => row.id === binding.rowId) + : undefined + } + + async function handoffLock(identity: string): Promise { + const spec = nativeAccountProviderLock(options.paths, identity) + const started = performance.now() + for (;;) { + const lock = await acquireRefreshFileLock({ + name: spec.name, + path: spec.path, + ttlMs: POOL_LOCK_DEFAULTS.ttlMs, + renew: true, + now, + onStep: options.onLockStep + ? (step) => options.onLockStep?.(spec, step) + : undefined, + }) + if (lock) { + try { + await lock.assertOwned() + options.onLockEvent?.({ + type: 'acquired', + name: spec.name, + path: spec.path, + }) + return lock + } catch (error) { + await lock.release().catch(() => {}) + try { + options.onLockEvent?.({ + type: 'released', + name: spec.name, + path: spec.path, + }) + } catch { + /* Ignore observer failures; notification callbacks must not decide + * whether the lock owner can refresh a token. */ + } + throw error + } + } + const remaining = + POOL_LOCK_DEFAULTS.timeoutMs - (performance.now() - started) + if (remaining <= 0) + throw new LockContentionError({ + target: spec.path, + name: spec.name, + timeoutMs: POOL_LOCK_DEFAULTS.timeoutMs, + }) + await new Promise((resolve) => + setTimeout(resolve, Math.min(POOL_LOCK_DEFAULTS.retryMs, remaining)), + ) + } + } + + async function run( + job: Job, + request: NativeRefreshRequest, + ): Promise { + const binding = request.binding + let attempt = 0 + let consumed = false + let persisted = false + let identity: string | undefined + let bootstrapStatus: NativeIdentityBootstrap = 'unavailable' + let handoff: NativeRefreshHandoff = 'not-needed' + let fingerprint: string | undefined + let held: RefreshFileLock | undefined + let releasing: Promise | undefined + let removeAbortListener: (() => void) | undefined + let aliasKey: string | undefined + let reconciliationFailed = false + + function identityProven() { + return bootstrapStatus === 'resolved' + } + + function observation(): ObservationBase { + return { + binding, + attempt, + credentialFingerprint: fingerprint, + bootstrap: bootstrapStatus, + handoff, + } + } + + async function reconcile(event: NativeRefreshObservation) { + try { + await options.reconcile(event) + } catch (error) { + reconciliationFailed = true + throw error + } + } + + async function releaseHandoff() { + const lock = held + held = undefined + removeAbortListener?.() + removeAbortListener = undefined + if (lock) { + releasing = (async () => { + await lock.release().catch(() => {}) + if (identity) { + const spec = nativeAccountProviderLock(options.paths, identity) + try { + options.onLockEvent?.({ + type: 'released', + name: spec.name, + path: spec.path, + }) + } catch { + /* A failed lock-event notification must not discard a replacement + * credential after the refresh token was consumed. */ + } + } + })() + } + await releasing + } + + async function skipLostHandoff() { + handoff = 'skipped-loss' + if (aliasKey) unregister(job, aliasKey) + await releaseHandoff() + } + + try { + for (attempt = 1; attempt <= 3; attempt++) { + consumed = false + let endpointCalled = false + bootstrapStatus = 'unavailable' + handoff = 'not-needed' + const restriction = await options.readRestrictions(binding) + let captured: PoolRow | undefined + const seen = await rowFor(binding) + fingerprint = seen?.fingerprint + const initialReason = + !seen || !nativeLocalPoolBindingMatches(binding, options.paths, seen) + ? 'binding-changed' + : request.signal?.aborted + ? 'cancelled' + : !seen.candidate || !seen.enabled + ? 'row-ineligible' + : restrictionReason(restriction, seen.fingerprint) + if (initialReason) { + await reconcile({ + ...observation(), + status: 'refused', + reason: initialReason, + consumed: false, + }) + return { status: 'refused', reason: initialReason, persisted: false } + } + if ( + binding.identity !== undefined && + request.rejectedAccessToken !== undefined && + seen?.credential?.type === 'oauth' && + seen.credential.access && + seen.credential.access !== request.rejectedAccessToken && + (seen.credential.expires ?? 0) > now() + ) { + await reconcile({ + ...observation(), + status: 'adopted', + successorFingerprint: fingerprintOf(seen.credential), + }) + return { + status: 'completed', + source: 'adopted', + identity: binding.identity, + proven: true, + persisted: false, + } + } + + try { + const outcome: RefreshOutcome = await store.refresh( + binding.rowId, + async (credential, row) => { + captured = row + fingerprint = row.fingerprint + // This read is outside pool locks. Recheck policy after file-lock waits. + const currentRestriction = await options.readRestrictions(binding) + const reason = request.signal?.aborted + ? 'cancelled' + : restrictionReason(currentRestriction, fingerprint) + if (reason) throw new LocalRefreshRefusal(reason) + endpointCalled = true + const successor = await provider({ + refreshToken: credential.refresh, + maxRetries: 0, + now, + }) + // From here on cancellation/bootstrap/handoff failures must not + // reject the callback: the refresh token has already been consumed. + consumed = true + try { + identity = ( + await bootstrap(successor.access, undefined, undefined) + ).accountUuid + bootstrapStatus = identity ? 'resolved' : 'unavailable' + } catch { + bootstrapStatus = 'failed' + } + if (binding.identity === undefined && identity) { + aliasKey = nativeLocalRefreshJobKey({ ...binding, identity }) + if (!jobs.has(aliasKey)) { + jobs.set(aliasKey, job) + job.keys.add(aliasKey) + } + if (request.signal?.aborted) { + handoff = 'skipped-cancelled' + unregister(job, aliasKey) + } else { + try { + held = await handoffLock(identity) + handoff = 'acquired' + const owned = held + const onAbort = () => { + if (held !== owned) return + handoff = 'skipped-cancelled' + if (aliasKey) unregister(job, aliasKey) + void releaseHandoff() + } + request.signal?.addEventListener('abort', onAbort, { + once: true, + }) + removeAbortListener = () => + request.signal?.removeEventListener('abort', onAbort) + if (request.signal?.aborted) onAbort() + void owned + .whenLost() + .then(async () => { + // A delayed loss callback for an old handoff lock must + // not release its replacement or remove a job key + // registered to a newer job. + if (held === owned) await skipLostHandoff() + }) + .catch(() => {}) + if (held === owned) await owned.assertOwned() + } catch (error) { + handoff = + error instanceof LockContentionError + ? 'skipped-contention' + : 'skipped-loss' + unregister(job, aliasKey) + await releaseHandoff() + } + } + } + return { + access: successor.access, + refresh: successor.refresh, + expires: successor.expires, + ...(identity ? { identity } : {}), + } + }, + { + ...(binding.identity !== undefined + ? { + providerLock: nativeAccountProviderLock( + options.paths, + binding.identity, + ), + } + : {}), + refuse: async (row) => { + if (!nativeLocalPoolBindingMatches(binding, options.paths, row)) + return 'binding-changed' + if (captured && !sameDispatch(captured, row)) + return 'dispatch-changed' + if (held) { + try { + await held.assertOwned() + } catch { + await skipLostHandoff() + } + } + // Commit eligibility is not serving eligibility. Disabling a + // duplicate or cancelling the caller cannot undo an exchange. + if (consumed) return undefined + if (request.signal?.aborted) return 'cancelled' + if (!row.candidate || !row.enabled) return 'row-ineligible' + return restrictionReason(restriction, row.fingerprint) + }, + onPersisted: async (_id, credential) => { + persisted = true + try { + if (held) { + try { + await held.assertOwned() + } catch { + await skipLostHandoff() + } + } + await reconcile({ + ...observation(), + status: 'persisted', + identity, + successorFingerprint: fingerprintOf(credential), + }) + } finally { + await releaseHandoff() + } + }, + }, + ) + switch (outcome.status) { + case 'rotated': + return { + status: 'completed', + source: 'rotated', + identity: outcome.identity, + proven: identityProven(), + persisted: true, + } + case 'refused': + await reconcile({ + ...observation(), + status: 'refused', + reason: outcome.reason, + consumed, + }) + return { + status: 'refused', + reason: outcome.reason, + persisted: false, + } + case 'identity-contradicted': + persisted = true + await reconcile({ + ...observation(), + status: 'identity-contradicted', + expectedIdentity: outcome.expectedIdentity, + returnedIdentity: outcome.returnedIdentity, + successorFingerprint: fingerprintOf(outcome.credential), + }) + return { + status: 'identity-contradicted', + expectedIdentity: outcome.expectedIdentity, + returnedIdentity: outcome.returnedIdentity, + persisted: true, + } + default: + return exhaustive(outcome) + } + } catch (caught) { + const error = asError(caught) + persisted ||= + error instanceof PoolOperationError && + (error.committed !== undefined || + error.phase === 'after-first-write') + if ( + !endpointCalled && + error instanceof PoolOperationError && + error.cause instanceof LocalRefreshRefusal + ) { + await reconcile({ + ...observation(), + status: 'refused', + reason: error.cause.reason, + consumed: false, + }) + return { + status: 'refused', + reason: error.cause.reason, + persisted: false, + } + } + const failure = failureOf(error) + if (!endpointCalled && failure.kind === 'provider') { + failure.kind = 'caller-hook' + failure.classification = 'permanent' + } + // Pass the failure observation to reconciliation only after the + // store operation has released its locks. + try { + await reconcile({ + ...observation(), + status: 'failed', + failure, + persisted, + }) + } catch { + /* If reconciliation fails, preserve the original error classification + * and whether the replacement credential was already persisted. */ + } + if ( + endpointCalled && + !consumed && + !persisted && + !reconciliationFailed && + !request.signal?.aborted && + failure.kind === 'provider' && + failure.classification === 'transient' && + attempt < 3 + ) { + await releaseHandoff() + if (aliasKey) unregister(job, aliasKey) + continue + } + return { status: 'failed', error, persisted, reconciliationFailed } + } finally { + await releaseHandoff() + } + } + throw new Error('Unreachable refresh attempt budget') + } catch (caught) { + const error = asError(caught) + try { + await reconcile({ + ...observation(), + status: 'failed', + failure: failureOf(error), + persisted, + }) + } catch { + /* If observation publication fails, do not return any credential as usable. */ + } + return { status: 'failed', error, persisted, reconciliationFailed } + } finally { + await releaseHandoff() + } + } + + async function refresh( + request: NativeRefreshRequest, + ): Promise { + if (request.mode !== 'local') + return { status: 'refused', reason: 'custody-mode', persisted: false } + if ( + !isNativeLocalPoolBinding(request.binding) || + request.binding.storageId !== options.paths.storageId + ) + return { status: 'refused', reason: 'binding-changed', persisted: false } + // Copy the request and binding before asynchronous work so caller + // mutations cannot change the job's inputs. + request = { ...request, binding: Object.freeze({ ...request.binding }) } + if (request.signal?.aborted) + return { status: 'refused', reason: 'cancelled', persisted: false } + const key = nativeLocalRefreshJobKey(request.binding) + let job = jobs.get(key) + if (!job) { + job = { + keys: new Set([key]), + promise: Promise.resolve({ + status: 'refused', + reason: 'initializing', + persisted: false, + }), + } + jobs.set(key, job) + const owner = job + job.promise = run(owner, request).finally(() => { + for (const ownedKey of owner.keys) unregister(owner, ownedKey) + }) + } + const result = await job.promise + if (request.signal?.aborted) + return { + status: 'refused', + reason: 'cancelled', + persisted: result.persisted, + } + if (result.status !== 'completed') return result + if (!result.proven) + return { + status: 'refused', + reason: 'identity-unproven', + persisted: result.persisted, + } + try { + const expected = + request.binding.identity === undefined && result.source === 'rotated' + ? { + ...request.binding, + ...(result.identity ? { identity: result.identity } : {}), + } + : request.binding + const restriction = await options.readRestrictions(expected) + const row = await rowFor(request.binding) + if ( + !row || + !nativeLocalPoolBindingMatches(expected, options.paths, row) || + !row.enabled || + !row.candidate + ) + return { + status: 'refused', + reason: 'row-ineligible', + persisted: result.persisted, + } + const reason = restrictionReason(restriction, row.fingerprint) + if (reason) + return { status: 'refused', reason, persisted: result.persisted } + const credential = row.credential + if ( + credential?.type !== 'oauth' || + !credential.access || + typeof credential.expires !== 'number' || + credential.expires <= now() + ) + return { + status: 'refused', + reason: 'access-unavailable', + persisted: result.persisted, + } + if (credential.access === request.rejectedAccessToken) + return { + status: 'refused', + reason: 'rejected-access-unchanged', + persisted: result.persisted, + } + return { + status: 'usable', + source: result.source, + binding: Object.freeze(expected), + access: credential.access, + expires: credential.expires, + } + } catch (error) { + return { + status: 'failed', + error: asError(error), + persisted: result.persisted, + reconciliationFailed: false, + } + } + } + + return { refresh } +} diff --git a/packages/core/src/tests/native-refresh-coordinator.test.ts b/packages/core/src/tests/native-refresh-coordinator.test.ts new file mode 100644 index 000000000..0678bfb02 --- /dev/null +++ b/packages/core/src/tests/native-refresh-coordinator.test.ts @@ -0,0 +1,1295 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + LockOwnershipError, + lockPathFor, + withLock, +} from '@cortexkit/common-auth/fs' +import { + fingerprintOf, + POOL_LOCK_DEFAULTS, + PoolOperationError, +} from '@cortexkit/common-auth/store' + +import { + ClaudeOAuthRefreshError, + type refreshClaudeOAuthToken, +} from '../auth.ts' +import type { + ProviderAccountUuid, + resolveClaudeCodeIdentity, +} from '../claude-code.ts' +import { + createNativeRefreshCoordinator, + type NativeRefreshCoordinatorOptions, + type NativeRefreshObservation, + type NativeRefreshRestriction, + nativeAccountProviderLock, +} from '../native-refresh-coordinator.ts' +import { + captureNativeLocalPoolBinding, + nativeLocalRefreshJobKey, +} from '../pool-binding.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { + createNativePoolStore, + type NativePoolStoreOptions, +} from '../pool-store.ts' + +const roots: string[] = [] +const quota = { + validate: (v: unknown) => typeof v === 'number', + merge: (_v: unknown, observation: unknown) => observation, +} +const allowed: NativeRefreshRestriction = { status: 'allowed' } + +function barrier() { + let release!: () => void + const promise = new Promise((resolve) => { + release = resolve + }) + return { promise, release } +} + +function credential(id: string) { + return { + type: 'oauth' as const, + access: `access-${id}`, + refresh: `refresh-${id}`, + expires: 4_000_000_000_000, + } +} + +function accountUuid(value: string) { + return value as ProviderAccountUuid +} +const identity: typeof resolveClaudeCodeIdentity = async () => ({ + deviceId: 'device', + sessionId: 'session', + accountUuid: accountUuid('A'), +}) +const provider: typeof refreshClaudeOAuthToken = async () => ({ + ...credential('new'), + expiresIn: 3600, +}) + +async function fixture(seams: Partial = {}) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'native-refresh-'))) + roots.push(root) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + const options = { ...seams, paths, quota } + const store = createNativePoolStore(options) + await store.initialize() + async function row(id: string) { + const read = await store.read() + if (read.status !== 'ready') throw new Error('Fixture pool not ready') + const found = read.rows.find((row) => row.id === id) + if (!found) throw new Error('Fixture row missing') + return found + } + async function binding(id: string) { + return captureNativeLocalPoolBinding(paths, await row(id)) + } + async function add(id: string, account?: string) { + await store.add({ id, identity: account, credential: credential(id) }) + } + const observations: NativeRefreshObservation[] = [] + function coordinator( + overrides: Partial = {}, + ) { + return createNativeRefreshCoordinator({ + ...options, + refreshToken: provider, + resolveIdentity: identity, + readRestrictions: async () => allowed, + reconcile: async (event) => { + observations.push(event) + }, + ...overrides, + }) + } + return { root, paths, store, row, binding, add, observations, coordinator } +} + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +test('account lock spec uses canonical store and identity, not epoch or row namespace', async () => { + const a = await fixture() + const b = await fixture() + const lock = nativeAccountProviderLock(a.paths, 'A') + expect(lock).toMatchObject({ path: a.paths.state, ...POOL_LOCK_DEFAULTS }) + expect(lock.name).toStartWith('native-account-provider-') + expect(lock.name).not.toBe('provider-anthropic') + expect(lock.name).not.toStartWith('row-') + expect(nativeAccountProviderLock(a.paths, 'A')).toEqual(lock) + expect(nativeAccountProviderLock(b.paths, 'A').name).not.toBe(lock.name) + expect(nativeAccountProviderLock(a.paths, 'B').name).not.toBe(lock.name) +}) + +test('independent known accounts reach provider concurrently', async () => { + const active = new Set() + const events: string[] = [] + const f = await fixture({ + onLockEvent: (event) => { + if (event.type === 'acquired') active.add(event.name) + else active.delete(event.name) + events.push(`${event.type}:${event.name}`) + }, + }) + await f.add('a', 'A') + await f.add('b', 'B') + const both = barrier() + const finish = barrier() + let calls = 0 + const c = f.coordinator({ + refreshToken: async (input) => { + expect(input.maxRetries).toBe(0) + if (++calls === 2) both.release() + await finish.promise + return { ...credential(input.refreshToken), expiresIn: 3600 } + }, + resolveIdentity: async (access) => ({ + deviceId: 'd', + sessionId: 's', + accountUuid: accountUuid(access.endsWith('a') ? 'A' : 'B'), + }), + }) + const a = c.refresh({ mode: 'local', binding: await f.binding('a') }) + const b = c.refresh({ mode: 'local', binding: await f.binding('b') }) + await both.promise + finish.release() + expect((await a).status).toBe('usable') + expect((await b).status).toBe('usable') + expect(calls).toBe(2) + expect(events).toContain( + `acquired:${nativeAccountProviderLock(f.paths, 'A').name}`, + ) + expect(active.size).toBe(0) +}) + +test('unknown rows serialize under provider-anthropic until reconciliation completes', async () => { + const f = await fixture() + await f.add('a') + await f.add('b') + const reconciled = barrier() + const finish = barrier() + const waiting = barrier() + let calls = 0 + const c = f.coordinator({ + onLockEvent: (e) => { + if (e.type === 'acquired' && e.name.includes('row') && calls === 1) + waiting.release() + }, + refreshToken: async (input) => { + calls++ + return { ...credential(input.refreshToken), expiresIn: 3600 } + }, + resolveIdentity: async () => ({ deviceId: 'd', sessionId: 's' }), + reconcile: async (e) => { + if (e.status === 'persisted' && e.binding.rowId === 'a') { + reconciled.release() + await finish.promise + } + }, + }) + const a = c.refresh({ mode: 'local', binding: await f.binding('a') }) + await reconciled.promise + const b = c.refresh({ mode: 'local', binding: await f.binding('b') }) + await waiting.promise + expect(calls).toBe(1) + finish.release() + expect((await a).status).toBe('refused') + expect((await b).status).toBe('refused') + expect(calls).toBe(2) +}) + +test('platform instances join identical known jobs but alias waiters never receive another row bearer', async () => { + const f = await fixture() + await f.add('a', 'A') + // The second row has the same account identity. The shared store disables + // it as a duplicate. + await f.add('alias', 'A') + const alias = { ...(await f.binding('a')), rowId: 'alias' } + const entered = barrier() + const finish = barrier() + let calls = 0 + const options = { + refreshToken: async () => { + calls++ + entered.release() + await finish.promise + return { ...credential('new'), expiresIn: 3600 } + }, + } + const c = f.coordinator(options) + const other = f.coordinator(options) + const request = { mode: 'local' as const, binding: await f.binding('a') } + const first = c.refresh(request) + await entered.promise + const joined = other.refresh(request) + const aliasJoined = other.refresh({ ...request, binding: alias }) + finish.release() + expect(await first).toMatchObject({ status: 'usable', access: 'access-new' }) + expect(await joined).toMatchObject({ status: 'usable', access: 'access-new' }) + expect(await aliasJoined).toMatchObject({ + status: 'refused', + reason: 'row-ineligible', + }) + expect((await f.row('alias')).credential).toMatchObject({ + access: 'access-alias', + }) + expect(calls).toBe(1) +}) + +test('different canonical stores never share jobs or successors', async () => { + const a = await fixture() + const b = await fixture() + await a.add('a', 'A') + await b.add('a', 'A') + const both = barrier() + const finish = barrier() + let calls = 0 + function options(label: string) { + return { + refreshToken: async () => { + if (++calls === 2) both.release() + await finish.promise + return { ...credential(label), expiresIn: 3600 } + }, + } + } + const first = a + .coordinator(options('one')) + .refresh({ mode: 'local', binding: await a.binding('a') }) + const second = b + .coordinator(options('two')) + .refresh({ mode: 'local', binding: await b.binding('a') }) + await both.promise + finish.release() + expect(await first).toMatchObject({ access: 'access-one' }) + expect(await second).toMatchObject({ access: 'access-two' }) + expect(calls).toBe(2) +}) + +test('callback-end identity alias joins during reconciliation and handoff encloses publication', async () => { + const active = new Set() + const f = await fixture({ + onLockEvent: (e) => { + if (e.type === 'acquired') active.add(e.name) + else active.delete(e.name) + }, + }) + await f.add('a') + const before = await f.binding('a') + const entered = barrier() + const finish = barrier() + let calls = 0 + let bootstraps = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + return { ...credential('new'), expiresIn: 3600 } + }, + resolveIdentity: async (access, model, account) => { + expect(access).toBe('access-new') + expect(model).toBeUndefined() + expect(account).toBeUndefined() + bootstraps++ + return { deviceId: 'd', sessionId: 's', accountUuid: accountUuid('A') } + }, + reconcile: async (e) => { + expect(e.status).toBe('persisted') + expect(active.has('provider-anthropic')).toBe(true) + expect(active.has(nativeAccountProviderLock(f.paths, 'A').name)).toBe( + true, + ) + expect(active.has('pool-config')).toBe(false) + expect(active.has('pool-state')).toBe(false) + entered.release() + await finish.promise + }, + }) + const first = c.refresh({ mode: 'local', binding: before }) + await entered.promise + // The store already contains account A's identity, but the refresh job has + // not published its result yet. + const learnt = await f.binding('a') + expect(learnt.identity).toBe('A') + let settled = false + const joined = c.refresh({ mode: 'local', binding: learnt }).then((r) => { + settled = true + return r + }) + await Promise.resolve() + expect(settled).toBe(false) + finish.release() + expect(await first).toMatchObject({ status: 'usable', binding: learnt }) + expect(await joined).toMatchObject({ status: 'usable', binding: learnt }) + expect(calls).toBe(1) + expect(bootstraps).toBe(1) + expect(active.size).toBe(0) +}) + +test('existing known job retains its alias while unknown handoff waits and both successors persist', async () => { + const knownReconciling = barrier() + const unknownBootstrapped = barrier() + const releaseKnown = barrier() + const f = await fixture() + // When both rows identify account A, the shared store keeps the earlier row + // active and disables the later row. + await f.add('unknown') + await f.add('known', 'A') + const knownBinding = await f.binding('known') + const unknownBinding = await f.binding('unknown') + let calls = 0 + const c = f.coordinator({ + refreshToken: async (input) => { + calls++ + return { ...credential(input.refreshToken), expiresIn: 3600 } + }, + resolveIdentity: async (access) => { + if (access.endsWith('unknown')) unknownBootstrapped.release() + return { deviceId: 'd', sessionId: 's', accountUuid: accountUuid('A') } + }, + reconcile: async (e) => { + f.observations.push(e) + if (e.status === 'persisted' && e.binding.rowId === 'known') { + knownReconciling.release() + await releaseKnown.promise + } + }, + }) + const known = c.refresh({ mode: 'local', binding: knownBinding }) + await knownReconciling.promise + const unknown = c.refresh({ mode: 'local', binding: unknownBinding }) + await unknownBootstrapped.promise + const alias = c.refresh({ + mode: 'local', + binding: { ...unknownBinding, identity: 'A' }, + }) + releaseKnown.release() + const knownResult = await known + const aliasResult = await alias + const unknownResult = await unknown + expect(knownResult.status).toBe('usable') + // The alias row had not been committed when the known row's refresh result + // was published. + expect(aliasResult.status).toBe('refused') + expect(unknownResult.status).toBe('usable') + expect(calls).toBe(2) + expect((await f.row('known')).credential).toMatchObject({ + access: 'access-refresh-known', + }) + expect((await f.row('unknown')).credential).toMatchObject({ + access: 'access-refresh-unknown', + }) + expect((await f.row('known')).enabled).toBe(false) + expect(f.observations.filter((e) => e.status === 'persisted')).toHaveLength(2) +}) + +test('commit-time duplicate disable preserves consumed successor and refuses serving', async () => { + const f = await fixture() + await f.add('earlier') + await f.add('later', 'A') + const exchanged = barrier() + const finishExchange = barrier() + const lateBinding = await f.binding('later') + const c = f.coordinator({ + refreshToken: async () => { + exchanged.release() + await finishExchange.promise + return { ...credential('successor'), expiresIn: 3600 } + }, + }) + const refresh = c.refresh({ mode: 'local', binding: lateBinding }) + await exchanged.promise + await f.store.recordIdentity('earlier', 'A', { credentialEpoch: 1 }) + expect((await f.row('later')).enabled).toBe(false) + finishExchange.release() + expect(await refresh).toMatchObject({ + status: 'refused', + reason: 'row-ineligible', + persisted: true, + }) + expect((await f.row('later')).credential).toMatchObject({ + access: 'access-successor', + refresh: 'refresh-successor', + }) + expect((await f.row('later')).enabled).toBe(false) + expect(f.observations).toContainEqual( + expect.objectContaining({ status: 'persisted' }), + ) +}) + +test('stale-token 401 adopts pure-store replacement without a provider call and clears old-token restrictions', async () => { + const f = await fixture() + await f.add('a', 'A') + const binding = await f.binding('a') + const stale = fingerprintOf(credential('a')) + await f.store.rotate('a', credential('new')) + let calls = 0 + let blocked = true + const c = f.coordinator({ + refreshToken: async () => { + calls++ + throw new Error('No refresh allowed') + }, + readRestrictions: async () => + blocked + ? { + status: 'blocked', + reason: 'invalid-grant', + credentialFingerprint: stale, + } + : allowed, + reconcile: async (event) => { + expect(event.status).toBe('adopted') + blocked = false + }, + }) + expect( + await c.refresh({ + mode: 'local', + binding, + rejectedAccessToken: 'access-a', + }), + ).toMatchObject({ status: 'usable', source: 'adopted', access: 'access-new' }) + expect(calls).toBe(0) +}) + +test('adoption is per-caller and never serves the access token that caller rejected', async () => { + const f = await fixture() + await f.add('a', 'A') + const binding = await f.binding('a') + await f.store.rotate('a', credential('new')) + const entered = barrier() + const finish = barrier() + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + reconcile: async (e) => { + expect(e.status).toBe('adopted') + entered.release() + await finish.promise + }, + }) + const first = c.refresh({ + mode: 'local', + binding, + rejectedAccessToken: 'access-a', + }) + await entered.promise + const joined = c.refresh({ + mode: 'local', + binding, + rejectedAccessToken: 'access-new', + }) + finish.release() + expect(await first).toMatchObject({ + status: 'usable', + source: 'adopted', + access: 'access-new', + }) + expect(await joined).toMatchObject({ + status: 'refused', + reason: 'rejected-access-unchanged', + }) + expect(calls).toBe(0) +}) + +test('same-token invalid grant and identity-unproven restrictions cannot be cleared by adoption', async () => { + const f = await fixture() + await f.add('a', 'A') + const binding = await f.binding('a') + await f.store.rotate('a', credential('new')) + let calls = 0 + for (const restriction of [ + { + status: 'blocked', + reason: 'invalid-grant', + credentialFingerprint: fingerprintOf(credential('new')), + }, + { status: 'blocked', reason: 'identity-unproven' }, + ] satisfies NativeRefreshRestriction[]) { + const c = f.coordinator({ + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + readRestrictions: async () => restriction, + }) + expect( + await c.refresh({ + mode: 'local', + binding, + rejectedAccessToken: 'access-a', + }), + ).toMatchObject({ status: 'refused', reason: restriction.reason }) + } + expect(calls).toBe(0) + expect(f.observations.map((e) => e.status)).toEqual(['refused', 'refused']) +}) + +test('custody, stale epoch and pre-exchange cancellation refuse without provider calls', async () => { + const f = await fixture() + await f.add('a', 'A') + const binding = await f.binding('a') + const abort = new AbortController() + abort.abort() + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + }) + expect(await c.refresh({ mode: 'claustrum', binding })).toMatchObject({ + reason: 'custody-mode', + }) + expect( + await c.refresh({ mode: 'local', binding, signal: abort.signal }), + ).toMatchObject({ reason: 'cancelled' }) + await f.store.replace('a', credential('replacement'), { identity: 'A' }) + expect(await c.refresh({ mode: 'local', binding })).toMatchObject({ + reason: 'binding-changed', + }) + expect(calls).toBe(0) +}) + +test('cancellation while waiting for physical locks is revalidated before exchange', async () => { + const f = await fixture() + await f.add('a', 'A') + const locked = barrier() + const finish = barrier() + const abort = new AbortController() + const spec = nativeAccountProviderLock(f.paths, 'A') + const holder = withLock( + spec.path, + { ...POOL_LOCK_DEFAULTS, name: spec.name }, + async () => { + locked.release() + await finish.promise + }, + ) + await locked.promise + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + }) + const result = c.refresh({ + mode: 'local', + binding: await f.binding('a'), + signal: abort.signal, + }) + abort.abort() + finish.release() + await holder + expect(await result).toMatchObject({ status: 'refused', reason: 'cancelled' }) + expect(calls).toBe(0) +}) + +test('after-exchange cancellation persists successor through bootstrap barrier and serves nobody cancelled', async () => { + const f = await fixture() + await f.add('a') + const abort = new AbortController() + const bootstrapping = barrier() + const finish = barrier() + const c = f.coordinator({ + resolveIdentity: async () => { + bootstrapping.release() + await finish.promise + return { deviceId: 'd', sessionId: 's', accountUuid: accountUuid('A') } + }, + }) + const request = c.refresh({ + mode: 'local', + binding: await f.binding('a'), + signal: abort.signal, + }) + await bootstrapping.promise + abort.abort() + finish.release() + expect(await request).toMatchObject({ + status: 'refused', + reason: 'cancelled', + persisted: true, + }) + expect((await f.row('a')).credential).toMatchObject(credential('new')) + expect(f.observations).toContainEqual( + expect.objectContaining({ + status: 'persisted', + handoff: 'skipped-cancelled', + }), + ) +}) + +test('after-exchange bootstrap failure is token-free, durable and non-serving without retry', async () => { + const f = await fixture() + await f.add('a') + const bootstrapping = barrier() + const finish = barrier() + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + resolveIdentity: async () => { + bootstrapping.release() + await finish.promise + throw new Error('ECONNRESET access-new refresh-new') + }, + }) + const request = c.refresh({ mode: 'local', binding: await f.binding('a') }) + await bootstrapping.promise + finish.release() + expect(await request).toMatchObject({ + status: 'refused', + reason: 'identity-unproven', + persisted: true, + }) + expect((await f.row('a')).credential).toMatchObject(credential('new')) + expect(f.observations).toContainEqual( + expect.objectContaining({ status: 'persisted', bootstrap: 'failed' }), + ) + expect(JSON.stringify(f.observations)).not.toContain('access-new') + expect(JSON.stringify(f.observations)).not.toContain('refresh-new') + expect(calls).toBe(1) +}) + +test('identity contradiction is durably quarantined with old identity, no persisted hook and no retry', async () => { + const f = await fixture() + await f.add('a', 'A') + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + resolveIdentity: async () => ({ + deviceId: 'd', + sessionId: 's', + accountUuid: accountUuid('B'), + }), + }) + expect( + await c.refresh({ mode: 'local', binding: await f.binding('a') }), + ).toEqual({ + status: 'identity-contradicted', + expectedIdentity: 'A', + returnedIdentity: 'B', + persisted: true, + }) + expect(await f.row('a')).toMatchObject({ + identity: 'A', + enabled: false, + credential: credential('new'), + }) + expect(f.observations.map((e) => e.status)).toEqual(['identity-contradicted']) + await expect(f.store.enable('a')).rejects.toMatchObject({ + kind: 'identity-contradicted', + }) + await f.store.replace('a', credential('verified'), { identity: 'A' }) + expect((await f.row('a')).enabled).toBe(false) + await f.store.enable('a') + expect((await f.row('a')).enabled).toBe(true) + expect(calls).toBe(1) +}) + +test('crash-forward contradiction write remains quarantined without ordinary reconciliation', async () => { + let failConfig = false + const f = await fixture({ + onStep: (step, info) => { + if ( + failConfig && + info.operation === 'refresh' && + step === 'before-config-write' + ) + throw new Error('synthetic interruption') + }, + }) + await f.add('a', 'A') + const binding = await f.binding('a') + failConfig = true + const c = f.coordinator({ + resolveIdentity: async () => ({ + deviceId: 'd', + sessionId: 's', + accountUuid: accountUuid('B'), + }), + }) + expect(await c.refresh({ mode: 'local', binding })).toMatchObject({ + status: 'failed', + persisted: true, + }) + expect(await f.row('a')).toMatchObject({ + identity: 'A', + enabled: false, + candidate: false, + credential: credential('new'), + }) + expect(f.observations.map((e) => e.status)).toEqual(['failed']) + failConfig = false + await expect(f.store.enable('a')).rejects.toMatchObject({ + kind: 'identity-contradicted', + }) + expect((await f.row('a')).enabled).toBe(false) +}) + +test('persisted reconciliation failure preserves committed fact and cleans learned registration and lease', async () => { + const f = await fixture() + await f.add('a') + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + reconcile: async (e) => { + f.observations.push(e) + throw new Error('publisher failed') + }, + }) + const result = await c.refresh({ + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toMatchObject({ + status: 'failed', + persisted: true, + reconciliationFailed: true, + }) + if (result.status !== 'failed') throw new Error('Expected failure') + expect(result.error).toBeInstanceOf(PoolOperationError) + expect(result.error).toMatchObject({ + kind: 'after-persist-hook', + committed: credential('new'), + }) + expect(f.observations.map((e) => e.status)).toEqual(['persisted', 'failed']) + expect(calls).toBe(1) + expect((await f.row('a')).credential).toMatchObject(credential('new')) + // After disposal removes the old job registration, a refresh for the newly + // learned account must use a different job. + expect( + await f.coordinator().refresh({ + mode: 'local', + binding: await f.binding('a'), + rejectedAccessToken: 'old', + }), + ).toMatchObject({ status: 'usable', source: 'adopted' }) + const spec = nativeAccountProviderLock(f.paths, 'A') + await withLock( + spec.path, + { ...POOL_LOCK_DEFAULTS, name: spec.name }, + async (lock) => { + await lock.assertOwned() + }, + ) +}) + +test('handoff ownership loss is non-fatal and removes only the unknown job registration', async () => { + let breakHandoff = true + const f = await fixture({ + onLockEvent: (e) => { + if ( + breakHandoff && + e.type === 'acquired' && + e.name.startsWith('native-account-provider') + ) { + breakHandoff = false + throw new LockOwnershipError({ target: e.path, name: e.name }) + } + }, + }) + await f.add('a') + expect( + await f + .coordinator() + .refresh({ mode: 'local', binding: await f.binding('a') }), + ).toMatchObject({ status: 'usable', access: 'access-new' }) + expect(f.observations).toContainEqual( + expect.objectContaining({ status: 'persisted', handoff: 'skipped-loss' }), + ) + expect((await f.row('a')).credential).toMatchObject(credential('new')) +}) + +test('lost handoff after callback end persists successor, skips registration and releases ownership', async () => { + const f = await fixture() + await f.add('a') + const spec = nativeAccountProviderLock(f.paths, 'A') + let handoffOwner: string | undefined + const c = f.coordinator({ + onLockEvent: (e) => { + if (e.type === 'acquired' && e.name === spec.name) handoffOwner = e.name + }, + onStep: async (step, info) => { + if (step === 'before-state-write' && info.operation === 'refresh') { + expect(handoffOwner).toBe(spec.name) + // Replace the recorded lock owner to simulate takeover. Keep the normal + // lease duration and renewal interval unchanged. + const path = lockPathFor(spec.path, spec.name) + const bytes = JSON.parse(await readFile(path, 'utf8')) + bytes.ownerId = 'synthetic-successor-owner' + await writeFile(path, JSON.stringify(bytes)) + } + }, + }) + const result = await c.refresh({ + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toMatchObject({ status: 'usable' }) + expect((await f.row('a')).credential).toMatchObject(credential('new')) + expect(f.observations).toContainEqual( + expect.objectContaining({ status: 'persisted', handoff: 'skipped-loss' }), + ) + // Releasing the original owner must not remove the lock file now owned by + // its replacement. + expect( + JSON.parse(await readFile(lockPathFor(spec.path, spec.name), 'utf8')) + .ownerId, + ).toBe('synthetic-successor-owner') +}) + +test('cancelled unknown predecessor cannot dispose an existing known registration or its waiters', async () => { + const f = await fixture() + await f.add('known', 'A') + await f.add('unknown') + const knownBinding = await f.binding('known') + const unknownBinding = await f.binding('unknown') + const entered = barrier() + const finish = barrier() + const abort = new AbortController() + let calls = 0 + const c = f.coordinator({ + refreshToken: async (input) => { + calls++ + return { ...credential(input.refreshToken), expiresIn: 3600 } + }, + resolveIdentity: async (access) => { + if (access.endsWith('unknown')) abort.abort() + return identity(access, undefined, undefined) + }, + reconcile: async (e) => { + if (e.status === 'persisted' && e.binding.rowId === 'known') { + entered.release() + await finish.promise + } + }, + }) + const known = c.refresh({ mode: 'local', binding: knownBinding }) + await entered.promise + const unknown = await c.refresh({ + mode: 'local', + binding: unknownBinding, + signal: abort.signal, + }) + expect(unknown).toMatchObject({ + status: 'refused', + reason: 'cancelled', + persisted: true, + }) + let settled = false + const waiter = c + .refresh({ mode: 'local', binding: knownBinding }) + .then((r) => { + settled = true + return r + }) + // A new job reads restrictions before proceeding. A caller joining an + // existing job instead waits for that job's pending reconciliation. + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + finish.release() + expect((await known).status).toBe('usable') + expect((await waiter).status).toBe('usable') + expect(calls).toBe(2) +}) + +test('cancelled alias waiter leaves owner job running and later waiters join it', async () => { + const f = await fixture() + await f.add('a', 'A') + const binding = await f.binding('a') + const entered = barrier() + const finish = barrier() + const abort = new AbortController() + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + entered.release() + await finish.promise + return provider({ refreshToken: 'x' }) + }, + }) + const owner = c.refresh({ mode: 'local', binding }) + await entered.promise + const cancelled = c.refresh({ mode: 'local', binding, signal: abort.signal }) + abort.abort() + const later = c.refresh({ mode: 'local', binding }) + finish.release() + expect((await owner).status).toBe('usable') + expect(await cancelled).toMatchObject({ + status: 'refused', + reason: 'cancelled', + persisted: true, + }) + expect((await later).status).toBe('usable') + expect(calls).toBe(1) +}) + +test('refusal and failure reconciliation run outside store locks and finish before job completion', async () => { + const active = new Set() + const f = await fixture({ + onLockEvent: (e) => { + if (e.type === 'acquired') active.add(e.name) + else active.delete(e.name) + }, + }) + await f.add('a', 'A') + const entered = barrier() + const finish = barrier() + let settled = false + const c = f.coordinator({ + readRestrictions: async () => { + expect(active.has('pool-config')).toBe(false) + expect(active.has('pool-state')).toBe(false) + return { status: 'blocked', reason: 'quota-ineligible' } + }, + reconcile: async (e) => { + expect(e.status).toBe('refused') + expect(active.has('pool-config')).toBe(false) + expect(active.has('pool-state')).toBe(false) + entered.release() + await finish.promise + }, + }) + const result = c + .refresh({ mode: 'local', binding: await f.binding('a') }) + .then((r) => { + settled = true + return r + }) + await entered.promise + expect(settled).toBe(false) + finish.release() + expect(await result).toMatchObject({ + status: 'refused', + reason: 'quota-ineligible', + }) + const failed = f.coordinator({ + refreshToken: async () => { + throw new ClaudeOAuthRefreshError(400, 'invalid_grant') + }, + reconcile: async (e) => { + expect(e.status).toBe('failed') + expect(active.size).toBe(0) + }, + }) + expect( + (await failed.refresh({ mode: 'local', binding: await f.binding('a') })) + .status, + ).toBe('failed') +}) + +test('policy hook failure before endpoint is not a physical transient retry', async () => { + const f = await fixture() + await f.add('a', 'A') + let reads = 0 + let calls = 0 + const c = f.coordinator({ + readRestrictions: async () => { + if (++reads === 2) throw new Error('ECONNRESET') + return allowed + }, + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + }) + expect( + await c.refresh({ mode: 'local', binding: await f.binding('a') }), + ).toMatchObject({ status: 'failed', persisted: false }) + expect(f.observations.at(-1)).toMatchObject({ + status: 'failed', + failure: { kind: 'caller-hook', classification: 'permanent' }, + }) + expect(calls).toBe(0) +}) + +test('strict stamp loss before capture refuses without provider, legacy fallback or repair', async () => { + const f = await fixture() + await f.add('a', 'A') + const binding = await f.binding('a') + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + delete state.accounts.a.commonAuthPool + await writeFile(f.paths.state, JSON.stringify(state)) + const before = await readFile(f.paths.state, 'utf8') + await writeFile( + f.paths.legacyState, + JSON.stringify({ access: 'host-access', refresh: 'host-refresh' }), + ) + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + }) + expect(await c.refresh({ mode: 'local', binding })).toMatchObject({ + status: 'refused', + reason: 'binding-changed', + }) + expect(calls).toBe(0) + expect(await readFile(f.paths.state, 'utf8')).toBe(before) +}) + +test( + 'handoff published timeout is non-fatal and a consumed successor stays durable', + async () => { + const f = await fixture() + await f.add('a') + const spec = nativeAccountProviderLock(f.paths, 'A') + const locked = barrier() + const finish = barrier() + const holder = withLock( + spec.path, + { ...POOL_LOCK_DEFAULTS, name: spec.name }, + async () => { + locked.release() + await finish.promise + }, + ) + await locked.promise + try { + const result = await f + .coordinator() + .refresh({ mode: 'local', binding: await f.binding('a') }) + expect(result).toMatchObject({ status: 'usable' }) + expect((await f.row('a')).credential).toMatchObject(credential('new')) + expect(f.observations).toContainEqual( + expect.objectContaining({ + status: 'persisted', + handoff: 'skipped-contention', + }), + ) + } finally { + finish.release() + await holder + } + expect( + await f.coordinator().refresh({ + mode: 'local', + binding: await f.binding('a'), + rejectedAccessToken: 'old', + }), + ).toMatchObject({ status: 'usable', source: 'adopted' }) + // Allow for the store's 15-second lock wait. The test does not shorten the + // lock timeout, lease duration or renewal interval. + }, + POOL_LOCK_DEFAULTS.timeoutMs + 5_000, +) + +test('commit dispatch-material fence refuses a same-epoch foreign edit after exchange', async () => { + const f = await fixture() + await f.add('a', 'A') + const bootstrapping = barrier() + const finish = barrier() + const c = f.coordinator({ + resolveIdentity: async () => { + bootstrapping.release() + await finish.promise + return identity('x', undefined, undefined) + }, + }) + const request = c.refresh({ mode: 'local', binding: await f.binding('a') }) + await bootstrapping.promise + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + state.accounts.a.access = 'foreign' + await writeFile(f.paths.state, JSON.stringify(state)) + finish.release() + expect(await request).toMatchObject({ status: 'failed', persisted: false }) + expect((await f.row('a')).stamp).toBe('mismatched') + expect(await readFile(f.paths.state, 'utf8')).not.toContain('access-new') +}) + +for (const [name, cause, expectedCalls, classification] of [ + [ + 'persistent 5xx', + new ClaudeOAuthRefreshError(503, 'unavailable'), + 3, + 'transient', + ], + [ + 'transient network', + Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }), + 3, + 'transient', + ], + [ + 'invalid_grant', + new ClaudeOAuthRefreshError(400, '{"error":"invalid_grant"}'), + 1, + 'invalid-grant', + ], + [ + 'invalid_grant even on 5xx', + new ClaudeOAuthRefreshError(503, 'invalid_grant'), + 1, + 'invalid-grant', + ], + ['rate limit', new ClaudeOAuthRefreshError(429, 'busy', '7'), 1, 'permanent'], + [ + 'permanent network', + new Error('certificate validation failed'), + 1, + 'permanent', + ], +] as const) { + test(`${name} has exactly bounded physical calls and retains the original classified cause`, async () => { + let captures = 0 + const f = await fixture({ + hold: () => { + captures++ + }, + }) + await f.add('a', 'A') + let calls = 0 + const c = f.coordinator({ + refreshToken: async (input) => { + expect(input.maxRetries).toBe(0) + calls++ + throw cause + }, + }) + const result = await c.refresh({ + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toMatchObject({ status: 'failed', persisted: false }) + if (result.status !== 'failed') throw new Error('Expected failed result') + expect(result.error.cause).toBe(cause) + expect(calls).toBe(expectedCalls) + expect(captures).toBe(expectedCalls) + expect(f.observations).toHaveLength(expectedCalls) + expect(f.observations.at(-1)).toMatchObject({ + status: 'failed', + failure: { kind: 'provider', classification }, + }) + expect((await f.row('a')).credential).toMatchObject(credential('a')) + }) +} + +test('same-token backoff reconciled after a transient attempt prevents the next physical call', async () => { + const f = await fixture() + await f.add('a', 'A') + let restriction: NativeRefreshRestriction = allowed + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + throw new ClaudeOAuthRefreshError(503, 'unavailable') + }, + readRestrictions: async () => restriction, + reconcile: async (e) => { + if (e.status === 'failed') + restriction = { + status: 'blocked', + reason: 'refresh-backoff', + credentialFingerprint: e.credentialFingerprint!, + } + }, + }) + expect( + await c.refresh({ mode: 'local', binding: await f.binding('a') }), + ).toMatchObject({ status: 'refused', reason: 'refresh-backoff' }) + expect(calls).toBe(1) +}) + +test('restriction change after exchange cannot discard rotation but does prevent serving', async () => { + const f = await fixture() + await f.add('a', 'A') + let restriction: NativeRefreshRestriction = allowed + const guarded = f.coordinator({ + readRestrictions: async () => restriction, + resolveIdentity: async () => { + restriction = { status: 'blocked', reason: 'quota-ineligible' } + return identity('x', undefined, undefined) + }, + }) + expect( + await guarded.refresh({ mode: 'local', binding: await f.binding('a') }), + ).toMatchObject({ + status: 'refused', + reason: 'quota-ineligible', + persisted: true, + }) + expect((await f.row('a')).credential).toMatchObject(credential('new')) +}) + +test('epoch-separated job cannot join an old job, and old terminal cleanup cannot erase the next job', async () => { + const f = await fixture() + await f.add('a', 'A') + const oldBinding = await f.binding('a') + const oldReconciling = barrier() + const finishOld = barrier() + const nextEntered = barrier() + const finishNext = barrier() + let calls = 0 + const c = f.coordinator({ + refreshToken: async () => { + calls++ + if (calls === 2) { + nextEntered.release() + await finishNext.promise + } + return { ...credential(`new-${calls}`), expiresIn: 3600 } + }, + reconcile: async (e) => { + if (e.status === 'persisted' && e.binding.credentialEpoch === 1) { + oldReconciling.release() + await finishOld.promise + } + }, + }) + const first = c.refresh({ mode: 'local', binding: oldBinding }) + await oldReconciling.promise + // Check that a new credential version produces a different refresh-job key + // before replacement acquires the row lock. + const nextBinding = { ...oldBinding, credentialEpoch: 2 } + expect(nativeLocalRefreshJobKey(nextBinding)).not.toBe( + nativeLocalRefreshJobKey(oldBinding), + ) + const premature = await c.refresh({ mode: 'local', binding: nextBinding }) + expect(premature).toMatchObject({ + status: 'refused', + reason: 'binding-changed', + }) + finishOld.release() + expect((await first).status).toBe('usable') + await f.store.replace('a', credential('replacement'), { identity: 'A' }) + const next = c.refresh({ mode: 'local', binding: await f.binding('a') }) + await nextEntered.promise + const joined = c.refresh({ mode: 'local', binding: await f.binding('a') }) + finishNext.release() + expect((await next).status).toBe('usable') + expect((await joined).status).toBe('usable') + expect(calls).toBe(2) +}) From 3bb674c4c6ae9d1ceb5ca5a58218885886b229f1 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:51:46 +0200 Subject: [PATCH 013/169] mason: separate rate-limit metadata from immediate retries Classify OAuth 429 failures as transient for native reconciliation, preserving status and Retry-After, but explicitly exclude 429 from the coordinator's immediate physical retry loop. Keep maxRetries:0 and one endpoint call. Correct the existing rate-limit expectation: permanent metadata incorrectly conflated deferred native backoff with immediate endpoint retryability. Both rate-limit tests failed before the source fix. Add real-store coverage for exact hook metadata and later same-token backoff refusal; three isolated negative controls prove classification, retry exclusion and restriction enforcement. Invalid-grant, replacement and consumed-successor behavior is unchanged. Core build and all 411 tests pass; workspace types pass with pinned Bun 1.3.14 and Node 24.16.0. No comments changed, as checked against the preceding commit; no fresh comment gate is required for this correction. --- .../core/src/native-refresh-coordinator.ts | 3 +- .../tests/native-refresh-coordinator.test.ts | 72 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index ebba521b5..dcc8d24ee 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -212,7 +212,7 @@ function failureOf(error: Error): NativeRefreshFailure { kind, classification: invalidGrant ? 'invalid-grant' - : cause.status >= 500 + : cause.status === 429 || cause.status >= 500 ? 'transient' : 'permanent', status: cause.status, @@ -683,6 +683,7 @@ export function createNativeRefreshCoordinator( !request.signal?.aborted && failure.kind === 'provider' && failure.classification === 'transient' && + failure.status !== 429 && attempt < 3 ) { await releaseHandoff() diff --git a/packages/core/src/tests/native-refresh-coordinator.test.ts b/packages/core/src/tests/native-refresh-coordinator.test.ts index 0678bfb02..9acba1c37 100644 --- a/packages/core/src/tests/native-refresh-coordinator.test.ts +++ b/packages/core/src/tests/native-refresh-coordinator.test.ts @@ -1155,7 +1155,7 @@ for (const [name, cause, expectedCalls, classification] of [ 1, 'invalid-grant', ], - ['rate limit', new ClaudeOAuthRefreshError(429, 'busy', '7'), 1, 'permanent'], + ['rate limit', new ClaudeOAuthRefreshError(429, 'busy', '7'), 1, 'transient'], [ 'permanent network', new Error('certificate validation failed'), @@ -1197,6 +1197,76 @@ for (const [name, cause, expectedCalls, classification] of [ }) } +test('rate limit reconciliation preserves transient retry metadata and blocks a later same-token refresh', async () => { + let captures = 0 + const f = await fixture({ + hold: () => { + captures++ + }, + }) + await f.add('a', 'A') + const binding = await f.binding('a') + const cause = new ClaudeOAuthRefreshError(429, 'busy', '7') + let calls = 0 + let restriction: NativeRefreshRestriction = allowed + const c = f.coordinator({ + refreshToken: async (input) => { + expect(input.maxRetries).toBe(0) + calls++ + throw cause + }, + readRestrictions: async () => restriction, + reconcile: async (event) => { + f.observations.push(event) + if ( + event.status === 'failed' && + event.failure.classification === 'transient' && + event.credentialFingerprint + ) { + restriction = { + status: 'blocked', + reason: 'refresh-backoff', + credentialFingerprint: event.credentialFingerprint, + } + } + }, + }) + const first = await c.refresh({ mode: 'local', binding }) + expect(first).toMatchObject({ + status: 'failed', + persisted: false, + reconciliationFailed: false, + }) + if (first.status !== 'failed') throw new Error('Expected failed result') + expect(first.error.cause).toBe(cause) + expect(f.observations).toHaveLength(1) + expect(f.observations[0]).toMatchObject({ + status: 'failed', + credentialFingerprint: fingerprintOf(credential('a')), + failure: { + kind: 'provider', + classification: 'transient', + status: 429, + retryAfter: 7, + }, + persisted: false, + }) + expect(calls).toBe(1) + expect(captures).toBe(1) + expect(await c.refresh({ mode: 'local', binding })).toMatchObject({ + status: 'refused', + reason: 'refresh-backoff', + persisted: false, + }) + expect(calls).toBe(1) + expect(captures).toBe(1) + expect(f.observations.map((event) => event.status)).toEqual([ + 'failed', + 'refused', + ]) + expect((await f.row('a')).credential).toMatchObject(credential('a')) +}) + test('same-token backoff reconciled after a transient attempt prevents the next physical call', async () => { const f = await fixture() await f.add('a', 'A') From 57d8288b415f4c1c2440c265f910d08417f6d09e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:01:46 +0200 Subject: [PATCH 014/169] mason: preserve proven sticky routing state Export a pure migration serializer that uses the existing normalizers without accepting their defaults, drops, clamping or discarded fields. Preserve clocks, affinity and deficit bytes; remap only explicit caller-proven account bindings and safely retain dictionary keys. Offline proof, journal and locked publication remain caller obligations; routing policy and persistence are unchanged. Add 60 focused tests, including causal real-router deficit attribution and three independently reddened/restored negative controls. Verified pinned Bun 1.3.14, Node 24.16.0, workspace build/types, full Core 465 tests, OpenCode sticky 28 tests and Pi routing/commands 50 tests. Parent pre-commit Sidekick comment review sk_998f6cfae10449e88d14d396fc84b58b approved the exact two-file change. --- packages/core/src/sticky-routing.ts | 85 ++++ .../tests/native-routing-migration.test.ts | 479 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 packages/core/src/tests/native-routing-migration.test.ts diff --git a/packages/core/src/sticky-routing.ts b/packages/core/src/sticky-routing.ts index 9c0b847af..39741e641 100644 --- a/packages/core/src/sticky-routing.ts +++ b/packages/core/src/sticky-routing.ts @@ -225,6 +225,91 @@ function normalizeState(value: unknown): StickyRouteState | undefined { } } +/** + * Produce deterministic journal bytes from captured, parsed legacy routing JSON. + * The offline caller must prove each old-to-concrete row binding, including main; + * a map entry is not credential or identity proof. Unmapped assignments and their + * implicit prompt-byte deficits are dropped. Quota observations must separately + * retain their proven checkedAt values for those deficits to remain attributable. + * + * The caller owns source/path revalidation, the digest, journal authority and the + * locked temp-plus-rename write with an ownership check before rename. An absent + * source is handled by the caller, not by substituting an empty state here. This + * helper neither authorizes migration nor reads clocks, prunes or writes state. + * Invalid source data or unusable map values throw Error('invalid-source') without + * including any captured data. Input must be parsed JSON, not executable objects. + */ +export function migrateStickyRoutingState( + source: unknown, + rowIdMap: ReadonlyMap, +): string { + try { + if (!isRecord(source) || !isRecord(source.assignments)) throw new Error() + const entries = Object.entries(source.assignments) + // The runtime normalizer writes into an ordinary dictionary. Neutral keys + // let it validate every record without invoking the __proto__ setter; the + // original session keys are restored only into a null-prototype dictionary. + const state = normalizeState({ + ...source, + assignments: Object.fromEntries( + entries.map(([, assignment], index) => [String(index), assignment]), + ), + }) + if ( + !state || + Object.keys(source).length !== 3 || + !Object.hasOwn(source, 'version') || + !Object.hasOwn(source, 'updatedAt') || + !Object.hasOwn(source, 'assignments') || + !Object.is(source.updatedAt, state.updatedAt) + ) { + throw new Error() + } + + // Validate even unused mappings. These are concrete pool row IDs, whose + // binding contract requires nonempty strings without surrounding whitespace. + for (const accountId of rowIdMap.values()) { + if ( + typeof accountId !== 'string' || + accountId.length === 0 || + accountId.trim() !== accountId + ) { + throw new Error() + } + } + + const assignments: Record = + Object.create(null) + for (const [index, [key, original]] of entries.entries()) { + const assignment = state.assignments[String(index)] + if (!isRecord(original) || !assignment) throw new Error() + // An absent legacy affinity is valid, but no other missing, extra or + // changed field is safe to import. Compare fields, not JSON key order. + const fields = Object.keys(assignment).filter( + (field) => + field !== 'affinityModelId' || + Object.hasOwn(original, 'affinityModelId'), + ) + if ( + Object.keys(original).length !== fields.length || + fields.some( + (field) => + !Object.hasOwn(original, field) || + !Object.is(original[field], Reflect.get(assignment, field)), + ) + ) { + throw new Error() + } + const accountId = rowIdMap.get(assignment.accountId) + if (accountId === undefined) continue + assignments[key] = { ...assignment, accountId } + } + return `${JSON.stringify({ ...state, assignments }, null, 2)}\n` + } catch { + throw new Error('invalid-source') + } +} + function emptyState(now: number): StickyRouteState { return { version: 1, updatedAt: now, assignments: {} } } diff --git a/packages/core/src/tests/native-routing-migration.test.ts b/packages/core/src/tests/native-routing-migration.test.ts new file mode 100644 index 000000000..e6bf45afa --- /dev/null +++ b/packages/core/src/tests/native-routing-migration.test.ts @@ -0,0 +1,479 @@ +import { expect, spyOn, test } from 'bun:test' +import { createHash } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + migrateStickyRoutingState, + type StickyRouteAssignment, + StickySessionRouter, +} from '../sticky-routing.ts' + +function assignment( + accountId = 'main', + overrides: Partial = {}, +): StickyRouteAssignment { + return { + accountId, + family: 'fable', + affinityModelId: 'claude-fable-5', + assignedAt: 11, + lastSeenAt: 23, + initialInputBytes: 4096.5, + quotaCheckedAt: 37, + ...overrides, + } +} + +function source( + assignments: Record = { session: assignment() }, +) { + return { version: 1, updatedAt: 713, assignments } +} + +const proven = new Map([ + ['main', 'former-main-row'], + ['fallback', 'fallback-row'], +]) + +function expectInvalid( + input: unknown, + map: ReadonlyMap = proven, +) { + let caught: unknown + try { + migrateStickyRoutingState(input, map) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).toBe('invalid-source') + expect(Object.hasOwn(caught as Error, 'cause')).toBe(false) +} + +test('serializes exact UTF-8 journal bytes with explicit main and fallback remaps', () => { + // Source field order is intentionally unlike the normalizer's output order. + const input = { + assignments: { + 'z-session': { + quotaCheckedAt: 37, + lastSeenAt: 23, + accountId: 'main', + assignedAt: 11, + initialInputBytes: 4096.5, + affinityModelId: 'claude-fable-café-💻', + family: 'fable', + }, + 'a-session': assignment('fallback', { + family: 'opus', + affinityModelId: 'claude-opus-4-8', + assignedAt: 12, + lastSeenAt: 24, + initialInputBytes: 8, + quotaCheckedAt: 38, + }), + }, + updatedAt: 713, + version: 1, + } + const expected = `{ + "version": 1, + "updatedAt": 713, + "assignments": { + "z-session": { + "accountId": "former-main-row", + "family": "fable", + "affinityModelId": "claude-fable-café-💻", + "assignedAt": 11, + "lastSeenAt": 23, + "initialInputBytes": 4096.5, + "quotaCheckedAt": 37 + }, + "a-session": { + "accountId": "fallback-row", + "family": "opus", + "affinityModelId": "claude-opus-4-8", + "assignedAt": 12, + "lastSeenAt": 24, + "initialInputBytes": 8, + "quotaCheckedAt": 38 + } + } +} +` + expect(migrateStickyRoutingState(input, proven)).toBe(expected) + expect(Buffer.from(migrateStickyRoutingState(input, proven), 'utf8')).toEqual( + Buffer.from(expected, 'utf8'), + ) +}) + +test('is deterministic across changing clocks and map insertion orders without reading time', () => { + const clock = spyOn(Date, 'now').mockReturnValue(1) + try { + const input = source() + const first = migrateStickyRoutingState(input, proven) + clock.mockReturnValue(9_000_000_000_000) + expect( + migrateStickyRoutingState(input, new Map([...proven].reverse())), + ).toBe(first) + clock.mockImplementation(() => { + throw new Error('clock must not be read') + }) + expect(migrateStickyRoutingState(input, proven)).toBe(first) + expect(clock).not.toHaveBeenCalled() + } finally { + clock.mockRestore() + } +}) + +test('timestamp control carries source updatedAt rather than replacing it', () => { + const migrated = JSON.parse(migrateStickyRoutingState(source(), proven)) + expect(migrated.updatedAt).toBe(713) +}) + +test('clamping control refuses negative bytes even in an orphan assignment', () => { + for (const accountId of ['main', 'unproven-private-row']) { + expectInvalid( + source({ session: assignment(accountId, { initialInputBytes: -0.25 }) }), + ) + } +}) + +test('orphan control drops unproven main and fallback without selecting another row', () => { + const input = source({ + mainSession: assignment(), + orphanSession: assignment('missing'), + survivor: assignment('fallback', { initialInputBytes: 8 }), + }) + const migrated = JSON.parse( + migrateStickyRoutingState(input, new Map([['fallback', 'explicit-row']])), + ) + expect(Object.keys(migrated.assignments)).toEqual(['survivor']) + expect(migrated.assignments.survivor.accountId).toBe('explicit-row') + expect(migrated.assignments.survivor.initialInputBytes).toBe(8) +}) + +test('a present source yields journal bytes even when every assignment is orphaned', () => { + expect(migrateStickyRoutingState(source(), new Map())).toBe( + '{\n "version": 1,\n "updatedAt": 713,\n "assignments": {}\n}\n', + ) +}) + +test('proven aliases share a concrete row without merging sessions or changing deficit clocks', () => { + const input = source({ + first: assignment('main', { initialInputBytes: 400 }), + second: assignment('alias', { initialInputBytes: 700 }), + staleQuota: assignment('alias', { + initialInputBytes: 9999, + quotaCheckedAt: 36, + }), + }) + const migrated = JSON.parse( + migrateStickyRoutingState( + input, + new Map([ + ['main', 'concrete-row'], + ['alias', 'concrete-row'], + ]), + ), + ) + expect(Object.keys(migrated.assignments)).toEqual([ + 'first', + 'second', + 'staleQuota', + ]) + for (const value of Object.values( + migrated.assignments, + ) as StickyRouteAssignment[]) { + expect(value.accountId).toBe('concrete-row') + expect(value.assignedAt).toBe(11) + expect(value.lastSeenAt).toBe(23) + } + expect(migrated.assignments.first.quotaCheckedAt).toBe(37) + expect(migrated.assignments.second.quotaCheckedAt).toBe(37) + expect(migrated.assignments.staleQuota.quotaCheckedAt).toBe(36) + expect(migrated.assignments.first.initialInputBytes).toBe(400) + expect(migrated.assignments.second.initialInputBytes).toBe(700) + expect(migrated.assignments.staleQuota.initialInputBytes).toBe(9999) +}) + +test('preserved quota timestamps causally retain only matching deficits in the real router', async () => { + const root = await mkdtemp(join(tmpdir(), 'native-routing-migration-')) + try { + const path = join(root, 'routing.json') + const hash = (id: string) => createHash('sha256').update(id).digest('hex') + const input = source({ + [hash('charged')]: assignment('main', { initialInputBytes: 400 }), + [hash('stale')]: assignment('fallback', { + initialInputBytes: 9000, + quotaCheckedAt: 36, + }), + [hash('orphan')]: assignment('orphan', { initialInputBytes: 99999 }), + }) + const candidates = ['former-main-row', 'fallback-row'].map( + (accountId, order) => ({ + accountId, + order, + quota: { + checkedAt: 37, + five_hour: { usedPercent: 0, remainingPercent: 100, checkedAt: 37 }, + seven_day: { usedPercent: 0, remainingPercent: 100, checkedAt: 37 }, + }, + }), + ) + const resolve = async (checkedAt: number) => { + await writeFile(path, migrateStickyRoutingState(input, proven)) + const router = new StickySessionRouter({ path, now: () => 100 }) + return router.resolve({ + sessionId: 'fresh', + family: 'general', + inputBytes: 1, + storage: null, + retainAccountIds: new Set(candidates.map(({ accountId }) => accountId)), + candidates: candidates.map((candidate) => ({ + ...candidate, + quota: { ...candidate.quota, checkedAt }, + })), + }) + } + expect((await resolve(37))?.accountId).toBe('fallback-row') + // Moving the snapshot clock beyond the preserved clock removes the charge; + // the ordinary equal-weight tie then selects the first candidate again. + expect((await resolve(38))?.accountId).toBe('former-main-row') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('retains old histories above the runtime assignment cap without pruning', () => { + const assignments = Object.fromEntries( + Array.from({ length: 4200 }, (_, index) => [ + `session-${index}`, + assignment('main', { + assignedAt: 1, + lastSeenAt: 2, + initialInputBytes: index + 0.5, + }), + ]), + ) + const migrated = JSON.parse( + migrateStickyRoutingState(source(assignments), proven), + ) + expect(Object.keys(migrated.assignments)).toHaveLength(4200) + expect(migrated.assignments['session-0']).toEqual({ + ...assignment('former-main-row'), + assignedAt: 1, + lastSeenAt: 2, + initialInputBytes: 0.5, + }) + expect(migrated.assignments['session-4199'].lastSeenAt).toBe(2) + expect(migrated.assignments['session-4199'].initialInputBytes).toBe(4199.5) +}) + +test('legacy absent affinity stays absent and empty string affinity is not guessed', () => { + const legacy: Partial = assignment() + delete legacy.affinityModelId + const migrated = JSON.parse( + migrateStickyRoutingState( + source({ legacy, empty: assignment('main', { affinityModelId: '' }) }), + proven, + ), + ) + expect(Object.hasOwn(migrated.assignments.legacy, 'affinityModelId')).toBe( + false, + ) + expect(migrated.assignments.empty.affinityModelId).toBe('') +}) + +test('does not invent stricter legacy account, key or finite-number constraints', () => { + const input = source({ + '': assignment('', { + family: 'general', + assignedAt: -1.5, + lastSeenAt: -7, + initialInputBytes: 0.5, + quotaCheckedAt: -2.25, + }), + }) + input.updatedAt = -0.75 + const migrated = JSON.parse( + migrateStickyRoutingState(input, new Map([['', 'row:synthetic']])), + ) + expect(migrated.updatedAt).toBe(-0.75) + expect(migrated.assignments['']).toEqual({ + accountId: 'row:synthetic', + family: 'general', + affinityModelId: 'claude-fable-5', + assignedAt: -1.5, + lastSeenAt: -7, + initialInputBytes: 0.5, + quotaCheckedAt: -2.25, + }) +}) + +test.each([ + ['absent', undefined], + ['null', null], + ['array', []], + ['scalar', 'private-source'], + ['empty', {}], + ['wrong version', { ...source(), version: 2 }], + ['string version', { ...source(), version: '1' }], + ['missing assignments', { version: 1, updatedAt: 713 }], + ['null assignments', { ...source(), assignments: null }], + ['array assignments', { ...source(), assignments: [] }], + ['extra root data', { ...source(), privateToken: 'synthetic-private-token' }], +])('refuses malformed root: %s', (_name, input) => { + expectInvalid(input) +}) + +test('refuses missing updatedAt rather than accepting the normalizer zero default', () => { + expectInvalid({ version: 1, assignments: {} }) +}) + +test.each([ + ['null', null], + ['undefined', undefined], + ['string', '713'], + ['boolean', false], + ['object', {}], + ['array', []], + ['NaN', NaN], + ['Infinity', Infinity], + ['-Infinity', -Infinity], +])('refuses invalid updatedAt: %s', (_name, updatedAt) => { + expectInvalid({ ...source(), updatedAt }) +}) + +test.each([ + ['null', null], + ['array', []], + ['scalar', 'synthetic-private-token'], + ['bad account', assignment(undefined, { accountId: 1 as unknown as string })], + ['bad family', { ...assignment(), family: 'mythos' }], + [ + 'throwing family conversion', + { ...assignment(), family: { toString: 'private' } }, + ], + ['string timestamp', { ...assignment(), assignedAt: '11' }], + ['null timestamp', { ...assignment(), lastSeenAt: null }], + ['infinite timestamp', { ...assignment(), quotaCheckedAt: Infinity }], + ['nonfinite bytes', { ...assignment(), initialInputBytes: NaN }], + ['null affinity', { ...assignment(), affinityModelId: null }], + ['undefined affinity', { ...assignment(), affinityModelId: undefined }], + ['array affinity', { ...assignment(), affinityModelId: [] }], + ['numeric affinity', { ...assignment(), affinityModelId: 42 }], + ['object affinity', { ...assignment(), affinityModelId: {} }], + ['extra field', { ...assignment(), token: 'synthetic-private-token' }], + ['extra dictionary field', { ...assignment(), constructor: 'private' }], +])( + 'refuses rejected or altered assignment in mapped and orphan rows: %s', + (_name, value) => { + expectInvalid(source({ session: value })) + expectInvalid(source({ session: value }), new Map()) + }, +) + +test('preserves a family value accepted unchanged by the pinned normalizer', () => { + // The normalizer checks String(family) but retains the original family value. + // Migration must not silently coerce it or invent a stricter schema here. + const migrated = JSON.parse( + migrateStickyRoutingState( + source({ session: { ...assignment(), family: ['fable'] } }), + proven, + ), + ) + expect(migrated.assignments.session.family).toEqual(['fable']) +}) + +test('refuses each missing required assignment field even when its account is unmapped', () => { + for (const field of [ + 'accountId', + 'family', + 'assignedAt', + 'lastSeenAt', + 'initialInputBytes', + 'quotaCheckedAt', + ]) { + const value: Record = { ...assignment() } + delete value[field] + expectInvalid(source({ session: value })) + expectInvalid(source({ session: value }), new Map()) + } +}) + +test.each(['', ' ', ' row', 'row ', null, undefined, 1])( + 'refuses unusable concrete map values, including unused entries: %p', + (value) => { + for (const key of ['main', 'unused']) { + expectInvalid(source(), new Map([[key, value as string]])) + } + }, +) + +test('preserves dangerous dictionary keys without prototype writes or hidden loss', () => { + const keys = [ + '2', + '10', + '__proto__', + 'constructor', + 'prototype', + 'toString', + 'toJSON', + ] + const input = JSON.parse( + JSON.stringify( + source(Object.fromEntries(keys.map((key) => [key, assignment()]))), + ), + ) + const migrated = JSON.parse(migrateStickyRoutingState(input, proven)) + expect(Object.keys(migrated.assignments)).toEqual(keys) + for (const key of keys) { + expect(Object.hasOwn(migrated.assignments, key)).toBe(true) + expect(migrated.assignments[key]).toEqual(assignment('former-main-row')) + expect(input.assignments[key]).toEqual(assignment()) + } + expect(Object.getPrototypeOf(input.assignments)).toBe(Object.prototype) + const invalid = JSON.parse( + JSON.stringify( + source( + Object.fromEntries([['__proto__', { ...assignment(), extra: true }]]), + ), + ), + ) + expectInvalid(invalid, new Map()) +}) + +test('does not mutate frozen sources or read-only maps on success or refusal', () => { + const input = Object.freeze( + source(Object.freeze({ session: Object.freeze(assignment()) })), + ) + const invalid = Object.freeze( + source( + Object.freeze({ + session: Object.freeze({ ...assignment(), extra: true }), + }), + ), + ) + const map = new Map(proven) + for (const method of ['set', 'delete', 'clear'] as const) { + Object.defineProperty(map, method, { + value: () => { + throw new Error('map must not be mutated') + }, + }) + } + Object.freeze(map) + const sourceBefore = JSON.stringify(input) + const invalidBefore = JSON.stringify(invalid) + const mapBefore = [...map] + expect( + JSON.parse(migrateStickyRoutingState(input, map)).assignments.session + .accountId, + ).toBe('former-main-row') + expectInvalid(invalid, map) + expect(JSON.stringify(input)).toBe(sourceBefore) + expect(JSON.stringify(invalid)).toBe(invalidBefore) + expect([...map]).toEqual(mapBefore) +}) From 4024a8dc0c5480e3bc34741993741052554a8bfb Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:05:19 +0200 Subject: [PATCH 015/169] mason: add pure native host auth entry codec Inspect parsed OpenCode/Pi Anthropic entries with complete canonical SHA-256 digests, explicit host schemas, exact activation recognition and broader placeholder refusal. Add presence-only supervised snapshot detection with fixed token-free refusals; return only frozen kind/digest records. Path safety, consent, physical writes and integration remain caller obligations. Verified under Bun 1.3.14 and Node 24.16.0: 22 codec tests, 487 Core tests, workspace build/types, six packaging tests including isolated consumer types/Node runtime, declaration closure and Pi dist import closure. Independently pinned 14 canonical byte/hash fixtures. Extra-property omission and snapshot truthiness mutants each reddened only the intended named test with three controls still green; both restored before all final gates. Parent pre-commit comment review approved all eight comments unchanged. --- packages/core/src/native-host-auth.ts | 170 +++++ .../core/src/tests/native-host-auth.test.ts | 644 ++++++++++++++++++ 2 files changed, 814 insertions(+) create mode 100644 packages/core/src/native-host-auth.ts create mode 100644 packages/core/src/tests/native-host-auth.test.ts diff --git a/packages/core/src/native-host-auth.ts b/packages/core/src/native-host-auth.ts new file mode 100644 index 000000000..0df2f3ccf --- /dev/null +++ b/packages/core/src/native-host-auth.ts @@ -0,0 +1,170 @@ +import { createHash } from 'node:crypto' + +import { custodyTombstoneOAuth } from './claustrum.ts' + +export class NativeHostAuthError extends Error { + readonly code: 'invalid-source' | 'supervised-auth-snapshot' + + constructor(code: 'invalid-source' | 'supervised-auth-snapshot') { + super( + code === 'supervised-auth-snapshot' + ? 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set. Run setup without OPENCODE_AUTH_CONTENT in the environment.' + : 'Invalid native host auth source.', + ) + this.name = 'NativeHostAuthError' + this.code = code === 'supervised-auth-snapshot' ? code : 'invalid-source' + } +} + +export function hasSupervisedAuthContentSnapshot( + env: NodeJS.ProcessEnv, +): boolean { + return env.OPENCODE_AUTH_CONTENT !== undefined +} + +export function requireNoSupervisedAuthContentSnapshot( + env: NodeJS.ProcessEnv, +): void { + if (hasSupervisedAuthContentSnapshot(env)) + throw new NativeHostAuthError('supervised-auth-snapshot') +} + +export interface NativeHostAuthEntryInspection { + readonly kind: 'absent' | 'oauth' | 'api' | 'api_key' | 'activation' + readonly digest: string +} + +function invalidSource(): never { + throw new NativeHostAuthError('invalid-source') +} + +function isDictionary(value: unknown): value is object { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +// Descriptors avoid getters; a Map also keeps __proto__ and constructor as data. +function ownDataProperties(value: object): Map { + const properties = new Map() + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') invalidSource() + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor || !Object.hasOwn(descriptor, 'value')) invalidSource() + properties.set(key, descriptor.value) + } + return properties +} + +function canonicalJson(value: unknown, ancestors = new Set()): string { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return JSON.stringify(value) + if (typeof value === 'number') { + if (!Number.isFinite(value)) invalidSource() + return JSON.stringify(value) + } + if (typeof value !== 'object') invalidSource() + if (ancestors.has(value)) invalidSource() + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) invalidSource() + const properties = ownDataProperties(value) + const length = properties.get('length') as number + // Holes and additional array properties cannot be represented by JSON arrays. + if (properties.size !== length + 1) invalidSource() + const items: string[] = [] + for (let index = 0; index < length; index++) { + const key = String(index) + if (!properties.has(key)) invalidSource() + items.push(canonicalJson(properties.get(key), ancestors)) + } + return `[${items.join(',')}]` + } + if (!isDictionary(value)) invalidSource() + const properties = ownDataProperties(value) + // Emit keys directly: rebuilding an object would reorder integer-like keys. + return `{${[...properties.keys()] + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${canonicalJson(properties.get(key), ancestors)}`, + ) + .join(',')}}` + } finally { + ancestors.delete(value) + } +} + +/** Inspect parsed host auth only; path safety, consent and writes belong to callers. */ +export function inspectNativeHostAuthEntry( + host: 'opencode' | 'pi', + hostAuth: unknown, +): Readonly { + try { + if (host !== 'opencode' && host !== 'pi') invalidSource() + if (hostAuth === undefined) + return Object.freeze({ kind: 'absent', digest: 'absent' }) + if (!isDictionary(hostAuth)) invalidSource() + const root = ownDataProperties(hostAuth) + if (!root.has('anthropic')) + return Object.freeze({ kind: 'absent', digest: 'absent' }) + const entry = root.get('anthropic') + if (!isDictionary(entry)) invalidSource() + const properties = ownDataProperties(entry) + const canonical = canonicalJson(entry) + let kind: NativeHostAuthEntryInspection['kind'] + const type = properties.get('type') + if (type === 'oauth') { + if ( + typeof properties.get('access') !== 'string' || + typeof properties.get('refresh') !== 'string' || + typeof properties.get('expires') !== 'number' || + !Number.isFinite(properties.get('expires')) + ) + invalidSource() + const tombstone = custodyTombstoneOAuth('anthropic') + kind = + properties.size === 4 && + Object.entries(tombstone).every( + ([key, value]) => properties.get(key) === value, + ) + ? 'activation' + : 'oauth' + } else if (host === 'opencode' && type === 'api') { + if (typeof properties.get('key') !== 'string') invalidSource() + kind = 'api' + } else if (host === 'pi' && type === 'api_key') { + if (properties.has('key') && typeof properties.get('key') !== 'string') + invalidSource() + if (properties.has('env')) { + const env = properties.get('env') + if (!isDictionary(env)) invalidSource() + for (const value of ownDataProperties(env).values()) + if (typeof value !== 'string') invalidSource() + } + kind = 'api_key' + } else { + invalidSource() + } + // Recognition is exact, but every authentication-bearing placeholder refuses. + if (kind !== 'activation') { + for (const field of ['access', 'refresh', 'key']) { + const value = properties.get(field) + if ( + typeof value === 'string' && + value.startsWith('claustrum-tombstone:') + ) + invalidSource() + } + } + return Object.freeze({ + kind, + digest: createHash('sha256').update(canonical, 'utf8').digest('hex'), + }) + } catch { + // Unsupported values (including reflective failures) never leak input or causes. + throw new NativeHostAuthError('invalid-source') + } +} diff --git a/packages/core/src/tests/native-host-auth.test.ts b/packages/core/src/tests/native-host-auth.test.ts new file mode 100644 index 000000000..be7bea860 --- /dev/null +++ b/packages/core/src/tests/native-host-auth.test.ts @@ -0,0 +1,644 @@ +import { expect, spyOn, test } from 'bun:test' +import { createHash } from 'node:crypto' +import { inspect } from 'node:util' + +import { + hasSupervisedAuthContentSnapshot, + inspectNativeHostAuthEntry, + NativeHostAuthError, + requireNoSupervisedAuthContentSnapshot, +} from '../native-host-auth.ts' + +// Hand-specified canonical bytes; hashes independently pinned with Node 24 SHA-256. +const fixtures = { + oauth: { + json: '{"access":"synthetic-access","expires":0,"refresh":"synthetic-refresh","type":"oauth"}', + digest: 'b006e78a28dd6ba18131eb83536a8be9919c684befbd2b30ac57df97ff8d11ed', + }, + api: { + json: '{"key":"synthetic-key","type":"api"}', + digest: 'a9c8e3e0cb3a568d6985e30789e259b055f50d3b25b3876a05c7102f38a3bb1e', + }, + piKey: { + json: '{"key":"synthetic-key","type":"api_key"}', + digest: '87c7cf64e78976315d895b7a2a6dfd95099eeed46cead0d83da5cd3f700fb09d', + }, + piEnv: { + json: '{"env":{"ANTHROPIC_API_KEY":"synthetic-env","Z":"value"},"type":"api_key"}', + digest: '4a1cd93e4d34540a04fed380a34ab15f342e4a92d6c7702a534257ca28ba1ace', + }, + piType: { + json: '{"type":"api_key"}', + digest: '8bdbf2b15baae9ecfba096133446a79e4055f9adb8a307ee863d1bc8eb6873ab', + }, + activation: { + json: '{"access":"","expires":0,"refresh":"claustrum-tombstone:v1:anthropic","type":"oauth"}', + digest: '34ff5181a0466836fec63f58d91bf4d04c46ec2090ab5ed3b6c554332c4bc669', + }, + nested: { + json: '{"access":"synthetic-access","expires":-7.5,"extra":{"10":"ten","2":"two","a":[3,{"a":null,"z":true}],"unicode":"café 💻"},"refresh":"synthetic-refresh","type":"oauth"}', + digest: '49704b4f0b0024550fa91412d5f4a5a710df8d64ee1b39bd5b650457115340cb', + }, + dangerous: { + json: '{"__proto__":{"polluted":true},"constructor":"synthetic-constructor","key":"synthetic-key","toJSON":"synthetic-toJSON","type":"api"}', + digest: 'f22bae7b3620eae7d6fdd07fa94b5cbd04e49b5cafc6bd168ee682245517e434', + }, + piEmpty: { + json: '{"env":{},"key":"","type":"api_key"}', + digest: 'e7f0c8b0d69b3777414e3500086b1db9316c1113fb79728de019fbabb950865f', + }, + array: { + json: '{"items":[1,2],"key":"synthetic-key","type":"api"}', + digest: '41c42c804e85878b9f4a773499aaa41d8059d2d21f461fb6f06c47c413f1bf2a', + }, + reversed: { + json: '{"items":[2,1],"key":"synthetic-key","type":"api"}', + digest: 'a1d0ef591ad02417054d74cbf35f5fe591c28131f661d0764025ab59e41e31cf', + }, + added: { + json: '{"key":"synthetic-key","note":"alpha","type":"api"}', + digest: 'be2927f31d021585aa2206661f177e2b0971ecff8ac80e003c592eb6eaf93c29', + }, + changed: { + json: '{"key":"synthetic-key","note":"beta","type":"api"}', + digest: '6916a73e8b04b460ce8ebd0a1d064dc1d546ea4afffc6d85ddb28ee53c8bb965', + }, + hidden: { + json: '{"hidden":"synthetic-hidden","key":"synthetic-key","type":"api"}', + digest: 'd73ad1b5f439f4b059813505a7829d5695bbe6a9a7e10f04498cd9da11c2c74a', + }, +} as const + +const hosts = ['opencode', 'pi'] as const +const oauth = { + type: 'oauth', + access: 'synthetic-access', + refresh: 'synthetic-refresh', + expires: 0, +} +const api = { type: 'api', key: 'synthetic-key' } + +function freezeJson(value: T): T { + if (value && typeof value === 'object') { + for (const descriptor of Object.values( + Object.getOwnPropertyDescriptors(value), + )) + if (Object.hasOwn(descriptor, 'value')) freezeJson(descriptor.value) + Object.freeze(value) + } + return value +} + +function caughtError(call: () => unknown): NativeHostAuthError { + let caught: unknown + try { + call() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(NativeHostAuthError) + return caught as NativeHostAuthError +} + +function assertErrorMetadata(error: NativeHostAuthError): void { + // Bun adds source-position properties to Error; none may hold credential data. + const allowed = new Set([ + 'code', + 'message', + 'name', + 'stack', + 'column', + 'line', + 'originalColumn', + 'originalLine', + 'sourceURL', + ]) + for (const key of Reflect.ownKeys(error)) + expect(allowed.has(key as string)).toBe(true) + expect(Object.hasOwn(error, 'cause')).toBe(false) + expect(inspect(error, { showHidden: true })).not.toContain('synthetic-') + expect(JSON.stringify(error)).not.toContain('synthetic-') +} + +function invalid(host: 'opencode' | 'pi', hostAuth: unknown): void { + const error = caughtError(() => inspectNativeHostAuthEntry(host, hostAuth)) + expect(error.name).toBe('NativeHostAuthError') + expect(error.code).toBe('invalid-source') + expect(error.message).toBe('Invalid native host auth source.') + assertErrorMetadata(error) +} + +function assertFixture( + host: 'opencode' | 'pi', + entry: unknown, + fixture: keyof typeof fixtures, + kind: 'oauth' | 'api' | 'api_key' | 'activation', +): void { + const input = freezeJson({ anthropic: entry }) + const before = JSON.stringify(input) + const result = inspectNativeHostAuthEntry(host, input) + expect(result).toEqual({ kind, digest: fixtures[fixture].digest }) + expect(result.digest).toMatch(/^[0-9a-f]{64}$/) + expect(Object.isFrozen(result)).toBe(true) + expect(Reflect.ownKeys(result).sort()).toEqual(['digest', 'kind']) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + for (const descriptor of Object.values( + Object.getOwnPropertyDescriptors(result), + )) { + expect(Object.hasOwn(descriptor, 'value')).toBe(true) + expect(descriptor.writable).toBe(false) + expect(descriptor.configurable).toBe(false) + expect(descriptor.enumerable).toBe(true) + } + expect(JSON.stringify(result)).not.toContain('synthetic-') + expect(inspect(result, { showHidden: true })).not.toContain('synthetic-') + expect(() => Object.assign(result, { kind: 'absent' })).toThrow(TypeError) + expect(JSON.stringify(input)).toBe(before) +} + +test('independent canonical byte fixtures retain their pinned SHA-256 hashes', () => { + for (const fixture of Object.values(fixtures)) + expect( + createHash('sha256').update(fixture.json, 'utf8').digest('hex'), + ).toBe(fixture.digest) +}) + +test('missing file and own entry are frozen absent results, not malformed fallbacks', () => { + for (const host of hosts) { + for (const source of [ + undefined, + {}, + { other: { key: 'synthetic-other' } }, + Object.create(null), + ]) { + const input = freezeJson(source) + const result = inspectNativeHostAuthEntry(host, input) + expect(result).toEqual({ kind: 'absent', digest: 'absent' }) + expect(Object.isFrozen(result)).toBe(true) + expect(Reflect.ownKeys(result).sort()).toEqual(['digest', 'kind']) + } + invalid(host, { anthropic: undefined }) + } +}) + +test('host-specific credential variants include Pi key-only, env-only and type-only', () => { + for (const host of hosts) assertFixture(host, oauth, 'oauth', 'oauth') + assertFixture('opencode', api, 'api', 'api') + assertFixture( + 'pi', + { type: 'api_key', key: 'synthetic-key' }, + 'piKey', + 'api_key', + ) + assertFixture( + 'pi', + { + type: 'api_key', + env: { Z: 'value', ANTHROPIC_API_KEY: 'synthetic-env' }, + }, + 'piEnv', + 'api_key', + ) + assertFixture('pi', { type: 'api_key' }, 'piType', 'api_key') + assertFixture( + 'pi', + { type: 'api_key', key: '', env: {} }, + 'piEmpty', + 'api_key', + ) + assertFixture( + 'pi', + { + type: 'api_key', + env: Object.assign(Object.create(null), { + ANTHROPIC_API_KEY: 'synthetic-env', + Z: 'value', + }), + }, + 'piEnv', + 'api_key', + ) + invalid('opencode', { anthropic: { type: 'api_key', key: 'synthetic-key' } }) + invalid('pi', { anthropic: api }) +}) + +test('OAuth declarations permit empty strings and finite past or very large expiries', () => { + for (const host of hosts) { + for (const expires of [0, -1.5, Number.MAX_VALUE]) { + const input = freezeJson({ + anthropic: { type: 'oauth', access: '', refresh: '', expires }, + }) + expect(inspectNativeHostAuthEntry(host, input).kind).toBe('oauth') + } + } + expect( + inspectNativeHostAuthEntry('opencode', { + anthropic: { type: 'api', key: '' }, + }).kind, + ).toBe('api') +}) + +test('canonical digest sorts nested keys including numeric keys and preserves Unicode', () => { + const entry = { + type: 'oauth', + refresh: 'synthetic-refresh', + expires: -7.5, + extra: { + unicode: 'café 💻', + '2': 'two', + a: [3, { z: true, a: null }], + '10': 'ten', + }, + access: 'synthetic-access', + } + for (const host of hosts) { + assertFixture(host, entry, 'nested', 'oauth') + assertFixture(host, JSON.parse(fixtures.nested.json), 'nested', 'oauth') + } +}) + +test('digest covers added and changed own entry properties', () => { + const initial = inspectNativeHostAuthEntry( + 'opencode', + freezeJson({ anthropic: { ...api } }), + ) + const added = inspectNativeHostAuthEntry( + 'opencode', + freezeJson({ anthropic: { ...api, note: 'alpha' } }), + ) + const changed = inspectNativeHostAuthEntry( + 'opencode', + freezeJson({ anthropic: { ...api, note: 'beta' } }), + ) + expect(initial.digest).toBe(fixtures.api.digest) + expect(added.digest).toBe(fixtures.added.digest) + expect(changed.digest).toBe(fixtures.changed.digest) + expect(added.digest).not.toBe(initial.digest) + expect(changed.digest).not.toBe(added.digest) +}) + +test('unrelated provider and root metadata changes leave the entry digest unchanged', () => { + const sources = [ + { anthropic: oauth, other: { key: 'synthetic-other-one' }, metadata: 1 }, + { + metadata: { extra: ['different'] }, + other: { key: 'synthetic-other-two' }, + anthropic: oauth, + }, + { anthropic: oauth }, + ] + for (const source of sources) { + const before = JSON.stringify(source) + expect( + inspectNativeHostAuthEntry('opencode', freezeJson(source)).digest, + ).toBe(fixtures.oauth.digest) + expect(JSON.stringify(source)).toBe(before) + } +}) + +test('array order changes the complete entry digest', () => { + assertFixture('opencode', { ...api, items: [1, 2] }, 'array', 'api') + assertFixture('opencode', { ...api, items: [2, 1] }, 'reversed', 'api') + expect(fixtures.array.digest as string).not.toBe(fixtures.reversed.digest) +}) + +test('dangerous own keys remain JSON data without prototype pollution or toJSON calls', () => { + const entry = JSON.parse(fixtures.dangerous.json) + assertFixture('opencode', entry, 'dangerous', 'api') + const nullEntry = Object.assign(Object.create(null), entry) + const root = Object.assign(Object.create(null), { anthropic: nullEntry }) + expect(inspectNativeHostAuthEntry('opencode', freezeJson(root)).digest).toBe( + fixtures.dangerous.digest, + ) + expect(Object.getPrototypeOf(entry)).toBe(Object.prototype) + expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false) + let toJSONCalls = 0 + invalid('opencode', { + anthropic: { + ...api, + toJSON: () => { + toJSONCalls++ + return {} + }, + }, + }) + expect(toJSONCalls).toBe(0) +}) + +test('non-enumerable own JSON data is fenced without being returned as hidden material', () => { + const entry = { ...api } + Object.defineProperty(entry, 'hidden', { value: 'synthetic-hidden' }) + assertFixture('opencode', entry, 'hidden', 'api') + invalid('opencode', { + anthropic: Object.defineProperty({ ...api }, 'hidden', { + value: undefined, + }), + }) +}) + +test('exact four-property tombstone is activation on both hosts, never OAuth', () => { + const entry = JSON.parse(fixtures.activation.json) + for (const host of hosts) + assertFixture(host, entry, 'activation', 'activation') +}) + +test('placeholder-like OAuth entries refuse beyond the exact activation shape', () => { + const entry = JSON.parse(fixtures.activation.json) + const malformed = [ + { ...entry, refresh: 'claustrum-tombstone:v1:other' }, + { ...entry, access: 'synthetic-nonempty' }, + { ...entry, expires: 1 }, + { ...entry, extra: null }, + { ...entry, refresh: 'claustrum-tombstone:' }, + { ...entry, refresh: 'claustrum-tombstone:v2:anthropic' }, + { ...entry, refresh: 'claustrum-tombstone:any-other-form' }, + ] + for (const host of hosts) + for (const candidate of malformed) + invalid(host, freezeJson({ anthropic: candidate })) +}) + +test('broad placeholder prefix refuses access, refresh and key on every credential shape', () => { + const shapes = [ + { host: 'opencode' as const, entry: oauth }, + { host: 'pi' as const, entry: oauth }, + { host: 'opencode' as const, entry: api }, + { host: 'pi' as const, entry: { type: 'api_key' } }, + ] + for (const { host, entry } of shapes) { + for (const field of ['access', 'refresh', 'key']) + for (const value of [ + 'claustrum-tombstone:', + 'claustrum-tombstone:v1:anthropic', + 'claustrum-tombstone:alternative', + ]) + invalid(host, freezeJson({ anthropic: { ...entry, [field]: value } })) + } +}) + +test('malformed roots and entry frames refuse with fixed token-free errors', () => { + for (const host of hosts) { + for (const root of [ + null, + [], + 'synthetic-root', + 0, + false, + () => {}, + new Date(0), + new Map(), + Object.setPrototypeOf([], null), + ]) + invalid(host, root) + for (const entry of [ + null, + undefined, + [], + 'synthetic-entry', + 0, + true, + {}, + { type: 'unknown-synthetic-type' }, + new Date(0), + ]) + invalid(host, { anthropic: entry }) + } + for (const host of [undefined, null, 'unknown', 'PI', 0]) { + const error = caughtError(() => + inspectNativeHostAuthEntry(host as 'pi', undefined), + ) + expect(error.code).toBe('invalid-source') + expect(error.message).toBe('Invalid native host auth source.') + } +}) + +test('required OAuth fields are own declarations and have exact JSON types', () => { + for (const host of hosts) { + for (const field of ['type', 'access', 'refresh', 'expires']) { + const missing: Record = { ...oauth } + delete missing[field] + invalid(host, { anthropic: missing }) + invalid(host, { + anthropic: Object.assign( + Object.create({ [field]: oauth[field as keyof typeof oauth] }), + missing, + ), + }) + for (const value of [ + undefined, + null, + {}, + [], + false, + field === 'expires' ? '0' : 1, + ]) + invalid(host, { anthropic: { ...oauth, [field]: value } }) + } + for (const expires of [NaN, Infinity, -Infinity]) + invalid(host, { anthropic: { ...oauth, expires } }) + invalid(host, Object.create({ anthropic: oauth })) + } +}) + +test('OpenCode requires an own string key and Pi rejects malformed present optional fields', () => { + invalid('opencode', { anthropic: { type: 'api' } }) + invalid('opencode', { + anthropic: Object.assign(Object.create({ key: 'synthetic-inherited' }), { + type: 'api', + }), + }) + for (const key of [undefined, null, false, 1, [], {}]) { + invalid('opencode', { anthropic: { type: 'api', key } }) + invalid('pi', { anthropic: { type: 'api_key', key } }) + } + for (const env of [ + undefined, + null, + [], + 'synthetic-env', + 1, + false, + new Date(0), + { KEY: null }, + { KEY: 1 }, + { KEY: {} }, + ]) + invalid('pi', { anthropic: { type: 'api_key', env } }) + invalid('pi', { + anthropic: { + type: 'api_key', + env: Object.create({ KEY: 'synthetic-inherited' }), + }, + }) +}) + +test('unsupported nested JSON values and cycles refuse, shared acyclic data is allowed', () => { + for (const extra of [ + undefined, + NaN, + Infinity, + -Infinity, + 1n, + Symbol('synthetic-symbol'), + () => {}, + new Date(0), + new Map(), + new Set(), + new Uint8Array([1]), + ]) + invalid('opencode', { anthropic: { ...api, extra: { nested: extra } } }) + const cyclic: Record = { ...api } + cyclic.self = cyclic + invalid('opencode', { anthropic: cyclic }) + const arrayCycle: unknown[] = [] + arrayCycle.push(arrayCycle) + invalid('opencode', { anthropic: { ...api, extra: arrayCycle } }) + const shared = freezeJson({ x: true }) + expect( + inspectNativeHostAuthEntry('opencode', { + anthropic: { ...api, a: shared, b: shared }, + }).kind, + ).toBe('api') +}) + +test('sparse arrays and extra array properties refuse instead of losing own data', () => { + const sparse = new Array(1) + const extra = Object.assign([1], { hidden: true }) + const hidden = Object.defineProperty([1], 'hidden', { value: true }) + const undefinedElement = [undefined] + for (const items of [sparse, extra, hidden, undefinedElement]) + invalid('opencode', { anthropic: { ...api, items } }) +}) + +test('symbols, accessors and reflective failures refuse without getter execution or raw causes', () => { + let getterCalls = 0 + const getter = { + get: () => { + getterCalls++ + throw new Error('synthetic-getter-secret') + }, + } + const rootGetter = Object.defineProperty({}, 'anthropic', getter) + const fieldGetter = Object.defineProperty({ ...api }, 'key', getter) + const nestedGetter = Object.defineProperty({}, 'extra', getter) + const envGetter = Object.defineProperty({}, 'KEY', getter) + const arrayGetter = Object.defineProperty([1], '0', getter) + for (const root of [ + rootGetter, + { anthropic: fieldGetter }, + { anthropic: { ...api, nested: nestedGetter } }, + { anthropic: { type: 'api_key', env: envGetter } }, + { anthropic: { ...api, array: arrayGetter } }, + { anthropic: api, [Symbol('synthetic-root-symbol')]: true }, + { anthropic: { ...api, [Symbol('synthetic-entry-symbol')]: true } }, + { + anthropic: { + ...api, + nested: { [Symbol('synthetic-nested-symbol')]: true }, + }, + }, + { + anthropic: { + ...api, + nested: Object.assign([1], { + [Symbol('synthetic-array-symbol')]: true, + }), + }, + }, + new Proxy( + {}, + { + ownKeys: () => { + throw new Error('synthetic-reflect-secret') + }, + }, + ), + ]) + invalid('opencode', root) + invalid('pi', { anthropic: { type: 'api_key', env: envGetter } }) + expect(getterCalls).toBe(0) +}) + +test('snapshot absence passes using only the explicit environment argument', () => { + for (const env of [ + freezeJson({}), + freezeJson({ OPENCODE_AUTH_CONTENT: undefined }), + ]) { + expect(hasSupervisedAuthContentSnapshot(env)).toBe(false) + expect(requireNoSupervisedAuthContentSnapshot(env)).toBeUndefined() + } +}) + +test('snapshot presence refuses empty, malformed, null-text and ordinary content without parsing', () => { + const parse = spyOn(JSON, 'parse').mockImplementation(() => { + throw new Error('Unexpected parsing') + }) + try { + for (const content of [ + '', + '{synthetic-malformed', + 'null', + '{"anthropic":{"key":"synthetic-snapshot"}}', + ]) { + const env = freezeJson({ OPENCODE_AUTH_CONTENT: content }) + expect(hasSupervisedAuthContentSnapshot(env)).toBe(true) + const error = caughtError(() => + requireNoSupervisedAuthContentSnapshot(env), + ) + expect(error.code).toBe('supervised-auth-snapshot') + expect(error.message).toBe( + 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set. Run setup without OPENCODE_AUTH_CONTENT in the environment.', + ) + assertErrorMetadata(error) + } + expect(parse).not.toHaveBeenCalled() + } finally { + parse.mockRestore() + } +}) + +test('pure calls do not read clocks, use HTTP, log inputs or inspect snapshot values', () => { + const fail = () => { + throw new Error('Unexpected impure call') + } + const spies = [ + spyOn(Date, 'now').mockImplementation(fail), + spyOn(performance, 'now').mockImplementation(fail), + spyOn(globalThis, 'fetch').mockImplementation( + Object.assign(fail, { preconnect: fail }), + ), + spyOn(console, 'log').mockImplementation(fail), + spyOn(console, 'warn').mockImplementation(fail), + spyOn(console, 'error').mockImplementation(fail), + spyOn(console, 'info').mockImplementation(fail), + spyOn(console, 'debug').mockImplementation(fail), + ] + let reads = 0 + const env = new Proxy( + { OPENCODE_AUTH_CONTENT: 'synthetic-snapshot' }, + { + get: (target, key) => { + expect(key).toBe('OPENCODE_AUTH_CONTENT') + reads++ + return target.OPENCODE_AUTH_CONTENT + }, + ownKeys: fail, + }, + ) + try { + for (const host of hosts) { + expect( + inspectNativeHostAuthEntry(host, freezeJson({ anthropic: oauth })).kind, + ).toBe('oauth') + invalid(host, { anthropic: null }) + } + expect(hasSupervisedAuthContentSnapshot(env)).toBe(true) + expect( + caughtError(() => requireNoSupervisedAuthContentSnapshot(env)).code, + ).toBe('supervised-auth-snapshot') + expect(reads).toBe(2) + for (const spy of spies) expect(spy).not.toHaveBeenCalled() + } finally { + for (const spy of spies) spy.mockRestore() + } +}) From 5cf4844c4bd99183f78ddc264e0c1dad9dc247c3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:16:37 +0200 Subject: [PATCH 016/169] reclaim: auto-preserved uncommitted changes (NOT reviewed work product) --- .../core/src/native-refresh-coordinator.ts | 100 ++- .../tests/native-refresh-coordinator.test.ts | 757 +++++++++++++++++- 2 files changed, 807 insertions(+), 50 deletions(-) diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index dcc8d24ee..c4b846195 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -13,6 +13,7 @@ import { PoolOperationError, type PoolRow, type RefreshOutcome, + type StoredCredential, } from '@cortexkit/common-auth/store' import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from './auth.ts' @@ -29,6 +30,7 @@ import { createNativePoolStore, type NativePoolStoreOptions, } from './pool-store.ts' +import { tokenFingerprint } from './token-fingerprint.ts' /** * Jobs distinguish credential replacements. This lock must still serialize @@ -83,6 +85,16 @@ export interface NativeRefreshFailure { retryAfter?: number } +/** + * Access can change while the refresh token and credential epoch stay the + * same. These fields identify the exact stored OAuth version observed. + */ +export interface NativeRefreshDispatchVersion { + readonly accessFingerprint?: string + readonly expires?: number + readonly lastRefreshedAt?: number +} + interface ObservationBase { /** * Identifies the row, credential version and account before refresh. Compare @@ -91,6 +103,7 @@ interface ObservationBase { binding: NativeLocalPoolBinding attempt: number credentialFingerprint?: string + credentialVersion?: NativeRefreshDispatchVersion bootstrap: NativeIdentityBootstrap handoff: NativeRefreshHandoff } @@ -98,16 +111,31 @@ interface ObservationBase { /** Token-free observations. In particular bootstrap exceptions are never forwarded. */ export type NativeRefreshObservation = ObservationBase & ( - | { status: 'persisted'; identity?: string; successorFingerprint: string } - | { status: 'adopted'; successorFingerprint: string } + | { + status: 'persisted' + identity?: string + successorFingerprint: string + successorVersion: NativeRefreshDispatchVersion + } + | { + status: 'adopted' + successorFingerprint: string + successorVersion: NativeRefreshDispatchVersion + } | { status: 'refused'; reason: string; consumed: boolean } | { status: 'identity-contradicted' expectedIdentity: string returnedIdentity: string successorFingerprint: string + successorVersion: NativeRefreshDispatchVersion + } + | { + status: 'failed' + failure: NativeRefreshFailure + persisted: boolean + committedVersion?: NativeRefreshDispatchVersion } - | { status: 'failed'; failure: NativeRefreshFailure; persisted: boolean } ) export interface NativeRefreshHooks { @@ -117,23 +145,31 @@ export interface NativeRefreshHooks { * matching credential fingerprint. Also report when a previously persisted * successor still lacks account validation. Do not read host credentials or * mutate the store. This callback runs outside shared store locks. + * Before exchange, capture the native error-clear markers for that + * observation. Do not replace them when later policy reads return newer state. */ readRestrictions( binding: NativeLocalPoolBinding, ): Promise /** - * Persist observations while holding pool-config, pool-state and then - * native-runtime locks. Re-read the bound row and verify its credential - * version, account identity, token fingerprint and error-clear markers - * before writing. A newly discovered identity is valid only on the row that - * received it. Clear obsolete token errors only after persistence or - * adoption; neither a quarantined credential nor a failed observation - * permits serving. If the successor was persisted but its account could not - * be validated, keep that credential marked unvalidated until a later - * identity check succeeds. This callback is awaited before the result - * returns and starts after shared store locks are released. Row and provider - * locks remain held during ordinary persistence reconciliation. Do not - * re-enter row operations or wait for callers joining this same job. + * Write credential-related refresh errors and validation state only while + * holding pool-config, pool-state, then native-runtime locks. Re-read the + * selected row and check its account/epoch, refresh fingerprint, and the + * error-clear markers captured before the exchange. Check access fingerprint, + * expiry and lastRefreshedAt too: credentialVersion identifies the credential + * captured before the exchange; successorVersion identifies the pool + * credential after a successful exchange or a concurrently completed + * rotation; committedVersion identifies a replacement saved before a later + * failure. Match the appropriate version before applying an observation. If + * that version is unavailable or a clear marker changed, do not update + * credential-related state. Newly learned identities apply only to the row + * that received them. Clear stale token errors only after confirming the + * replacement is saved or adopting a current credential from the pool. A + * quarantined or unvalidated credential must not serve. Keep a saved + * successor unvalidated if account checks failed. Reconciliation finishes + * before refresh returns, outside shared store locks; ordinary row/provider + * locks remain held. Do not re-enter row operations or wait for callers + * joining this job. */ reconcile(observation: NativeRefreshObservation): Promise } @@ -236,6 +272,20 @@ function asError(error: unknown): Error { : new Error('Native refresh callback failed', { cause: error }) } +function dispatchVersion( + credential: StoredCredential, +): NativeRefreshDispatchVersion { + if (credential.type !== 'oauth') + throw new Error('Local refresh requires an OAuth credential') + return Object.freeze({ + accessFingerprint: credential.access + ? tokenFingerprint(credential.access) + : undefined, + expires: credential.expires, + lastRefreshedAt: credential.lastRefreshedAt, + }) +} + class LocalRefreshRefusal extends Error { constructor(readonly reason: string) { super('Local refresh policy refused the exchange') @@ -353,6 +403,8 @@ export function createNativeRefreshCoordinator( let bootstrapStatus: NativeIdentityBootstrap = 'unavailable' let handoff: NativeRefreshHandoff = 'not-needed' let fingerprint: string | undefined + let credentialVersion: NativeRefreshDispatchVersion | undefined + let committedVersion: NativeRefreshDispatchVersion | undefined let held: RefreshFileLock | undefined let releasing: Promise | undefined let removeAbortListener: (() => void) | undefined @@ -368,6 +420,7 @@ export function createNativeRefreshCoordinator( binding, attempt, credentialFingerprint: fingerprint, + credentialVersion, bootstrap: bootstrapStatus, handoff, } @@ -424,6 +477,10 @@ export function createNativeRefreshCoordinator( let captured: PoolRow | undefined const seen = await rowFor(binding) fingerprint = seen?.fingerprint + credentialVersion = + seen?.credential?.type === 'oauth' + ? dispatchVersion(seen.credential) + : undefined const initialReason = !seen || !nativeLocalPoolBindingMatches(binding, options.paths, seen) ? 'binding-changed' @@ -453,6 +510,7 @@ export function createNativeRefreshCoordinator( ...observation(), status: 'adopted', successorFingerprint: fingerprintOf(seen.credential), + successorVersion: dispatchVersion(seen.credential), }) return { status: 'completed', @@ -469,6 +527,7 @@ export function createNativeRefreshCoordinator( async (credential, row) => { captured = row fingerprint = row.fingerprint + credentialVersion = dispatchVersion(credential) // This read is outside pool locks. Recheck policy after file-lock waits. const currentRestriction = await options.readRestrictions(binding) const reason = request.signal?.aborted @@ -575,6 +634,7 @@ export function createNativeRefreshCoordinator( }, onPersisted: async (_id, credential) => { persisted = true + committedVersion = dispatchVersion(credential) try { if (held) { try { @@ -588,6 +648,7 @@ export function createNativeRefreshCoordinator( status: 'persisted', identity, successorFingerprint: fingerprintOf(credential), + successorVersion: committedVersion, }) } finally { await releaseHandoff() @@ -618,12 +679,14 @@ export function createNativeRefreshCoordinator( } case 'identity-contradicted': persisted = true + committedVersion = dispatchVersion(outcome.credential) await reconcile({ ...observation(), status: 'identity-contradicted', expectedIdentity: outcome.expectedIdentity, returnedIdentity: outcome.returnedIdentity, successorFingerprint: fingerprintOf(outcome.credential), + successorVersion: committedVersion, }) return { status: 'identity-contradicted', @@ -640,6 +703,11 @@ export function createNativeRefreshCoordinator( error instanceof PoolOperationError && (error.committed !== undefined || error.phase === 'after-first-write') + if ( + error instanceof PoolOperationError && + error.committed?.type === 'oauth' + ) + committedVersion = dispatchVersion(error.committed) if ( !endpointCalled && error instanceof PoolOperationError && @@ -670,6 +738,7 @@ export function createNativeRefreshCoordinator( status: 'failed', failure, persisted, + committedVersion, }) } catch { /* If reconciliation fails, preserve the original error classification @@ -704,6 +773,7 @@ export function createNativeRefreshCoordinator( status: 'failed', failure: failureOf(error), persisted, + committedVersion, }) } catch { /* If observation publication fails, do not return any credential as usable. */ diff --git a/packages/core/src/tests/native-refresh-coordinator.test.ts b/packages/core/src/tests/native-refresh-coordinator.test.ts index 9acba1c37..24fea9195 100644 --- a/packages/core/src/tests/native-refresh-coordinator.test.ts +++ b/packages/core/src/tests/native-refresh-coordinator.test.ts @@ -1,5 +1,20 @@ -import { afterEach, expect, test } from 'bun:test' -import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { + afterEach, + beforeAll, + test as bunTest, + describe, + expect, +} from 'bun:test' +import { AsyncLocalStorage } from 'node:async_hooks' +import { appendFileSync, existsSync } from 'node:fs' +import { + mkdtemp, + readFile, + realpath, + rm, + unlink, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -12,6 +27,7 @@ import { fingerprintOf, POOL_LOCK_DEFAULTS, PoolOperationError, + type PoolRow, } from '@cortexkit/common-auth/store' import { @@ -25,21 +41,74 @@ import type { import { createNativeRefreshCoordinator, type NativeRefreshCoordinatorOptions, + type NativeRefreshDispatchVersion, type NativeRefreshObservation, type NativeRefreshRestriction, + type NativeRefreshResult, nativeAccountProviderLock, } from '../native-refresh-coordinator.ts' import { captureNativeLocalPoolBinding, + nativeLocalPoolBindingMatches, nativeLocalRefreshJobKey, } from '../pool-binding.ts' import { resolveNativePoolPaths } from '../pool-paths.ts' import { createNativePoolStore, type NativePoolStoreOptions, + nativePoolStoreLocks, } from '../pool-store.ts' +import { tokenFingerprint } from '../token-fingerprint.ts' const roots: string[] = [] +class TestLifetime { + readonly roots = new Set() + readonly gates = new Set<() => void>() + readonly calls = new Set>() + body?: Promise + closing = false + + track(call: Promise): Promise { + this.calls.add(call) + void call.finally(() => this.calls.delete(call)).catch(() => {}) + return call + } + + async finish() { + this.closing = true + for (const release of this.gates) release() + this.gates.clear() + await this.body?.catch(() => {}) + while (this.calls.size) await Promise.allSettled([...this.calls]) + } +} + +const testLifetime = new AsyncLocalStorage() +let currentLifetime: TestLifetime | undefined + +function test( + name: string, + body: () => void | Promise, + timeout?: number, +) { + bunTest( + name, + () => { + const scenario = new TestLifetime() + currentLifetime = scenario + const running = testLifetime.run(scenario, async () => { + await body() + }) + scenario.body = running + return running + }, + timeout, + ) +} + +function track(call: Promise): Promise { + return testLifetime.getStore()?.track(call) ?? call +} const quota = { validate: (v: unknown) => typeof v === 'number', merge: (_v: unknown, observation: unknown) => observation, @@ -51,6 +120,9 @@ function barrier() { const promise = new Promise((resolve) => { release = resolve }) + const scenario = testLifetime.getStore() + if (scenario?.closing) release() + else scenario?.gates.add(release) return { promise, release } } @@ -78,13 +150,24 @@ const provider: typeof refreshClaudeOAuthToken = async () => ({ async function fixture(seams: Partial = {}) { const root = await realpath(await mkdtemp(join(tmpdir(), 'native-refresh-'))) - roots.push(root) + const scenario = testLifetime.getStore() + if (scenario) scenario.roots.add(root) + else roots.push(root) const paths = await resolveNativePoolPaths( join(root, 'anthropic-auth.json'), join(root, 'anthropic-auth-state.json'), ) const options = { ...seams, paths, quota } - const store = createNativePoolStore(options) + const store = new Proxy(createNativePoolStore(options), { + get(target, property) { + const member = Reflect.get(target, property) + if (typeof member !== 'function') return member + return (...args: unknown[]) => { + const result = Reflect.apply(member, target, args) + return result instanceof Promise ? track(result) : result + } + }, + }) await store.initialize() async function row(id: string) { const read = await store.read() @@ -103,7 +186,7 @@ async function fixture(seams: Partial = {}) { function coordinator( overrides: Partial = {}, ) { - return createNativeRefreshCoordinator({ + const coordinator = createNativeRefreshCoordinator({ ...options, refreshToken: provider, resolveIdentity: identity, @@ -113,14 +196,333 @@ async function fixture(seams: Partial = {}) { }, ...overrides, }) + return { + refresh: (request: Parameters[0]) => + track(coordinator.refresh(request)), + } } return { root, paths, store, row, binding, add, observations, coordinator } } afterEach(async () => { + const scenario = currentLifetime + const unscopedRoots = roots.splice(0) + // Bun reports a timeout without cancelling the test body. Open that body's + // waiting gates, wait for the body and pending store calls, then delete the + // test's files. + await scenario?.finish() await Promise.all( - roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + [...unscopedRoots, ...(scenario?.roots ?? [])].map((root) => + rm(root, { recursive: true, force: true }), + ), ) + if (timeoutProbePool && timeoutProbeLog) { + appendFileSync( + timeoutProbeLog, + `${JSON.stringify({ event: `cleanup:${++timeoutProbeCleanupCount}`, finallySeen: timeoutProbeFinallySeen, poolExists: existsSync(timeoutProbePool) })}\n`, + ) + } +}) + +type VersionedObservation = NativeRefreshObservation & { + successorVersion?: NativeRefreshDispatchVersion +} + +function dispatchMatches( + version: NativeRefreshDispatchVersion | undefined, + row: PoolRow, +) { + const credential = row.credential + return ( + version !== undefined && + credential?.type === 'oauth' && + version.accessFingerprint === + (credential.access ? tokenFingerprint(credential.access) : undefined) && + version.expires === credential.expires && + version.lastRefreshedAt === credential.lastRefreshedAt + ) +} + +async function publicationLocks( + f: Awaited>, + publish: (assertOwned: () => Promise) => Promise, +) { + const [config, state] = nativePoolStoreLocks(f.paths) + return withLock( + config.path, + { ...POOL_LOCK_DEFAULTS, name: config.name }, + async (configLock) => + withLock( + state.path, + { ...POOL_LOCK_DEFAULTS, name: state.name }, + async (stateLock) => + withLock( + f.paths.runtime, + { ...POOL_LOCK_DEFAULTS, name: 'native-runtime' }, + async (runtimeLock) => { + const assertOwned = async () => { + await configLock.assertOwned() + await stateLock.assertOwned() + await runtimeLock.assertOwned() + } + await assertOwned() + return publish(assertOwned) + }, + ), + ), + ) +} + +for (const terminal of ['persisted', 'failed'] as const) { + for (const replacement of ['access-only', 'same-material'] as const) { + const change = + replacement === 'access-only' + ? 'access-only same-epoch rotation' + : 'a newer refresh stamp with repeated dispatch material' + test(`${terminal} observation fences ${change} after row and provider lease loss`, async () => { + const f = await fixture() + await f.add('a', 'A') + const binding = await f.binding('a') + const entered = barrier() + const release = barrier() + const finishProvider = barrier() + const clearMarkers = { refreshErrorClearedAt: 7, quotaErrorClearedAt: 9 } + const capturedClearMarkers = { ...clearMarkers } + let markersCaptured = false + let observation: VersionedObservation | undefined + let freshAccepted = false + let staleAccepted = false + let oldFieldsAccepted = false + await publicationLocks(f, async (assertOwned) => { + await assertOwned() + await writeFile( + f.paths.runtime, + JSON.stringify({ ...clearMarkers, error: 'older-observation' }), + { mode: 0o600 }, + ) + }) + let expectedVersion: NativeRefreshDispatchVersion | undefined + const c = f.coordinator({ + readRestrictions: async () => { + const native = JSON.parse(await readFile(f.paths.runtime, 'utf8')) + if (!markersCaptured) { + capturedClearMarkers.refreshErrorClearedAt = + native.refreshErrorClearedAt + capturedClearMarkers.quotaErrorClearedAt = + native.quotaErrorClearedAt + markersCaptured = true + } + return allowed + }, + refreshToken: async () => { + if (terminal === 'failed') { + entered.release() + await finishProvider.promise + throw new ClaudeOAuthRefreshError(400, 'invalid_grant') + } + return { + ...credential('a'), + access: 'access-first-successor', + expiresIn: 3600, + } + }, + reconcile: async (event) => { + observation = event + if (terminal === 'persisted') { + const row = await f.row('a') + if (row.credential?.type !== 'oauth') + throw new Error('OAuth fixture required') + expectedVersion = { + accessFingerprint: tokenFingerprint('access-first-successor'), + expires: row.credential.expires, + lastRefreshedAt: row.credential.lastRefreshedAt, + } + freshAccepted = dispatchMatches(observation.successorVersion, row) + entered.release() + await release.promise + } + await publicationLocks(f, async (assertOwned) => { + const row = await f.row('a') + const native = JSON.parse(await readFile(f.paths.runtime, 'utf8')) + const expectedFingerprint = + event.status === 'persisted' + ? event.successorFingerprint + : event.credentialFingerprint + oldFieldsAccepted = + nativeLocalPoolBindingMatches(binding, f.paths, row) && + row.fingerprint === expectedFingerprint && + native.refreshErrorClearedAt === + capturedClearMarkers.refreshErrorClearedAt && + native.quotaErrorClearedAt === + capturedClearMarkers.quotaErrorClearedAt + const version = + terminal === 'persisted' + ? observation?.successorVersion + : observation?.credentialVersion + staleAccepted = oldFieldsAccepted && dispatchMatches(version, row) + if (staleAccepted) { + await assertOwned() + await writeFile( + f.paths.runtime, + JSON.stringify({ + ...native, + error: 'stale-observation-overwrite', + }), + { mode: 0o600 }, + ) + } + }) + }, + }) + const before = await f.row('a') + if (before.credential?.type !== 'oauth') + throw new Error('OAuth fixture required') + if (terminal === 'failed') { + expectedVersion = { + accessFingerprint: tokenFingerprint('access-a'), + expires: before.credential.expires, + lastRefreshedAt: before.credential.lastRefreshedAt, + } + } + const job = c.refresh({ mode: 'local', binding }) + await entered.promise + await unlink(lockPathFor(f.paths.state, 'row-A')) + const providerLock = nativeAccountProviderLock(f.paths, 'A') + await unlink(lockPathFor(providerLock.path, providerLock.name)) + await f.store.rotate('a', { + ...credential('a'), + access: + replacement === 'access-only' + ? 'access-second-successor' + : terminal === 'persisted' + ? 'access-first-successor' + : 'access-a', + }) + const rotated = await f.row('a') + expect(rotated).toMatchObject({ + stamp: 'bound', + credentialEpoch: binding.credentialEpoch, + identity: binding.identity, + }) + expect(rotated.fingerprint).toBe(before.fingerprint) + await publicationLocks(f, async (assertOwned) => { + await assertOwned() + await writeFile( + f.paths.runtime, + JSON.stringify({ ...clearMarkers, error: 'newer-observation' }), + { mode: 0o600 }, + ) + }) + release.release() + finishProvider.release() + const result = await job + expect(result.status).toBe(terminal === 'persisted' ? 'usable' : 'failed') + expect(oldFieldsAccepted).toBe(true) + expect(staleAccepted).toBe(false) + expect(JSON.parse(await readFile(f.paths.runtime, 'utf8'))).toEqual({ + ...clearMarkers, + error: 'newer-observation', + }) + expect(observation).toMatchObject( + terminal === 'persisted' + ? { successorVersion: expectedVersion } + : { credentialVersion: expectedVersion }, + ) + if (terminal === 'persisted') expect(freshAccepted).toBe(true) + else + expect(dispatchMatches(observation?.credentialVersion, before)).toBe( + true, + ) + expect( + Object.isFrozen( + terminal === 'persisted' + ? observation?.successorVersion + : observation?.credentialVersion, + ), + ).toBe(true) + }) + } +} + +async function waitForProviders( + entered: Promise, + jobs: Promise[], + trace: () => string, +) { + await Promise.race([ + entered, + ...jobs.map(async (job) => { + const result = await job + throw new Error( + `Refresh ended before both providers entered: ${result.status}\n${trace()}`, + ) + }), + ]) +} + +test('captured clear markers fence a failure even when its dispatch version still matches', async () => { + const f = await fixture() + await f.add('a', 'A') + const binding = await f.binding('a') + const entered = barrier() + const release = barrier() + const capturedMarkers = { refreshErrorClearedAt: 7, quotaErrorClearedAt: 9 } + await publicationLocks(f, async (assertOwned) => { + await assertOwned() + await writeFile(f.paths.runtime, JSON.stringify(capturedMarkers), { + mode: 0o600, + }) + }) + let versionMatched = false + let freshAccepted = false + let published = false + const c = f.coordinator({ + refreshToken: async () => { + throw new ClaudeOAuthRefreshError(429, 'busy', '7') + }, + reconcile: async (event) => { + if (event.status !== 'failed') + throw new Error('Failure observation expected') + const row = await f.row('a') + const currentMarkers = JSON.parse(await readFile(f.paths.runtime, 'utf8')) + freshAccepted = + dispatchMatches(event.credentialVersion, row) && + capturedMarkers.refreshErrorClearedAt === + currentMarkers.refreshErrorClearedAt + entered.release() + await release.promise + await publicationLocks(f, async () => { + const row = await f.row('a') + const currentMarkers = JSON.parse( + await readFile(f.paths.runtime, 'utf8'), + ) + versionMatched = dispatchMatches(event.credentialVersion, row) + published = + nativeLocalPoolBindingMatches(binding, f.paths, row) && + event.credentialFingerprint === row.fingerprint && + versionMatched && + capturedMarkers.refreshErrorClearedAt === + currentMarkers.refreshErrorClearedAt && + capturedMarkers.quotaErrorClearedAt === + currentMarkers.quotaErrorClearedAt + }) + }, + }) + const job = c.refresh({ mode: 'local', binding }) + await entered.promise + await publicationLocks(f, async (assertOwned) => { + const currentMarkers = JSON.parse(await readFile(f.paths.runtime, 'utf8')) + currentMarkers.refreshErrorClearedAt++ + await assertOwned() + await writeFile(f.paths.runtime, JSON.stringify(currentMarkers), { + mode: 0o600, + }) + }) + release.release() + expect((await job).status).toBe('failed') + expect(freshAccepted).toBe(true) + expect(versionMatched).toBe(true) + expect(published).toBe(false) }) test('account lock spec uses canonical store and identity, not epoch or row namespace', async () => { @@ -137,44 +539,322 @@ test('account lock spec uses canonical store and identity, not epoch or row name }) test('independent known accounts reach provider concurrently', async () => { - const active = new Set() - const events: string[] = [] - const f = await fixture({ - onLockEvent: (event) => { - if (event.type === 'acquired') active.add(event.name) - else active.delete(event.name) - events.push(`${event.type}:${event.name}`) - }, - }) + const started = performance.now() + const trace: string[] = [] + const phase = (label: string) => { + trace.push(`${(performance.now() - started).toFixed(2)}ms ${label}`) + } + const finish = barrier() + const jobs: Promise[] = [] + const diagnostic = setTimeout( + () => console.error(`INDEPENDENT-TRACE pending\n${trace.join('\n')}`), + 4_000, + ) + try { + phase('fixture:start') + const active = new Set() + const events: string[] = [] + const f = await fixture({ + onStep: (step, info) => phase(`${info.operation}:${info.rowId}:${step}`), + onLockEvent: (event) => { + phase(`${event.type}:${event.name}`) + if (event.type === 'acquired') active.add(event.name) + else active.delete(event.name) + events.push(`${event.type}:${event.name}`) + }, + }) + phase('fixture:initialized') + phase('add-a:start') + await f.add('a', 'A') + phase('add-a:end') + phase('add-b:start') + await f.add('b', 'B') + phase('add-b:end') + const both = barrier() + let calls = 0 + const c = f.coordinator({ + refreshToken: async (input) => { + phase(`provider:${calls + 1}`) + expect(input.maxRetries).toBe(0) + if (++calls === 2) both.release() + await finish.promise + return { ...credential(input.refreshToken), expiresIn: 3600 } + }, + resolveIdentity: async (access) => ({ + deviceId: 'd', + sessionId: 's', + accountUuid: accountUuid(access.endsWith('a') ? 'A' : 'B'), + }), + }) + phase('binding-a:start') + const aBinding = await f.binding('a') + phase('binding-a:end') + phase('binding-b:start') + const bBinding = await f.binding('b') + phase('binding-b:end/launch-both') + const a = c.refresh({ mode: 'local', binding: aBinding }).then((result) => { + phase(`terminal-a:${result.status}`) + return result + }) + const b = c.refresh({ mode: 'local', binding: bBinding }).then((result) => { + phase(`terminal-b:${result.status}`) + return result + }) + jobs.push(a, b) + await waitForProviders(both.promise, jobs, () => trace.join('\n')) + phase('providers:both') + finish.release() + expect((await a).status).toBe('usable') + expect((await b).status).toBe('usable') + expect(calls).toBe(2) + expect(events).toContain( + `acquired:${nativeAccountProviderLock(f.paths, 'A').name}`, + ) + expect(active.size).toBe(0) + phase('complete') + } catch (error) { + console.error(`INDEPENDENT-TRACE failed\n${trace.join('\n')}`) + throw error + } finally { + finish.release() + await Promise.allSettled(jobs) + clearTimeout(diagnostic) + } +}) + +test('concurrency fixture reports terminal refusal and releases the waiting provider before cleanup', async () => { + const f = await fixture() await f.add('a', 'A') await f.add('b', 'B') - const both = barrier() + const aBinding = await f.binding('a') + const bBinding = await f.binding('b') + const entered = barrier() const finish = barrier() - let calls = 0 const c = f.coordinator({ - refreshToken: async (input) => { - expect(input.maxRetries).toBe(0) - if (++calls === 2) both.release() + refreshToken: async () => { + entered.release() await finish.promise - return { ...credential(input.refreshToken), expiresIn: 3600 } + return provider({ refreshToken: 'synthetic' }) }, - resolveIdentity: async (access) => ({ - deviceId: 'd', - sessionId: 's', - accountUuid: accountUuid(access.endsWith('a') ? 'A' : 'B'), - }), + readRestrictions: async (binding) => + binding.rowId === 'b' + ? { status: 'blocked', reason: 'quota-ineligible' } + : allowed, }) - const a = c.refresh({ mode: 'local', binding: await f.binding('a') }) - const b = c.refresh({ mode: 'local', binding: await f.binding('b') }) - await both.promise - finish.release() + const a = c.refresh({ mode: 'local', binding: aBinding }) + await entered.promise + const b = c.refresh({ mode: 'local', binding: bBinding }) + const neverBothEntered = barrier() + try { + await expect( + waitForProviders( + neverBothEntered.promise, + [a, b], + () => 'provider-A waiting; row-B refused', + ), + ).rejects.toThrow('Refresh ended before both providers entered: refused') + } finally { + finish.release() + await Promise.allSettled([a, b]) + } expect((await a).status).toBe('usable') - expect((await b).status).toBe('usable') - expect(calls).toBe(2) - expect(events).toContain( - `acquired:${nativeAccountProviderLock(f.paths, 'A').name}`, + expect(await b).toMatchObject({ + status: 'refused', + reason: 'quota-ineligible', + }) + const lock = nativeAccountProviderLock(f.paths, 'A') + await withLock( + lock.path, + { ...POOL_LOCK_DEFAULTS, name: lock.name }, + async (owned) => { + await owned.assertOwned() + }, ) - expect(active.size).toBe(0) +}) + +const timeoutProbePool = process.env.NATIVE_REFRESH_TIMEOUT_POOL +const timeoutProbeLog = process.env.NATIVE_REFRESH_TIMEOUT_LOG +let timeoutProbeFinallySeen = false +let timeoutProbeCleanupCount = 0 +if (timeoutProbePool && timeoutProbeLog) { + describe('runner timeout probe', () => { + const entered = barrier() + const release = barrier() + let job: Promise + const record = ( + event: string, + data: Record = {}, + ) => { + appendFileSync(timeoutProbeLog, `${JSON.stringify({ event, ...data })}\n`) + } + beforeAll(async () => { + roots.push(timeoutProbePool) + const paths = await resolveNativePoolPaths( + join(timeoutProbePool, 'anthropic-auth.json'), + join(timeoutProbePool, 'anthropic-auth-state.json'), + ) + const store = createNativePoolStore({ paths, quota }) + const read = await store.read() + if (read.status !== 'ready' || !read.rows[0]) + throw new Error('Prepared probe row required') + const binding = captureNativeLocalPoolBinding(paths, read.rows[0]) + const coordinator = createNativeRefreshCoordinator({ + paths, + quota, + readRestrictions: async () => allowed, + reconcile: async (event) => { + record(`reconcile:${event.status}`) + }, + resolveIdentity: identity, + refreshToken: async () => { + record('provider:entered') + entered.release() + await release.promise + record('provider:returned') + return provider({ refreshToken: 'synthetic' }) + }, + }) + job = coordinator.refresh({ mode: 'local', binding }) + await entered.promise + }) + test('runner timeout probe deadline', async () => { + const timer = setTimeout(release.release, 200) + try { + const result = await job + record('body:returned', { status: result.status }) + } finally { + timeoutProbeFinallySeen = true + record('body:finally') + clearTimeout(timer) + } + }, 50) + test('runner timeout probe observer', async () => { + const result = await job + record('observer:returned', { status: result.status }) + expect(result.status).toBe('usable') + const successor = await fixture() + await successor.add('successor', 'A') + const next = await successor.coordinator().refresh({ + mode: 'local', + binding: await successor.binding('successor'), + }) + expect(next.status).toBe('usable') + }) + }) +} + +test('real Bun timeout preserves active fixture files until the body actually finishes', async () => { + const pool = await fixture() + await pool.add('probe', 'A') + const ledger = await fixture() + const log = join(ledger.root, 'timeout-probe.jsonl') + await writeFile(log, '', { mode: 0o600 }) + const child = Bun.spawn( + [ + process.execPath, + 'test', + import.meta.file, + '--test-name-pattern', + 'runner timeout probe', + ], + { + env: { + ...process.env, + NATIVE_REFRESH_TIMEOUT_POOL: pool.root, + NATIVE_REFRESH_TIMEOUT_LOG: log, + }, + stdout: 'pipe', + stderr: 'pipe', + stdin: 'ignore', + }, + ) + const [exit, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + new Response(child.stdout).text(), + ]) + const events = (await readFile(log, 'utf8')) + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + event: string + finallySeen?: boolean + poolExists?: boolean + status?: string + }, + ) + if (!events.find((event) => event.event === 'cleanup:1')?.finallySeen) + console.error(`BUN-TIMEOUT-PROBE ${JSON.stringify(events)}`) + expect(exit).toBe(1) + expect(stderr).toContain('runner timeout probe deadline') + expect(stderr).toContain('1 pass') + expect(stderr).toContain('1 fail') + expect(stderr).not.toContain('Unhandled error') + expect(events.find((event) => event.event === 'cleanup:1')).toMatchObject({ + finallySeen: true, + poolExists: false, + }) + expect(events.find((event) => event.event === 'body:returned')).toMatchObject( + { status: 'usable' }, + ) + expect( + events.findIndex((event) => event.event === 'body:finally'), + ).toBeLessThan(events.findIndex((event) => event.event === 'cleanup:1')) +}) + +test('lifetime waits for body reads and detached published-store writes and opens late gates', async () => { + const scenario = new TestLifetime() + const entered = barrier() + let finalRead = false + let lateGateOpened = false + let store: ReturnType | undefined + let detached: + | ReturnType['add']> + | undefined + scenario.body = testLifetime.run(scenario, async () => { + const f = await fixture({ + onStep: async (step) => { + if (step !== 'before-state-write') return + const parked = barrier() + entered.release() + await parked.promise + const late = barrier() + await late.promise + lateGateOpened = true + }, + }) + store = f.store + detached = f.store.add({ + id: 'detached', + identity: 'A', + credential: credential('detached'), + }) + const bodyGate = barrier() + await bodyGate.promise + await f.store.read() + finalRead = true + }) + try { + await entered.promise + await scenario.finish() + await detached + expect(finalRead).toBe(true) + expect(lateGateOpened).toBe(true) + expect(scenario.calls.size).toBe(0) + expect(await store?.read()).toMatchObject({ + status: 'ready', + rows: [expect.objectContaining({ id: 'detached', stamp: 'bound' })], + }) + } finally { + await scenario.finish() + await Promise.all( + [...scenario.roots].map((root) => + rm(root, { recursive: true, force: true }), + ), + ) + } }) test('unknown rows serialize under provider-anthropic until reconciliation completes', async () => { @@ -782,6 +1462,13 @@ test('persisted reconciliation failure preserves committed fact and cleans learn committed: credential('new'), }) expect(f.observations.map((e) => e.status)).toEqual(['persisted', 'failed']) + const failed = f.observations.find((event) => event.status === 'failed') + expect(failed?.committedVersion?.accessFingerprint).toBe( + tokenFingerprint('access-new'), + ) + expect(failed?.credentialVersion?.accessFingerprint).toBe( + tokenFingerprint('access-a'), + ) expect(calls).toBe(1) expect((await f.row('a')).credential).toMatchObject(credential('new')) // After disposal removes the old job registration, a refresh for the newly From fd2f562ff036aba25dc771f29ace88f66523f8d7 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:46:07 +0200 Subject: [PATCH 017/169] mason: add v2 authority journal and safe fixture lifetimes Require explicit source digests and canonical path captures, and record host/routing expectations atomically at verified. Refuse v1 journals and preserve prepared expectations on retries. Migration and crash fixtures now prepare expectations before activation while retaining their authority and ownership assertions. Validate native Windows and POSIX path syntax, and defer fixture cleanup until complete bodies and registered detached work settle. Verification passes 521 Core tests, 6 packaging tests, workspace types/lock/build and declaration/import closure. Earlier full-suite delays remain unattributed; the premature-cleanup race is independently proven and corrected. --- packages/core/src/pool-authority.ts | 206 ++++- .../core/src/tests/native-runtime.test.ts | 13 +- .../src/tests/pool-authority-crash-child.ts | 157 +++- .../core/src/tests/pool-authority.test.ts | 797 +++++++++++++++++- packages/core/src/tests/test-lifetime.test.ts | 331 ++++++++ packages/core/src/tests/test-lifetime.ts | 146 ++++ 6 files changed, 1599 insertions(+), 51 deletions(-) create mode 100644 packages/core/src/tests/test-lifetime.test.ts create mode 100644 packages/core/src/tests/test-lifetime.ts diff --git a/packages/core/src/pool-authority.ts b/packages/core/src/pool-authority.ts index 7bf2135dd..bb7486641 100644 --- a/packages/core/src/pool-authority.ts +++ b/packages/core/src/pool-authority.ts @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises' +import { isAbsolute, normalize, parse, sep } from 'node:path' import { withLock, writeJsonAtomic } from '@cortexkit/common-auth/fs' import { parseJsonRedacted } from './json.ts' @@ -16,15 +17,38 @@ export type NativeMigrationPhase = (typeof PHASES)[number] export interface NativeMigrationSources { config: string | null state: string | null - hostAuth: string | null + /** SHA-256 of the host's anthropic entry with sorted JSON object keys; other providers are excluded. */ + hostAuth: string + routing: string | null +} + +export interface NativeMigrationRoutingPaths { + source: string + destination: string +} + +export interface NativeMigrationInput { + host: 'opencode' | 'pi' + sources: NativeMigrationSources + routingPaths: NativeMigrationRoutingPaths + hostAuthPath: string +} + +export interface NativeMigrationExpectations { + expectedHostAuth: string + expectedRouting: string | null } export interface NativeMigrationJournal { - version: 1 + version: 2 storageId: string host: 'opencode' | 'pi' phase: NativeMigrationPhase sources: NativeMigrationSources + routingPaths: NativeMigrationRoutingPaths + hostAuthPath: string + expectedHostAuth: string + expectedRouting: string | null } export class NativeAuthorityError extends Error { @@ -57,20 +81,60 @@ function isPhase(value: unknown): value is NativeMigrationPhase { return PHASES.some((phase) => phase === value) } -function isDigest(value: unknown): value is string | null { +function hasKeys(value: Record, keys: string[]): boolean { + return ( + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ) +} + +function isDigest(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length === 64 && + /^[a-f0-9]{64}$/.test(value) + ) +} + +function isFileDigest(value: unknown): value is string | null { + return value === null || isDigest(value) +} + +function isHostEntry(value: unknown): value is string { + return value === 'absent' || isDigest(value) +} + +/** Check path syntax only; callers resolve symlinks and path aliases and recheck the captured file paths before writing. */ +function isCapturedPath(value: unknown): value is string { return ( - value === null || - (typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)) + typeof value === 'string' && + isAbsolute(value) && + !/\p{Cc}/u.test(value) && + !value.endsWith(sep) && + normalize(value) === value && + // A Windows rooted path without a drive/share still depends on ambient state. + (sep !== '\\' || parse(value).root !== sep) && + !value.split(sep).some((part) => part === '.' || part === '..') ) } function isSources(value: unknown): value is NativeMigrationSources { return ( isRecord(value) && - Object.keys(value).length === 3 && - isDigest(value.config) && - isDigest(value.state) && - isDigest(value.hostAuth) + hasKeys(value, ['config', 'state', 'hostAuth', 'routing']) && + isFileDigest(value.config) && + isFileDigest(value.state) && + isHostEntry(value.hostAuth) && + isFileDigest(value.routing) + ) +} + +function isRoutingPaths(value: unknown): value is NativeMigrationRoutingPaths { + return ( + isRecord(value) && + hasKeys(value, ['source', 'destination']) && + isCapturedPath(value.source) && + isCapturedPath(value.destination) ) } @@ -80,17 +144,43 @@ function decodeJournal( ): NativeMigrationJournal { if ( !isRecord(value) || - Object.keys(value).length !== 5 || - value.version !== 1 || + !hasKeys(value, [ + 'version', + 'storageId', + 'host', + 'phase', + 'sources', + 'routingPaths', + 'hostAuthPath', + 'expectedHostAuth', + 'expectedRouting', + ]) || + value.version !== 2 || value.storageId !== storageId || (value.host !== 'opencode' && value.host !== 'pi') || !isPhase(value.phase) || - !isSources(value.sources) + !isSources(value.sources) || + !isRoutingPaths(value.routingPaths) || + !isCapturedPath(value.hostAuthPath) || + (value.expectedHostAuth !== 'unprepared' && + !isHostEntry(value.expectedHostAuth)) || + (value.expectedRouting !== 'unprepared' && + !isFileDigest(value.expectedRouting)) + ) { + throw new NativeAuthorityError('invalid-journal') + } + const unprepared = value.expectedHostAuth === 'unprepared' + if ( + unprepared !== (value.expectedRouting === 'unprepared') || + (value.phase === 'building' && !unprepared) || + (value.phase !== 'building' && value.phase !== 'verified' && unprepared) || + (!unprepared && + (value.sources.routing === null) !== (value.expectedRouting === null)) ) { throw new NativeAuthorityError('invalid-journal') } return { - version: 1, + version: 2, storageId, host: value.host, phase: value.phase, @@ -98,7 +188,15 @@ function decodeJournal( config: value.sources.config, state: value.sources.state, hostAuth: value.sources.hostAuth, + routing: value.sources.routing, + }, + routingPaths: { + source: value.routingPaths.source, + destination: value.routingPaths.destination, }, + hostAuthPath: value.hostAuthPath, + expectedHostAuth: value.expectedHostAuth, + expectedRouting: value.expectedRouting, } } @@ -154,19 +252,23 @@ async function writeJournal( await hooks.onWriteStep?.('after-write', journal) } -/** Existing source baselines cannot be replaced by a new migration attempt. */ +/** Migration resume retains the original digests and resolved paths so a retry cannot adopt changed inputs or redirect writes. */ export async function beginNativeMigration( paths: NativePoolPaths, - input: { host: 'opencode' | 'pi'; sources: NativeMigrationSources }, + input: NativeMigrationInput, hooks: JournalHooks = {}, ): Promise { const initial = decodeJournal( { - version: 1, + version: 2, storageId: paths.storageId, host: input.host, phase: 'building', sources: input.sources, + routingPaths: input.routingPaths, + hostAuthPath: input.hostAuthPath, + expectedHostAuth: 'unprepared', + expectedRouting: 'unprepared', }, paths.storageId, ) @@ -184,7 +286,14 @@ export async function beginNativeMigration( if (current) { if ( current.host !== initial.host || - JSON.stringify(current.sources) !== JSON.stringify(initial.sources) + current.sources.config !== initial.sources.config || + current.sources.state !== initial.sources.state || + current.sources.hostAuth !== initial.sources.hostAuth || + current.sources.routing !== initial.sources.routing || + current.routingPaths.source !== initial.routingPaths.source || + current.routingPaths.destination !== + initial.routingPaths.destination || + current.hostAuthPath !== initial.hostAuthPath ) { throw new NativeAuthorityError('journal-conflict') } @@ -196,7 +305,63 @@ export async function beginNativeMigration( ) } -/** Only the immediately following phase may be committed; retries are idempotent. */ +/** Save the expected host-auth entry and routing digests together before changing files, so recovery can recognize completed writes. */ +export async function recordNativeMigrationExpectations( + paths: NativePoolPaths, + expectations: NativeMigrationExpectations, + hooks: JournalHooks = {}, +): Promise { + if ( + !isRecord(expectations) || + !hasKeys(expectations, ['expectedHostAuth', 'expectedRouting']) || + !isHostEntry(expectations.expectedHostAuth) || + !isFileDigest(expectations.expectedRouting) + ) { + throw new NativeAuthorityError('invalid-journal') + } + // Capture values before waiting for the lock; callers cannot change the pair + // between validation and publication by mutating their input object. + const { expectedHostAuth, expectedRouting } = expectations + return withLock( + paths.journal, + { + name: 'migration-journal', + ttlMs: 30_000, + timeoutMs: 15_000, + ...hooks.lockOptions, + renew: true, + }, + async (lock) => { + const current = await readNativeMigrationJournal(paths) + if (!current) throw new NativeAuthorityError('migration-required') + if ( + current.phase !== 'verified' || + (current.sources.routing === null) !== (expectedRouting === null) + ) { + throw new NativeAuthorityError('journal-conflict') + } + if (current.expectedHostAuth !== 'unprepared') { + if ( + current.expectedHostAuth !== expectedHostAuth || + current.expectedRouting !== expectedRouting + ) { + throw new NativeAuthorityError('journal-conflict') + } + return current + } + const updated = { ...current, expectedHostAuth, expectedRouting } + await writeJournal(paths, updated, () => lock.assertOwned(), hooks) + return updated + }, + ) +} + +/** + * Advance one phase at a time. A retry of the same transition returns the journal. + * Before recording activation, the caller must compare the host's Anthropic auth + * entry and routing file with the saved expected values. This journal records the + * migration stage; it does not read or verify those files. + */ export async function advanceNativeMigration( paths: NativePoolPaths, expected: NativeMigrationPhase, @@ -224,6 +389,11 @@ export async function advanceNativeMigration( if (current.phase === next) return current if (current.phase !== expected) throw new NativeAuthorityError('journal-conflict') + if ( + next === 'activation-installed' && + current.expectedHostAuth === 'unprepared' + ) + throw new NativeAuthorityError('journal-conflict') const updated = { ...current, phase: next } await writeJournal(paths, updated, () => lock.assertOwned(), hooks) return updated diff --git a/packages/core/src/tests/native-runtime.test.ts b/packages/core/src/tests/native-runtime.test.ts index 090f0af2e..7535cbab0 100644 --- a/packages/core/src/tests/native-runtime.test.ts +++ b/packages/core/src/tests/native-runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from 'bun:test' +import { expect } from 'bun:test' import { chmod, lstat, @@ -24,23 +24,18 @@ import { updateNativeRuntime, } from '../native-runtime.ts' import { tokenFingerprint } from '../token-fingerprint.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' const storageId = 'a'.repeat(64) const vaultAccountIdentity = '11111111-2222-4333-8444-555555555555' -const roots: string[] = [] +const { test, deferCleanup } = createTestLifetimeSuite() async function fixture() { const root = await mkdtemp(join(tmpdir(), 'anthropic-native-runtime-')) - roots.push(root) + deferCleanup(() => rm(root, { recursive: true, force: true })) return join(root, 'private', 'native-state.json') } -afterEach(async () => { - await Promise.all( - roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), - ) -}) - // The fixture includes both local and vaulted account metadata without OAuth tokens. const full = { version: 1, diff --git a/packages/core/src/tests/pool-authority-crash-child.ts b/packages/core/src/tests/pool-authority-crash-child.ts index 309e5711a..6a78b6b84 100644 --- a/packages/core/src/tests/pool-authority-crash-child.ts +++ b/packages/core/src/tests/pool-authority-crash-child.ts @@ -1,16 +1,149 @@ import { join } from 'node:path' -import { - advanceNativeMigration, - beginNativeMigration, - type NativeMigrationPhase, +import type { + NativeMigrationInput, + NativeMigrationPhase, } from '../pool-authority.ts' -import { resolveNativePoolPaths } from '../pool-paths.ts' +import { type NativePoolPaths, resolveNativePoolPaths } from '../pool-paths.ts' + +async function checkWindowsPaths(): Promise { + const { mock } = await import('bun:test') + const path = await import('node:path') + const win = path.win32 + // Mock Windows absolute-path, normalization and parsing rules, plus the path + // separator, only in this child. The parent's Node path module stays unchanged. + mock.module('node:path', () => ({ ...path, ...win, default: win })) + let lockCalls = 0 + let filesystemCalls = 0 + let writes = 0 + const reachedLock = new Error('Structural path boundary reached') + mock.module('@cortexkit/common-auth/fs', () => ({ + withLock: async () => { + lockCalls++ + throw reachedLock + }, + writeJsonAtomic: async () => { + writes++ + throw new Error('Unexpected journal write') + }, + })) + const fs = await import('node:fs/promises') + mock.module('node:fs/promises', () => + Object.fromEntries( + Object.keys(fs).map((key) => [ + key, + async () => { + filesystemCalls++ + throw new Error('Physical filesystem access is forbidden') + }, + ]), + ), + ) + const { beginNativeMigration } = await import('../pool-authority.ts') + const roots = [ + 'C:\\Users\\synthetic\\AppData\\pool', + '\\\\server\\share\\synthetic\\pool', + ] + let canonicalWindowsPaths = true + let acceptedCaptures = 0 + let rejectedCaptures = 0 + for (const root of roots) { + const paths: NativePoolPaths = { + legacyConfig: win.join(root, 'legacy-config.json'), + legacyState: win.join(root, 'legacy-state.json'), + config: win.join(root, 'pool-config.json'), + state: win.join(root, 'pool-state.json'), + runtime: win.join(root, 'runtime.json'), + roster: win.join(root, 'roster.json'), + journal: win.join(root, 'journal.json'), + storageId: 'f'.repeat(64), + } + const input: NativeMigrationInput = { + host: 'opencode', + sources: { + config: null, + state: null, + hostAuth: 'absent', + routing: 'c'.repeat(64), + }, + routingPaths: { + source: win.join(root, 'legacy-routing.json'), + destination: win.join(root, 'native-routing.json'), + }, + hostAuthPath: win.join(root, 'auth.json'), + } + canonicalWindowsPaths &&= [ + input.hostAuthPath, + ...Object.values(input.routingPaths), + ].every((value) => win.isAbsolute(value) && win.normalize(value) === value) + try { + await beginNativeMigration(paths, input) + } catch (error) { + if (error === reachedLock) acceptedCaptures++ + } + if (root !== roots[0]) continue + for (const invalidPath of [ + 'relative\\auth.json', + 'C:auth.json', + '\\auth.json', + 'C:\\', + '\\\\server\\share\\', + 'C:\\tmp\\auth\\', + 'C:\\tmp\\\\auth.json', + 'C:\\tmp\\.\\auth.json', + 'C:\\tmp\\..\\auth.json', + 'C:/tmp/auth.json', + 'C:\\tmp\\auth\n.json', + ]) { + for (const invalid of [ + { ...input, hostAuthPath: invalidPath }, + { + ...input, + routingPaths: { ...input.routingPaths, source: invalidPath }, + }, + { + ...input, + routingPaths: { ...input.routingPaths, destination: invalidPath }, + }, + ]) { + try { + await beginNativeMigration(paths, invalid) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'invalid-journal' + ) + rejectedCaptures++ + } + } + } + } + console.log( + JSON.stringify({ + canonicalWindowsPaths, + acceptedCaptures, + rejectedCaptures, + lockCalls, + filesystemCalls, + writes, + }), + ) +} const root = process.argv[2] const action = process.argv[3] const point = process.argv[4] if (!root || !action || (point !== 'before-write' && point !== 'after-write')) throw new Error('Invalid fixture arguments') +if (action === 'windows-paths') { + await checkWindowsPaths() + process.exit(0) +} +const { + advanceNativeMigration, + beginNativeMigration, + recordNativeMigrationExpectations, +} = await import('../pool-authority.ts') const paths = await resolveNativePoolPaths( join(root, 'anthropic-auth.json'), join(root, 'anthropic-auth-state.json'), @@ -28,11 +161,23 @@ if (action === 'building') { sources: { config: 'a'.repeat(64), state: 'b'.repeat(64), - hostAuth: null, + hostAuth: 'absent', + routing: 'c'.repeat(64), }, + routingPaths: { + source: join(root, 'legacy-routing.json'), + destination: join(root, 'native-routing.json'), + }, + hostAuthPath: join(root, 'host-auth.json'), }, hooks, ) +} else if (action === 'expectations') { + await recordNativeMigrationExpectations( + paths, + { expectedHostAuth: 'd'.repeat(64), expectedRouting: 'e'.repeat(64) }, + hooks, + ) } else { const transitions = new Map< string, diff --git a/packages/core/src/tests/pool-authority.test.ts b/packages/core/src/tests/pool-authority.test.ts index 5b0fb2c12..a0bcec744 100644 --- a/packages/core/src/tests/pool-authority.test.ts +++ b/packages/core/src/tests/pool-authority.test.ts @@ -1,6 +1,7 @@ -import { afterEach, expect, test } from 'bun:test' +import { expect } from 'bun:test' import { mkdtemp, + readdir, readFile, realpath, rm, @@ -8,43 +9,57 @@ import { writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { inspectNativeHostAuthEntry } from '../native-host-auth.ts' import { advanceNativeMigration, beginNativeMigration, + type NativeMigrationInput, type NativeMigrationPhase, readNativeMigrationJournal, + recordNativeMigrationExpectations, requireNativePoolAuthority, } from '../pool-authority.ts' -import { resolveNativePoolPaths } from '../pool-paths.ts' +import { type NativePoolPaths, resolveNativePoolPaths } from '../pool-paths.ts' import { createNativePoolStore } from '../pool-store.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' -const roots: string[] = [] -const input = { - host: 'opencode' as const, - sources: { config: 'a'.repeat(64), state: 'b'.repeat(64), hostAuth: null }, +const { test, deferCleanup } = createTestLifetimeSuite() +const expectations = { + expectedHostAuth: 'd'.repeat(64), + expectedRouting: 'e'.repeat(64), +} + +function captureInput(paths: NativePoolPaths): NativeMigrationInput { + const root = dirname(paths.config) + return { + host: 'opencode', + sources: { + config: 'a'.repeat(64), + state: 'b'.repeat(64), + hostAuth: 'absent', + routing: 'c'.repeat(64), + }, + routingPaths: { + source: join(root, 'legacy-routing.json'), + destination: join(root, 'native-routing.json'), + }, + hostAuthPath: join(root, 'host-auth.json'), + } } async function fixture() { - const root = await realpath( - await mkdtemp(join(tmpdir(), 'anthropic-pool-authority-')), - ) - roots.push(root) + const created = await mkdtemp(join(tmpdir(), 'anthropic-pool-authority-')) + deferCleanup(() => rm(created, { recursive: true, force: true })) + const root = await realpath(created) return resolveNativePoolPaths( join(root, 'anthropic-auth.json'), join(root, 'anthropic-auth-state.json'), ) } -afterEach(async () => { - const owned = roots.splice(0) - await Promise.all( - owned.map((root) => rm(root, { recursive: true, force: true })), - ) -}) - test('a readable empty pool without a committed journal cannot authorize dispatch', async () => { const paths = await fixture() const store = createNativePoolStore({ @@ -60,6 +75,7 @@ test('a readable empty pool without a committed journal cannot authorize dispatc test('authority is unavailable until the forward-only commit, and survives cleanup', async () => { const paths = await fixture() + const input = captureInput(paths) await beginNativeMigration(paths, input) expect((await stat(paths.journal)).mode & 0o777).toBe(0o600) await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ @@ -69,6 +85,7 @@ test('authority is unavailable until the forward-only commit, and survives clean await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ code: 'migration-incomplete', }) + await recordNativeMigrationExpectations(paths, expectations) await advanceNativeMigration(paths, 'verified', 'activation-installed') await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ code: 'migration-incomplete', @@ -81,6 +98,7 @@ test('authority is unavailable until the forward-only commit, and survives clean test('exact replay is idempotent, while changed sources and host ownership refuse', async () => { const paths = await fixture() + const input = captureInput(paths) const first = await beginNativeMigration(paths, input) const before = await readFile(paths.journal, 'utf8') expect(await beginNativeMigration(paths, input)).toEqual(first) @@ -103,6 +121,7 @@ test('exact replay is idempotent, while changed sources and host ownership refus test('skipped or backward phases never change the journal', async () => { const paths = await fixture() + const input = captureInput(paths) await beginNativeMigration(paths, input) const before = await readFile(paths.journal, 'utf8') await expect( @@ -122,6 +141,7 @@ test('skipped or backward phases never change the journal', async () => { test('a copied, malformed or credential-bearing journal refuses without overwriting its bytes', async () => { const paths = await fixture() + const input = captureInput(paths) await beginNativeMigration(paths, input) const journal = await readFile(paths.journal, 'utf8') const other = await fixture() @@ -152,6 +172,7 @@ test('a copied, malformed or credential-bearing journal refuses without overwrit test('interruption before rename leaves no authority, while interruption after rename resumes forward', async () => { const paths = await fixture() + const input = captureInput(paths) const crash = async () => { throw new Error('simulated interruption') } @@ -175,6 +196,7 @@ test('interruption before rename leaves no authority, while interruption after r ).rejects.toThrow('simulated interruption') expect((await beginNativeMigration(paths, input)).phase).toBe('building') await advanceNativeMigration(paths, 'building', 'verified') + await recordNativeMigrationExpectations(paths, expectations) await advanceNativeMigration(paths, 'verified', 'activation-installed') await expect( advanceNativeMigration(paths, 'activation-installed', 'committed', { @@ -191,6 +213,7 @@ test('interruption before rename leaves no authority, while interruption after r test('concurrent starts publish one journal without rewriting its baselines', async () => { const paths = await fixture() + const input = captureInput(paths) let writes = 0 const hooks = { onWriteStep: async (step: string) => { @@ -207,6 +230,604 @@ test('concurrent starts publish one journal without rewriting its baselines', as ) }) +test('v2 persists exactly nine token-free fields and explicit captured bindings', async () => { + const paths = await fixture() + const input = captureInput(paths) + const journal = await beginNativeMigration(paths, input) + const text = await readFile(paths.journal, 'utf8') + expect(JSON.parse(text)).toEqual({ + version: 2, + storageId: paths.storageId, + host: 'opencode', + phase: 'building', + sources: { + config: 'a'.repeat(64), + state: 'b'.repeat(64), + hostAuth: 'absent', + routing: 'c'.repeat(64), + }, + routingPaths: input.routingPaths, + hostAuthPath: input.hostAuthPath, + expectedHostAuth: 'unprepared', + expectedRouting: 'unprepared', + }) + expect(Object.keys(journal)).toHaveLength(9) + expect(Object.keys(journal.sources).sort()).toEqual([ + 'config', + 'hostAuth', + 'routing', + 'state', + ]) + expect(Object.keys(journal.routingPaths).sort()).toEqual([ + 'destination', + 'source', + ]) + // The host-auth and routing files do not exist. Creating the journal must use + // the supplied entry and file digests without reading those paths. + for (const path of [ + input.hostAuthPath, + input.routingPaths.source, + input.routingPaths.destination, + ]) { + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + } +}) + +test('canonical Anthropic entry digests persist without any credential strings', async () => { + const paths = await fixture() + const input = captureInput(paths) + const access = 'synthetic-access-secret' + const refresh = 'synthetic-refresh-secret' + const key = 'synthetic-api-secret' + input.sources.hostAuth = inspectNativeHostAuthEntry('opencode', { + anthropic: { type: 'oauth', access, refresh, expires: 123 }, + other: { key }, + }).digest + const expectedHostAuth = inspectNativeHostAuthEntry('opencode', { + anthropic: { type: 'api', key }, + }).digest + await beginNativeMigration(paths, input) + await advanceNativeMigration(paths, 'building', 'verified') + await recordNativeMigrationExpectations(paths, { + ...expectations, + expectedHostAuth, + }) + const text = await readFile(paths.journal, 'utf8') + expect(JSON.parse(text).sources.hostAuth).toBe(input.sources.hostAuth) + expect(JSON.parse(text).expectedHostAuth).toBe(expectedHostAuth) + for (const secret of [access, refresh, key]) + expect(text).not.toContain(secret) +}) + +test('v1 journals refuse unchanged rather than supplying v2 defaults', async () => { + const paths = await fixture() + const input = captureInput(paths) + const text = JSON.stringify({ + version: 1, + storageId: paths.storageId, + host: input.host, + phase: 'verified', + sources: { + config: input.sources.config, + state: input.sources.state, + hostAuth: null, + }, + }) + await writeFile(paths.journal, text) + await expect(readNativeMigrationJournal(paths)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + await expect(beginNativeMigration(paths, input)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + expect(await readFile(paths.journal, 'utf8')).toBe(text) +}) + +test('closed v2 decoder rejects missing, extra and credential-bearing keys in every map', async () => { + const paths = await fixture() + const input = captureInput(paths) + const journal = await beginNativeMigration(paths, input) + const secret = 'synthetic-access-secret' + const invalid: unknown[] = [] + for (const key of Object.keys(journal)) { + const missing = { ...journal } as Record + delete missing[key] + invalid.push(missing) + } + for (const key of Object.keys(journal.sources)) { + const missing = { ...journal.sources } as Record + delete missing[key] + invalid.push({ ...journal, sources: missing }) + } + for (const key of Object.keys(journal.routingPaths)) { + const missing = { ...journal.routingPaths } as Record + delete missing[key] + invalid.push({ ...journal, routingPaths: missing }) + } + for (const key of [ + 'credential', + 'access', + 'refresh', + 'token', + 'key', + 'extra', + ]) { + invalid.push( + { ...journal, [key]: secret }, + { ...journal, sources: { ...journal.sources, [key]: secret } }, + { ...journal, routingPaths: { ...journal.routingPaths, [key]: secret } }, + ) + } + invalid.push( + { ...journal, sources: [] }, + { ...journal, routingPaths: [] }, + { + ...journal, + sources: { ...journal.sources, config: undefined, token: secret }, + }, + { + ...journal, + routingPaths: { + destination: input.routingPaths.destination, + token: secret, + }, + }, + ) + for (const value of invalid) { + const text = JSON.stringify(value) + await writeFile(paths.journal, text) + let caught: unknown + try { + await beginNativeMigration(paths, input) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ code: 'invalid-journal' }) + expect((caught as Error).message).toBe( + 'Anthropic account migration journal is invalid', + ) + expect((caught as Error).message).not.toContain(secret) + expect(await readFile(paths.journal, 'utf8')).toBe(text) + } +}) + +test('sources distinguish Anthropic entry absence from nullable file digests', async () => { + const paths = await fixture() + const input = captureInput(paths) + const journal = await beginNativeMigration(paths, input) + for (const key of ['config', 'state', 'hostAuth', 'routing'] as const) { + for (const value of [ + null, + 'absent', + 'unprepared', + 'A'.repeat(64), + 'a'.repeat(63), + `${'a'.repeat(64)}\n`, + 0, + {}, + [], + ]) { + if (value === null && key !== 'hostAuth') continue + if (value === 'absent' && key === 'hostAuth') continue + const text = JSON.stringify({ + ...journal, + sources: { ...journal.sources, [key]: value }, + }) + await writeFile(paths.journal, text) + await expect(readNativeMigrationJournal(paths)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + expect(await readFile(paths.journal, 'utf8')).toBe(text) + } + } + for (const hostAuth of ['absent', 'f'.repeat(64)]) { + const absentFiles = { + ...journal, + sources: { config: null, state: null, hostAuth, routing: null }, + } + await writeFile(paths.journal, JSON.stringify(absentFiles)) + expect(await readNativeMigrationJournal(paths)).toEqual(absentFiles) + } +}) + +test('begin requires every capture field instead of guessing paths or routing absence', async () => { + const paths = await fixture() + const input = captureInput(paths) + const invalid: unknown[] = [] + for (const key of ['host', 'sources', 'routingPaths', 'hostAuthPath']) { + const missing = { ...input } as Record + delete missing[key] + invalid.push(missing) + } + for (const key of Object.keys(input.sources)) { + const sources = { ...input.sources } as Record + delete sources[key] + invalid.push({ ...input, sources }) + } + for (const value of invalid) { + await expect( + beginNativeMigration(paths, value as NativeMigrationInput), + ).rejects.toMatchObject({ code: 'invalid-journal' }) + expect(await readNativeMigrationJournal(paths)).toBeUndefined() + } +}) + +test('path shapes refuse without normalizing or accessing physical leaves', async () => { + const paths = await fixture() + const input = captureInput(paths) + const journal = await beginNativeMigration(paths, input) + for (const path of [ + '', + 'relative/auth.json', + '/', + '/tmp/auth/', + '/tmp//auth.json', + '/tmp/./auth.json', + '/tmp/../auth.json', + '/tmp/auth\u0000.json', + '/tmp/auth\n.json', + null, + 1, + ]) { + for (const invalid of [ + { ...journal, hostAuthPath: path }, + { ...journal, routingPaths: { ...journal.routingPaths, source: path } }, + { + ...journal, + routingPaths: { ...journal.routingPaths, destination: path }, + }, + ]) { + const text = JSON.stringify(invalid) + await writeFile(paths.journal, text) + await expect(readNativeMigrationJournal(paths)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + expect(await readFile(paths.journal, 'utf8')).toBe(text) + } + } +}) + +test('native canonical leaf spellings retain their exact path bytes', async () => { + const paths = await fixture() + const input = captureInput(paths) + // Backslash is a separator on Windows and a literal filename byte on POSIX. + input.hostAuthPath = join(dirname(paths.config), 'host\\auth.json') + const journal = await beginNativeMigration(paths, input) + expect(journal.hostAuthPath).toBe(input.hostAuthPath) + expect((await readNativeMigrationJournal(paths))?.hostAuthPath).toBe( + input.hostAuthPath, + ) +}) + +test('isolated Windows semantics accept canonical drive and UNC captures without source I/O', async () => { + const paths = await fixture() + const child = Bun.spawn( + [ + process.execPath, + fileURLToPath( + new URL('./pool-authority-crash-child.ts', import.meta.url), + ), + dirname(paths.config), + 'windows-paths', + 'before-write', + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const stdout = await new Response(child.stdout).text() + const stderr = await new Response(child.stderr).text() + expect(await child.exited, stderr).toBe(0) + expect(stderr).toBe('') + expect(JSON.parse(stdout)).toEqual({ + canonicalWindowsPaths: true, + acceptedCaptures: 2, + rejectedCaptures: 33, + lockCalls: 2, + filesystemCalls: 0, + writes: 0, + }) + // The child mocks Node's Windows path operations; the parent must still use + // its own operating system's path operations after the child exits. + expect((await beginNativeMigration(paths, captureInput(paths))).phase).toBe( + 'building', + ) +}) + +for (const field of ['config', 'state', 'hostAuth', 'routing'] as const) { + test(`${field} source drift refuses without changing journal bytes`, async () => { + const paths = await fixture() + const input = captureInput(paths) + await beginNativeMigration(paths, input) + await advanceNativeMigration(paths, 'building', 'verified') + await recordNativeMigrationExpectations(paths, expectations) + const before = await readFile(paths.journal, 'utf8') + await expect( + beginNativeMigration(paths, { + ...input, + sources: { ...input.sources, [field]: 'f'.repeat(64) }, + }), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + }) +} + +test('host-auth path drift refuses without changing journal bytes', async () => { + const paths = await fixture() + const input = captureInput(paths) + await beginNativeMigration(paths, input) + const before = await readFile(paths.journal, 'utf8') + await expect( + beginNativeMigration(paths, { + ...input, + hostAuthPath: join(dirname(paths.config), 'other-host-auth.json'), + }), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) +}) + +for (const field of ['source', 'destination'] as const) { + test(`routing ${field} path drift refuses without changing journal bytes`, async () => { + const paths = await fixture() + const input = captureInput(paths) + await beginNativeMigration(paths, input) + const before = await readFile(paths.journal, 'utf8') + await expect( + beginNativeMigration(paths, { + ...input, + routingPaths: { + ...input.routingPaths, + [field]: join(dirname(paths.config), 'other-routing.json'), + }, + }), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + }) +} + +test('begin retry ignores map insertion order and never resets prepared expectations', async () => { + const paths = await fixture() + const input = captureInput(paths) + // Set routing source and destination to the same path explicitly; neither + // field gets its value from an implicit destination default. + input.routingPaths.destination = input.routingPaths.source + await beginNativeMigration(paths, input) + await advanceNativeMigration(paths, 'building', 'verified') + const prepared = await recordNativeMigrationExpectations(paths, expectations) + const before = await readFile(paths.journal, 'utf8') + const retry = { + ...input, + sources: { + routing: input.sources.routing, + hostAuth: input.sources.hostAuth, + state: input.sources.state, + config: input.sources.config, + }, + routingPaths: { + destination: input.routingPaths.destination, + source: input.routingPaths.source, + }, + } + expect( + await beginNativeMigration(paths, retry, { + onWriteStep: async () => { + throw new Error('Unexpected retry write') + }, + }), + ).toEqual(prepared) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + await advanceNativeMigration(paths, 'verified', 'activation-installed') + expect(await beginNativeMigration(paths, retry)).toEqual({ + ...prepared, + phase: 'activation-installed', + }) +}) + +test('expectation recording publishes one complete pair at verified with no-write retries and conflicts', async () => { + const paths = await fixture() + await beginNativeMigration(paths, captureInput(paths)) + const verified = await advanceNativeMigration(paths, 'building', 'verified') + let writes = 0 + const recorded = await recordNativeMigrationExpectations( + paths, + expectations, + { + onWriteStep: async (step) => { + if (step === 'after-write') writes++ + }, + }, + ) + expect(recorded).toEqual({ + ...verified, + expectedHostAuth: 'd'.repeat(64), + expectedRouting: 'e'.repeat(64), + }) + expect(await readNativeMigrationJournal(paths)).toEqual(recorded) + expect(writes).toBe(1) + const before = await readFile(paths.journal, 'utf8') + const inode = (await stat(paths.journal)).ino + expect( + await recordNativeMigrationExpectations(paths, expectations, { + onWriteStep: async () => { + throw new Error('Unexpected retry write') + }, + }), + ).toEqual(recorded) + expect((await stat(paths.journal)).ino).toBe(inode) + for (const changed of [ + { ...expectations, expectedHostAuth: 'absent' }, + { ...expectations, expectedRouting: 'f'.repeat(64) }, + ]) { + await expect( + recordNativeMigrationExpectations(paths, changed), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + } + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) +}) + +test('expectations can be recorded only at verified and activation requires preparation', async () => { + const paths = await fixture() + await expect( + recordNativeMigrationExpectations(paths, expectations), + ).rejects.toMatchObject({ code: 'migration-required' }) + await beginNativeMigration(paths, captureInput(paths)) + const building = await readFile(paths.journal, 'utf8') + await expect( + recordNativeMigrationExpectations(paths, expectations), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(building) + await advanceNativeMigration(paths, 'building', 'verified') + const verified = await readFile(paths.journal, 'utf8') + await expect( + advanceNativeMigration(paths, 'verified', 'activation-installed'), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(verified) + await recordNativeMigrationExpectations(paths, expectations) + for (const [previous, next] of [ + ['verified', 'activation-installed'], + ['activation-installed', 'committed'], + ['committed', 'retired'], + ] as const) { + const journal = await advanceNativeMigration(paths, previous, next) + expect(journal).toMatchObject(expectations) + const before = await readFile(paths.journal, 'utf8') + await expect( + recordNativeMigrationExpectations(paths, expectations), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + } +}) + +test('expectation input refuses unprepared, malformed and credential-bearing values', async () => { + const paths = await fixture() + await beginNativeMigration(paths, captureInput(paths)) + await advanceNativeMigration(paths, 'building', 'verified') + const before = await readFile(paths.journal, 'utf8') + for (const invalid of [ + { expectedHostAuth: 'unprepared', expectedRouting: 'unprepared' }, + { ...expectations, expectedHostAuth: 'unprepared' }, + { ...expectations, expectedRouting: 'unprepared' }, + { ...expectations, expectedHostAuth: null }, + { ...expectations, expectedRouting: 'absent' }, + { ...expectations, expectedHostAuth: 'D'.repeat(64) }, + { ...expectations, expectedHostAuth: `${'d'.repeat(64)}\n` }, + { ...expectations, expectedRouting: 0 }, + { expectedHostAuth: 'absent' }, + { expectedRouting: null }, + { ...expectations, credential: 'synthetic-api-secret' }, + ]) { + await expect( + recordNativeMigrationExpectations(paths, invalid as typeof expectations), + ).rejects.toMatchObject({ code: 'invalid-journal' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + } +}) + +test('absent routing and absent entry remain distinct from unprepared expectations', async () => { + const paths = await fixture() + const input = captureInput(paths) + input.sources.routing = null + const journal = await beginNativeMigration(paths, input) + expect(journal.sources.hostAuth).toBe('absent') + expect(journal.sources.routing).toBeNull() + expect(journal.expectedHostAuth).toBe('unprepared') + expect(journal.expectedRouting).toBe('unprepared') + await advanceNativeMigration(paths, 'building', 'verified') + const before = await readFile(paths.journal, 'utf8') + await expect( + recordNativeMigrationExpectations(paths, expectations), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + const absent = { expectedHostAuth: 'absent', expectedRouting: null } + const prepared = await recordNativeMigrationExpectations(paths, absent) + expect(prepared).toMatchObject(absent) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + expect(await recordNativeMigrationExpectations(paths, absent)).toEqual( + prepared, + ) + const other = await fixture() + await beginNativeMigration(other, captureInput(other)) + await advanceNativeMigration(other, 'building', 'verified') + const present = await readFile(other.journal, 'utf8') + await expect( + recordNativeMigrationExpectations(other, absent), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(other.journal, 'utf8')).toBe(present) +}) + +test('decoder rejects mixed, phase-incoherent and routing-inconsistent expectations unchanged', async () => { + const paths = await fixture() + const journal = await beginNativeMigration(paths, captureInput(paths)) + const invalid: unknown[] = [ + { ...journal, expectedHostAuth: 'absent' }, + { ...journal, expectedRouting: null }, + { ...journal, ...expectations }, + { ...journal, phase: 'verified', expectedHostAuth: 'absent' }, + { ...journal, phase: 'verified', expectedRouting: 'e'.repeat(64) }, + { ...journal, phase: 'verified', expectedHostAuth: null }, + { ...journal, phase: 'verified', expectedRouting: 'absent' }, + { ...journal, phase: 'verified', expectedHostAuth: 'x'.repeat(64) }, + { ...journal, phase: 'verified', ...expectations, expectedRouting: null }, + { + ...journal, + phase: 'verified', + ...expectations, + sources: { ...journal.sources, routing: null }, + }, + ] + for (const phase of ['activation-installed', 'committed', 'retired']) + invalid.push({ ...journal, phase }) + for (const value of invalid) { + const text = JSON.stringify(value) + await writeFile(paths.journal, text) + await expect(readNativeMigrationJournal(paths)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + expect(await readFile(paths.journal, 'utf8')).toBe(text) + } +}) + +test('concurrent expectation recorders publish only one immutable pair', async () => { + const paths = await fixture() + await beginNativeMigration(paths, captureInput(paths)) + await advanceNativeMigration(paths, 'building', 'verified') + let writes = 0 + const hooks = { + onWriteStep: async (step: string) => { + if (step === 'after-write') writes++ + }, + } + const journals = await Promise.all( + Array.from({ length: 4 }, () => + recordNativeMigrationExpectations(paths, expectations, hooks), + ), + ) + expect(writes).toBe(1) + expect( + journals.every( + (journal) => + journal.expectedHostAuth === expectations.expectedHostAuth && + journal.expectedRouting === expectations.expectedRouting, + ), + ).toBe(true) + const before = await readFile(paths.journal, 'utf8') + await expect( + recordNativeMigrationExpectations( + paths, + { expectedHostAuth: 'absent', expectedRouting: 'f'.repeat(64) }, + hooks, + ), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(writes).toBe(1) + expect(await readFile(paths.journal, 'utf8')).toBe(before) +}) + const phases: NativeMigrationPhase[] = [ 'building', 'verified', @@ -219,6 +840,7 @@ for (const point of ['before-write', 'after-write'] as const) { for (const [index, target] of phases.entries()) { test(`real process exit ${point} at ${target} preserves the authority boundary and resumes forward`, async () => { const paths = await fixture() + const input = captureInput(paths) const previous = phases[index - 1] if (previous) { await beginNativeMigration(paths, input) @@ -226,8 +848,12 @@ for (const point of ['before-write', 'after-write'] as const) { const before = phases[phase - 1] const after = phases[phase] if (!before || !after) throw new Error('Invalid fixture stage') + if (before === 'verified') + await recordNativeMigrationExpectations(paths, expectations) await advanceNativeMigration(paths, before, after) } + if (previous === 'verified') + await recordNativeMigrationExpectations(paths, expectations) } const child = Bun.spawn( [ @@ -247,9 +873,14 @@ for (const point of ['before-write', 'after-write'] as const) { const abandoned = JSON.parse(await readFile(lockPath, 'utf8')) expect(abandoned.ownerId).toEqual(expect.any(String)) expect(abandoned.expiresAt).toEqual(expect.any(Number)) + const inode = (await stat(lockPath)).ino // The process is dead. Expire only its fixture marker so reclamation // does not depend on elapsed wall time or shorten any live lease. await writeFile(lockPath, JSON.stringify({ ...abandoned, expiresAt: 0 })) + expect((await stat(lockPath)).ino).toBe(inode) + expect(JSON.parse(await readFile(lockPath, 'utf8')).ownerId).toBe( + abandoned.ownerId, + ) const expected = point === 'after-write' ? target : previous expect((await readNativeMigrationJournal(paths))?.phase).toBe(expected) if (expected === 'committed' || expected === 'retired') { @@ -266,6 +897,8 @@ for (const point of ['before-write', 'after-write'] as const) { const before = phases[phase - 1] const after = phases[phase] if (!before || !after) throw new Error('Invalid fixture stage') + if (before === 'verified') + await recordNativeMigrationExpectations(paths, expectations) await advanceNativeMigration(paths, before, after) } await requireNativePoolAuthority(paths) @@ -274,8 +907,109 @@ for (const point of ['before-write', 'after-write'] as const) { } } +for (const point of ['before-write', 'after-write'] as const) { + test(`real process exit ${point} at expectation rename preserves a complete pair and verified recovery`, async () => { + const paths = await fixture() + const input = captureInput(paths) + await beginNativeMigration(paths, input) + const verified = await advanceNativeMigration(paths, 'building', 'verified') + const before = await readFile(paths.journal, 'utf8') + const child = Bun.spawn( + [ + process.execPath, + fileURLToPath( + new URL('./pool-authority-crash-child.ts', import.meta.url), + ), + dirname(paths.config), + 'expectations', + point, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const stderr = await new Response(child.stderr).text() + expect(await child.exited, stderr).toBe(19) + const lockPath = `${paths.journal}.migration-journal.lock` + const abandoned = JSON.parse(await readFile(lockPath, 'utf8')) + expect(abandoned.ownerId).toEqual(expect.any(String)) + expect(abandoned.expiresAt).toEqual(expect.any(Number)) + const inode = (await stat(lockPath)).ino + // The child has exited before we expire its migration-journal lock marker. + // Change only expiry, preserving the owner identifier and inode, to test + // abandoned-lock recovery without changing production renewal or leases. + await writeFile(lockPath, JSON.stringify({ ...abandoned, expiresAt: 0 })) + expect((await stat(lockPath)).ino).toBe(inode) + expect(JSON.parse(await readFile(lockPath, 'utf8')).ownerId).toBe( + abandoned.ownerId, + ) + + const text = await readFile(paths.journal, 'utf8') + const raw = JSON.parse(text) + expect(Object.keys(raw)).toHaveLength(9) + expect(raw.phase).toBe('verified') + expect([raw.expectedHostAuth, raw.expectedRouting]).toEqual( + point === 'before-write' + ? ['unprepared', 'unprepared'] + : ['d'.repeat(64), 'e'.repeat(64)], + ) + if (point === 'before-write') expect(text).toBe(before) + expect(await readNativeMigrationJournal(paths)).toEqual( + point === 'before-write' ? verified : { ...verified, ...expectations }, + ) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + const stages = (await readdir(dirname(paths.journal))).filter( + (name) => + name.startsWith(`${basename(paths.journal)}.`) && name.endsWith('.tmp'), + ) + expect(stages).toHaveLength(point === 'before-write' ? 1 : 0) + + let writes = 0 + const recovered = await recordNativeMigrationExpectations( + paths, + expectations, + { + onWriteStep: async (step) => { + if (step === 'after-write') writes++ + }, + }, + ) + expect(writes).toBe(point === 'before-write' ? 1 : 0) + expect(recovered).toEqual({ ...verified, ...expectations }) + const recoveredBytes = await readFile(paths.journal, 'utf8') + expect( + await recordNativeMigrationExpectations(paths, expectations, { + onWriteStep: async () => { + throw new Error('Unexpected recovery retry write') + }, + }), + ).toEqual(recovered) + expect(await beginNativeMigration(paths, input)).toEqual(recovered) + expect(await readFile(paths.journal, 'utf8')).toBe(recoveredBytes) + await advanceNativeMigration(paths, 'verified', 'activation-installed') + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + await advanceNativeMigration(paths, 'activation-installed', 'committed') + await advanceNativeMigration(paths, 'committed', 'retired') + await requireNativePoolAuthority(paths) + expect(await readNativeMigrationJournal(paths)).toEqual({ + ...recovered, + phase: 'retired', + }) + // Abandoned temporary migration files remain in the directory; recovery + // must use only the committed journal, not those files' contents. + expect( + (await readdir(dirname(paths.journal))).filter((name) => + stages.includes(name), + ), + ).toEqual(stages) + }) +} + test('expired successor lease refuses before publishing even while its owner bytes remain', async () => { const paths = await fixture() + const input = captureInput(paths) let matchedOwnerAfterExpiry = false await expect( beginNativeMigration(paths, input, { @@ -299,3 +1033,30 @@ test('expired successor lease refuses before publishing even while its owner byt await beginNativeMigration(paths, input) expect((await readNativeMigrationJournal(paths))?.phase).toBe('building') }) + +test('expired expectation publisher refuses without changing either verified expectation', async () => { + const paths = await fixture() + await beginNativeMigration(paths, captureInput(paths)) + await advanceNativeMigration(paths, 'building', 'verified') + const before = await readFile(paths.journal, 'utf8') + let matchedOwnerAfterExpiry = false + await expect( + recordNativeMigrationExpectations(paths, expectations, { + lockOptions: { ttlMs: 100, timeoutMs: 3_000 }, + onWriteStep: async (step) => { + if (step !== 'before-write') return + const lockPath = `${paths.journal}.migration-journal.lock` + const owner = JSON.parse(await readFile(lockPath, 'utf8')) + await Bun.sleep(Math.max(0, owner.expiresAt - Date.now() + 1)) + const after = JSON.parse(await readFile(lockPath, 'utf8')) + matchedOwnerAfterExpiry = + owner.ownerId === after.ownerId && after.expiresAt <= Date.now() + }, + }), + ).rejects.toMatchObject({ name: 'LockOwnershipError' }) + expect(matchedOwnerAfterExpiry).toBe(true) + expect(await readFile(paths.journal, 'utf8')).toBe(before) + expect( + await recordNativeMigrationExpectations(paths, expectations), + ).toMatchObject({ phase: 'verified', ...expectations }) +}) diff --git a/packages/core/src/tests/test-lifetime.test.ts b/packages/core/src/tests/test-lifetime.test.ts new file mode 100644 index 000000000..728a2f337 --- /dev/null +++ b/packages/core/src/tests/test-lifetime.test.ts @@ -0,0 +1,331 @@ +import { afterAll, afterEach, expect, test as registerTest } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + createTestLifetimeSuite, + type TestGate, + TestLifetime, +} from './test-lifetime.ts' + +const childMode = process.env.NATIVE_TEST_LIFETIME_CHILD +const childName = 'controlled fixture deadline' + +if (childMode) { + if (childMode !== 'managed' && childMode !== 'unsafe') + throw new Error('Invalid lifetime fixture mode') + const root = process.env.NATIVE_TEST_LIFETIME_ROOT + if (!root) throw new Error('Missing lifetime fixture root') + const leaf = join(root, 'fixture.txt') + const events: string[] = [] + const result = { + finalRead: 'pending', + detachedRead: 'pending', + bodyDone: false, + detachedDone: false, + cleanupAfterBody: false, + cleanupAfterDetached: false, + lateGateOpened: false, + } + let bodyFinished!: () => void + let detachedFinished!: () => void + const bodyDone = new Promise((resolve) => { + bodyFinished = resolve + }) + const detachedDone = new Promise((resolve) => { + detachedFinished = resolve + }) + const managed = + childMode === 'managed' ? createTestLifetimeSuite() : undefined + let closing = false + const unsafeGates = new Set<() => void>() + const gate = (): TestGate => { + if (managed) return managed.gate() + let resolve!: () => void + const wait = new Promise((done) => { + resolve = done + }) + const open = () => { + unsafeGates.delete(open) + resolve() + } + if (closing) open() + else unsafeGates.add(open) + return { wait, open } + } + const cleanup = async () => { + events.push('cleanup-start') + result.cleanupAfterBody = result.bodyDone + result.cleanupAfterDetached = result.detachedDone + await rm(root, { recursive: true, force: true }) + events.push('cleanup-end') + } + const inspectLeaf = async () => { + try { + return await readFile(leaf, 'utf8') + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return 'ENOENT' + throw error + } + } + if (!managed) + afterEach(async () => { + // Remove fixture.txt before unblocking the test body. The resumed body and + // its detached task must read ENOENT to demonstrate this unsafe order. + await cleanup() + closing = true + for (const open of [...unsafeGates]) open() + }) + const test = managed?.test ?? registerTest + test(childName, async () => { + events.push('body-start') + if (managed) managed.deferCleanup(cleanup) + const initial = gate() + await initial.wait + const late = gate() + let automaticallyOpened = false + void late.wait.then(() => { + automaticallyOpened = true + }) + await Promise.resolve() + result.lateGateOpened = automaticallyOpened + // If teardown did not open the late gate, open it manually so the test can + // finish. Only teardown's automatic opening satisfies the assertion. + if (!result.lateGateOpened) late.open() + await late.wait + const detached = (async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 25)) + result.detachedRead = await inspectLeaf() + events.push('detached-read') + } finally { + result.detachedDone = true + detachedFinished() + } + })() + managed?.trackDetached(detached) + try { + result.finalRead = await inspectLeaf() + events.push('final-read') + } finally { + result.bodyDone = true + events.push('body-end') + bodyFinished() + } + // The 100 ms deadline makes Bun start teardown while this body still waits + // on its initial test gate. + }, 100) + afterAll(async () => { + await bodyDone + await detachedDone + console.log( + `LIFETIME_CHILD_RESULT ${JSON.stringify({ ...result, events })}`, + ) + }) +} else { + const { test, deferCleanup } = createTestLifetimeSuite() + + test('lifetime finish waits for the entire body including final reads', async () => { + const lifetime = new TestLifetime() + const gate = lifetime.gate() + const events: string[] = [] + lifetime.deferCleanup(() => { + events.push('cleanup') + }) + const body = lifetime.runBody(async () => { + await gate.wait + await Promise.resolve() + events.push('final-read') + await Promise.resolve() + events.push('body-end') + }) + await lifetime.finish() + await body + expect(events).toEqual(['final-read', 'body-end', 'cleanup']) + }) + + test('lifetime drains detached calls added by other detached calls before cleanup', async () => { + const lifetime = new TestLifetime() + const gate = lifetime.gate() + const events: string[] = [] + let completed!: () => void + const done = new Promise((resolve) => { + completed = resolve + }) + lifetime.deferCleanup(() => { + events.push('cleanup') + }) + const body = lifetime.runBody(() => { + lifetime.trackDetached( + (async () => { + await gate.wait + lifetime.trackDetached( + (async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + events.push('detached-final-read') + completed() + })(), + ) + })(), + ) + }) + await lifetime.finish() + await body + await done + expect(events).toEqual(['detached-final-read', 'cleanup']) + }) + + test('lifetime opens gates created after teardown starts', async () => { + const lifetime = new TestLifetime() + const initial = lifetime.gate() + let opened = false + let automaticallyOpened = false + const body = lifetime.runBody(async () => { + await initial.wait + const late = lifetime.gate() + void late.wait.then(() => { + opened = true + }) + await Promise.resolve() + automaticallyOpened = opened + if (!opened) late.open() + await late.wait + }) + await lifetime.finish() + await body + expect(automaticallyOpened).toBe(true) + }) + + test('lifetime includes late cleanup registration and cleans only once', async () => { + const lifetime = new TestLifetime() + const initial = lifetime.gate() + let cleanups = 0 + const body = lifetime.runBody(async () => { + await initial.wait + lifetime.deferCleanup(() => { + cleanups++ + }) + }) + const first = lifetime.finish() + expect(lifetime.finish()).toBe(first) + await first + await body + expect(cleanups).toBe(1) + expect(() => lifetime.deferCleanup(() => {})).toThrow('closed') + }) + + test('lifetime reports detached failures after draining and still runs every cleanup', async () => { + const lifetime = new TestLifetime() + const failure = new Error('detached assertion failed') + const cleanupFailure = new Error('cleanup failed') + const events: string[] = [] + lifetime.trackDetached(Promise.reject(failure)) + lifetime.deferCleanup(() => { + events.push('first') + throw cleanupFailure + }) + lifetime.deferCleanup(() => { + events.push('second') + }) + await expect(lifetime.finish()).rejects.toMatchObject({ + errors: [failure, cleanupFailure], + }) + expect(events).toEqual(['first', 'second']) + }) + + test('lifetime preserves the original body failure while waiting before cleanup', async () => { + const lifetime = new TestLifetime() + const failure = new Error('body assertion failed') + let cleaned = false + lifetime.deferCleanup(() => { + cleaned = true + }) + const body = lifetime.runBody(() => { + throw failure + }) + await expect(body).rejects.toBe(failure) + await lifetime.finish() + expect(cleaned).toBe(true) + }) + + for (const mode of ['unsafe', 'managed'] as const) { + test(`real Bun child timeout ${mode === 'managed' ? 'waits for final reads and detached work before cleanup' : 'exposes the former premature cleanup order'}`, async () => { + const parent = fileURLToPath( + new URL( + '../../../../node_modules/.cache/native-journal-lifetimes/', + import.meta.url, + ), + ) + await mkdir(parent, { recursive: true }) + const root = await mkdtemp(join(parent, `${mode}-`)) + deferCleanup(() => rm(root, { recursive: true, force: true })) + await writeFile(join(root, 'fixture.txt'), 'fixture-alive') + const child = Bun.spawn( + [ + process.execPath, + 'test', + fileURLToPath(import.meta.url), + '--test-name-pattern', + `^${childName}$`, + ], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + env: { + ...process.env, + NATIVE_TEST_LIFETIME_CHILD: mode, + NATIVE_TEST_LIFETIME_ROOT: root, + }, + stdout: 'pipe', + stderr: 'pipe', + }, + ) + const stdout = await new Response(child.stdout).text() + const stderr = await new Response(child.stderr).text() + const exit = await child.exited + const logRoot = fileURLToPath( + new URL( + '../../../../node_modules/.cache/native-journal-diagnostics/', + import.meta.url, + ), + ) + await mkdir(logRoot, { recursive: true }) + const prefix = join(logRoot, `lifetime-${basename(root)}`) + await writeFile(`${prefix}.stdout`, stdout) + await writeFile(`${prefix}.stderr`, stderr) + console.log(`Lifetime child logs: ${prefix}.{stdout,stderr}`) + expect(exit, stderr).toBe(1) + expect(stderr).toContain(`(fail) ${childName}`) + expect(stderr).toContain('this test timed out after 100ms') + expect(stderr).toContain('\n 1 fail\n') + expect(stderr).not.toContain('Unhandled error') + const line = stdout + .split('\n') + .find((value) => value.startsWith('LIFETIME_CHILD_RESULT ')) + expect(line).toBeDefined() + const result = JSON.parse(line!.slice('LIFETIME_CHILD_RESULT '.length)) + expect(result).toMatchObject({ + finalRead: mode === 'managed' ? 'fixture-alive' : 'ENOENT', + detachedRead: mode === 'managed' ? 'fixture-alive' : 'ENOENT', + bodyDone: true, + detachedDone: true, + lateGateOpened: true, + cleanupAfterBody: mode === 'managed', + cleanupAfterDetached: mode === 'managed', + }) + if (mode === 'managed') { + expect(result.events.indexOf('cleanup-start')).toBeGreaterThan( + result.events.indexOf('body-end'), + ) + expect(result.events.indexOf('cleanup-start')).toBeGreaterThan( + result.events.indexOf('detached-read'), + ) + } else { + expect(result.events.indexOf('cleanup-end')).toBeLessThan( + result.events.indexOf('final-read'), + ) + } + }) + } +} diff --git a/packages/core/src/tests/test-lifetime.ts b/packages/core/src/tests/test-lifetime.ts new file mode 100644 index 000000000..6a9973822 --- /dev/null +++ b/packages/core/src/tests/test-lifetime.ts @@ -0,0 +1,146 @@ +import { afterEach, test as registerTest } from 'bun:test' +import { AsyncLocalStorage } from 'node:async_hooks' + +export interface TestGate { + wait: Promise + open(): void +} + +/** + * Bun may start teardown without cancelling a timed-out body. Keep its fixtures + * alive through the entire body and explicitly registered detached work. + */ +export class TestLifetime { + private bodySettled: Promise | undefined + private readonly detached = new Set>() + private readonly gates = new Set<() => void>() + private readonly cleanups: Array<() => void | Promise> = [] + private readonly errors: unknown[] = [] + private closing = false + private finished = false + private finishing: Promise | undefined + + runBody(body: () => T | Promise): Promise { + if (this.bodySettled || this.closing) + throw new Error('Test body lifetime already started or closed') + const running = Promise.resolve().then(body) + // Return the body promise to Bun so it reports failed assertions. Teardown + // waits for settlement without reporting the same body failure again. + this.bodySettled = running.then( + () => {}, + () => {}, + ) + return running + } + + /** Register detached work before the body ends; failures surface after draining. */ + trackDetached(work: Promise): void { + if (this.finished) throw new Error('Test fixture lifetime is closed') + const settled = work.then( + () => {}, + (error) => { + this.errors.push(error) + }, + ) + this.detached.add(settled) + void settled.then(() => { + this.detached.delete(settled) + }) + } + + gate(): TestGate { + let resolve!: () => void + const wait = new Promise((done) => { + resolve = done + }) + const open = () => { + this.gates.delete(open) + resolve() + } + // A still-running body can create a gate after teardown has already begun. + if (this.closing) open() + else this.gates.add(open) + return { wait, open } + } + + deferCleanup(cleanup: () => void | Promise): void { + if (this.finished) throw new Error('Test fixture lifetime is closed') + this.cleanups.push(cleanup) + } + + finish(): Promise { + this.finishing ??= this.drainAndClean() + return this.finishing + } + + private async drainDetached(): Promise { + while (this.detached.size) await Promise.all([...this.detached]) + } + + private async drainAndClean(): Promise { + this.closing = true + for (const open of [...this.gates]) open() + await this.bodySettled + await this.drainDetached() + for ( + let cleanup = this.cleanups.shift(); + cleanup; + cleanup = this.cleanups.shift() + ) { + try { + await cleanup() + } catch (error) { + this.errors.push(error) + } + await this.drainDetached() + } + this.finished = true + if (this.errors.length) + throw new AggregateError(this.errors, 'Test fixture lifetime failed') + } +} + +/** Associate each test's fixtures with its async body so a late cleanup cannot remove another test's files. */ +export function createTestLifetimeSuite() { + const context = new AsyncLocalStorage() + const lifetimes = new Set() + const current = () => { + const lifetime = context.getStore() + if (!lifetime) throw new Error('Test fixture has no body lifetime') + return lifetime + } + afterEach(async () => { + const results = await Promise.allSettled( + [...lifetimes].map(async (lifetime) => { + try { + await lifetime.finish() + } finally { + lifetimes.delete(lifetime) + } + }), + ) + const failures = results.filter((result) => result.status === 'rejected') + if (failures.length) + throw new AggregateError( + failures.map((result) => result.reason), + 'Test fixture teardown failed', + ) + }) + return { + test(name: string, body: () => unknown, timeout?: number): void { + registerTest( + name, + () => { + const lifetime = new TestLifetime() + lifetimes.add(lifetime) + return context.run(lifetime, () => lifetime.runBody(body)) + }, + timeout, + ) + }, + deferCleanup: (cleanup: () => void | Promise) => + current().deferCleanup(cleanup), + gate: () => current().gate(), + trackDetached: (work: Promise) => current().trackDetached(work), + } +} From 52088f8e6da0e7704c26edfefdc76fa1dd89f4be Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:19:25 +0200 Subject: [PATCH 018/169] mason: add native credential validation foundation Define closed positive local evidence tied to its own known binding and exact OAuth lineage/access/expiry/refresh stamp. Decode the optional runtime proof without adding serving or recovery behavior. Preserve runtime v1 and existing transition fences. Use the shared body-draining test lifetime helper byte-identically from fd2f562ff036aba25dc771f29ace88f66523f8d7. Add proof-schema, exact-match, writer and body-drain regression tests; three source mutation controls each fail only their named assertion and restore exactly. Verified with pinned Bun 1.3.14: 12 focused tests, 544 Core tests, 6 packaging tests, native declaration closure, workspace types/lock and five-file Biome check. Fresh complete-delta Sidekick review sk_47dddc59021f4dde95d8d980aac5e1bb and explicit parent local-commit approval received. --- packages/core/src/index.ts | 1 + .../core/src/native-credential-validation.ts | 132 +++++ packages/core/src/native-runtime.ts | 20 + .../native-credential-validation.test.ts | 522 ++++++++++++++++++ packages/core/src/tests/test-lifetime.ts | 146 +++++ 5 files changed, 821 insertions(+) create mode 100644 packages/core/src/native-credential-validation.ts create mode 100644 packages/core/src/tests/native-credential-validation.test.ts create mode 100644 packages/core/src/tests/test-lifetime.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5315fa266..d18f28971 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,6 +22,7 @@ export * from './logging.ts' export * from './mid-conversation-output-config.ts' export * from './model-remap.ts' export * from './models.ts' +export * from './native-credential-validation.ts' export * from './oauth-profile.ts' export * from './pkce.ts' export * from './pool-paths.ts' diff --git a/packages/core/src/native-credential-validation.ts b/packages/core/src/native-credential-validation.ts new file mode 100644 index 000000000..8ce8e4ee8 --- /dev/null +++ b/packages/core/src/native-credential-validation.ts @@ -0,0 +1,132 @@ +import { fingerprintOf } from '@cortexkit/common-auth/store' + +import { + isNativeLocalPoolBinding, + type NativeLocalPoolBinding, +} from './pool-binding.ts' +import { tokenFingerprint } from './token-fingerprint.ts' + +/** + * The access-token fingerprint, expiry and last recorded refresh time identify + * the captured credentials. No bearer material is stored. + */ +export interface NativeLocalCredentialVersion { + readonly accessFingerprint: string + readonly expires: number + readonly lastRefreshedAt?: number +} + +/** + * Positive local account evidence belongs to its own binding, not merely to + * the runtime entry containing it. Copying an entry must not transfer evidence + * to another row, storage location, credential epoch or account identity. + * This evidence does not establish freshness or eligibility to serve. + */ +export interface NativeLocalCredentialValidation { + readonly binding: NativeLocalPoolBinding & { readonly identity: string } + /** Common-auth's full SHA-256 of `oauth\0` followed by the refresh token. */ + readonly credentialFingerprint: string + readonly version: NativeLocalCredentialVersion +} + +/** + * Core publishes this local input type so consumers do not need common-auth + * declarations. + */ +export interface NativeLocalCredentialMaterial { + readonly type: string + readonly access?: string + readonly refresh?: string + readonly expires?: number + readonly lastRefreshedAt?: number +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function keys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + return ( + required.every((key) => Object.hasOwn(value, key)) && + Object.keys(value).every( + (key) => required.includes(key) || optional.includes(key), + ) + ) +} + +function timestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +function token(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.trim() === value +} + +/** Validate the closed, token-free proof schema; no defaults or I/O are used. */ +export function isNativeLocalCredentialValidation( + value: unknown, +): value is NativeLocalCredentialValidation { + if ( + !record(value) || + !keys(value, ['binding', 'credentialFingerprint', 'version']) || + !isNativeLocalPoolBinding(value.binding) || + value.binding.identity === undefined || + typeof value.credentialFingerprint !== 'string' || + !/^[a-f0-9]{64}$/.test(value.credentialFingerprint) || + !record(value.version) || + !keys(value.version, ['accessFingerprint', 'expires'], ['lastRefreshedAt']) + ) + return false + return ( + typeof value.version.accessFingerprint === 'string' && + /^[a-f0-9]{16}$/.test(value.version.accessFingerprint) && + timestamp(value.version.expires) && + value.version.expires > 0 && + (!Object.hasOwn(value.version, 'lastRefreshedAt') || + timestamp(value.version.lastRefreshedAt)) + ) +} + +/** + * Match only the captured binding and exact OAuth material. Expired material + * can still match evidence; serving freshness is a separate decision. + */ +export function nativeLocalCredentialValidationMatches( + validation: unknown, + binding: NativeLocalPoolBinding, + material: NativeLocalCredentialMaterial | undefined, +): boolean { + if ( + !isNativeLocalCredentialValidation(validation) || + !isNativeLocalPoolBinding(binding) || + binding.identity === undefined || + !record(material) || + material.type !== 'oauth' || + !token(material.access) || + !token(material.refresh) || + !timestamp(material.expires) || + material.expires <= 0 || + (Object.hasOwn(material, 'lastRefreshedAt') && + !timestamp(material.lastRefreshedAt)) + ) + return false + return ( + validation.binding.storageId === binding.storageId && + validation.binding.rowId === binding.rowId && + validation.binding.credentialEpoch === binding.credentialEpoch && + validation.binding.identity === binding.identity && + validation.credentialFingerprint === + fingerprintOf({ type: 'oauth', refresh: material.refresh }) && + validation.version.accessFingerprint === + tokenFingerprint(material.access) && + validation.version.expires === material.expires && + // An absent stamp is not a zero stamp and must never acquire a default. + Object.hasOwn(validation.version, 'lastRefreshedAt') === + Object.hasOwn(material, 'lastRefreshedAt') && + validation.version.lastRefreshedAt === material.lastRefreshedAt + ) +} diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index a054856ba..bf9884726 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -11,6 +11,10 @@ import type { } from './accounts.ts' import { quotaSnapshotCheckedAt } from './accounts.ts' import { parseJsonRedacted } from './json.ts' +import { + isNativeLocalCredentialValidation, + type NativeLocalCredentialValidation, +} from './native-credential-validation.ts' import { fromNativeQuotaMap, type NativeQuotaMap, @@ -41,6 +45,7 @@ export type NativeRuntimeBinding = */ export interface NativeRuntimeEntry { binding: NativeRuntimeBinding + credentialValidation?: NativeLocalCredentialValidation lastUsed?: number lastRefreshedAt?: number lastRefreshError?: AccountOperationError @@ -233,6 +238,7 @@ function prime(value: unknown): value is PrimeUsageCounters { } const entryKeys: readonly string[] = [ + 'credentialValidation', 'lastUsed', 'lastRefreshedAt', 'lastRefreshError', @@ -269,6 +275,20 @@ function entry( ) return false const observedBinding = value.binding + if (Object.hasOwn(value, 'credentialValidation')) { + const proof = value.credentialValidation + // A carried proof must not survive rebinding, even if token bytes repeat. + if ( + observedBinding.kind !== 'local' || + observedBinding.identity === undefined || + !isNativeLocalCredentialValidation(proof) || + proof.binding.storageId !== observedBinding.storageId || + proof.binding.rowId !== observedBinding.rowId || + proof.binding.credentialEpoch !== observedBinding.credentialEpoch || + proof.binding.identity !== observedBinding.identity + ) + return false + } for (const key of [ 'lastUsed', 'lastRefreshedAt', diff --git a/packages/core/src/tests/native-credential-validation.test.ts b/packages/core/src/tests/native-credential-validation.test.ts new file mode 100644 index 000000000..44118c88f --- /dev/null +++ b/packages/core/src/tests/native-credential-validation.test.ts @@ -0,0 +1,522 @@ +import { expect } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +import { + isNativeLocalCredentialValidation, + type NativeLocalCredentialMaterial, + type NativeLocalCredentialValidation, + nativeLocalCredentialValidationMatches, +} from '../native-credential-validation.ts' +import { + decodeNativeRuntime, + NativeRuntimeError, + type NativeRuntimeState, + readNativeRuntime, + updateNativeRuntime, +} from '../native-runtime.ts' +import type { NativeLocalPoolBinding } from '../pool-binding.ts' +import { tokenFingerprint } from '../token-fingerprint.ts' +import { createTestLifetimeSuite, TestLifetime } from './test-lifetime.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() + +const storageId = 'a'.repeat(64) +const binding: NativeLocalPoolBinding & { readonly identity: string } = { + kind: 'local', + storageId, + rowId: 'synthetic-row-A', + credentialEpoch: 4, + identity: 'synthetic-account-A', +} +const material: NativeLocalCredentialMaterial = { + type: 'oauth', + access: 'synthetic-access-A', + refresh: 'synthetic-refresh-A', + expires: 1_000, + lastRefreshedAt: 100, +} +// Fixed SHA-256 examples test the full refresh-lineage hash and truncated +// access-token hash without using the implementation to compute expected values. +const proof: NativeLocalCredentialValidation = { + binding: { ...binding }, + credentialFingerprint: + '008f37156dffc3a2d0fc5793e4115ced8dd87f9609aa0e14f11aa09f45daaf04', + version: { + accessFingerprint: '270863a0169db796', + expires: 1_000, + lastRefreshedAt: 100, + }, +} + +function runtime( + credentialValidation: unknown = proof, + enclosingBinding: unknown = binding, +) { + return { + version: 1, + storageId, + accounts: { + [binding.rowId]: { binding: enclosingBinding, credentialValidation }, + }, + } +} + +function state(): NativeRuntimeState { + return decodeNativeRuntime(runtime(), storageId) +} + +async function fixture() { + const parent = join( + import.meta.dir, + '../../../../node_modules/.cache/native-credential-validation', + ) + await mkdir(parent, { recursive: true, mode: 0o700 }) + const root = await mkdtemp(join(parent, 'runtime-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + return join(root, 'private', 'runtime.json') +} + +test('exact OAuth evidence matches the independent lineage and access vectors without mutation', () => { + const before = JSON.stringify({ proof, binding, material }) + expect(isNativeLocalCredentialValidation(proof)).toBe(true) + expect(nativeLocalCredentialValidationMatches(proof, binding, material)).toBe( + true, + ) + expect(proof.credentialFingerprint).toHaveLength(64) + expect(proof.credentialFingerprint).not.toBe( + tokenFingerprint(material.refresh!), + ) + expect(proof.version.accessFingerprint).toHaveLength(16) + expect(JSON.stringify({ proof, binding, material })).toBe(before) +}) + +test('matching rejects every changed nested binding component even when credential bytes repeat', () => { + for (const changed of [ + { ...binding, storageId: 'b'.repeat(64) }, + { ...binding, rowId: 'synthetic-row-B' }, + { ...binding, credentialEpoch: 5 }, + { ...binding, identity: 'synthetic-account-B' }, + ]) { + expect( + nativeLocalCredentialValidationMatches(proof, changed, material), + ).toBe(false) + } +}) + +test('matching compares lastRefreshedAt exactly including strict absence versus zero', () => { + const unstampedProof = { + ...proof, + version: { + accessFingerprint: proof.version.accessFingerprint, + expires: 1_000, + }, + } + const { lastRefreshedAt: _stamp, ...unstampedMaterial } = material + expect( + nativeLocalCredentialValidationMatches( + unstampedProof, + binding, + unstampedMaterial, + ), + ).toBe(true) + expect( + nativeLocalCredentialValidationMatches(proof, binding, { + ...material, + lastRefreshedAt: 101, + }), + ).toBe(false) + expect( + nativeLocalCredentialValidationMatches(proof, binding, unstampedMaterial), + ).toBe(false) + expect( + nativeLocalCredentialValidationMatches(unstampedProof, binding, material), + ).toBe(false) + const zeroMaterial = { ...material, lastRefreshedAt: 0 } + const zeroProof = { + ...proof, + version: { ...proof.version, lastRefreshedAt: 0 }, + } + expect( + nativeLocalCredentialValidationMatches(zeroProof, binding, zeroMaterial), + ).toBe(true) + expect( + nativeLocalCredentialValidationMatches( + unstampedProof, + binding, + zeroMaterial, + ), + ).toBe(false) + expect( + nativeLocalCredentialValidationMatches( + zeroProof, + binding, + unstampedMaterial, + ), + ).toBe(false) +}) + +test('matching rejects changed refresh lineage, access token and expiry', () => { + for (const changed of [ + { ...material, refresh: 'synthetic-refresh-B' }, + { ...material, access: 'synthetic-access-B' }, + { ...material, expires: 1_001 }, + ]) { + expect( + nativeLocalCredentialValidationMatches(proof, binding, changed), + ).toBe(false) + } + for (const changed of [ + { ...proof, credentialFingerprint: 'b'.repeat(64) }, + { ...proof, credentialFingerprint: tokenFingerprint(material.refresh!) }, + { + ...proof, + version: { ...proof.version, accessFingerprint: 'b'.repeat(16) }, + }, + { ...proof, version: { ...proof.version, expires: 1_001 } }, + ]) { + expect( + nativeLocalCredentialValidationMatches(changed, binding, material), + ).toBe(false) + } +}) + +test('missing proof, unknown identity, non-OAuth and empty or unsafe material cannot match', () => { + const { identity: _identity, ...unknownBinding } = binding + expect( + nativeLocalCredentialValidationMatches(undefined, binding, material), + ).toBe(false) + expect(nativeLocalCredentialValidationMatches(null, binding, material)).toBe( + false, + ) + expect( + nativeLocalCredentialValidationMatches(proof, unknownBinding, material), + ).toBe(false) + expect( + isNativeLocalCredentialValidation({ ...proof, binding: unknownBinding }), + ).toBe(false) + for (const changed of [ + undefined, + { type: 'api', apiKey: 'synthetic-api-key' }, + { ...material, type: 'api' }, + { ...material, type: 'custody' }, + { type: 'oauth' }, + { ...material, access: undefined }, + { ...material, access: '' }, + { ...material, access: ' synthetic-access-A' }, + { ...material, refresh: '' }, + { ...material, refresh: ' ' }, + { ...material, refresh: undefined }, + { ...material, expires: undefined }, + { ...material, expires: 0 }, + { ...material, expires: -1 }, + { ...material, expires: 1.5 }, + { ...material, expires: Number.MAX_SAFE_INTEGER + 1 }, + { ...material, lastRefreshedAt: undefined }, + { ...material, lastRefreshedAt: -1 }, + { ...material, lastRefreshedAt: Number.NaN }, + { ...material, lastRefreshedAt: Number.POSITIVE_INFINITY }, + { ...material, lastRefreshedAt: Number.MAX_SAFE_INTEGER + 1 }, + ]) { + expect( + nativeLocalCredentialValidationMatches(proof, binding, changed), + ).toBe(false) + } +}) + +test('closed proof and runtime decoders reject malformed keys, fingerprints, bindings and timestamps', () => { + const invalid: unknown[] = [ + null, + [], + {}, + { ...proof, binding: undefined }, + { ...proof, credentialFingerprint: undefined }, + { ...proof, version: undefined }, + { ...proof, validated: true }, + { ...proof, status: 'unvalidated' }, + { ...proof, checkedAt: 200 }, + { ...proof, refresh: material.refresh }, + { ...proof, binding: { ...binding, extra: true } }, + { ...proof, binding: { ...binding, identity: undefined } }, + { ...proof, binding: { ...binding, identity: '' } }, + { ...proof, binding: { ...binding, rowId: ' row' } }, + { ...proof, binding: { ...binding, storageId: 'a'.repeat(16) } }, + { ...proof, binding: { ...binding, credentialEpoch: 0 } }, + { ...proof, binding: { ...binding, credentialEpoch: 1.5 } }, + { + ...proof, + binding: { ...binding, credentialEpoch: Number.MAX_SAFE_INTEGER + 1 }, + }, + { ...proof, version: { ...proof.version, extra: true } }, + { ...proof, version: { ...proof.version, access: material.access } }, + ] + for (const fingerprint of [ + '', + 'a'.repeat(63), + 'a'.repeat(65), + 'A'.repeat(64), + 'g'.repeat(64), + material.refresh, + ` ${proof.credentialFingerprint}`, + ]) { + invalid.push({ ...proof, credentialFingerprint: fingerprint }) + } + for (const fingerprint of [ + '', + 'a'.repeat(15), + 'a'.repeat(17), + 'A'.repeat(16), + 'g'.repeat(16), + material.access, + 'sk-ant-oat01-synthetic-bearer', + ]) { + invalid.push({ + ...proof, + version: { ...proof.version, accessFingerprint: fingerprint }, + }) + } + for (const expires of [ + undefined, + 0, + -1, + 1.5, + '1000', + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) { + invalid.push({ ...proof, version: { ...proof.version, expires } }) + } + for (const lastRefreshedAt of [ + undefined, + -1, + 0.5, + '100', + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) { + invalid.push({ ...proof, version: { ...proof.version, lastRefreshedAt } }) + } + for (const candidate of invalid) { + expect(isNativeLocalCredentialValidation(candidate)).toBe(false) + expect( + nativeLocalCredentialValidationMatches(candidate, binding, material), + ).toBe(false) + expect(() => decodeNativeRuntime(runtime(candidate), storageId)).toThrow( + 'Anthropic runtime state is invalid', + ) + } +}) + +test('runtime proof belongs only to the enclosing known local binding, never vault or API entries', () => { + const { identity: _identity, ...unknownBinding } = binding + for (const enclosing of [ + unknownBinding, + { ...binding, credentialEpoch: 5 }, + { ...binding, identity: 'synthetic-account-B' }, + { kind: 'api', storageId, rowId: binding.rowId }, + { + kind: 'custody', + storageId, + routeId: binding.rowId, + credentialId: 'synthetic-vault-credential', + accountIdentity: binding.identity, + recordVersion: 1, + }, + ]) { + expect(() => + decodeNativeRuntime(runtime(proof, enclosing), storageId), + ).toThrow('Anthropic runtime state is invalid') + } + for (const changed of [ + { ...binding, storageId: 'b'.repeat(64) }, + { ...binding, rowId: 'synthetic-row-B' }, + { ...binding, credentialEpoch: 5 }, + { ...binding, identity: 'synthetic-account-B' }, + ]) { + expect(() => + decodeNativeRuntime(runtime({ ...proof, binding: changed }), storageId), + ).toThrow('Anthropic runtime state is invalid') + } + const noProof = { + version: 1, + storageId, + accounts: { [binding.rowId]: { binding: unknownBinding } }, + } + expect( + decodeNativeRuntime(noProof, storageId).accounts[binding.rowId]! + .credentialValidation, + ).toBeUndefined() +}) + +test('expired but exact evidence remains valid without inferring serving freshness', () => { + expect(material.expires!).toBeLessThan(Date.now()) + expect(nativeLocalCredentialValidationMatches(proof, binding, material)).toBe( + true, + ) + expect( + decodeNativeRuntime(runtime(), storageId).accounts[binding.rowId]! + .credentialValidation, + ).toEqual(proof) +}) + +test('runtime writer and reader preserve proof without serializing raw secrets or exposing them in errors', async () => { + const path = await fixture() + expect(await updateNativeRuntime(path, storageId, () => state())).toEqual( + state(), + ) + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'ready', + state: state(), + }) + const bytes = await readFile(path, 'utf8') + expect( + JSON.parse(bytes).accounts[binding.rowId].credentialValidation, + ).toEqual(proof) + for (const secret of [ + material.access!, + material.refresh!, + 'synthetic-api-key', + ]) { + expect(bytes).not.toContain(secret) + } + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(dirname(path))).mode & 0o777).toBe(0o700) + try { + await updateNativeRuntime(path, storageId, () => + decodeNativeRuntime( + runtime({ ...proof, credentialFingerprint: material.refresh }), + storageId, + ), + ) + throw new Error('invalid proof was accepted') + } catch (error) { + expect(error).toBeInstanceOf(NativeRuntimeError) + expect(String(error)).toBe( + 'NativeRuntimeError: Anthropic runtime state is invalid', + ) + expect(String(error)).not.toContain(material.refresh!) + expect(String(error)).not.toContain(material.access!) + } + expect(await readFile(path, 'utf8')).toBe(bytes) +}) + +test('clear-marker updates preserve existing proof byte-equivalent and never synthesize missing proof', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + const before = JSON.stringify( + JSON.parse(await readFile(path, 'utf8')).accounts[binding.rowId] + .credentialValidation, + ) + await updateNativeRuntime(path, storageId, (current) => { + current.accounts[binding.rowId]!.refreshErrorClearedAt = 200 + current.accounts[binding.rowId]!.quotaErrorClearedAt = 200 + return current + }) + const after = JSON.parse(await readFile(path, 'utf8')).accounts[binding.rowId] + expect(JSON.stringify(after.credentialValidation)).toBe(before) + const missingPath = await fixture() + await updateNativeRuntime(missingPath, storageId, () => ({ + version: 1, + storageId, + accounts: { [binding.rowId]: { binding } }, + })) + const updated = await updateNativeRuntime( + missingPath, + storageId, + (current) => { + current.accounts[binding.rowId]!.refreshErrorClearedAt = 200 + return current + }, + ) + expect( + Object.hasOwn(updated.accounts[binding.rowId]!, 'credentialValidation'), + ).toBe(false) + expect(await readFile(missingPath, 'utf8')).not.toContain( + 'credentialValidation', + ) +}) + +test('writer rejects carried proof after epoch advance but permits absent or newly bound evidence', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + const before = await readFile(path, 'utf8') + await expect( + updateNativeRuntime(path, storageId, (current) => { + current.accounts[binding.rowId]!.binding = { + ...binding, + credentialEpoch: 5, + } + return current + }), + ).rejects.toMatchObject({ code: 'invalid-runtime' }) + expect(await readFile(path, 'utf8')).toBe(before) + const without = await updateNativeRuntime(path, storageId, (current) => { + current.accounts[binding.rowId]!.binding = { + ...binding, + credentialEpoch: 5, + } + delete current.accounts[binding.rowId]!.credentialValidation + return current + }) + expect(without.accounts[binding.rowId]!.credentialValidation).toBeUndefined() + const rebound = { ...binding, credentialEpoch: 6 } + const newProof = { ...proof, binding: rebound } + const updated = await updateNativeRuntime(path, storageId, (current) => { + current.accounts[binding.rowId]!.binding = rebound + current.accounts[binding.rowId]!.credentialValidation = newProof + return current + }) + expect(updated.accounts[binding.rowId]!.credentialValidation).toEqual( + newProof, + ) + expect( + nativeLocalCredentialValidationMatches(newProof, rebound, material), + ).toBe(true) + await expect( + updateNativeRuntime(path, storageId, () => state()), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) +}) + +test('lifetime teardown drains the whole runtime body before deleting its fixture', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => state()) + const lifetime = new TestLifetime() + const releaseBody = Promise.withResolvers() + let bodyFinished = false + lifetime.deferCleanup(async () => { + if (!bodyFinished) + throw new Error('Runtime fixture cleanup preceded body completion') + await rm(dirname(dirname(path)), { recursive: true, force: true }) + }) + const body = lifetime.runBody(async () => { + await releaseBody.promise + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'ready', + state: state(), + }) + bodyFinished = true + }) + // Starting teardown must not remove files a paused body still needs to read. + const finishing = lifetime.finish().then( + () => undefined, + (error: unknown) => error, + ) + let failure: unknown + try { + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'ready', + state: state(), + }) + } finally { + releaseBody.resolve() + const results = await Promise.all([body, finishing]) + failure = results[1] + } + expect(failure).toBeUndefined() + expect(bodyFinished).toBe(true) + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'missing', + }) +}) diff --git a/packages/core/src/tests/test-lifetime.ts b/packages/core/src/tests/test-lifetime.ts new file mode 100644 index 000000000..6a9973822 --- /dev/null +++ b/packages/core/src/tests/test-lifetime.ts @@ -0,0 +1,146 @@ +import { afterEach, test as registerTest } from 'bun:test' +import { AsyncLocalStorage } from 'node:async_hooks' + +export interface TestGate { + wait: Promise + open(): void +} + +/** + * Bun may start teardown without cancelling a timed-out body. Keep its fixtures + * alive through the entire body and explicitly registered detached work. + */ +export class TestLifetime { + private bodySettled: Promise | undefined + private readonly detached = new Set>() + private readonly gates = new Set<() => void>() + private readonly cleanups: Array<() => void | Promise> = [] + private readonly errors: unknown[] = [] + private closing = false + private finished = false + private finishing: Promise | undefined + + runBody(body: () => T | Promise): Promise { + if (this.bodySettled || this.closing) + throw new Error('Test body lifetime already started or closed') + const running = Promise.resolve().then(body) + // Return the body promise to Bun so it reports failed assertions. Teardown + // waits for settlement without reporting the same body failure again. + this.bodySettled = running.then( + () => {}, + () => {}, + ) + return running + } + + /** Register detached work before the body ends; failures surface after draining. */ + trackDetached(work: Promise): void { + if (this.finished) throw new Error('Test fixture lifetime is closed') + const settled = work.then( + () => {}, + (error) => { + this.errors.push(error) + }, + ) + this.detached.add(settled) + void settled.then(() => { + this.detached.delete(settled) + }) + } + + gate(): TestGate { + let resolve!: () => void + const wait = new Promise((done) => { + resolve = done + }) + const open = () => { + this.gates.delete(open) + resolve() + } + // A still-running body can create a gate after teardown has already begun. + if (this.closing) open() + else this.gates.add(open) + return { wait, open } + } + + deferCleanup(cleanup: () => void | Promise): void { + if (this.finished) throw new Error('Test fixture lifetime is closed') + this.cleanups.push(cleanup) + } + + finish(): Promise { + this.finishing ??= this.drainAndClean() + return this.finishing + } + + private async drainDetached(): Promise { + while (this.detached.size) await Promise.all([...this.detached]) + } + + private async drainAndClean(): Promise { + this.closing = true + for (const open of [...this.gates]) open() + await this.bodySettled + await this.drainDetached() + for ( + let cleanup = this.cleanups.shift(); + cleanup; + cleanup = this.cleanups.shift() + ) { + try { + await cleanup() + } catch (error) { + this.errors.push(error) + } + await this.drainDetached() + } + this.finished = true + if (this.errors.length) + throw new AggregateError(this.errors, 'Test fixture lifetime failed') + } +} + +/** Associate each test's fixtures with its async body so a late cleanup cannot remove another test's files. */ +export function createTestLifetimeSuite() { + const context = new AsyncLocalStorage() + const lifetimes = new Set() + const current = () => { + const lifetime = context.getStore() + if (!lifetime) throw new Error('Test fixture has no body lifetime') + return lifetime + } + afterEach(async () => { + const results = await Promise.allSettled( + [...lifetimes].map(async (lifetime) => { + try { + await lifetime.finish() + } finally { + lifetimes.delete(lifetime) + } + }), + ) + const failures = results.filter((result) => result.status === 'rejected') + if (failures.length) + throw new AggregateError( + failures.map((result) => result.reason), + 'Test fixture teardown failed', + ) + }) + return { + test(name: string, body: () => unknown, timeout?: number): void { + registerTest( + name, + () => { + const lifetime = new TestLifetime() + lifetimes.add(lifetime) + return context.run(lifetime, () => lifetime.runBody(body)) + }, + timeout, + ) + }, + deferCleanup: (cleanup: () => void | Promise) => + current().deferCleanup(cleanup), + gate: () => current().gate(), + trackDetached: (work: Promise) => current().trackDetached(work), + } +} From f4b1ac2f4c1f6681f2f53b39f8e05424283f82e3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:45:06 +0200 Subject: [PATCH 019/169] mason: add exact-version native validation retry schema Add optional validationRetry with the approved closed credential subject, safe ordered timestamps and binding equality to the enclosing known local entry. Preserve runtime version 1, the sole credentialValidation proof slot, and existing writer transitions. Cover round trips, closed nested keys, malformed subjects, strict refresh stamp presence, caller isolation, redaction, metadata/proof preservation and unchanged fences. Binding-match and extra-key mutations each fail only their named control; restore exact bytes before verification. Bun 1.3.14: 13 focused, 591 full Core and six packaging tests passed; workspace types passed. Parent Sidekick approved revised reader-oriented comments. Schema validation alone cannot verify the actual pool row is OAuth; guarded publication remains a separate obligation. --- packages/core/src/native-runtime.ts | 43 ++ .../src/tests/native-validation-retry.test.ts | 642 ++++++++++++++++++ 2 files changed, 685 insertions(+) create mode 100644 packages/core/src/tests/native-validation-retry.test.ts diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index bf9884726..4d6750e2d 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -38,6 +38,20 @@ export type NativeRuntimeBinding = | NativeLocalPoolBinding | NativeCustodyRuntimeBinding +/** + * A failed account check is recorded against the exact credentials it used. + * This lets a retry delay apply only to those credentials, without treating + * the failure as evidence that the account check succeeded. + */ +export interface NativeLocalValidationRetry { + readonly subject: Pick< + NativeLocalCredentialValidation, + 'binding' | 'credentialFingerprint' | 'version' + > + readonly checkedAt: number + readonly nextRetryAt: number +} + /** * Per-account retry errors, profiles, quota-priming usage and observation * identifiers stored separately from credentials. Vault quota is stored here; @@ -46,6 +60,7 @@ export type NativeRuntimeBinding = export interface NativeRuntimeEntry { binding: NativeRuntimeBinding credentialValidation?: NativeLocalCredentialValidation + validationRetry?: NativeLocalValidationRetry lastUsed?: number lastRefreshedAt?: number lastRefreshError?: AccountOperationError @@ -209,6 +224,27 @@ function operationError(value: unknown): value is AccountOperationError { ) } +function localValidationRetry( + value: unknown, + observedBinding: NativeRuntimeBinding, +): value is NativeLocalValidationRetry { + return ( + record(value) && + keys(value, ['subject', 'checkedAt', 'nextRetryAt']) && + observedBinding.kind === 'local' && + observedBinding.identity !== undefined && + // This validator checks credential fields, not whether an account check succeeded. + isNativeLocalCredentialValidation(value.subject) && + value.subject.binding.storageId === observedBinding.storageId && + value.subject.binding.rowId === observedBinding.rowId && + value.subject.binding.credentialEpoch === observedBinding.credentialEpoch && + value.subject.binding.identity === observedBinding.identity && + counter(value.checkedAt) && + counter(value.nextRetryAt) && + value.nextRetryAt >= value.checkedAt + ) +} + function profile(value: unknown): value is OAuthAccountProfile { return ( record(value) && @@ -239,6 +275,7 @@ function prime(value: unknown): value is PrimeUsageCounters { const entryKeys: readonly string[] = [ 'credentialValidation', + 'validationRetry', 'lastUsed', 'lastRefreshedAt', 'lastRefreshError', @@ -275,6 +312,12 @@ function entry( ) return false const observedBinding = value.binding + if ( + !optional(value, 'validationRetry', (retry) => + localValidationRetry(retry, observedBinding), + ) + ) + return false if (Object.hasOwn(value, 'credentialValidation')) { const proof = value.credentialValidation // A carried proof must not survive rebinding, even if token bytes repeat. diff --git a/packages/core/src/tests/native-validation-retry.test.ts b/packages/core/src/tests/native-validation-retry.test.ts new file mode 100644 index 000000000..98207677f --- /dev/null +++ b/packages/core/src/tests/native-validation-retry.test.ts @@ -0,0 +1,642 @@ +import { expect } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +import type { NativeLocalCredentialValidation } from '../native-credential-validation.ts' +import { + decodeNativeRuntime, + type NativeLocalValidationRetry, + NativeRuntimeError, + type NativeRuntimeState, + readNativeRuntime, + updateNativeRuntime, +} from '../native-runtime.ts' +import type { NativeLocalPoolBinding } from '../pool-binding.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() +const storageId = 'a'.repeat(64) +const binding: NativeLocalPoolBinding & { readonly identity: string } = { + kind: 'local', + storageId, + rowId: 'synthetic-row-A', + credentialEpoch: 4, + identity: 'synthetic-account-A', +} +const subject: NativeLocalValidationRetry['subject'] = { + binding: { ...binding }, + credentialFingerprint: + '008f37156dffc3a2d0fc5793e4115ced8dd87f9609aa0e14f11aa09f45daaf04', + version: { + accessFingerprint: '270863a0169db796', + expires: 1_000, + lastRefreshedAt: 100, + }, +} +const retry: NativeLocalValidationRetry = { + subject, + checkedAt: 200, + nextRetryAt: 300, +} +const proof: NativeLocalCredentialValidation = { + ...subject, + version: { ...subject.version, accessFingerprint: 'b'.repeat(16) }, +} + +function runtime( + validationRetry: unknown, + enclosingBinding: unknown = binding, +) { + return { + version: 1, + storageId, + accounts: { + [binding.rowId]: { binding: enclosingBinding, validationRetry }, + }, + } +} + +function without(value: object, key: string): Record { + const result: Record = { ...value } + delete result[key] + return result +} + +function richState(): NativeRuntimeState { + return decodeNativeRuntime( + { + version: 1, + storageId, + relay: { token: 'synthetic-relay-metadata' }, + accounts: { + [binding.rowId]: { + binding: { ...binding }, + credentialValidation: structuredClone(proof), + validationRetry: structuredClone(retry), + lastUsed: 0, + lastRefreshedAt: 100, + lastRefreshError: { + message: 'synthetic refresh failure', + checkedAt: 180, + nextRetryAt: 280, + retryCount: 1, + accountIdentity: binding.identity, + tokenHash: 'c'.repeat(64), + refreshTokenFingerprint: 'd'.repeat(16), + status: 503, + permanent: false, + }, + refreshErrorClearedAt: 0, + refreshLeaseId: 'synthetic-lease', + refreshLeaseUntil: 400, + refreshLeaseTokenHash: 'c'.repeat(64), + lastQuotaRefreshError: { + message: 'synthetic quota failure', + checkedAt: 190, + nextRetryAt: 290, + accountIdentity: binding.identity, + }, + quotaErrorGeneration: 0, + quotaErrorClearedAt: 0, + quotaCheckedAt: 100, + quotaToken: 'e'.repeat(16), + profile: { + tier: 'max', + orgType: 'individual', + checkedAt: 100, + accountIdentity: binding.identity, + providerAccountUuid: binding.identity, + tokenFingerprint: 'e'.repeat(16), + }, + prime: { count: 1, inputTokens: 2, outputTokens: 3, since: 0 }, + authLineageId: 'synthetic-lineage', + primeAuthLineageRefreshTokenFingerprint: 'd'.repeat(16), + }, + 'synthetic-row-B': { + binding: { + kind: 'local', + storageId, + rowId: 'synthetic-row-B', + credentialEpoch: 1, + }, + lastUsed: 0, + }, + 'synthetic-vault-route': { + binding: { + kind: 'custody', + storageId, + routeId: 'synthetic-vault-route', + credentialId: 'synthetic-vault-credential', + accountIdentity: 'synthetic-vault-account', + recordVersion: 0, + }, + profile: { + tier: 'max', + orgType: 'team', + checkedAt: 0, + accountIdentity: 'synthetic-vault-account', + }, + }, + }, + }, + storageId, + ) +} + +async function fixture() { + const parent = join( + import.meta.dir, + '../../../../node_modules/.cache/native-validation-retry', + ) + await mkdir(parent, { recursive: true, mode: 0o700 }) + const root = await mkdtemp(join(parent, 'runtime-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + return join(root, 'private', 'runtime.json') +} + +test('validation retry round-trips its exact token-free tuple without mutating callers or other fields', async () => { + const path = await fixture() + const input = richState() + const before = structuredClone(input) + const decoded = decodeNativeRuntime(input, storageId) + expect(decoded).toEqual(before) + expect(decoded.accounts[binding.rowId]!.validationRetry).not.toBe( + input.accounts[binding.rowId]!.validationRetry, + ) + for (const key of ['binding', 'version'] as const) + expect( + decoded.accounts[binding.rowId]!.validationRetry!.subject[key], + ).not.toBe(input.accounts[binding.rowId]!.validationRetry!.subject[key]) + decoded.accounts[binding.rowId]!.lastUsed = 9 + expect(input).toEqual(before) + expect(await updateNativeRuntime(path, storageId, () => input)).toEqual( + before, + ) + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'ready', + state: before, + }) + expect(input).toEqual(before) + const bytes = await readFile(path, 'utf8') + const saved = JSON.parse(bytes).accounts[binding.rowId].validationRetry + expect(saved).toEqual(retry) + expect(Object.keys(saved).sort()).toEqual([ + 'checkedAt', + 'nextRetryAt', + 'subject', + ]) + expect(Object.keys(saved.subject).sort()).toEqual([ + 'binding', + 'credentialFingerprint', + 'version', + ]) + expect(Object.keys(saved.subject.binding).sort()).toEqual([ + 'credentialEpoch', + 'identity', + 'kind', + 'rowId', + 'storageId', + ]) + expect(Object.keys(saved.subject.version).sort()).toEqual([ + 'accessFingerprint', + 'expires', + 'lastRefreshedAt', + ]) + for (const forbidden of ['"access"', '"refresh"', '"apiKey"']) + expect(bytes).not.toContain(forbidden) + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(dirname(path))).mode & 0o777).toBe(0o700) +}) + +test('validation retry extra-key control rejects every layer including raw secrets and error text', () => { + const secret = 'sk-ant-oat01-synthetic-bearer-secret' + for (const [key, value] of Object.entries({ + extra: true, + message: 'synthetic raw provider error', + error: 'synthetic raw provider error', + access: secret, + refresh: 'synthetic-refresh-secret', + apiKey: 'synthetic-api-secret', + retryCount: 1, + type: 'api', + accountIdentity: binding.identity, + tokenHash: 'a'.repeat(64), + credentialValidation: subject, + status: 'proven', + })) { + for (const candidate of [ + { ...retry, [key]: value }, + { ...retry, subject: { ...subject, [key]: value } }, + { + ...retry, + subject: { ...subject, binding: { ...binding, [key]: value } }, + }, + { + ...retry, + subject: { ...subject, version: { ...subject.version, [key]: value } }, + }, + ]) { + expect(() => decodeNativeRuntime(runtime(candidate), storageId)).toThrow( + 'Anthropic runtime state is invalid', + ) + } + } +}) + +test('validation retry binding mismatch control rejects cross-row storage epoch and identity reuse without publication', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => richState()) + const before = await readFile(path) + for (const changed of [ + { ...binding, storageId: 'b'.repeat(64) }, + { ...binding, rowId: 'synthetic-row-B' }, + { ...binding, credentialEpoch: 5 }, + { ...binding, identity: 'synthetic-account-B' }, + ]) { + const candidate = runtime({ + ...retry, + subject: { ...subject, binding: changed }, + }) + expect(() => decodeNativeRuntime(candidate, storageId)).toThrow( + 'Anthropic runtime state is invalid', + ) + await expect( + updateNativeRuntime(path, storageId, () => + decodeNativeRuntime(candidate, storageId), + ), + ).rejects.toMatchObject({ code: 'invalid-runtime' }) + expect(await readFile(path)).toEqual(before) + } + for (const changed of [ + { ...binding, rowId: 'synthetic-row-B' }, + { ...binding, storageId: 'b'.repeat(64) }, + { ...binding, credentialEpoch: 5 }, + { ...binding, identity: 'synthetic-account-B' }, + ]) { + const copied = { + version: 1, + storageId: changed.storageId, + accounts: { + [changed.rowId]: { binding: changed, validationRetry: retry }, + }, + } + expect(() => decodeNativeRuntime(copied, changed.storageId)).toThrow( + 'Anthropic runtime state is invalid', + ) + } +}) + +test('validation retry rejects missing required keys and malformed record shapes', () => { + const invalid: unknown[] = [undefined, null, [], 'synthetic error', 0, {}] + for (const key of ['subject', 'checkedAt', 'nextRetryAt']) + invalid.push(without(retry, key), { ...retry, [key]: undefined }) + for (const key of ['binding', 'credentialFingerprint', 'version']) + invalid.push({ ...retry, subject: without(subject, key) }) + for (const key of [ + 'kind', + 'storageId', + 'rowId', + 'credentialEpoch', + 'identity', + ]) + invalid.push({ + ...retry, + subject: { ...subject, binding: without(binding, key) }, + }) + for (const key of ['accessFingerprint', 'expires']) + invalid.push({ + ...retry, + subject: { ...subject, version: without(subject.version, key) }, + }) + for (const value of [undefined, null, [], 'synthetic error', 0]) { + invalid.push({ ...retry, subject: value }) + for (const key of ['binding', 'version']) + invalid.push({ ...retry, subject: { ...subject, [key]: value } }) + } + for (const candidate of invalid) + expect(() => decodeNativeRuntime(runtime(candidate), storageId)).toThrow( + 'Anthropic runtime state is invalid', + ) +}) + +test('validation retry rejects every malformed nested binding field', () => { + for (const [key, values] of Object.entries({ + kind: [undefined, 'custody', 'api', '', 0], + storageId: [undefined, '', 'a'.repeat(16), 'A'.repeat(64), 'g'.repeat(64)], + rowId: [undefined, '', ' row', 'row ', 0], + credentialEpoch: [undefined, 0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '4'], + identity: [undefined, '', ' account', 'account ', 0], + })) { + for (const value of values) + expect(() => + decodeNativeRuntime( + runtime({ + ...retry, + subject: { ...subject, binding: { ...binding, [key]: value } }, + }), + storageId, + ), + ).toThrow('Anthropic runtime state is invalid') + } +}) + +test('validation retry rejects malformed lineage access fingerprints expiry and strict refresh stamps', () => { + for (const credentialFingerprint of [ + undefined, + '', + 'a'.repeat(16), + 'a'.repeat(63), + 'a'.repeat(65), + 'A'.repeat(64), + 'g'.repeat(64), + 'synthetic-refresh-secret', + ` ${subject.credentialFingerprint}`, + ]) { + expect(() => + decodeNativeRuntime( + runtime({ ...retry, subject: { ...subject, credentialFingerprint } }), + storageId, + ), + ).toThrow('Anthropic runtime state is invalid') + } + for (const accessFingerprint of [ + undefined, + '', + 'a'.repeat(15), + 'a'.repeat(17), + 'a'.repeat(64), + 'A'.repeat(16), + 'g'.repeat(16), + 'sk-ant-oat01-synthetic-bearer-secret', + 'synthetic raw provider error', + ]) { + expect(() => + decodeNativeRuntime( + runtime({ + ...retry, + subject: { + ...subject, + version: { ...subject.version, accessFingerprint }, + }, + }), + storageId, + ), + ).toThrow('Anthropic runtime state is invalid') + } + for (const field of ['expires', 'lastRefreshedAt']) { + const invalid: unknown[] = [ + undefined, + null, + -1, + 0.5, + '100', + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ] + if (field === 'expires') invalid.push(0) + for (const value of invalid) + expect(() => + decodeNativeRuntime( + runtime({ + ...retry, + subject: { + ...subject, + version: { ...subject.version, [field]: value }, + }, + }), + storageId, + ), + ).toThrow('Anthropic runtime state is invalid') + } +}) + +test('validation retry is refused on unknown identity vault and API entries', () => { + for (const enclosing of [ + without(binding, 'identity'), + { ...binding, identity: undefined }, + { kind: 'api', storageId, rowId: binding.rowId }, + { + kind: 'custody', + storageId, + routeId: binding.rowId, + credentialId: 'synthetic-vault-credential', + accountIdentity: binding.identity, + recordVersion: 1, + }, + ]) + expect(() => + decodeNativeRuntime(runtime(retry, enclosing), storageId), + ).toThrow('Anthropic runtime state is invalid') +}) + +test('validation retry uses safe ordered required observation and retry times', () => { + for (const key of ['checkedAt', 'nextRetryAt']) { + for (const value of [ + undefined, + null, + -1, + 0.5, + '200', + 'synthetic raw provider error', + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) + expect(() => + decodeNativeRuntime(runtime({ ...retry, [key]: value }), storageId), + ).toThrow('Anthropic runtime state is invalid') + } + expect(() => + decodeNativeRuntime(runtime({ ...retry, nextRetryAt: 199 }), storageId), + ).toThrow('Anthropic runtime state is invalid') + for (const [checkedAt, nextRetryAt] of [ + [0, 0], + [0, 1], + [200, 200], + [200, Number.MAX_SAFE_INTEGER], + [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], + ]) { + const candidate = runtime({ ...retry, checkedAt, nextRetryAt }) + expect(decodeNativeRuntime(candidate, storageId)).toEqual( + candidate, + ) + } +}) + +test('validation retry preserves strict stamp absence versus zero through disk round-trip', async () => { + const path = await fixture() + for (const version of [ + { accessFingerprint: subject.version.accessFingerprint, expires: 1_000 }, + { ...subject.version, lastRefreshedAt: 0 }, + ]) { + const candidate = runtime({ ...retry, subject: { ...subject, version } }) + await updateNativeRuntime(path, storageId, () => + decodeNativeRuntime(candidate, storageId), + ) + const read = await readNativeRuntime(path, storageId) + expect(read).toEqual({ status: 'ready', state: candidate }) + if (read.status !== 'ready') throw new Error('Runtime fixture missing') + const saved = read.state.accounts[binding.rowId]!.validationRetry! + expect(Object.hasOwn(saved.subject.version, 'lastRefreshedAt')).toBe( + Object.hasOwn(version, 'lastRefreshedAt'), + ) + expect(saved.subject.version.lastRefreshedAt).toBe( + 'lastRefreshedAt' in version ? 0 : undefined, + ) + } +}) + +test('adding and removing validation retry neither creates nor erases positive evidence or account metadata', async () => { + for (const hasProof of [false, true]) { + const path = await fixture() + const base = richState() + delete base.accounts[binding.rowId]!.validationRetry + if (!hasProof) delete base.accounts[binding.rowId]!.credentialValidation + expect(await updateNativeRuntime(path, storageId, () => base)).toEqual(base) + const before = await readFile(path) + const added = await updateNativeRuntime(path, storageId, (current) => { + current.accounts[binding.rowId]!.validationRetry = structuredClone(retry) + return current + }) + expect(added).toEqual({ + ...base, + accounts: { + ...base.accounts, + [binding.rowId]: { + ...base.accounts[binding.rowId]!, + validationRetry: retry, + }, + }, + }) + const read = await readNativeRuntime(path, storageId) + expect(read).toEqual({ status: 'ready', state: added }) + expect( + Object.hasOwn(added.accounts[binding.rowId]!, 'credentialValidation'), + ).toBe(hasProof) + const removed = await updateNativeRuntime(path, storageId, (current) => { + delete current.accounts[binding.rowId]!.validationRetry + return current + }) + expect(removed).toEqual(base) + expect(await readFile(path)).toEqual(before) + } +}) + +test('validation retry does not normalize valid changed credential tuple components', () => { + for (const changed of [ + { ...subject, credentialFingerprint: 'b'.repeat(64) }, + { + ...subject, + version: { ...subject.version, accessFingerprint: 'c'.repeat(16) }, + }, + { ...subject, version: { ...subject.version, expires: 1_001 } }, + { ...subject, version: { ...subject.version, lastRefreshedAt: 101 } }, + ]) { + const candidate = runtime({ ...retry, subject: changed }) + const before = structuredClone(candidate) + const decoded = decodeNativeRuntime(candidate, storageId) + expect(decoded).toEqual(before) + expect(candidate).toEqual(before) + expect( + decoded.accounts[binding.rowId]!.credentialValidation, + ).toBeUndefined() + } +}) + +test('validation retry secret-bearing input reports redacted errors and preserves exact persisted bytes', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => richState()) + const before = await readFile(path) + for (const secret of [ + 'sk-ant-oat01-synthetic-bearer-secret', + 'synthetic-refresh-secret', + 'synthetic raw provider error', + ]) { + const candidate = runtime({ + ...retry, + subject: { ...subject, credentialFingerprint: secret }, + }) + await expect( + updateNativeRuntime(path, storageId, () => + decodeNativeRuntime(candidate, storageId), + ), + ).rejects.toMatchObject({ + code: 'invalid-runtime', + message: 'Anthropic runtime state is invalid', + }) + expect(await readFile(path)).toEqual(before) + const malformedPath = await fixture() + await mkdir(dirname(malformedPath), { mode: 0o700 }) + const bytes = JSON.stringify(candidate) + await writeFile(malformedPath, bytes, { mode: 0o600 }) + try { + await readNativeRuntime(malformedPath, storageId) + throw new Error('Malformed retry was accepted') + } catch (error) { + expect(error).toBeInstanceOf(NativeRuntimeError) + expect(String(error)).toBe( + 'NativeRuntimeError: Anthropic runtime state is invalid', + ) + expect(String(error)).not.toContain(secret) + } + expect(await readFile(malformedPath, 'utf8')).toBe(bytes) + } +}) + +test('validation retry does not relax same-epoch identity changes or monotone clear fences', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => richState()) + const before = await readFile(path) + for (const key of [ + 'refreshErrorClearedAt', + 'quotaErrorClearedAt', + 'quotaErrorGeneration', + ] as const) { + await expect( + updateNativeRuntime(path, storageId, (current) => { + delete current.accounts[binding.rowId]![key] + return current + }), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) + expect(await readFile(path)).toEqual(before) + } + for (const changed of [ + without(binding, 'identity'), + { ...binding, identity: 'synthetic-account-B' }, + ]) { + await expect( + updateNativeRuntime(path, storageId, () => + decodeNativeRuntime( + { + version: 1, + storageId, + accounts: { [binding.rowId]: { binding: changed } }, + }, + storageId, + ), + ), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) + expect(await readFile(path)).toEqual(before) + } + const unknownPath = await fixture() + await updateNativeRuntime(unknownPath, storageId, () => + decodeNativeRuntime( + { + version: 1, + storageId, + accounts: { + [binding.rowId]: { binding: without(binding, 'identity') }, + }, + }, + storageId, + ), + ) + const unknownBytes = await readFile(unknownPath) + await expect( + updateNativeRuntime(unknownPath, storageId, () => + decodeNativeRuntime(runtime(retry), storageId), + ), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) + expect(await readFile(unknownPath)).toEqual(unknownBytes) +}) From 87f65f69010a93fd8cb0cd6c8ca29ee4414a0631 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:34:21 +0200 Subject: [PATCH 020/169] mason: require positive native credential admission Expose authorize with mandatory serving or refresh intent. Match validation evidence against one captured requesting row, carry immutable per-attempt publication context, and retain stored replacement lineage/version while sanitizing failures. Deliberately tighten former test expectations: an unproven concurrently changed successor is refused, missing identity evidence no longer prohibits exchange recovery, raw committed-error carriers become structured failures, and validation repair requires account lookup rather than adoption. Add bidirectional absence/zero timestamp regressions and minimal child failure diagnostics. The historical child fixture stall remains unresolved; retained phase controls and passing gates do not claim a timing fix. No producer adoption or live wiring is included. --- .../core/src/native-refresh-coordinator.ts | 652 +++++-- .../tests/native-refresh-coordinator.test.ts | 1584 +++++++++++++++-- 2 files changed, 2000 insertions(+), 236 deletions(-) diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index c4b846195..5c136fff7 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -18,6 +18,11 @@ import { import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from './auth.ts' import { resolveClaudeCodeIdentity } from './claude-code.ts' +import { + type NativeLocalCredentialValidation, + type NativeLocalCredentialVersion, + nativeLocalCredentialValidationMatches, +} from './native-credential-validation.ts' import { isTransientNetworkError } from './network-errors.ts' import { isNativeLocalPoolBinding, @@ -54,11 +59,7 @@ export type NativeRefreshRestriction = | { status: 'allowed' } | { status: 'blocked' - reason: - | 'quota-ineligible' - | 'account-disabled' - | 'local-mode-unavailable' - | 'identity-unproven' + reason: 'quota-ineligible' | 'account-disabled' | 'local-mode-unavailable' } | { status: 'blocked' @@ -79,7 +80,7 @@ export type NativeRefreshHandoff = | 'skipped-cancelled' export interface NativeRefreshFailure { - kind: PoolFailureKind | 'caller-hook' + kind: PoolFailureKind | 'caller-hook' | 'validation' classification: 'transient' | 'invalid-grant' | 'permanent' status?: number retryAfter?: number @@ -89,10 +90,57 @@ export interface NativeRefreshFailure { * Access can change while the refresh token and credential epoch stay the * same. These fields identify the exact stored OAuth version observed. */ -export interface NativeRefreshDispatchVersion { - readonly accessFingerprint?: string - readonly expires?: number - readonly lastRefreshedAt?: number +export type NativeRefreshDispatchVersion = Partial + +/** + * Identifies a captured pool row by storage, row id, credential epoch and + * recorded account UUID, together with its full refresh-token fingerprint and + * access fingerprint, expiry and optional refresh timestamp. Neither token is + * included; access fingerprint and expiry may be absent before recovery. + */ +export interface NativeRefreshSubject { + readonly binding: NativeLocalPoolBinding + readonly credentialFingerprint: string + readonly version: NativeRefreshDispatchVersion +} + +export type NativeKnownCredentialSubject = NativeLocalCredentialValidation + +/** + * Records the runtime binding and error-reset timestamps/generation observed + * for the captured pool row. Null means no binding or no reset value was + * present; a recorded timestamp or generation of zero must remain distinct. + */ +export interface NativeRefreshContext { + readonly subject: NativeRefreshSubject + readonly runtimeBinding: NativeLocalPoolBinding | null + readonly refreshErrorClearedAt: number | null + readonly quotaErrorClearedAt: number | null + readonly quotaErrorGeneration: number | null +} + +export interface NativeRefreshPolicy { + readonly restriction: NativeRefreshRestriction + readonly context: NativeRefreshContext +} + +export type NativeServingAdmission = + | { status: 'proven'; validation: NativeLocalCredentialValidation } + | { status: 'unproven' } + | { + status: 'blocked' + reason: + | 'quota-ineligible' + | 'account-disabled' + | 'local-mode-unavailable' + | 'refresh-backoff' + | 'invalid-grant' + | 'validation-backoff' + } + +export interface NativeCommittedMaterial { + readonly credentialFingerprint: string + readonly version: NativeRefreshDispatchVersion } interface ObservationBase { @@ -104,11 +152,23 @@ interface ObservationBase { attempt: number credentialFingerprint?: string credentialVersion?: NativeRefreshDispatchVersion + /** + * Runtime binding and error-reset values read before the provider exchange or + * account lookup. Later serving checks must not replace these values: doing + * so could let an older network result overwrite an intervening error reset. + */ + context?: NativeRefreshContext bootstrap: NativeIdentityBootstrap handoff: NativeRefreshHandoff } -/** Token-free observations. In particular bootstrap exceptions are never forwarded. */ +/** + * Observations contain no tokens or exceptions from the provider account lookup. + * Only a successful account lookup reported by a persisted or + * validation-observed event can support new validation evidence. An adopted + * event merely records credentials already present on the requesting pool row; + * it performs no account lookup. Failed events cannot create validation evidence. + */ export type NativeRefreshObservation = ObservationBase & ( | { @@ -116,12 +176,18 @@ export type NativeRefreshObservation = ObservationBase & identity?: string successorFingerprint: string successorVersion: NativeRefreshDispatchVersion + committed: NativeCommittedMaterial } | { status: 'adopted' successorFingerprint: string successorVersion: NativeRefreshDispatchVersion } + | { + status: 'validation-observed' + identity: string + context: NativeRefreshContext + } | { status: 'refused'; reason: string; consumed: boolean } | { status: 'identity-contradicted' @@ -129,47 +195,59 @@ export type NativeRefreshObservation = ObservationBase & returnedIdentity: string successorFingerprint: string successorVersion: NativeRefreshDispatchVersion + committed: NativeCommittedMaterial } | { status: 'failed' failure: NativeRefreshFailure persisted: boolean - committedVersion?: NativeRefreshDispatchVersion + committed?: NativeCommittedMaterial } ) export interface NativeRefreshHooks { /** - * Read current local-mode, account-disable, quota and refresh-error - * restrictions from native state. Apply token-bound errors only to their - * matching credential fingerprint. Also report when a previously persisted - * successor still lacks account validation. Do not read host credentials or - * mutate the store. This callback runs outside shared store locks. - * Before exchange, capture the native error-clear markers for that - * observation. Do not replace them when later policy reads return newer state. + * Read native local-mode, account-disable, quota and refresh-error restrictions + * for the captured row. Credential-bound errors apply only to the matching + * full refresh-token fingerprint; absent account-validation evidence must not + * prevent a refresh exchange. Echo the supplied row binding, refresh-token + * fingerprint and access version. Include the observed runtime binding, + * refreshErrorClearedAt, quotaErrorClearedAt and quotaErrorGeneration, using + * null for absent values. Before each physical exchange the coordinator keeps + * a new frozen copy after lock waits, outside pool-config/pool-state locks. + * Do not read host credentials or mutate the store. */ - readRestrictions( - binding: NativeLocalPoolBinding, - ): Promise + readRestrictions(subject: NativeRefreshSubject): Promise /** - * Write credential-related refresh errors and validation state only while - * holding pool-config, pool-state, then native-runtime locks. Re-read the - * selected row and check its account/epoch, refresh fingerprint, and the - * error-clear markers captured before the exchange. Check access fingerprint, - * expiry and lastRefreshedAt too: credentialVersion identifies the credential - * captured before the exchange; successorVersion identifies the pool - * credential after a successful exchange or a concurrently completed - * rotation; committedVersion identifies a replacement saved before a later - * failure. Match the appropriate version before applying an observation. If - * that version is unavailable or a clear marker changed, do not update - * credential-related state. Newly learned identities apply only to the row - * that received them. Clear stale token errors only after confirming the - * replacement is saved or adopting a current credential from the pool. A - * quarantined or unvalidated credential must not serve. Keep a saved - * successor unvalidated if account checks failed. Reconciliation finishes - * before refresh returns, outside shared store locks; ordinary row/provider - * locks remain held. Do not re-enter row operations or wait for callers - * joining this job. + * Read serving restrictions and validation evidence for the supplied row + * binding, full refresh-token fingerprint and access version, without token + * input or state writes. The binding has a recorded account UUID, but that + * UUID alone does not establish that the presented credential was validated. + * Returned evidence must match the credentials on the captured requesting row. + */ + readAdmission( + subject: NativeKnownCredentialSubject, + ): Promise + /** + * Acquire pool-config, pool-state, then native-runtime before saving refresh + * errors or validation evidence. Re-read the observation's pool row and check + * storage, row id, credential epoch, recorded UUID, full refresh-token + * fingerprint, access fingerprint, expiry and lastRefreshedAt presence/value. + * credentialVersion names the credential captured before network work; + * successorVersion names the observed replacement; committed names the + * replacement actually saved before a later failure. A failed observation + * without committed credentials must not infer a replacement from a newer row. + * Compare the runtime binding and error-reset timestamps/generation with + * context. Changed reset values mean the older observation cannot overwrite + * state cleared while its exchange or lookup was pending. Learned UUIDs belong + * only to the row receiving the replacement. Clear obsolete credential-bound + * errors only after confirming which replacement is stored. Failed account + * lookups leave stored replacements without new validation evidence; observing + * an already advanced row or a failed operation cannot create that evidence. + * Reconciliation completes before authorize returns. Shared config/state locks + * have been released; successful serialized exchanges still hold row/provider + * leases, but failed and validation-only observations do not promise them. + * Do not start row operations or wait for callers joining the pending job. */ reconcile(observation: NativeRefreshObservation): Promise } @@ -182,6 +260,7 @@ export interface NativeRefreshCoordinatorOptions } export interface NativeRefreshRequest { + intent: 'serve' | 'refresh' mode: 'local' | 'claustrum' binding: NativeLocalPoolBinding /** A 401's dispatched access token, used only for replacement adoption. */ @@ -192,14 +271,17 @@ export interface NativeRefreshRequest { export type NativeRefreshResult = | { status: 'usable' - source: 'rotated' | 'adopted' + source: 'current' | 'validated' | 'rotated' | 'adopted' binding: NativeLocalPoolBinding /** - * Read from the requesting row after the job completes. An alias must - * not receive the credential refreshed for a different row. + * Token from the row addressed by request.binding.rowId whose validation + * evidence was checked. A caller for another row with the same account UUID + * may share recovery work, but must not receive the job owner's row token. */ access: string expires: number + subject: NativeKnownCredentialSubject + validation: NativeLocalCredentialValidation } | { status: 'refused'; reason: string; persisted: boolean } | { @@ -210,7 +292,7 @@ export type NativeRefreshResult = } | { status: 'failed' - error: Error + failure: NativeRefreshFailure persisted: boolean reconciliationFailed: boolean } @@ -218,9 +300,8 @@ export type NativeRefreshResult = type JobResult = | { status: 'completed' - source: 'rotated' | 'adopted' + source: 'current' | 'validated' | 'rotated' | 'adopted' identity?: string - proven: boolean persisted: boolean } | Exclude @@ -242,7 +323,7 @@ function unregister(job: Job, key: string) { function failureOf(error: Error): NativeRefreshFailure { const cause = error instanceof PoolOperationError ? error.cause : error const kind = error instanceof PoolOperationError ? error.kind : 'caller-hook' - if (cause instanceof ClaudeOAuthRefreshError) { + if (kind === 'provider' && cause instanceof ClaudeOAuthRefreshError) { const invalidGrant = /\binvalid_grant\b/.test(cause.body) return { kind, @@ -282,7 +363,9 @@ function dispatchVersion( ? tokenFingerprint(credential.access) : undefined, expires: credential.expires, - lastRefreshedAt: credential.lastRefreshedAt, + ...(Object.hasOwn(credential, 'lastRefreshedAt') + ? { lastRefreshedAt: credential.lastRefreshedAt } + : {}), }) } @@ -315,7 +398,10 @@ function sameDispatch(left: PoolRow, right: PoolRow): boolean { b?.type === 'oauth' && a.access === b.access && a.refresh === b.refresh && - a.expires === b.expires + a.expires === b.expires && + Object.hasOwn(a, 'lastRefreshedAt') === + Object.hasOwn(b, 'lastRefreshedAt') && + a.lastRefreshedAt === b.lastRefreshedAt ) } @@ -323,6 +409,90 @@ function exhaustive(outcome: never): never { throw new Error(`Unsupported refresh outcome: ${String(outcome)}`) } +function sameBinding(a: NativeLocalPoolBinding, b: NativeLocalPoolBinding) { + return ( + isNativeLocalPoolBinding(a) && + isNativeLocalPoolBinding(b) && + a.storageId === b.storageId && + a.rowId === b.rowId && + a.credentialEpoch === b.credentialEpoch && + a.identity === b.identity + ) +} + +function sameSubject(a: NativeRefreshSubject, b: NativeRefreshSubject) { + return ( + sameBinding(a.binding, b.binding) && + a.credentialFingerprint === b.credentialFingerprint && + a.version.accessFingerprint === b.version.accessFingerprint && + a.version.expires === b.version.expires && + Object.hasOwn(a.version, 'lastRefreshedAt') === + Object.hasOwn(b.version, 'lastRefreshedAt') && + a.version.lastRefreshedAt === b.version.lastRefreshedAt + ) +} + +function copySubject(subject: T): T { + return Object.freeze({ + ...subject, + binding: Object.freeze({ ...subject.binding }), + version: Object.freeze({ ...subject.version }), + }) +} + +function subjectOf( + binding: NativeLocalPoolBinding, + row: PoolRow, +): NativeRefreshSubject { + if (row.credential?.type !== 'oauth' || !row.fingerprint) + throw new LocalRefreshRefusal('access-unavailable') + return copySubject({ + binding, + credentialFingerprint: row.fingerprint, + version: dispatchVersion(row.credential), + }) +} + +function committedOf(credential: StoredCredential): NativeCommittedMaterial { + return Object.freeze({ + credentialFingerprint: fingerprintOf(credential), + version: dispatchVersion(credential), + }) +} + +function freezeContext( + context: NativeRefreshContext, + subject: NativeRefreshSubject, +): NativeRefreshContext { + if ( + !sameSubject(context.subject, subject) || + (context.runtimeBinding !== null && + !isNativeLocalPoolBinding(context.runtimeBinding)) || + ![ + context.refreshErrorClearedAt, + context.quotaErrorClearedAt, + context.quotaErrorGeneration, + ].every( + (value) => + value === null || (Number.isSafeInteger(value) && Number(value) >= 0), + ) + ) + throw new Error('Native policy context does not match the captured subject') + // Copy only the supplied row/credential identifiers, observed runtime binding + // and error-reset values. The hook cannot attach tokens to the observation or + // change its captured reset values by later mutating its returned object. + return Object.freeze({ + subject: copySubject(subject), + runtimeBinding: + context.runtimeBinding === null + ? null + : Object.freeze({ ...context.runtimeBinding }), + refreshErrorClearedAt: context.refreshErrorClearedAt, + quotaErrorClearedAt: context.quotaErrorClearedAt, + quotaErrorGeneration: context.quotaErrorGeneration, + }) +} + /** Independent local machinery: construction performs no network or migration. */ export function createNativeRefreshCoordinator( options: NativeRefreshCoordinatorOptions, @@ -339,6 +509,92 @@ export function createNativeRefreshCoordinator( : undefined } + async function readPolicy( + subject: NativeRefreshSubject, + ): Promise { + let policy: NativeRefreshPolicy + try { + policy = await options.readRestrictions(subject) + } catch (cause) { + throw new Error('Native policy callback failed', { cause }) + } + return Object.freeze({ + restriction: Object.freeze({ ...policy.restriction }), + context: freezeContext(policy.context, subject), + }) + } + + async function admit( + request: NativeRefreshRequest, + expected: NativeLocalPoolBinding, + row: PoolRow | undefined, + source: Extract['source'], + persisted: boolean, + ): Promise { + const refuse = (reason: string): NativeRefreshResult => ({ + status: 'refused', + reason, + persisted, + }) + if ( + !row || + !nativeLocalPoolBindingMatches(expected, options.paths, row) || + !row.enabled || + !row.candidate + ) + return refuse('row-ineligible') + if (expected.identity === undefined) return refuse('identity-unproven') + const credential = row.credential + if ( + credential?.type !== 'oauth' || + !credential.access || + typeof credential.expires !== 'number' || + credential.expires <= now() + ) + return refuse('access-unavailable') + if (credential.access === request.rejectedAccessToken) + return refuse('rejected-access-unchanged') + const subject: NativeKnownCredentialSubject = copySubject({ + binding: { ...expected, identity: expected.identity }, + credentialFingerprint: fingerprintOf(credential), + version: { + ...dispatchVersion(credential), + accessFingerprint: tokenFingerprint(credential.access), + expires: credential.expires, + }, + }) + let admission: NativeServingAdmission + try { + admission = await options.readAdmission(subject) + } catch (cause) { + throw new Error('Native admission callback failed', { cause }) + } + if (admission.status === 'blocked') return refuse(admission.reason) + if (admission.status !== 'proven') return refuse('proof-missing') + if ( + !nativeLocalCredentialValidationMatches( + admission.validation, + expected, + credential, + ) + ) + return refuse('proof-mismatch') + if (request.signal?.aborted) return refuse('cancelled') + if (credential.expires <= now()) return refuse('access-unavailable') + // Return the token from the same requesting row whose binding, refresh-token + // fingerprint and access version just matched the validation evidence. Do + // not substitute a newer row after awaiting the admission hook. + return Object.freeze({ + status: 'usable', + source, + binding: subject.binding, + access: credential.access, + expires: credential.expires, + subject, + validation: copySubject(admission.validation), + }) + } + async function handoffLock(identity: string): Promise { const spec = nativeAccountProviderLock(options.paths, identity) const started = performance.now() @@ -404,23 +660,21 @@ export function createNativeRefreshCoordinator( let handoff: NativeRefreshHandoff = 'not-needed' let fingerprint: string | undefined let credentialVersion: NativeRefreshDispatchVersion | undefined - let committedVersion: NativeRefreshDispatchVersion | undefined + let committed: NativeCommittedMaterial | undefined + let context: NativeRefreshContext | undefined let held: RefreshFileLock | undefined let releasing: Promise | undefined let removeAbortListener: (() => void) | undefined let aliasKey: string | undefined let reconciliationFailed = false - function identityProven() { - return bootstrapStatus === 'resolved' - } - function observation(): ObservationBase { return { binding, attempt, credentialFingerprint: fingerprint, credentialVersion, + context, bootstrap: bootstrapStatus, handoff, } @@ -431,7 +685,9 @@ export function createNativeRefreshCoordinator( await options.reconcile(event) } catch (error) { reconciliationFailed = true - throw error + throw new Error('Native reconciliation callback failed', { + cause: error, + }) } } @@ -469,11 +725,19 @@ export function createNativeRefreshCoordinator( try { for (attempt = 1; attempt <= 3; attempt++) { + // Clear the previous attempt's UUID, refresh lineage, access version and + // runtime context before awaiting another row read. If the next policy + // read fails, its failed observation must not reuse earlier reset values. consumed = false let endpointCalled = false + identity = undefined + fingerprint = undefined + credentialVersion = undefined + committed = undefined + context = undefined + aliasKey = undefined bootstrapStatus = 'unavailable' handoff = 'not-needed' - const restriction = await options.readRestrictions(binding) let captured: PoolRow | undefined const seen = await rowFor(binding) fingerprint = seen?.fingerprint @@ -481,6 +745,7 @@ export function createNativeRefreshCoordinator( seen?.credential?.type === 'oauth' ? dispatchVersion(seen.credential) : undefined + let policy: NativeRefreshPolicy | undefined const initialReason = !seen || !nativeLocalPoolBindingMatches(binding, options.paths, seen) ? 'binding-changed' @@ -488,7 +753,7 @@ export function createNativeRefreshCoordinator( ? 'cancelled' : !seen.candidate || !seen.enabled ? 'row-ineligible' - : restrictionReason(restriction, seen.fingerprint) + : undefined if (initialReason) { await reconcile({ ...observation(), @@ -498,25 +763,130 @@ export function createNativeRefreshCoordinator( }) return { status: 'refused', reason: initialReason, persisted: false } } + if (!seen) throw new Error('Captured row unavailable') + policy = await readPolicy(subjectOf(binding, seen)) + const policyReason = restrictionReason( + policy.restriction, + seen.fingerprint, + ) + if (policyReason) { + await reconcile({ + ...observation(), + status: 'refused', + reason: policyReason, + consumed: false, + }) + return { status: 'refused', reason: policyReason, persisted: false } + } if ( binding.identity !== undefined && - request.rejectedAccessToken !== undefined && + (request.intent === 'serve' || + request.rejectedAccessToken !== undefined) && seen?.credential?.type === 'oauth' && seen.credential.access && seen.credential.access !== request.rejectedAccessToken && (seen.credential.expires ?? 0) > now() ) { - await reconcile({ - ...observation(), - status: 'adopted', - successorFingerprint: fingerprintOf(seen.credential), - successorVersion: dispatchVersion(seen.credential), - }) + const admission = await admit( + request, + binding, + seen, + 'adopted', + false, + ) + if (admission.status === 'usable') { + // The adopted observation records credentials already present on the + // requesting pool row. No account lookup was performed, so observing + // that row cannot produce new validation evidence. + await reconcile({ + ...observation(), + status: 'adopted', + successorFingerprint: fingerprintOf(seen.credential), + successorVersion: dispatchVersion(seen.credential), + }) + } else if ( + admission.status === 'refused' && + admission.reason === 'proof-missing' + ) { + // Check the captured access token against the UUID already recorded + // on its pool row, outside store/publication locks and without + // rewriting identity. Saving evidence must still match that row's + // binding, refresh lineage, access fingerprint, expiry and timestamp. + context = policy.context + let validatedIdentity: string | undefined + let validationFailure: NativeRefreshFailure | undefined + try { + validatedIdentity = ( + await bootstrap(seen.credential.access, undefined, undefined) + ).accountUuid + bootstrapStatus = validatedIdentity ? 'resolved' : 'unavailable' + if (!validatedIdentity) + validationFailure = { + kind: 'validation', + classification: 'transient', + } + } catch (caught) { + bootstrapStatus = 'failed' + validationFailure = { + kind: 'validation', + classification: + isTransientNetworkError(caught) || + (caught instanceof ClaudeOAuthRefreshError && + (caught.status === 429 || caught.status >= 500)) + ? 'transient' + : 'permanent', + } + } + if (validationFailure) { + Object.freeze(validationFailure) + try { + await reconcile({ + ...observation(), + status: 'failed', + failure: validationFailure, + persisted: false, + }) + } catch { + // Failure to save the account-lookup result must preserve the + // lookup's failure classification, not report a refresh-endpoint + // authentication failure instead. + } + return { + status: 'failed', + failure: validationFailure, + persisted: false, + reconciliationFailed, + } + } + if (validatedIdentity !== binding.identity) { + await reconcile({ + ...observation(), + status: 'refused', + reason: 'validation-contradicted', + consumed: false, + }) + return { + status: 'refused', + reason: 'validation-contradicted', + persisted: false, + } + } + await reconcile({ + ...observation(), + status: 'validation-observed', + identity: binding.identity, + context, + }) + } else return admission return { status: 'completed', - source: 'adopted', + source: + admission.status === 'usable' + ? request.rejectedAccessToken + ? 'adopted' + : 'current' + : 'validated', identity: binding.identity, - proven: true, persisted: false, } } @@ -529,10 +899,11 @@ export function createNativeRefreshCoordinator( fingerprint = row.fingerprint credentialVersion = dispatchVersion(credential) // This read is outside pool locks. Recheck policy after file-lock waits. - const currentRestriction = await options.readRestrictions(binding) + policy = await readPolicy(subjectOf(binding, row)) + context = policy.context const reason = request.signal?.aborted ? 'cancelled' - : restrictionReason(currentRestriction, fingerprint) + : restrictionReason(policy.restriction, fingerprint) if (reason) throw new LocalRefreshRefusal(reason) endpointCalled = true const successor = await provider({ @@ -544,9 +915,18 @@ export function createNativeRefreshCoordinator( // reject the callback: the refresh token has already been consumed. consumed = true try { - identity = ( + const returnedIdentity = ( await bootstrap(successor.access, undefined, undefined) ).accountUuid + // An empty or whitespace-padded account UUID cannot identify a + // shared account job. Treat the lookup as unavailable and still + // save the replacement: the provider consumed the refresh token. + identity = + typeof returnedIdentity === 'string' && + returnedIdentity.length > 0 && + returnedIdentity.trim() === returnedIdentity + ? returnedIdentity + : undefined bootstrapStatus = identity ? 'resolved' : 'unavailable' } catch { bootstrapStatus = 'failed' @@ -630,11 +1010,13 @@ export function createNativeRefreshCoordinator( if (consumed) return undefined if (request.signal?.aborted) return 'cancelled' if (!row.candidate || !row.enabled) return 'row-ineligible' - return restrictionReason(restriction, row.fingerprint) + return policy + ? restrictionReason(policy.restriction, row.fingerprint) + : undefined }, onPersisted: async (_id, credential) => { persisted = true - committedVersion = dispatchVersion(credential) + committed = committedOf(credential) try { if (held) { try { @@ -647,8 +1029,9 @@ export function createNativeRefreshCoordinator( ...observation(), status: 'persisted', identity, - successorFingerprint: fingerprintOf(credential), - successorVersion: committedVersion, + successorFingerprint: committed.credentialFingerprint, + successorVersion: committed.version, + committed, }) } finally { await releaseHandoff() @@ -662,7 +1045,6 @@ export function createNativeRefreshCoordinator( status: 'completed', source: 'rotated', identity: outcome.identity, - proven: identityProven(), persisted: true, } case 'refused': @@ -679,14 +1061,15 @@ export function createNativeRefreshCoordinator( } case 'identity-contradicted': persisted = true - committedVersion = dispatchVersion(outcome.credential) + committed = committedOf(outcome.credential) await reconcile({ ...observation(), status: 'identity-contradicted', expectedIdentity: outcome.expectedIdentity, returnedIdentity: outcome.returnedIdentity, - successorFingerprint: fingerprintOf(outcome.credential), - successorVersion: committedVersion, + successorFingerprint: committed.credentialFingerprint, + successorVersion: committed.version, + committed, }) return { status: 'identity-contradicted', @@ -707,7 +1090,7 @@ export function createNativeRefreshCoordinator( error instanceof PoolOperationError && error.committed?.type === 'oauth' ) - committedVersion = dispatchVersion(error.committed) + committed = committedOf(error.committed) if ( !endpointCalled && error instanceof PoolOperationError && @@ -730,6 +1113,7 @@ export function createNativeRefreshCoordinator( failure.kind = 'caller-hook' failure.classification = 'permanent' } + Object.freeze(failure) // Pass the failure observation to reconciliation only after the // store operation has released its locks. try { @@ -738,7 +1122,7 @@ export function createNativeRefreshCoordinator( status: 'failed', failure, persisted, - committedVersion, + committed, }) } catch { /* If reconciliation fails, preserve the original error classification @@ -759,7 +1143,7 @@ export function createNativeRefreshCoordinator( if (aliasKey) unregister(job, aliasKey) continue } - return { status: 'failed', error, persisted, reconciliationFailed } + return { status: 'failed', failure, persisted, reconciliationFailed } } finally { await releaseHandoff() } @@ -767,28 +1151,43 @@ export function createNativeRefreshCoordinator( throw new Error('Unreachable refresh attempt budget') } catch (caught) { const error = asError(caught) + persisted ||= + error instanceof PoolOperationError && + (error.committed !== undefined || error.phase === 'after-first-write') + if ( + error instanceof PoolOperationError && + error.committed?.type === 'oauth' + ) + committed = committedOf(error.committed) try { await reconcile({ ...observation(), status: 'failed', failure: failureOf(error), persisted, - committedVersion, + committed, }) } catch { /* If observation publication fails, do not return any credential as usable. */ } - return { status: 'failed', error, persisted, reconciliationFailed } + return { + status: 'failed', + failure: failureOf(error), + persisted, + reconciliationFailed, + } } finally { await releaseHandoff() } } - async function refresh( + async function authorize( request: NativeRefreshRequest, ): Promise { if (request.mode !== 'local') return { status: 'refused', reason: 'custody-mode', persisted: false } + if (request.intent !== 'serve' && request.intent !== 'refresh') + return { status: 'refused', reason: 'invalid-intent', persisted: false } if ( !isNativeLocalPoolBinding(request.binding) || request.binding.storageId !== options.paths.storageId @@ -801,6 +1200,38 @@ export function createNativeRefreshCoordinator( return { status: 'refused', reason: 'cancelled', persisted: false } const key = nativeLocalRefreshJobKey(request.binding) let job = jobs.get(key) + if (!job && request.intent === 'serve') { + try { + const current = await admit( + request, + request.binding, + await rowFor(request.binding), + 'current', + false, + ) + if ( + current.status !== 'refused' || + ![ + 'proof-missing', + 'identity-unproven', + 'access-unavailable', + 'rejected-access-unchanged', + ].includes(current.reason) + ) + return current + } catch (caught) { + return { + status: 'failed', + failure: failureOf(asError(caught)), + persisted: false, + reconciliationFailed: false, + } + } + // While the admission hook was awaited, another caller may have started + // recovery for the same storage, credential epoch and account UUID (or + // unknown-identity row). Check for that job before starting duplicate work. + job = jobs.get(key) + } if (!job) { job = { keys: new Set([key]), @@ -824,12 +1255,6 @@ export function createNativeRefreshCoordinator( persisted: result.persisted, } if (result.status !== 'completed') return result - if (!result.proven) - return { - status: 'refused', - reason: 'identity-unproven', - persisted: result.persisted, - } try { const expected = request.binding.identity === undefined && result.source === 'rotated' @@ -838,56 +1263,23 @@ export function createNativeRefreshCoordinator( ...(result.identity ? { identity: result.identity } : {}), } : request.binding - const restriction = await options.readRestrictions(expected) const row = await rowFor(request.binding) - if ( - !row || - !nativeLocalPoolBindingMatches(expected, options.paths, row) || - !row.enabled || - !row.candidate - ) - return { - status: 'refused', - reason: 'row-ineligible', - persisted: result.persisted, - } - const reason = restrictionReason(restriction, row.fingerprint) - if (reason) - return { status: 'refused', reason, persisted: result.persisted } - const credential = row.credential - if ( - credential?.type !== 'oauth' || - !credential.access || - typeof credential.expires !== 'number' || - credential.expires <= now() + return await admit( + request, + expected, + row, + result.source, + result.persisted, ) - return { - status: 'refused', - reason: 'access-unavailable', - persisted: result.persisted, - } - if (credential.access === request.rejectedAccessToken) - return { - status: 'refused', - reason: 'rejected-access-unchanged', - persisted: result.persisted, - } - return { - status: 'usable', - source: result.source, - binding: Object.freeze(expected), - access: credential.access, - expires: credential.expires, - } } catch (error) { return { status: 'failed', - error: asError(error), + failure: failureOf(asError(error)), persisted: result.persisted, reconciliationFailed: false, } } } - return { refresh } + return { authorize } } diff --git a/packages/core/src/tests/native-refresh-coordinator.test.ts b/packages/core/src/tests/native-refresh-coordinator.test.ts index 24fea9195..f4be4f69b 100644 --- a/packages/core/src/tests/native-refresh-coordinator.test.ts +++ b/packages/core/src/tests/native-refresh-coordinator.test.ts @@ -6,7 +6,14 @@ import { expect, } from 'bun:test' import { AsyncLocalStorage } from 'node:async_hooks' -import { appendFileSync, existsSync } from 'node:fs' +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs' import { mkdtemp, readFile, @@ -38,13 +45,21 @@ import type { ProviderAccountUuid, resolveClaudeCodeIdentity, } from '../claude-code.ts' +import { + type NativeLocalCredentialValidation, + nativeLocalCredentialValidationMatches, +} from '../native-credential-validation.ts' import { createNativeRefreshCoordinator, + type NativeRefreshContext, type NativeRefreshCoordinatorOptions, type NativeRefreshDispatchVersion, type NativeRefreshObservation, + type NativeRefreshPolicy, + type NativeRefreshRequest, type NativeRefreshRestriction, type NativeRefreshResult, + type NativeRefreshSubject, nativeAccountProviderLock, } from '../native-refresh-coordinator.ts' import { @@ -59,30 +74,10 @@ import { nativePoolStoreLocks, } from '../pool-store.ts' import { tokenFingerprint } from '../token-fingerprint.ts' +import { TestLifetime } from './test-lifetime.ts' const roots: string[] = [] -class TestLifetime { - readonly roots = new Set() - readonly gates = new Set<() => void>() - readonly calls = new Set>() - body?: Promise - closing = false - - track(call: Promise): Promise { - this.calls.add(call) - void call.finally(() => this.calls.delete(call)).catch(() => {}) - return call - } - - async finish() { - this.closing = true - for (const release of this.gates) release() - this.gates.clear() - await this.body?.catch(() => {}) - while (this.calls.size) await Promise.allSettled([...this.calls]) - } -} - +const scenarioRoots = new WeakMap>() const testLifetime = new AsyncLocalStorage() let currentLifetime: TestLifetime | undefined @@ -96,18 +91,23 @@ function test( () => { const scenario = new TestLifetime() currentLifetime = scenario - const running = testLifetime.run(scenario, async () => { - await body() - }) - scenario.body = running - return running + return testLifetime.run(scenario, () => scenario.runBody(body)) }, timeout, ) } function track(call: Promise): Promise { - return testLifetime.getStore()?.track(call) ?? call + // A Bun deadline can end while the body or its store operations still run. + // Return the original promise so assertions see any rejection, and separately + // wait for its settlement before removing the test's pool fixture files. + testLifetime.getStore()?.trackDetached( + call.then( + () => {}, + () => {}, + ), + ) + return call } const quota = { validate: (v: unknown) => typeof v === 'number', @@ -115,14 +115,62 @@ const quota = { } const allowed: NativeRefreshRestriction = { status: 'allowed' } +function policyFor( + subject: NativeRefreshSubject, + restriction = allowed, +): NativeRefreshPolicy { + return { + restriction, + context: { + subject, + runtimeBinding: subject.binding, + refreshErrorClearedAt: null, + quotaErrorClearedAt: null, + quotaErrorGeneration: null, + }, + } +} + +function proofFor( + binding: NativeLocalCredentialValidation['binding'], + row: PoolRow, +): NativeLocalCredentialValidation { + const c = row.credential + if (c?.type !== 'oauth' || !c.access || !c.expires) + throw new Error('Proof requires OAuth material') + return { + binding, + credentialFingerprint: fingerprintOf(c), + version: { + accessFingerprint: tokenFingerprint(c.access), + expires: c.expires, + ...(Object.hasOwn(c, 'lastRefreshedAt') + ? { lastRefreshedAt: c.lastRefreshedAt } + : {}), + }, + } +} + +type FixtureOverrides = Omit< + Partial, + 'readRestrictions' +> & { + readRestrictions?: ( + subject: NativeRefreshSubject, + ) => Promise + publishProof?: boolean +} + function barrier() { + const scenario = testLifetime.getStore() + if (scenario) { + const gate = scenario.gate() + return { promise: gate.wait, release: gate.open } + } let release!: () => void const promise = new Promise((resolve) => { release = resolve }) - const scenario = testLifetime.getStore() - if (scenario?.closing) release() - else scenario?.gates.add(release) return { promise, release } } @@ -151,8 +199,14 @@ const provider: typeof refreshClaudeOAuthToken = async () => ({ async function fixture(seams: Partial = {}) { const root = await realpath(await mkdtemp(join(tmpdir(), 'native-refresh-'))) const scenario = testLifetime.getStore() - if (scenario) scenario.roots.add(root) - else roots.push(root) + if (scenario) { + let ownedRoots = scenarioRoots.get(scenario) + if (!ownedRoots) { + ownedRoots = new Set() + scenarioRoots.set(scenario, ownedRoots) + } + ownedRoots.add(root) + } else roots.push(root) const paths = await resolveNativePoolPaths( join(root, 'anthropic-auth.json'), join(root, 'anthropic-auth-state.json'), @@ -183,38 +237,106 @@ async function fixture(seams: Partial = {}) { await store.add({ id, identity: account, credential: credential(id) }) } const observations: NativeRefreshObservation[] = [] - function coordinator( - overrides: Partial = {}, - ) { + const proofs = new Map() + async function seedProof(id: string) { + const b = await binding(id) + if (b.identity === undefined) + throw new Error('Seeded evidence requires known identity') + const proof = proofFor({ ...b, identity: b.identity }, await row(id)) + proofs.set(id, proof) + return proof + } + function coordinator(overrides: FixtureOverrides = {}) { const coordinator = createNativeRefreshCoordinator({ ...options, refreshToken: provider, resolveIdentity: identity, - readRestrictions: async () => allowed, + ...overrides, + readRestrictions: async (subject) => { + const result = (await overrides.readRestrictions?.(subject)) ?? allowed + return 'restriction' in result ? result : policyFor(subject, result) + }, + readAdmission: + overrides.readAdmission ?? + (async (subject) => { + const proof = proofs.get(subject.binding.rowId) + return proof + ? { status: 'proven', validation: proof } + : { status: 'unproven' } + }), reconcile: async (event) => { - observations.push(event) + if (overrides.reconcile) await overrides.reconcile(event) + else observations.push(event) + // For newly observed credentials, the fixture saves validation evidence + // only after a successful provider account lookup and a matching current + // row binding, refresh lineage and access version. A completed job or an + // adopted observation cannot create that evidence. + if ( + overrides.publishProof === false || + event.bootstrap !== 'resolved' || + (event.status !== 'persisted' && + event.status !== 'validation-observed') || + !event.identity + ) + return + const current = await row(event.binding.rowId) + const b = { ...event.binding, identity: event.identity } + const material = + event.status === 'persisted' + ? event.committed + : event.context?.subject + if ( + !nativeLocalPoolBindingMatches(b, paths, current) || + !material || + current.fingerprint !== material.credentialFingerprint || + !dispatchMatches(material.version, current) + ) + return + proofs.set( + current.id, + proofFor({ ...b, identity: event.identity }, current), + ) }, - ...overrides, }) return { - refresh: (request: Parameters[0]) => - track(coordinator.refresh(request)), + // These refresh tests ask for an exchange even when access is unexpired. + // The helper supplies refresh intent; the production factory exposes only + // authorize, requiring each caller to select serve or refresh explicitly. + refresh: (request: Omit) => + track(coordinator.authorize({ ...request, intent: 'refresh' })), + authorize: (request: NativeRefreshRequest) => + track(coordinator.authorize(request)), } } - return { root, paths, store, row, binding, add, observations, coordinator } + return { + root, + paths, + store, + row, + binding, + add, + observations, + coordinator, + proofs, + seedProof, + } } afterEach(async () => { const scenario = currentLifetime + traceTimeoutProbe('afterEach:entered') const unscopedRoots = roots.splice(0) // Bun reports a timeout without cancelling the test body. Open that body's // waiting gates, wait for the body and pending store calls, then delete the // test's files. + traceTimeoutProbe('lifetime:finish-start') await scenario?.finish() + traceTimeoutProbe('lifetime:finish-complete') await Promise.all( - [...unscopedRoots, ...(scenario?.roots ?? [])].map((root) => - rm(root, { recursive: true, force: true }), - ), + [ + ...unscopedRoots, + ...(scenario ? (scenarioRoots.get(scenario) ?? []) : []), + ].map((root) => rm(root, { recursive: true, force: true })), ) if (timeoutProbePool && timeoutProbeLog) { appendFileSync( @@ -239,6 +361,8 @@ function dispatchMatches( version.accessFingerprint === (credential.access ? tokenFingerprint(credential.access) : undefined) && version.expires === credential.expires && + Object.hasOwn(version, 'lastRefreshedAt') === + Object.hasOwn(credential, 'lastRefreshedAt') && version.lastRefreshedAt === credential.lastRefreshedAt ) } @@ -287,8 +411,6 @@ for (const terminal of ['persisted', 'failed'] as const) { const release = barrier() const finishProvider = barrier() const clearMarkers = { refreshErrorClearedAt: 7, quotaErrorClearedAt: 9 } - const capturedClearMarkers = { ...clearMarkers } - let markersCaptured = false let observation: VersionedObservation | undefined let freshAccepted = false let staleAccepted = false @@ -303,16 +425,16 @@ for (const terminal of ['persisted', 'failed'] as const) { }) let expectedVersion: NativeRefreshDispatchVersion | undefined const c = f.coordinator({ - readRestrictions: async () => { + readRestrictions: async (subject) => { const native = JSON.parse(await readFile(f.paths.runtime, 'utf8')) - if (!markersCaptured) { - capturedClearMarkers.refreshErrorClearedAt = - native.refreshErrorClearedAt - capturedClearMarkers.quotaErrorClearedAt = - native.quotaErrorClearedAt - markersCaptured = true + return { + ...policyFor(subject), + context: { + ...policyFor(subject).context, + refreshErrorClearedAt: native.refreshErrorClearedAt, + quotaErrorClearedAt: native.quotaErrorClearedAt, + }, } - return allowed }, refreshToken: async () => { if (terminal === 'failed') { @@ -352,9 +474,8 @@ for (const terminal of ['persisted', 'failed'] as const) { nativeLocalPoolBindingMatches(binding, f.paths, row) && row.fingerprint === expectedFingerprint && native.refreshErrorClearedAt === - capturedClearMarkers.refreshErrorClearedAt && - native.quotaErrorClearedAt === - capturedClearMarkers.quotaErrorClearedAt + event.context?.refreshErrorClearedAt && + native.quotaErrorClearedAt === event.context?.quotaErrorClearedAt const version = terminal === 'persisted' ? observation?.successorVersion @@ -416,7 +537,9 @@ for (const terminal of ['persisted', 'failed'] as const) { release.release() finishProvider.release() const result = await job - expect(result.status).toBe(terminal === 'persisted' ? 'usable' : 'failed') + expect(result.status).toBe( + terminal === 'persisted' ? 'refused' : 'failed', + ) expect(oldFieldsAccepted).toBe(true) expect(staleAccepted).toBe(false) expect(JSON.parse(await readFile(f.paths.runtime, 'utf8'))).toEqual({ @@ -477,6 +600,17 @@ test('captured clear markers fence a failure even when its dispatch version stil let freshAccepted = false let published = false const c = f.coordinator({ + readRestrictions: async (subject) => { + const markers = JSON.parse(await readFile(f.paths.runtime, 'utf8')) + return { + ...policyFor(subject), + context: { + ...policyFor(subject).context, + refreshErrorClearedAt: markers.refreshErrorClearedAt, + quotaErrorClearedAt: markers.quotaErrorClearedAt, + }, + } + }, refreshToken: async () => { throw new ClaudeOAuthRefreshError(429, 'busy', '7') }, @@ -487,7 +621,7 @@ test('captured clear markers fence a failure even when its dispatch version stil const currentMarkers = JSON.parse(await readFile(f.paths.runtime, 'utf8')) freshAccepted = dispatchMatches(event.credentialVersion, row) && - capturedMarkers.refreshErrorClearedAt === + event.context?.refreshErrorClearedAt === currentMarkers.refreshErrorClearedAt entered.release() await release.promise @@ -501,9 +635,9 @@ test('captured clear markers fence a failure even when its dispatch version stil nativeLocalPoolBindingMatches(binding, f.paths, row) && event.credentialFingerprint === row.fingerprint && versionMatched && - capturedMarkers.refreshErrorClearedAt === + event.context?.refreshErrorClearedAt === currentMarkers.refreshErrorClearedAt && - capturedMarkers.quotaErrorClearedAt === + event.context?.quotaErrorClearedAt === currentMarkers.quotaErrorClearedAt }) }, @@ -636,8 +770,8 @@ test('concurrency fixture reports terminal refusal and releases the waiting prov await finish.promise return provider({ refreshToken: 'synthetic' }) }, - readRestrictions: async (binding) => - binding.rowId === 'b' + readRestrictions: async (subject) => + subject.binding.rowId === 'b' ? { status: 'blocked', reason: 'quota-ineligible' } : allowed, }) @@ -674,6 +808,13 @@ test('concurrency fixture reports terminal refusal and releases the waiting prov const timeoutProbePool = process.env.NATIVE_REFRESH_TIMEOUT_POOL const timeoutProbeLog = process.env.NATIVE_REFRESH_TIMEOUT_LOG +function traceTimeoutProbe(event: string, data: Record = {}) { + if (!timeoutProbeLog) return + appendFileSync( + timeoutProbeLog, + `${JSON.stringify({ event, at: Date.now(), monotonic: performance.now(), ...data })}\n`, + ) +} let timeoutProbeFinallySeen = false let timeoutProbeCleanupCount = 0 if (timeoutProbePool && timeoutProbeLog) { @@ -685,7 +826,7 @@ if (timeoutProbePool && timeoutProbeLog) { event: string, data: Record = {}, ) => { - appendFileSync(timeoutProbeLog, `${JSON.stringify({ event, ...data })}\n`) + traceTimeoutProbe(event, data) } beforeAll(async () => { roots.push(timeoutProbePool) @@ -698,12 +839,27 @@ if (timeoutProbePool && timeoutProbeLog) { if (read.status !== 'ready' || !read.rows[0]) throw new Error('Prepared probe row required') const binding = captureNativeLocalPoolBinding(paths, read.rows[0]) + let proof: NativeLocalCredentialValidation | undefined const coordinator = createNativeRefreshCoordinator({ paths, quota, - readRestrictions: async () => allowed, + readRestrictions: async (subject) => policyFor(subject), + readAdmission: async () => + proof + ? { status: 'proven', validation: proof } + : { status: 'unproven' }, reconcile: async (event) => { record(`reconcile:${event.status}`) + if ( + event.status === 'persisted' && + event.identity && + event.bootstrap === 'resolved' + ) { + const current = await store.read() + const row = current.status === 'ready' ? current.rows[0] : undefined + if (!row) throw new Error('Persisted row required') + proof = proofFor({ ...binding, identity: event.identity }, row) + } }, resolveIdentity: identity, refreshToken: async () => { @@ -714,7 +870,7 @@ if (timeoutProbePool && timeoutProbeLog) { return provider({ refreshToken: 'synthetic' }) }, }) - job = coordinator.refresh({ mode: 'local', binding }) + job = coordinator.authorize({ mode: 'local', binding, intent: 'refresh' }) await entered.promise }) test('runner timeout probe deadline', async () => { @@ -744,6 +900,7 @@ if (timeoutProbePool && timeoutProbeLog) { } test('real Bun timeout preserves active fixture files until the body actually finishes', async () => { + const parentStarted = Date.now() const pool = await fixture() await pool.add('probe', 'A') const ledger = await fixture() @@ -768,11 +925,38 @@ test('real Bun timeout preserves active fixture files until the body actually fi stdin: 'ignore', }, ) - const [exit, stderr] = await Promise.all([ + let diagnostics: string | undefined + const retain = (stderr?: string, stdout?: string) => { + if (!diagnostics) { + const root = join( + process.cwd(), + 'node_modules/.cache/native-coordinator-diagnostics', + ) + mkdirSync(root, { recursive: true }) + diagnostics = mkdtempSync(join(root, 'timeout-')) + console.error(`BUN-TIMEOUT-DIAGNOSTICS ${diagnostics}`) + } + const events = readFileSync(log, 'utf8') + writeFileSync(join(diagnostics, 'events.jsonl'), events) + if (stderr !== undefined) writeFileSync(join(diagnostics, 'stderr'), stderr) + if (stdout !== undefined) writeFileSync(join(diagnostics, 'stdout'), stdout) + appendFileSync( + join(diagnostics, 'snapshots.jsonl'), + `${JSON.stringify({ at: Date.now(), parentStarted, exitCode: child.exitCode, stderr, stdout, events })}\n`, + ) + } + // Retain the hook ledger before the five-second deadline without adding pipe + // readers. Output is saved at EOF, including output truncated by a killed child. + const diagnostic = setTimeout( + () => retain(), + Math.max(0, parentStarted + 4_000 - Date.now()), + ) + const [exit, stderr, stdout] = await Promise.all([ child.exited, new Response(child.stderr).text(), new Response(child.stdout).text(), ]) + clearTimeout(diagnostic) const events = (await readFile(log, 'utf8')) .trim() .split('\n') @@ -785,8 +969,15 @@ test('real Bun timeout preserves active fixture files until the body actually fi status?: string }, ) - if (!events.find((event) => event.event === 'cleanup:1')?.finallySeen) - console.error(`BUN-TIMEOUT-PROBE ${JSON.stringify(events)}`) + if ( + exit !== 1 || + !stderr.includes('1 pass') || + !stderr.includes('1 fail') || + stderr.includes('Unhandled error') || + !events.find((event) => event.event === 'cleanup:1')?.finallySeen || + events.find((event) => event.event === 'cleanup:1')?.poolExists !== false + ) + retain(stderr, stdout) expect(exit).toBe(1) expect(stderr).toContain('runner timeout probe deadline') expect(stderr).toContain('1 pass') @@ -813,36 +1004,38 @@ test('lifetime waits for body reads and detached published-store writes and open let detached: | ReturnType['add']> | undefined - scenario.body = testLifetime.run(scenario, async () => { - const f = await fixture({ - onStep: async (step) => { - if (step !== 'before-state-write') return - const parked = barrier() - entered.release() - await parked.promise - const late = barrier() - await late.promise - lateGateOpened = true - }, - }) - store = f.store - detached = f.store.add({ - id: 'detached', - identity: 'A', - credential: credential('detached'), - }) - const bodyGate = barrier() - await bodyGate.promise - await f.store.read() - finalRead = true - }) + const body = testLifetime.run(scenario, () => + scenario.runBody(async () => { + const f = await fixture({ + onStep: async (step) => { + if (step !== 'before-state-write') return + const parked = barrier() + entered.release() + await parked.promise + const late = barrier() + await late.promise + lateGateOpened = true + }, + }) + store = createNativePoolStore({ paths: f.paths, quota }) + detached = f.store.add({ + id: 'detached', + identity: 'A', + credential: credential('detached'), + }) + const bodyGate = barrier() + await bodyGate.promise + await f.store.read() + finalRead = true + }), + ) try { await entered.promise await scenario.finish() + await body await detached expect(finalRead).toBe(true) expect(lateGateOpened).toBe(true) - expect(scenario.calls.size).toBe(0) expect(await store?.read()).toMatchObject({ status: 'ready', rows: [expect.objectContaining({ id: 'detached', stamp: 'bound' })], @@ -850,7 +1043,7 @@ test('lifetime waits for body reads and detached published-store writes and open } finally { await scenario.finish() await Promise.all( - [...scenario.roots].map((root) => + [...(scenarioRoots.get(scenario) ?? [])].map((root) => rm(root, { recursive: true, force: true }), ), ) @@ -1117,6 +1310,7 @@ test('stale-token 401 adopts pure-store replacement without a provider call and const binding = await f.binding('a') const stale = fingerprintOf(credential('a')) await f.store.rotate('a', credential('new')) + await f.seedProof('a') let calls = 0 let blocked = true const c = f.coordinator({ @@ -1152,6 +1346,7 @@ test('adoption is per-caller and never serves the access token that caller rejec await f.add('a', 'A') const binding = await f.binding('a') await f.store.rotate('a', credential('new')) + await f.seedProof('a') const entered = barrier() const finish = barrier() let calls = 0 @@ -1190,7 +1385,7 @@ test('adoption is per-caller and never serves the access token that caller rejec expect(calls).toBe(0) }) -test('same-token invalid grant and identity-unproven restrictions cannot be cleared by adoption', async () => { +test('same-token invalid grant restriction cannot be cleared by adoption', async () => { const f = await fixture() await f.add('a', 'A') const binding = await f.binding('a') @@ -1202,7 +1397,6 @@ test('same-token invalid grant and identity-unproven restrictions cannot be clea reason: 'invalid-grant', credentialFingerprint: fingerprintOf(credential('new')), }, - { status: 'blocked', reason: 'identity-unproven' }, ] satisfies NativeRefreshRestriction[]) { const c = f.coordinator({ refreshToken: async () => { @@ -1220,7 +1414,7 @@ test('same-token invalid grant and identity-unproven restrictions cannot be clea ).toMatchObject({ status: 'refused', reason: restriction.reason }) } expect(calls).toBe(0) - expect(f.observations.map((e) => e.status)).toEqual(['refused', 'refused']) + expect(f.observations.map((e) => e.status)).toEqual(['refused']) }) test('custody, stale epoch and pre-exchange cancellation refuse without provider calls', async () => { @@ -1456,14 +1650,14 @@ test('persisted reconciliation failure preserves committed fact and cleans learn reconciliationFailed: true, }) if (result.status !== 'failed') throw new Error('Expected failure') - expect(result.error).toBeInstanceOf(PoolOperationError) - expect(result.error).toMatchObject({ + expect(result.failure).toMatchObject({ kind: 'after-persist-hook', - committed: credential('new'), }) + expect(JSON.stringify(result)).not.toContain('access-new') + expect(JSON.stringify(result)).not.toContain('refresh-new') expect(f.observations.map((e) => e.status)).toEqual(['persisted', 'failed']) const failed = f.observations.find((event) => event.status === 'failed') - expect(failed?.committedVersion?.accessFingerprint).toBe( + expect(failed?.committed?.version.accessFingerprint).toBe( tokenFingerprint('access-new'), ) expect(failed?.credentialVersion?.accessFingerprint).toBe( @@ -1479,7 +1673,7 @@ test('persisted reconciliation failure preserves committed fact and cleans learn binding: await f.binding('a'), rejectedAccessToken: 'old', }), - ).toMatchObject({ status: 'usable', source: 'adopted' }) + ).toMatchObject({ status: 'usable', source: 'validated' }) const spec = nativeAccountProviderLock(f.paths, 'A') await withLock( spec.path, @@ -1872,7 +2066,7 @@ for (const [name, cause, expectedCalls, classification] of [ }) expect(result).toMatchObject({ status: 'failed', persisted: false }) if (result.status !== 'failed') throw new Error('Expected failed result') - expect(result.error.cause).toBe(cause) + expect(result.failure).toMatchObject({ kind: 'provider', classification }) expect(calls).toBe(expectedCalls) expect(captures).toBe(expectedCalls) expect(f.observations).toHaveLength(expectedCalls) @@ -1925,7 +2119,10 @@ test('rate limit reconciliation preserves transient retry metadata and blocks a reconciliationFailed: false, }) if (first.status !== 'failed') throw new Error('Expected failed result') - expect(first.error.cause).toBe(cause) + expect(first.failure).toMatchObject({ + kind: 'provider', + classification: 'transient', + }) expect(f.observations).toHaveLength(1) expect(f.observations[0]).toMatchObject({ status: 'failed', @@ -1986,6 +2183,17 @@ test('restriction change after exchange cannot discard rotation but does prevent let restriction: NativeRefreshRestriction = allowed const guarded = f.coordinator({ readRestrictions: async () => restriction, + readAdmission: async (subject) => { + if ( + restriction.status === 'blocked' && + restriction.reason === 'quota-ineligible' + ) + return { status: 'blocked', reason: 'quota-ineligible' } + const validation = f.proofs.get(subject.binding.rowId) + return validation + ? { status: 'proven', validation } + : { status: 'unproven' } + }, resolveIdentity: async () => { restriction = { status: 'blocked', reason: 'quota-ineligible' } return identity('x', undefined, undefined) @@ -2050,3 +2258,1167 @@ test('epoch-separated job cannot join an old job, and old terminal cleanup canno expect((await joined).status).toBe('usable') expect(calls).toBe(2) }) + +test('seeded positive proof serves current material without a job or exchange', async () => { + const f = await fixture() + await f.add('a', 'A') + const validation = await f.seedProof('a') + const c = f.coordinator({ + refreshToken: async () => { + throw new Error('Unexpected exchange') + }, + resolveIdentity: async () => { + throw new Error('Unexpected bootstrap') + }, + readRestrictions: async () => { + throw new Error('Serving is not exchange eligibility') + }, + }) + const result = await c.authorize({ + intent: 'serve', + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toMatchObject({ + status: 'usable', + source: 'current', + access: 'access-a', + subject: validation, + validation, + }) + if (result.status !== 'usable') throw new Error('Expected receipt') + expect( + nativeLocalCredentialValidationMatches( + result.validation, + result.binding, + (await f.row('a')).credential, + ), + ).toBe(true) + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result.subject.version)).toBe(true) + expect(Object.isFrozen(result.validation.binding)).toBe(true) + expect(JSON.stringify(result.subject)).not.toContain('access-a') + expect(f.observations).toEqual([]) + const publicFactory = createNativeRefreshCoordinator({ + paths: f.paths, + quota, + readRestrictions: async (subject) => policyFor(subject), + readAdmission: async () => ({ status: 'proven', validation }), + reconcile: async () => {}, + }) + expect(Object.keys(publicFactory)).toEqual(['authorize']) +}) + +test('explicit refresh exchanges proven unexpired material but a proven 401 successor adopts', async () => { + const f = await fixture() + await f.add('a', 'A') + await f.seedProof('a') + let exchanges = 0 + let bootstraps = 0 + const c = f.coordinator({ + refreshToken: async () => { + exchanges++ + return provider({ refreshToken: 'x' }) + }, + resolveIdentity: async () => { + bootstraps++ + return identity('x', undefined, undefined) + }, + }) + const binding = await f.binding('a') + expect( + await c.authorize({ intent: 'refresh', mode: 'local', binding }), + ).toMatchObject({ status: 'usable', source: 'rotated', access: 'access-new' }) + expect(exchanges).toBe(1) + expect(bootstraps).toBe(1) + expect( + await c.authorize({ + intent: 'refresh', + mode: 'local', + binding, + rejectedAccessToken: 'access-a', + }), + ).toMatchObject({ status: 'usable', source: 'adopted' }) + expect(exchanges).toBe(1) + expect(bootstraps).toBe(1) +}) + +test('positive admission refuses a forged exact-version echo', async () => { + const f = await fixture() + await f.add('a', 'A') + const proof = await f.seedProof('a') + const forged = { + ...proof, + version: { ...proof.version, expires: proof.version.expires + 1 }, + } + const c = f.coordinator({ + readAdmission: async () => ({ status: 'proven', validation: forged }), + }) + expect( + await c.authorize({ + intent: 'serve', + mode: 'local', + binding: await f.binding('a'), + }), + ).toMatchObject({ + status: 'refused', + reason: 'proof-mismatch', + persisted: false, + }) + expect(f.observations).toEqual([]) +}) + +for (const defect of [ + 'missing', + 'malformed', + 'lineage', + 'binding', + 'access', + 'stamp-presence', + 'stamp-value', +] as const) { + test(`positive admission rejects ${defect} evidence even after successful recovery`, async () => { + const f = await fixture() + await f.add('a', 'A') + const binding = await f.binding('a') + const c = f.coordinator({ + readAdmission: async () => { + const proof = f.proofs.get('a') + if (!proof || defect === 'missing') return { status: 'unproven' } + const validation = + defect === 'malformed' + ? JSON.parse('{"version":{}}') + : defect === 'lineage' + ? { ...proof, credentialFingerprint: '0'.repeat(64) } + : defect === 'binding' + ? { ...proof, binding: { ...proof.binding, rowId: 'other' } } + : defect === 'access' + ? { + ...proof, + version: { + ...proof.version, + accessFingerprint: '0'.repeat(16), + }, + } + : defect === 'stamp-value' + ? { + ...proof, + version: { + ...proof.version, + lastRefreshedAt: + (proof.version.lastRefreshedAt ?? 0) + 1, + }, + } + : { + ...proof, + version: { + accessFingerprint: proof.version.accessFingerprint, + expires: proof.version.expires, + }, + } + return { status: 'proven', validation } + }, + }) + expect( + await c.authorize({ intent: 'refresh', mode: 'local', binding }), + ).toMatchObject({ + status: 'refused', + reason: defect === 'missing' ? 'proof-missing' : 'proof-mismatch', + persisted: true, + }) + expect((await f.row('a')).credential).toMatchObject(credential('new')) + }) +} + +test('changed-access 401 successor stays unservable when validation publication is declined', async () => { + const f = await fixture() + await f.add('a', 'A') + await f.store.rotate('a', credential('advanced')) + let bootstraps = 0 + const c = f.coordinator({ + publishProof: false, + refreshToken: async () => { + throw new Error('Do not exchange advanced material') + }, + resolveIdentity: async (access) => { + expect(access).toBe('access-advanced') + bootstraps++ + return identity(access, undefined, undefined) + }, + }) + expect( + await c.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + rejectedAccessToken: 'access-a', + }), + ).toMatchObject({ status: 'refused', reason: 'proof-missing' }) + expect(bootstraps).toBe(1) + expect(f.observations.map((e) => e.status)).toEqual(['validation-observed']) + expect(f.proofs.size).toBe(0) +}) + +test('validation-only success never writes pool identity or dispatch material', async () => { + const events: string[] = [] + const f = await fixture({ + onStep: (_step, info) => { + events.push(info.operation) + }, + }) + await f.add('a', 'A') + const configBefore = await readFile(f.paths.config, 'utf8') + const stateBefore = await readFile(f.paths.state, 'utf8') + events.length = 0 + let bootstraps = 0 + const c = f.coordinator({ + refreshToken: async () => { + throw new Error('No exchange') + }, + resolveIdentity: async (access) => { + expect(access).toBe('access-a') + bootstraps++ + return identity(access, undefined, undefined) + }, + }) + const request = { + intent: 'serve' as const, + mode: 'local' as const, + binding: await f.binding('a'), + } + expect(await c.authorize(request)).toMatchObject({ + status: 'usable', + source: 'validated', + access: 'access-a', + }) + expect(await c.authorize(request)).toMatchObject({ + status: 'usable', + source: 'current', + }) + expect(bootstraps).toBe(1) + expect(events).not.toContain('recordIdentity') + expect(events).not.toContain('refresh') + expect(await readFile(f.paths.config, 'utf8')).toBe(configBefore) + expect(await readFile(f.paths.state, 'utf8')).toBe(stateBefore) + expect(f.observations[0]).toMatchObject({ + status: 'validation-observed', + context: { subject: f.proofs.get('a') }, + }) +}) + +for (const bootstrapResult of [ + 'unavailable', + 'transient', + 'mismatch', +] as const) { + test(`validation-only ${bootstrapResult} cannot prove identity or leak exceptions`, async () => { + const f = await fixture() + await f.add('a', 'A') + const before = await readFile(f.paths.state, 'utf8') + const c = f.coordinator({ + resolveIdentity: async () => { + if (bootstrapResult === 'transient') + throw Object.assign(new Error('access-a refresh-a socket'), { + code: 'ECONNRESET', + }) + return { + deviceId: 'd', + sessionId: 's', + ...(bootstrapResult === 'mismatch' + ? { accountUuid: accountUuid('B') } + : {}), + } + }, + }) + const result = await c.authorize({ + intent: 'serve', + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toMatchObject( + bootstrapResult === 'mismatch' + ? { status: 'refused', reason: 'validation-contradicted' } + : { + status: 'failed', + failure: { kind: 'validation', classification: 'transient' }, + persisted: false, + }, + ) + expect(f.proofs.size).toBe(0) + expect(await readFile(f.paths.state, 'utf8')).toBe(before) + expect(JSON.stringify([result, f.observations])).not.toContain('access-a') + expect(JSON.stringify([result, f.observations])).not.toContain('refresh-a') + }) +} + +test('exact-version validation backoff suppresses bootstrap but not a changed version', async () => { + const f = await fixture() + await f.add('a', 'A') + let backoff: NativeRefreshSubject | undefined + let bootstraps = 0 + const c = f.coordinator({ + readAdmission: async (subject) => { + const proof = f.proofs.get('a') + if (proof) return { status: 'proven', validation: proof } + if (backoff && JSON.stringify(backoff) === JSON.stringify(subject)) + return { status: 'blocked', reason: 'validation-backoff' } + return { status: 'unproven' } + }, + resolveIdentity: async () => { + if (++bootstraps === 1) + throw Object.assign(new Error('reset'), { code: 'ECONNRESET' }) + return identity('x', undefined, undefined) + }, + reconcile: async (event) => { + if (event.status === 'failed') backoff = event.context?.subject + }, + }) + const request = { + intent: 'serve' as const, + mode: 'local' as const, + binding: await f.binding('a'), + } + expect(await c.authorize(request)).toMatchObject({ + status: 'failed', + failure: { kind: 'validation' }, + }) + expect(backoff).toBeDefined() + expect(await c.authorize(request)).toMatchObject({ + status: 'refused', + reason: 'validation-backoff', + }) + expect(bootstraps).toBe(1) + await f.store.rotate('a', { ...credential('a'), access: 'advanced' }) + expect(await c.authorize(request)).toMatchObject({ + status: 'usable', + source: 'validated', + access: 'advanced', + }) + expect(bootstraps).toBe(2) +}) + +for (const material of ['unknown', 'expired', 'missing', 'rejected'] as const) { + test(`serve uses serialized exchange for ${material} material despite missing proof`, async () => { + let captures = 0 + const f = await fixture({ + hold: () => { + captures++ + }, + }) + await f.add('a', material === 'unknown' ? undefined : 'A') + if (material === 'expired') + await f.store.rotate('a', { ...credential('a'), expires: 1 }) + if (material === 'missing') + await f.store.rotate('a', { + type: 'oauth', + refresh: 'refresh-a', + expires: credential('a').expires, + }) + let exchanges = 0 + const c = f.coordinator({ + refreshToken: async () => { + exchanges++ + return provider({ refreshToken: 'x' }) + }, + }) + expect( + await c.authorize({ + intent: 'serve', + mode: 'local', + binding: await f.binding('a'), + ...(material === 'rejected' ? { rejectedAccessToken: 'access-a' } : {}), + }), + ).toMatchObject({ status: 'usable', source: 'rotated' }) + expect(exchanges).toBe(1) + expect(captures).toBe(1) + }) +} + +for (const change of ['access', 'expiry', 'stamp', 'stamp-absence'] as const) { + test(`validation-only publication fences same-epoch ${change} change`, async () => { + const f = await fixture() + await f.add('a', 'A') + const initial = await f.row('a') + const binding = await f.binding('a') + const entered = barrier() + const finish = barrier() + const c = f.coordinator({ + resolveIdentity: async () => { + entered.release() + await finish.promise + return identity('x', undefined, undefined) + }, + }) + const pending = c.authorize({ intent: 'serve', mode: 'local', binding }) + await entered.promise + if (change === 'stamp-absence') { + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + delete state.accounts.a.lastRefreshedAt + await writeFile(f.paths.state, JSON.stringify(state)) + } else { + await f.store.rotate('a', { + ...credential('a'), + ...(change === 'access' + ? { access: 'advanced' } + : change === 'expiry' + ? { expires: credential('a').expires - 1 } + : {}), + }) + } + const advanced = await f.row('a') + expect(advanced.fingerprint).toBe(initial.fingerprint) + expect(advanced.credentialEpoch).toBe(binding.credentialEpoch) + finish.release() + expect(await pending).toMatchObject({ + status: 'refused', + reason: 'proof-missing', + }) + expect(f.proofs.size).toBe(0) + expect(f.observations.map((e) => e.status)).toEqual(['validation-observed']) + }) +} + +for (const echo of ['captured', 'newer'] as const) { + test(`admission with ${echo} proof never substitutes a newer row after comparison`, async () => { + const f = await fixture() + await f.add('a', 'A') + const proof = await f.seedProof('a') + const c = f.coordinator({ + readAdmission: async (subject) => { + expect(subject).toEqual(proof) + await f.store.rotate('a', { ...credential('a'), access: 'advanced' }) + const newer = await f.seedProof('a') + return { + status: 'proven', + validation: echo === 'captured' ? proof : newer, + } + }, + }) + const result = await c.authorize({ + intent: 'serve', + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toMatchObject( + echo === 'captured' + ? { status: 'usable', access: 'access-a', validation: proof } + : { status: 'refused', reason: 'proof-mismatch' }, + ) + expect((await f.row('a')).credential).toMatchObject({ access: 'advanced' }) + }) +} + +test('each joined caller admits its own independently proven concurrent exact version', async () => { + const f = await fixture() + await f.add('a', 'A') + const entered = barrier() + const finish = barrier() + const c = f.coordinator({ + reconcile: async (e) => { + if (e.status === 'persisted') { + entered.release() + await finish.promise + } + }, + }) + const binding = await f.binding('a') + const owner = c.authorize({ intent: 'refresh', mode: 'local', binding }) + await entered.promise + const joined = f + .coordinator() + .authorize({ intent: 'serve', mode: 'local', binding }) + // The provider looked up access-new. Replace row a with access-independent and + // seed separate validation evidence for that version. The older account lookup + // must not validate the changed access token just because refresh lineage agrees. + await unlink(lockPathFor(f.paths.state, 'row-A')) + const spec = nativeAccountProviderLock(f.paths, 'A') + await unlink(lockPathFor(spec.path, spec.name)) + await f.store.rotate('a', { ...credential('new'), access: 'independent' }) + const proof = await f.seedProof('a') + finish.release() + for (const result of await Promise.all([owner, joined])) + expect(result).toMatchObject({ + status: 'usable', + access: 'independent', + validation: proof, + subject: proof, + }) +}) + +test('publication context survives adapter mutation and final admission', async () => { + const f = await fixture() + await f.add('a', 'A') + let captured: NativeRefreshContext | undefined + let reads = 0 + const c = f.coordinator({ + readRestrictions: async (subject) => { + const policy = policyFor(subject) + const context = { + ...policy.context, + refreshErrorClearedAt: ++reads, + quotaErrorGeneration: 0, + } + if (reads === 2) captured = context + return { ...policy, context } + }, + refreshToken: async () => { + if (!captured) throw new Error('Context must precede provider') + // Change the policy hook's returned error-reset timestamp and generation. + // The persisted observation must retain the values read before exchange. + Object.assign(captured, { + refreshErrorClearedAt: 999, + quotaErrorGeneration: null, + }) + return provider({ refreshToken: 'x' }) + }, + }) + expect( + await c.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }), + ).toMatchObject({ status: 'usable' }) + const context = f.observations.find((e) => e.status === 'persisted')?.context + expect(context).toMatchObject({ + refreshErrorClearedAt: 2, + quotaErrorClearedAt: null, + quotaErrorGeneration: 0, + }) + expect(context).not.toBe(captured) + expect(Object.isFrozen(context)).toBe(true) + expect(Object.isFrozen(context?.subject.binding)).toBe(true) + expect(Object.isFrozen(context?.subject.version)).toBe(true) + expect(reads).toBe(2) +}) + +test('context echo mismatch refuses before provider and cannot publish credential facts', async () => { + const f = await fixture() + await f.add('a', 'A') + let calls = 0 + const c = f.coordinator({ + readRestrictions: async (subject) => { + const policy = policyFor(subject) + return { + ...policy, + context: { + ...policy.context, + subject: { + ...subject, + version: { ...subject.version, lastRefreshedAt: 0 }, + }, + }, + } + }, + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + }) + expect( + await c.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }), + ).toMatchObject({ status: 'failed', failure: { kind: 'caller-hook' } }) + expect(calls).toBe(0) + expect(f.observations[0]?.context).toBeUndefined() + expect(f.proofs.size).toBe(0) +}) + +test('overlapping instances and retries retain distinct per-attempt marker contexts', async () => { + const f = await fixture() + await f.add('a', 'A') + await f.add('b', 'B') + const entered = barrier() + const finish = barrier() + let marker = 7 + let aCalls = 0 + const readRestrictions = async (subject: NativeRefreshSubject) => ({ + ...policyFor(subject), + context: { ...policyFor(subject).context, refreshErrorClearedAt: marker }, + }) + const a = f.coordinator({ + readRestrictions, + refreshToken: async () => { + if (++aCalls === 1) { + entered.release() + await finish.promise + throw new ClaudeOAuthRefreshError(503, 'retry') + } + return provider({ refreshToken: 'x' }) + }, + reconcile: async (e) => { + f.observations.push(e) + if (e.status === 'failed') marker = 11 + }, + }) + const b = f.coordinator({ + readRestrictions, + resolveIdentity: async () => ({ + deviceId: 'd', + sessionId: 's', + accountUuid: accountUuid('B'), + }), + }) + const owner = a.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }) + await entered.promise + marker = 9 + expect( + await b.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('b'), + }), + ).toMatchObject({ status: 'usable' }) + finish.release() + expect(await owner).toMatchObject({ status: 'usable' }) + expect( + f.observations.map((e) => [ + e.binding.rowId, + e.attempt, + e.context?.refreshErrorClearedAt, + ]), + ).toEqual([ + ['b', 1, 9], + ['a', 1, 7], + ['a', 2, 11], + ]) + const subjects = f.observations.map((e) => e.context?.subject.binding.rowId) + expect(subjects).toEqual(['b', 'a', 'a']) +}) + +test('physical exchange captures markers after row provider and shared store waits', async () => { + const f = await fixture() + await f.add('a', 'A') + const entered = barrier() + const finish = barrier() + let marker = 1 + let reads = 0 + const spec = nativeAccountProviderLock(f.paths, 'A') + const holder = track( + withLock( + spec.path, + { ...POOL_LOCK_DEFAULTS, name: spec.name }, + async () => { + entered.release() + await finish.promise + }, + ), + ) + await entered.promise + const preliminary = barrier() + const c = f.coordinator({ + readRestrictions: async (subject) => { + if (++reads === 1) preliminary.release() + return { + ...policyFor(subject), + context: { + ...policyFor(subject).context, + refreshErrorClearedAt: marker, + }, + } + }, + hold: async () => { + // The store has captured row a's credential under the provider lease and + // released config/state locks. Change the reset timestamp before the + // exchange callback reads policy, so it must observe 3 rather than 1. + marker = 3 + }, + }) + const pending = c.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }) + await preliminary.promise + marker = 2 + finish.release() + await holder + expect(await pending).toMatchObject({ status: 'usable' }) + expect( + f.observations.find((e) => e.status === 'persisted')?.context + ?.refreshErrorClearedAt, + ).toBe(3) +}) + +test('failed retry recapture cannot reuse the previous attempt context', async () => { + const f = await fixture() + await f.add('a', 'A') + let reads = 0 + const c = f.coordinator({ + readRestrictions: async (subject) => { + if (++reads === 3) throw new Error('capture failed access-a refresh-a') + return policyFor(subject) + }, + refreshToken: async () => { + throw new ClaudeOAuthRefreshError(503, 'retry') + }, + }) + expect( + await c.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }), + ).toMatchObject({ status: 'failed', failure: { kind: 'caller-hook' } }) + expect(f.observations).toHaveLength(2) + expect(f.observations[0]?.context).toBeDefined() + expect(f.observations[1]).toMatchObject({ attempt: 2, status: 'failed' }) + expect(f.observations[1]?.context).toBeUndefined() +}) + +test('strict refresh stamp prevents overwriting a concurrent repeated dispatch', async () => { + let time = 1_000 + const f = await fixture({ now: () => time }) + await f.add('a', 'A') + const binding = await f.binding('a') + const entered = barrier() + const finish = barrier() + const c = f.coordinator({ + resolveIdentity: async () => { + entered.release() + await finish.promise + return identity('x', undefined, undefined) + }, + }) + const pending = c.authorize({ intent: 'refresh', mode: 'local', binding }) + await entered.promise + // Rewrite row a's lastRefreshedAt without changing access, refresh or expiry. + // Keep row/provider leases intact so sameDispatch's timestamp comparison must + // refuse the overwrite, rather than an earlier lock-ownership check doing so. + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + state.accounts.a.lastRefreshedAt = ++time + await writeFile(f.paths.state, JSON.stringify(state)) + expect((await f.row('a')).credential).toMatchObject({ + lastRefreshedAt: 1_001, + }) + finish.release() + expect(await pending).toMatchObject({ + status: 'refused', + reason: 'dispatch-changed', + persisted: false, + }) + expect((await f.row('a')).credential).toMatchObject({ + access: 'access-a', + lastRefreshedAt: 1_001, + }) + expect(f.observations.map((e) => e.status)).toEqual(['refused']) +}) + +test('failed committed attribution retains actual stored lineage and exact version', async () => { + let interrupt = false + const f = await fixture({ + onStep: (step, info) => { + if ( + interrupt && + info.operation === 'refresh' && + step === 'before-config-write' + ) + throw new Error('access-new refresh-new interruption') + }, + }) + await f.add('a', 'A') + const binding = await f.binding('a') + const before = await f.row('a') + interrupt = true + const result = await f + .coordinator({ + resolveIdentity: async () => ({ + deviceId: 'd', + sessionId: 's', + accountUuid: accountUuid('B'), + }), + }) + .authorize({ intent: 'refresh', mode: 'local', binding }) + expect(result).toMatchObject({ status: 'failed', persisted: true }) + const stored = await f.row('a') + expect(stored.credential).toMatchObject(credential('new')) + expect(f.observations).toHaveLength(1) + const failed = f.observations[0] + if (failed?.status !== 'failed') + throw new Error('Failed observation required') + expect(failed.committed?.credentialFingerprint).toBe( + fingerprintOf(credential('new')), + ) + expect(failed.committed?.credentialFingerprint).not.toBe(before.fingerprint) + expect(dispatchMatches(failed.committed?.version, stored)).toBe(true) + expect(failed.context?.subject.credentialFingerprint).toBe(before.fingerprint) + expect(Object.isFrozen(failed.committed)).toBe(true) + expect(f.proofs.size).toBe(0) + expect(JSON.stringify([result, failed])).not.toContain('access-new') + expect(JSON.stringify([result, failed])).not.toContain('refresh-new') +}) + +test('after-first-write without committed material never guesses a committed subject', async () => { + const f = await fixture() + await f.add('a', 'A') + const c = f.coordinator({ + hold: async () => { + throw new PoolOperationError({ + operation: 'refresh', + rowId: 'a', + phase: 'after-first-write', + retryable: false, + kind: 'after-persist-hook', + cause: new Error('secret access-a refresh-a'), + }) + }, + }) + expect( + await c.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }), + ).toMatchObject({ status: 'failed', persisted: true }) + expect(f.observations).toHaveLength(1) + const failed = f.observations[0] + if (failed?.status !== 'failed') + throw new Error('Failed observation required') + expect(failed.persisted).toBe(true) + expect(failed.committed).toBeUndefined() + expect(f.proofs.size).toBe(0) +}) + +test('hook exceptions are not upstream authentication failures or public bearer carriers', async () => { + const f = await fixture() + await f.add('a', 'A') + await f.seedProof('a') + const error = new PoolOperationError({ + operation: 'refresh', + rowId: 'a', + phase: 'after-first-write', + retryable: false, + kind: 'provider', + committed: credential('secret'), + cause: new ClaudeOAuthRefreshError(400, 'invalid_grant access-secret'), + }) + const result = await f + .coordinator({ + readAdmission: async () => { + throw error + }, + }) + .authorize({ + intent: 'serve', + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toEqual({ + status: 'failed', + failure: { kind: 'caller-hook', classification: 'permanent' }, + persisted: false, + reconciliationFailed: false, + }) + expect(JSON.stringify(result)).not.toContain('secret') + const failedValidation = await f + .coordinator({ + readAdmission: async () => ({ status: 'unproven' }), + resolveIdentity: async () => { + throw Object.assign(new Error('access-secret'), { code: 'ECONNRESET' }) + }, + reconcile: async () => { + throw error + }, + }) + .authorize({ + intent: 'serve', + mode: 'local', + binding: await f.binding('a'), + }) + expect(failedValidation).toMatchObject({ + status: 'failed', + failure: { kind: 'validation', classification: 'transient' }, + reconciliationFailed: true, + }) +}) + +test('a marker clear during validation declines stale proof publication', async () => { + const f = await fixture() + await f.add('a', 'A') + let marker: number | null = null + const entered = barrier() + const finish = barrier() + let published = false + const c = f.coordinator({ + publishProof: false, + readRestrictions: async (subject) => ({ + ...policyFor(subject), + context: { ...policyFor(subject).context, refreshErrorClearedAt: marker }, + }), + resolveIdentity: async () => { + entered.release() + await finish.promise + return identity('x', undefined, undefined) + }, + reconcile: async (event) => { + f.observations.push(event) + if ( + event.status !== 'validation-observed' || + event.context?.refreshErrorClearedAt !== marker + ) + return + const row = await f.row('a') + const subject = event.context.subject + if ( + !nativeLocalPoolBindingMatches(subject.binding, f.paths, row) || + row.fingerprint !== subject.credentialFingerprint || + !dispatchMatches(subject.version, row) + ) + return + await f.seedProof('a') + published = true + }, + }) + const pending = c.authorize({ + intent: 'serve', + mode: 'local', + binding: await f.binding('a'), + }) + await entered.promise + marker = 0 + finish.release() + expect(await pending).toMatchObject({ + status: 'refused', + reason: 'proof-missing', + }) + expect(published).toBe(false) + expect(f.observations[0]?.context?.refreshErrorClearedAt).toBeNull() + expect(f.proofs.size).toBe(0) +}) + +test('exchange recaptures a same-epoch access expiry and stamp change after preliminary policy', async () => { + const f = await fixture() + await f.add('a', 'A') + let reads = 0 + let captured: NativeRefreshSubject | undefined + const c = f.coordinator({ + readRestrictions: async (subject) => { + if (++reads === 1) + await f.store.rotate('a', { + ...credential('a'), + access: 'advanced', + expires: credential('a').expires - 1, + }) + else captured = subject + return policyFor(subject) + }, + refreshToken: async () => { + const current = await f.row('a') + expect(captured?.version).toMatchObject({ + accessFingerprint: tokenFingerprint('advanced'), + expires: credential('a').expires - 1, + }) + expect(dispatchMatches(captured?.version, current)).toBe(true) + return provider({ refreshToken: 'x' }) + }, + }) + expect( + await c.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }), + ).toMatchObject({ status: 'usable' }) + expect(f.observations[0]?.context?.subject).toEqual(captured) +}) + +test('a failed admission after persistence is sanitized without discarding reconciliation facts', async () => { + const f = await fixture() + await f.add('a', 'A') + const result = await f + .coordinator({ + readAdmission: async () => { + throw new Error('access-new refresh-new admission failed') + }, + }) + .authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toEqual({ + status: 'failed', + failure: { kind: 'caller-hook', classification: 'permanent' }, + persisted: true, + reconciliationFailed: false, + }) + expect((await f.row('a')).credential).toMatchObject(credential('new')) + expect(f.observations[0]).toMatchObject({ + status: 'persisted', + committed: { credentialFingerprint: fingerprintOf(credential('new')) }, + }) + expect(JSON.stringify(result)).not.toContain('access-new') +}) + +test('malformed bootstrap identity cannot discard an already consumed successor', async () => { + const f = await fixture() + await f.add('a') + const result = await f + .coordinator({ + resolveIdentity: async () => ({ + deviceId: 'd', + sessionId: 's', + accountUuid: accountUuid(' '), + }), + }) + .authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toMatchObject({ + status: 'refused', + reason: 'identity-unproven', + persisted: true, + }) + expect((await f.row('a')).credential).toMatchObject(credential('new')) + expect((await f.row('a')).identity).toBeUndefined() + expect(f.observations[0]).toMatchObject({ + status: 'persisted', + bootstrap: 'unavailable', + committed: { credentialFingerprint: fingerprintOf(credential('new')) }, + }) +}) + +test('positive proof does not override closed serving eligibility restrictions', async () => { + const f = await fixture() + await f.add('a', 'A') + await f.seedProof('a') + const binding = await f.binding('a') + for (const reason of [ + 'quota-ineligible', + 'account-disabled', + 'local-mode-unavailable', + 'refresh-backoff', + 'invalid-grant', + 'validation-backoff', + ] as const) { + const result = await f + .coordinator({ + readAdmission: async () => ({ status: 'blocked', reason }), + refreshToken: async () => { + throw new Error('Serving block must not exchange') + }, + }) + .authorize({ intent: 'serve', mode: 'local', binding }) + expect(result).toEqual({ status: 'refused', reason, persisted: false }) + } + expect(f.observations).toEqual([]) +}) + +for (const initialStamp of ['absent', 'zero'] as const) { + const change = initialStamp === 'absent' ? 'absent-to-zero' : 'zero-to-absent' + + test(`context echo rejects ${change} refresh timestamp substitution`, async () => { + const f = await fixture() + await f.add('a', 'A') + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + if (initialStamp === 'absent') delete state.accounts.a.lastRefreshedAt + else state.accounts.a.lastRefreshedAt = 0 + await writeFile(f.paths.state, JSON.stringify(state)) + const before = await f.row('a') + expect(before.stamp).toBe('bound') + expect(Object.hasOwn(before.credential ?? {}, 'lastRefreshedAt')).toBe( + initialStamp === 'zero', + ) + let calls = 0 + const c = f.coordinator({ + readRestrictions: async (subject) => { + expect(Object.hasOwn(subject.version, 'lastRefreshedAt')).toBe( + initialStamp === 'zero', + ) + const version = { ...subject.version } + // Echo the same pool row, refresh lineage, access fingerprint and expiry, + // but replace an absent refresh timestamp with zero or zero with absence. + if (initialStamp === 'absent') version.lastRefreshedAt = 0 + else delete version.lastRefreshedAt + const policy = policyFor(subject) + return { + ...policy, + context: { ...policy.context, subject: { ...subject, version } }, + } + }, + refreshToken: async () => { + calls++ + return provider({ refreshToken: 'x' }) + }, + }) + const result = await c.authorize({ + intent: 'refresh', + mode: 'local', + binding: await f.binding('a'), + }) + expect(result).toMatchObject({ + status: 'failed', + failure: { kind: 'caller-hook', classification: 'permanent' }, + persisted: false, + }) + expect(calls).toBe(0) + expect((await f.row('a')).credential).toEqual(before.credential) + expect(f.observations).toHaveLength(1) + expect(f.observations[0]?.context).toBeUndefined() + expect(f.proofs.size).toBe(0) + }) + + test(`commit comparison rejects ${change} refresh timestamp foreign write`, async () => { + const f = await fixture() + await f.add('a', 'A') + const initialState = JSON.parse(await readFile(f.paths.state, 'utf8')) + if (initialStamp === 'absent') + delete initialState.accounts.a.lastRefreshedAt + else initialState.accounts.a.lastRefreshedAt = 0 + await writeFile(f.paths.state, JSON.stringify(initialState)) + const before = await f.row('a') + expect(before.stamp).toBe('bound') + expect(Object.hasOwn(before.credential ?? {}, 'lastRefreshedAt')).toBe( + initialStamp === 'zero', + ) + const binding = await f.binding('a') + const entered = barrier() + const finish = barrier() + const c = f.coordinator({ + resolveIdentity: async () => { + entered.release() + await finish.promise + return identity('x', undefined, undefined) + }, + }) + const pending = c.authorize({ intent: 'refresh', mode: 'local', binding }) + await entered.promise + // Change only row a's recorded refresh timestamp while the provider's + // replacement waits for its account lookup. Keep row/provider leases intact + // so comparing the captured and current credentials decides the refusal. + const currentState = JSON.parse(await readFile(f.paths.state, 'utf8')) + if (initialStamp === 'absent') currentState.accounts.a.lastRefreshedAt = 0 + else delete currentState.accounts.a.lastRefreshedAt + await writeFile(f.paths.state, JSON.stringify(currentState)) + const changed = await f.row('a') + expect(changed.stamp).toBe('bound') + expect(changed.fingerprint).toBe(before.fingerprint) + expect(changed.credentialEpoch).toBe(before.credentialEpoch) + expect(Object.hasOwn(changed.credential ?? {}, 'lastRefreshedAt')).toBe( + initialStamp === 'absent', + ) + finish.release() + expect(await pending).toMatchObject({ + status: 'refused', + reason: 'dispatch-changed', + persisted: false, + }) + expect((await f.row('a')).credential).toEqual(changed.credential) + expect(f.observations).toHaveLength(1) + expect(f.observations[0]).toMatchObject({ + status: 'refused', + reason: 'dispatch-changed', + consumed: true, + }) + expect(f.proofs.size).toBe(0) + }) +} From 7f05b3e9559ef92905902b3d24534efde1235663 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:20:40 +0200 Subject: [PATCH 021/169] test: join runtime update siblings before fixture cleanup --- .../core/src/tests/native-runtime.test.ts | 151 ++++++++++++++++-- 1 file changed, 141 insertions(+), 10 deletions(-) diff --git a/packages/core/src/tests/native-runtime.test.ts b/packages/core/src/tests/native-runtime.test.ts index 7535cbab0..de0a2d147 100644 --- a/packages/core/src/tests/native-runtime.test.ts +++ b/packages/core/src/tests/native-runtime.test.ts @@ -24,7 +24,7 @@ import { updateNativeRuntime, } from '../native-runtime.ts' import { tokenFingerprint } from '../token-fingerprint.ts' -import { createTestLifetimeSuite } from './test-lifetime.ts' +import { createTestLifetimeSuite, TestLifetime } from './test-lifetime.ts' const storageId = 'a'.repeat(64) const vaultAccountIdentity = '11111111-2222-4333-8444-555555555555' @@ -36,6 +36,16 @@ async function fixture() { return join(root, 'private', 'native-state.json') } +async function joinRuntimeUpdates(updates: Promise[]) { + try { + return await Promise.all(updates) + } finally { + // Promise.all rejects before unfinished siblings settle. Keep the body alive + // until they finish using its fixture, without replacing the original error. + await Promise.allSettled(updates) + } +} + // The fixture includes both local and vaulted account metadata without OAuth tokens. const full = { version: 1, @@ -663,22 +673,143 @@ test('runtime malformed UTF-8 cannot silently change a persisted relay secret', test('runtime concurrent updates read under its own lock without lost counters', async () => { const path = await fixture() await updateNativeRuntime(path, storageId, () => state()) - await Promise.all( - Array.from({ length: 12 }, () => - updateNativeRuntime(path, storageId, (current) => { - const local = current.accounts['local-row'] - if (!local?.prime) throw new Error('fixture prime missing') - local.prime.count += 1 - return current - }), - ), + const updates = Array.from({ length: 12 }, () => + updateNativeRuntime(path, storageId, (current) => { + const local = current.accounts['local-row'] + if (!local?.prime) throw new Error('fixture prime missing') + local.prime.count += 1 + return current + }), ) + await joinRuntimeUpdates(updates) const read = await readNativeRuntime(path, storageId) expect(read.status).toBe('ready') if (read.status !== 'ready') throw new Error('runtime missing') expect(read.state.accounts['local-row']?.prime?.count).toBe(17) }) +test('runtime sibling join keeps an active fixture until settlement and preserves the first failure', async () => { + const path = await fixture() + const root = dirname(dirname(path)) + // Use separate locks so one runtime update can fail while the other waits + // inside the fixture directory that cleanup will remove. + const failedPath = join(dirname(path), 'refused-state.json') + await updateNativeRuntime(path, storageId, () => state()) + await updateNativeRuntime(failedPath, storageId, () => state()) + const before = await readFile(path, 'utf8') + const lifetime = new TestLifetime() + const resumeSibling = lifetime.gate() + const entered = Promise.withResolvers() + const firstFailure = Promise.withResolvers() + const removed = Promise.withResolvers() + let siblingSettled = false + let cleanupStarted = false + let cleanupAfterSibling = false + let stageRead = 'pending' + let siblingRead = 'pending' + lifetime.deferCleanup(async () => { + cleanupStarted = true + cleanupAfterSibling = siblingSettled + try { + await rm(root, { recursive: true, force: true }) + } finally { + removed.resolve() + } + }) + const sibling = updateNativeRuntime( + path, + storageId, + (current) => ({ ...current, relay: { token: 'synthetic-sibling-relay' } }), + { + beforeRename: async () => { + const names = (await readdir(dirname(path))).filter((name) => + isNativeRuntimeStagingName(path, name), + ) + const name = names[0] + if (names.length !== 1 || !name) + throw new Error('fixture sibling stage missing') + const stage = join(dirname(path), name) + entered.resolve() + await resumeSibling.wait + // After teardown releases the sibling, yield through real file reads. If + // the test body has settled, fixture cleanup can start before these finish. + try { + await readFile(stage) + stageRead = 'present' + } catch (error) { + if ( + !( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) + ) + throw error + stageRead = 'ENOENT' + } + // If cleanup started before this update settled, wait for fixture deletion + // to finish so the following read observes the completed removal. + if (cleanupStarted) await removed.promise + try { + siblingRead = await readFile(path, 'utf8') + } catch (error) { + if ( + !( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) + ) + throw error + siblingRead = 'ENOENT' + } + }, + }, + ).finally(() => { + siblingSettled = true + // Unblock the failing update if this sibling stops before reaching its hook. + entered.resolve() + }) + const first = updateNativeRuntime( + failedPath, + storageId, + (current) => current, + { + beforeRename: async () => { + await entered.promise + throw new Error('synthetic publication refusal') + }, + }, + ).catch((error: unknown) => { + firstFailure.resolve(error) + throw error + }) + const updates = [first, sibling] + const body = lifetime.runBody(() => joinRuntimeUpdates(updates)) + const observedBody = body.catch((error: unknown) => error) + const originalError = await firstFailure.promise + await lifetime.finish() + const bodyError = await observedBody + const results = await Promise.allSettled(updates) + expect(originalError).toMatchObject({ + code: 'publication-refused', + message: 'Anthropic runtime publication was refused', + }) + expect(bodyError).toBe(originalError) + expect(results[0]).toMatchObject({ + status: 'rejected', + reason: originalError, + }) + expect(cleanupAfterSibling).toBe(true) + expect(stageRead).toBe('present') + expect(siblingRead).toBe(before) + expect(results[1]).toMatchObject({ + status: 'fulfilled', + value: { relay: { token: 'synthetic-sibling-relay' } }, + }) + await expect(stat(root)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + test('runtime hook refusal before rename preserves exact old bytes and removes staging', async () => { const path = await fixture() await updateNativeRuntime(path, storageId, () => state()) From 73843b88188c8534fb184e4aba941715334c66ae Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:00:14 +0200 Subject: [PATCH 022/169] mason: isolate native lock diagnostics from refresh control Define a closed acquired/released/contended native event contract and share fire-and-forget observer isolation between the store adapter and manual handoff emissions. Reject unsafe narrow user callbacks while accepting old producer records in wide observers. Keep common-auth 0.9.0 and awaited control hooks unchanged; ambiguous primitive null results do not invent physical contention. The former handoff ownership-loss fixture simulated loss by throwing from an acquired observer. Replace its skipped-loss expectation with acquired handoff, resolved identity and persisted successor evidence: diagnostics must not control refresh. Genuine takeover and timeout regressions remain. Add isolated child checks, awaited barriers, explicit release tracking and a packed strict-negative consumer. Verified with pinned Bun 1.3.14: 15 focused checks, 650 Core tests, six packaging checks, workspace types and lock integrity. Five owned non-vacuity controls reddened their exact named tests and were restored. Parent explicitly approved the exact revised seven-file delta and unchanged comments before commit. --- .../scripts/check-native-type-closure.test.ts | 46 +++++- packages/core/src/native-lock-observer.ts | 33 ++++ .../core/src/native-refresh-coordinator.ts | 45 +++--- packages/core/src/pool-store.ts | 11 +- .../src/tests/native-lock-observer.test.ts | 149 ++++++++++++++++++ .../tests/native-refresh-coordinator.test.ts | 114 ++++++++++++-- packages/core/src/tests/pool-store.test.ts | 42 +++++ 7 files changed, 394 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/native-lock-observer.ts create mode 100644 packages/core/src/tests/native-lock-observer.test.ts diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index 8f3887e11..61d6a9f47 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -262,7 +262,7 @@ test('packed Core types and Node runtime work in a fresh consumer without common consumer, 'consumer.ts', ` - import { createNativePoolStore, resolveNativePoolPaths, type NativePoolStoreOptions } from '@cortexkit/anthropic-auth-core'; + import { createNativePoolStore, resolveNativePoolPaths, type NativePoolStoreOptions, type NativeLockEvent } from '@cortexkit/anthropic-auth-core'; import * as core from '@cortexkit/anthropic-auth-core'; const paths = await resolveNativePoolPaths('./account.json', './state.json'); const options: NativePoolStoreOptions = { @@ -270,10 +270,24 @@ test('packed Core types and Node runtime work in a fresh consumer without common onStep: async (step, info) => { const name: string = step; const id: string | undefined = info.rowId; void [name, id]; }, hold: (point, id) => { const name: 'refresh-before-provider' | 'pull-before-request' = point; void [name, id]; }, logger: { warn: (message) => { const text: string = message; void text; } }, - onLockEvent: (event) => { const phase: 'acquired' | 'released' = event.type; void phase; }, + onLockEvent: (event) => { + const phase: 'acquired' | 'released' | 'contended' = event.type; + const diagnostic: NativeLockEvent = event; + const fields: string[] = [event.name, event.path]; + void [phase, diagnostic, fields]; + }, onLockStep: async (lock, step) => { const text: string = lock.path + step; void text; }, }; const store = createNativePoolStore(options); + const wideObserver = (event: NativeLockEvent): void => { void event; }; + const compatible: NativePoolStoreOptions = { ...options, onLockEvent: wideObserver }; + const oldProducerRecord: { type: 'acquired' | 'released'; name: string; path: string } = { type: 'acquired', name: 'synthetic', path: paths.state }; + compatible.onLockEvent?.(oldProducerRecord); + const contention: Parameters>[0] = { type: 'contended', name: 'synthetic', path: paths.state }; + options.onLockEvent?.(contention); + type ClosedEvent = NativeLockEvent['type'] extends 'acquired' | 'released' | 'contended' ? true : false; + const closed: ClosedEvent = true; + void closed; const roster: string = paths.roster; await store.add({ id: 'synthetic', credential: { type: 'oauth', access: 'synthetic-access', refresh: 'synthetic-refresh', expires: 4000000000000 } }); const loaded = await store.read(); @@ -294,6 +308,34 @@ test('packed Core types and Node runtime work in a fresh consumer without common console.log( `Consumer tsc: ${checked.length} files checked, all within the fresh prefix`, ) + await put( + consumer, + 'narrow.ts', + ` + import type { NativePoolStoreOptions } from '@cortexkit/anthropic-auth-core'; + declare const options: NativePoolStoreOptions; + const narrowObserver = (event: { type: 'acquired' | 'released'; name: string; path: string }): void => { void event; }; + const unsafe: NativePoolStoreOptions = { ...options, onLockEvent: narrowObserver }; + void unsafe; + `, + ) + const consumerConfig = JSON.parse( + await readFile(join(consumer, 'tsconfig.json'), 'utf8'), + ) + consumerConfig.files.push('narrow.ts') + await put(consumer, 'tsconfig.json', JSON.stringify(consumerConfig)) + const negative = Bun.spawnSync([tsc, '-p', 'tsconfig.json'], { + cwd: consumer, + stdout: 'pipe', + stderr: 'pipe', + }) + const negativeOutput = negative.stdout.toString() + negative.stderr.toString() + console.log(negativeOutput) + expect(negative.exitCode).toBe(1) + expect(negativeOutput).toContain('narrow.ts') + expect(negativeOutput).toContain('TS2322') + expect(negativeOutput).toContain('"contended"') + console.log('Native observer strict boundary: narrow user callback rejected') await put( consumer, 'runtime.mjs', diff --git a/packages/core/src/native-lock-observer.ts b/packages/core/src/native-lock-observer.ts new file mode 100644 index 000000000..9000f461a --- /dev/null +++ b/packages/core/src/native-lock-observer.ts @@ -0,0 +1,33 @@ +/** Diagnostic evidence, not an ownership assertion or a control hook. */ +export type NativeLockEvent = { + type: 'acquired' | 'released' | 'contended' + name: string + path: string +} + +/** + * Diagnostics cannot decide whether a lock owner refreshes or saves a token. + * Consume returned rejections without awaiting: an observer may never settle. + * With no observer, leave the producer callback absent and allocate no wrapper. + */ +export function nativeLockObserver( + observer: ((event: NativeLockEvent) => void) | undefined, +): ((event: NativeLockEvent) => void) | undefined { + if (!observer) return undefined + return (event) => { + try { + const result: unknown = observer(event) + if ( + result !== null && + (typeof result === 'object' || typeof result === 'function') && + 'then' in result && + typeof result.then === 'function' + ) { + void Promise.resolve(result).catch(() => {}) + } + } catch { + // Includes callback throws and throwing thenable accessors. Ownership and + // awaited lock-step errors still propagate through their separate paths. + } + } +} diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index fd93a6c71..e3183e434 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -23,6 +23,7 @@ import { type NativeLocalCredentialVersion, nativeLocalCredentialValidationMatches, } from './native-credential-validation.ts' +import { nativeLockObserver } from './native-lock-observer.ts' import { isTransientNetworkError } from './network-errors.ts' import { isNativeLocalPoolBinding, @@ -507,6 +508,7 @@ export function createNativeRefreshCoordinator( const now = options.now ?? Date.now const provider = options.refreshToken ?? refreshClaudeOAuthToken const bootstrap = options.resolveIdentity ?? resolveClaudeCodeIdentity + const onLockEvent = nativeLockObserver(options.onLockEvent) async function rowFor(binding: NativeLocalPoolBinding) { const read = await store.read() @@ -618,27 +620,25 @@ export function createNativeRefreshCoordinator( if (lock) { try { await lock.assertOwned() - options.onLockEvent?.({ - type: 'acquired', + } catch (error) { + await lock.release().catch(() => {}) + onLockEvent?.({ + type: 'released', name: spec.name, path: spec.path, }) - return lock - } catch (error) { - await lock.release().catch(() => {}) - try { - options.onLockEvent?.({ - type: 'released', - name: spec.name, - path: spec.path, - }) - } catch { - /* Ignore observer failures; notification callbacks must not decide - * whether the lock owner can refresh a token. */ - } throw error } + onLockEvent?.({ + type: 'acquired', + name: spec.name, + path: spec.path, + }) + return lock } + // The installed primitive's null result can mean refusals other than a + // live holder. Contention diagnostics need a producer acquisition event; + // guessing here would report ownership evidence the primitive never gave. const remaining = POOL_LOCK_DEFAULTS.timeoutMs - (performance.now() - started) if (remaining <= 0) @@ -707,16 +707,11 @@ export function createNativeRefreshCoordinator( await lock.release().catch(() => {}) if (identity) { const spec = nativeAccountProviderLock(options.paths, identity) - try { - options.onLockEvent?.({ - type: 'released', - name: spec.name, - path: spec.path, - }) - } catch { - /* A failed lock-event notification must not discard a replacement - * credential after the refresh token was consumed. */ - } + onLockEvent?.({ + type: 'released', + name: spec.name, + path: spec.path, + }) } })() } diff --git a/packages/core/src/pool-store.ts b/packages/core/src/pool-store.ts index e599a5567..ff373ed68 100644 --- a/packages/core/src/pool-store.ts +++ b/packages/core/src/pool-store.ts @@ -3,8 +3,14 @@ import { openPoolStore, } from '@cortexkit/common-auth/store' +import { + type NativeLockEvent, + nativeLockObserver, +} from './native-lock-observer.ts' import type { NativePoolPaths } from './pool-paths.ts' +export type { NativeLockEvent } from './native-lock-observer.ts' + interface NativeQuotaCodec { validate(value: unknown): boolean merge(stored: unknown | undefined, observation: unknown): unknown @@ -13,10 +19,11 @@ interface NativeQuotaCodec { export interface NativePoolStoreOptions extends Pick< OpenPoolStoreOptions, - 'now' | 'onStep' | 'hold' | 'logger' | 'onLockEvent' | 'onLockStep' + 'now' | 'onStep' | 'hold' | 'logger' | 'onLockStep' > { paths: NativePoolPaths quota: NativeQuotaCodec + onLockEvent?: (event: NativeLockEvent) => void } /** @@ -46,7 +53,7 @@ export function createNativePoolStore(options: NativePoolStoreOptions) { onStep: options.onStep, hold: options.hold, logger: options.logger, - onLockEvent: options.onLockEvent, + onLockEvent: nativeLockObserver(options.onLockEvent), onLockStep: options.onLockStep, }) } diff --git a/packages/core/src/tests/native-lock-observer.test.ts b/packages/core/src/tests/native-lock-observer.test.ts new file mode 100644 index 000000000..a268cbb34 --- /dev/null +++ b/packages/core/src/tests/native-lock-observer.test.ts @@ -0,0 +1,149 @@ +import { expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { nativeLockObserver } from '../native-lock-observer.ts' + +test('absent native observer stays absent', () => { + expect(nativeLockObserver(undefined)).toBeUndefined() +}) + +test('native diagnostics isolate throwing then accessors and preserve event fields', () => { + const event = { + type: 'contended' as const, + name: 'synthetic', + path: '/synthetic', + } + let received: unknown + let inspected = 0 + const notify = nativeLockObserver((value) => { + received = value + return Object.defineProperty({}, 'then', { + get() { + inspected++ + throw new Error('diagnostic accessor') + }, + }) + }) + expect(() => notify?.(event)).not.toThrow() + expect(received).toBe(event) + expect(inspected).toBe(1) +}) + +// Rejections must be observed by the runtime, not hidden by the test runner's +// rejection handling. Each child exercises the real store and native handoff. +const childSource = ` + import assert from 'node:assert/strict'; + import { setImmediate } from 'node:timers/promises'; + import { resolveNativePoolPaths } from './packages/core/src/pool-paths.ts'; + import { createNativePoolStore } from './packages/core/src/pool-store.ts'; + import { captureNativeLocalPoolBinding } from './packages/core/src/pool-binding.ts'; + import { createNativeRefreshCoordinator, nativeAccountProviderLock } from './packages/core/src/native-refresh-coordinator.ts'; + import { tokenFingerprint } from './packages/core/src/token-fingerprint.ts'; + import { fingerprintOf } from '@cortexkit/common-auth/store'; + const { root, target, mode } = JSON.parse(process.env.NATIVE_OBSERVER_PROBE); + process.on('unhandledRejection', (error) => { + console.error('ESCAPED DIAGNOSTIC REJECTION', error); + process.exitCode = 1; + }); + const paths = await resolveNativePoolPaths(root + '/account.json', root + '/state.json'); + const quota = { validate: () => true, merge: (_, value) => value }; + const store = createNativePoolStore({ paths, quota }); + await store.initialize(); + await store.add({ id: 'a', credential: { type: 'oauth', access: 'old', refresh: 'old-refresh', expires: 4000000000000 } }); + const read = await store.read(); + assert.equal(read.status, 'ready'); + const binding = captureNativeLocalPoolBinding(paths, read.rows[0]); + const handoff = nativeAccountProviderLock(paths, 'A'); + const events = []; + let bootstraps = 0; + let providers = 0; + let persisted; + const coordinator = createNativeRefreshCoordinator({ + paths, quota, + onLockEvent: (event) => { + if (target === 'handoff' && event.name !== handoff.name) return; + if (target === 'store' && event.name !== 'pool-state') return; + assert.equal(event.path, paths.state); + events.push(event.type); + if (mode === 'throw') throw new Error('diagnostic throw'); + if (mode === 'reject') return Promise.reject(new Error('diagnostic rejection')); + if (mode === 'thenable') return { then: (_, reject) => reject(new Error('diagnostic thenable rejection')) }; + if (mode === 'never') return new Promise(() => {}); + throw new Error('unknown probe mode'); + }, + refreshToken: async () => { + providers++; + return { type: 'oauth', access: 'new', refresh: 'new-refresh', expires: 4000000000000, expiresIn: 3600 }; + }, + resolveIdentity: async () => { + bootstraps++; + return { deviceId: 'd', sessionId: 's', accountUuid: 'A' }; + }, + readRestrictions: async (subject) => ({ + restriction: { status: 'allowed' }, + context: { subject, runtimeBinding: subject.binding, refreshErrorClearedAt: null, quotaErrorClearedAt: null, quotaErrorGeneration: null }, + }), + reconcile: async (event) => { if (event.status === 'persisted') persisted = event; }, + readAdmission: async (subject) => ({ status: 'proven', validation: subject }), + }); + const result = await coordinator.authorize({ mode: 'local', intent: 'refresh', binding }); + assert.equal(result.status, 'usable'); + assert.equal(result.access, 'new'); + assert.equal(bootstraps, 1); + assert.equal(providers, 1); + assert.equal(persisted.handoff, 'acquired'); + assert.equal(persisted.bootstrap, 'resolved'); + assert.equal(persisted.identity, 'A'); + assert.equal(persisted.committed.version.accessFingerprint, tokenFingerprint('new')); + const loaded = await store.read(); + assert.equal(loaded.rows[0].credential.access, 'new'); + assert.equal(loaded.rows[0].identity, 'A'); + assert.equal(loaded.rows[0].fingerprint, fingerprintOf(loaded.rows[0].credential)); + assert.ok(events.includes('acquired')); + assert.ok(events.includes('released')); + assert.equal(events.includes('contended'), false); + if (target === 'handoff') assert.deepEqual(events, ['acquired', 'released']); + await setImmediate(); + await setImmediate(); + console.log('native diagnostic probe passed: ' + target + '/' + mode); +` + +for (const target of ['store', 'handoff']) { + for (const mode of ['throw', 'reject', 'thenable', 'never']) { + test(`native ${target} diagnostics isolate ${mode} returns in a child`, () => { + const workspace = resolve(import.meta.dir, '../../../..') + const parent = join( + workspace, + 'node_modules/.cache/native-observer-children', + ) + mkdirSync(parent, { recursive: true }) + const root = mkdtempSync(join(parent, 'probe-')) + try { + const child = Bun.spawnSync([process.execPath, '--eval', childSource], { + cwd: workspace, + env: { + ...process.env, + NATIVE_OBSERVER_PROBE: JSON.stringify({ root, target, mode }), + }, + stdout: 'pipe', + stderr: 'pipe', + timeout: 10_000, + }) + const output = child.stdout.toString() + child.stderr.toString() + expect({ + exitCode: child.exitCode, + stderr: child.stderr.toString(), + }).toEqual({ + exitCode: 0, + stderr: '', + }) + expect(output).toContain( + `native diagnostic probe passed: ${target}/${mode}`, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 15_000) + } +} diff --git a/packages/core/src/tests/native-refresh-coordinator.test.ts b/packages/core/src/tests/native-refresh-coordinator.test.ts index 9b3fe4896..aa25623bc 100644 --- a/packages/core/src/tests/native-refresh-coordinator.test.ts +++ b/packages/core/src/tests/native-refresh-coordinator.test.ts @@ -70,6 +70,7 @@ import { import { resolveNativePoolPaths } from '../pool-paths.ts' import { createNativePoolStore, + type NativeLockEvent, type NativePoolStoreOptions, nativePoolStoreLocks, } from '../pool-store.ts' @@ -174,6 +175,22 @@ function barrier() { return { promise, release } } +function trackLock(active: Set, event: NativeLockEvent) { + if (event.type === 'acquired') active.add(event.name) + else if (event.type === 'released') active.delete(event.name) +} + +test('contended diagnostic preserves acquired evidence until explicit release', () => { + const active = new Set() + const lock = { name: 'native-account-provider-synthetic', path: '/synthetic' } + trackLock(active, { type: 'acquired', ...lock }) + expect(active.has(lock.name)).toBe(true) + trackLock(active, { type: 'contended', ...lock }) + expect(active.has(lock.name)).toBe(true) + trackLock(active, { type: 'released', ...lock }) + expect(active.has(lock.name)).toBe(false) +}) + function credential(id: string) { return { type: 'oauth' as const, @@ -692,8 +709,7 @@ test('independent known accounts reach provider concurrently', async () => { onStep: (step, info) => phase(`${info.operation}:${info.rowId}:${step}`), onLockEvent: (event) => { phase(`${event.type}:${event.name}`) - if (event.type === 'acquired') active.add(event.name) - else active.delete(event.name) + trackLock(active, event) events.push(`${event.type}:${event.name}`) }, }) @@ -1159,8 +1175,7 @@ test('callback-end identity alias joins during reconciliation and handoff enclos const active = new Set() const f = await fixture({ onLockEvent: (e) => { - if (e.type === 'acquired') active.add(e.name) - else active.delete(e.name) + trackLock(active, e) }, }) await f.add('a') @@ -1685,16 +1700,13 @@ test('persisted reconciliation failure preserves committed fact and cleans learn ) }) -test('handoff ownership loss is non-fatal and removes only the unknown job registration', async () => { - let breakHandoff = true +test('throwing handoff diagnostics preserve handoff and identity validation', async () => { + const events: NativeLockEvent[] = [] + let bootstraps = 0 const f = await fixture({ onLockEvent: (e) => { - if ( - breakHandoff && - e.type === 'acquired' && - e.name.startsWith('native-account-provider') - ) { - breakHandoff = false + if (e.name.startsWith('native-account-provider')) { + events.push(e) throw new LockOwnershipError({ target: e.path, name: e.name }) } }, @@ -1702,15 +1714,76 @@ test('handoff ownership loss is non-fatal and removes only the unknown job regis await f.add('a') expect( await f - .coordinator() + .coordinator({ + resolveIdentity: async () => { + bootstraps++ + return { + deviceId: 'd', + sessionId: 's', + accountUuid: accountUuid('A'), + } + }, + }) .refresh({ mode: 'local', binding: await f.binding('a') }), ).toMatchObject({ status: 'usable', access: 'access-new' }) expect(f.observations).toContainEqual( - expect.objectContaining({ status: 'persisted', handoff: 'skipped-loss' }), + expect.objectContaining({ + status: 'persisted', + handoff: 'acquired', + bootstrap: 'resolved', + identity: 'A', + }), ) + expect(bootstraps).toBe(1) + const spec = nativeAccountProviderLock(f.paths, 'A') + expect(events).toEqual([ + { type: 'acquired', name: spec.name, path: spec.path }, + { type: 'released', name: spec.name, path: spec.path }, + ]) expect((await f.row('a')).credential).toMatchObject(credential('new')) }) +test('diagnostic isolation preserves awaited handoff lock-step barriers', async () => { + const f = await fixture() + await f.add('a') + const spec = nativeAccountProviderLock(f.paths, 'A') + const entered = barrier() + const finish = barrier() + let settled = false + let released = false + const c = f.coordinator({ + onLockEvent: (event) => { + if (event.name === spec.name && event.type === 'released') released = true + return new Promise(() => {}) + }, + onLockStep: async (lock, step) => { + if (lock.name === spec.name && step === 'release-owner-confirmed') { + entered.release() + await finish.promise + } + }, + }) + const operation = c + .refresh({ mode: 'local', binding: await f.binding('a') }) + .then((result) => { + settled = true + return result + }) + try { + await entered.promise + expect(settled).toBe(false) + expect(released).toBe(false) + } finally { + finish.release() + await operation + } + expect(await operation).toMatchObject({ + status: 'usable', + access: 'access-new', + }) + expect(released).toBe(true) +}) + test('lost handoff after callback end persists successor, skips registration and releases ownership', async () => { const f = await fixture() await f.add('a') @@ -1841,8 +1914,7 @@ test('refusal and failure reconciliation run outside store locks and finish befo const active = new Set() const f = await fixture({ onLockEvent: (e) => { - if (e.type === 'acquired') active.add(e.name) - else active.delete(e.name) + trackLock(active, e) }, }) await f.add('a', 'A') @@ -1960,9 +2032,14 @@ test( }, ) await locked.promise + const handoffEvents: NativeLockEvent[] = [] try { const result = await f - .coordinator() + .coordinator({ + onLockEvent: (event) => { + if (event.name === spec.name) handoffEvents.push(event) + }, + }) .refresh({ mode: 'local', binding: await f.binding('a') }) expect(result).toMatchObject({ status: 'usable' }) expect((await f.row('a')).credential).toMatchObject(credential('new')) @@ -1972,6 +2049,9 @@ test( handoff: 'skipped-contention', }), ) + // The old acquisition primitive gives no diagnostic reason for null. + // A timed-out wait must not invent acquired, released or contended events. + expect(handoffEvents).toEqual([]) } finally { finish.release() await holder diff --git a/packages/core/src/tests/pool-store.test.ts b/packages/core/src/tests/pool-store.test.ts index 15d3b05b1..7ebb6eb55 100644 --- a/packages/core/src/tests/pool-store.test.ts +++ b/packages/core/src/tests/pool-store.test.ts @@ -265,6 +265,48 @@ test('construction, pure reads and migration additions never install a quota pul }) }) +test('diagnostic isolation preserves awaited store lock-step barriers', async () => { + const { paths } = await fixture() + let open!: () => void + const gate = new Promise((resolve) => { + open = resolve + }) + let enter!: () => void + const entered = new Promise((resolve) => { + enter = resolve + }) + let settled = false + const released: string[] = [] + const store = createNativePoolStore({ + paths, + quota, + onLockEvent: (event) => { + if (event.type === 'released') released.push(event.name) + return new Promise(() => {}) + }, + onLockStep: async (lock, step) => { + if (lock.name === 'pool-state' && step === 'release-owner-confirmed') { + enter() + await gate + } + }, + }) + const operation = store.initialize().then((result) => { + settled = true + return result + }) + try { + await entered + expect(settled).toBe(false) + expect(released).not.toContain('pool-state') + } finally { + open() + await operation + } + expect(settled).toBe(true) + expect(released).toContain('pool-state') +}) + test('refuses same-epoch access corruption before refresh or observation persistence', async () => { const { paths, store } = await fixture() await store.initialize() From b78aadfb32387ad240708c4fb4769cb2a08cd425 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:33:53 +0200 Subject: [PATCH 023/169] mason: publish guarded first native identity Add a Core-internal first-identity publisher with copied successful observation evidence, exact pool/runtime/context predicates, production config/state/runtime leases and one shared atomic runtime replacement. Keep generic writer APIs and same-epoch refusal unchanged. Canonically discard unproven account metadata while preserving generation and representable error-clear floors. Verify pinned Bun 1.3.14: 50 narrow tests, 664 Core tests, six packaging tests, workspace TypeScript 7.0.2 and lock consistency. Owned observation-only controls independently redden exact-version/context/reset and each production lease assertion; restored controls pass. Proofs and bounded unchanged-source journal-timeout diagnosis remain in tmp/first-identity-proof/. The independently reported journal timeout remains UNKNOWN; no timing/source/dependency change is included. Parent explicitly approved the exact three-file delta after source, comment and unmasked assertion-control review (sk_c5649977e8184ec3a4799e223c00d5b9). --- packages/core/src/native-runtime.ts | 278 +++- .../src/tests/native-first-identity.test.ts | 1147 +++++++++++++++++ .../core/src/tests/native-runtime.test.ts | 74 ++ 3 files changed, 1496 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/tests/native-first-identity.test.ts diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index 4d6750e2d..aec0960a6 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -3,6 +3,7 @@ import { constants } from 'node:fs' import { lstat, mkdir, open, rename, rm } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' import { withLock } from '@cortexkit/common-auth/fs' +import { POOL_LOCK_DEFAULTS } from '@cortexkit/common-auth/store' import type { AccountOperationError, @@ -14,15 +15,24 @@ import { parseJsonRedacted } from './json.ts' import { isNativeLocalCredentialValidation, type NativeLocalCredentialValidation, + nativeLocalCredentialValidationMatches, } from './native-credential-validation.ts' import { fromNativeQuotaMap, type NativeQuotaMap, + nativeQuotaCodec, } from './native-quota-codec.ts' +import type { + NativeRefreshContext, + NativeRefreshObservation, +} from './native-refresh-coordinator.ts' import { isNativeLocalPoolBinding, type NativeLocalPoolBinding, + nativeLocalPoolBindingMatches, } from './pool-binding.ts' +import type { NativePoolPaths } from './pool-paths.ts' +import { createNativePoolStore, nativePoolStoreLocks } from './pool-store.ts' /** Identifies the vault credential and Claude account that supplied an observation. This binding contains no access token. */ export interface NativeCustodyRuntimeBinding { @@ -526,10 +536,30 @@ export function isNativeRuntimeStagingName( ) } +interface FirstIdentityPublication { + readonly predecessor: NativeLocalPoolBinding + readonly context: NativeRefreshContext + readonly proof: NativeLocalCredentialValidation +} + +function sameLocalBinding( + a: NativeLocalPoolBinding, + b: NativeLocalPoolBinding, +) { + return ( + a.storageId === b.storageId && + a.rowId === b.rowId && + a.credentialEpoch === b.credentialEpoch && + a.identity === b.identity + ) +} + function assertTransitions( previous: NativeRuntimeState, next: NativeRuntimeState, + firstIdentity?: FirstIdentityPublication, ): void { + let authorized = false for (const [id, incoming] of Object.entries(next.accounts)) { const old = previous.accounts[id] if (!old) continue @@ -539,8 +569,20 @@ function assertTransitions( throw new NativeRuntimeError('runtime-conflict') if (incoming.binding.credentialEpoch > old.binding.credentialEpoch) continue - if (old.binding.identity !== incoming.binding.identity) - throw new NativeRuntimeError('runtime-conflict') + if (old.binding.identity !== incoming.binding.identity) { + if ( + !firstIdentity || + authorized || + id !== firstIdentity.predecessor.rowId || + old.binding.identity !== undefined || + !sameLocalBinding(old.binding, firstIdentity.predecessor) || + !sameLocalBinding(incoming.binding, firstIdentity.proof.binding) + ) + throw new NativeRuntimeError('runtime-conflict') + authorized = true + // Adding the first account UUID still runs the checks below that preserve + // error-reset timestamps and reject errors older than a concurrent reset. + } } else if ( old.binding.kind === 'custody' && incoming.binding.kind === 'custody' @@ -596,6 +638,8 @@ function assertTransitions( throw new NativeRuntimeError('runtime-conflict') } } + if (firstIdentity && !authorized) + throw new NativeRuntimeError('publication-refused') } /** @@ -611,6 +655,21 @@ export async function updateNativeRuntime( current: NativeRuntimeState, ) => NativeRuntimeState | Promise, hooks: NativeRuntimeWriteHooks = {}, +): Promise { + return writeNativeRuntime(path, storageId, change, hooks) +} + +// Both publication paths use the same lock, strict decoder and atomic replacement. +// Only the dedicated publisher supplies checked pool/runtime evidence authorizing +// the first account UUID change; a generic write has no such authorization. +async function writeNativeRuntime( + path: string, + storageId: string, + change: ( + current: NativeRuntimeState, + ) => NativeRuntimeState | Promise, + hooks: NativeRuntimeWriteHooks, + firstIdentity?: FirstIdentityPublication, ): Promise { checkPath(path) try { @@ -628,7 +687,7 @@ export async function updateNativeRuntime( await change(structuredClone(current)), storageId, ) - assertTransitions(current, next) + assertTransitions(current, next, firstIdentity) const bytes = `${JSON.stringify(next, null, 2)}\n` const stage = join( dirname(path), @@ -668,3 +727,216 @@ export async function updateNativeRuntime( throw new NativeRuntimeError('runtime-io') } } + +function dispatchVersion(value: unknown): value is Record { + return ( + record(value) && + keys(value, [], ['accessFingerprint', 'expires', 'lastRefreshedAt']) && + optional( + value, + 'accessFingerprint', + (item) => item === undefined || tokenFingerprintValue(item), + ) && + optional(value, 'expires', (item) => item === undefined || counter(item)) && + optional(value, 'lastRefreshedAt', counter) + ) +} + +function sameVersion(a: Record, b: Record) { + return ['accessFingerprint', 'expires', 'lastRefreshedAt'].every( + (key) => + Object.hasOwn(a, key) === Object.hasOwn(b, key) && a[key] === b[key], + ) +} + +function captureFirstIdentity( + paths: NativePoolPaths, + observation: NativeRefreshObservation, +): FirstIdentityPublication { + const refuse = () => new NativeRuntimeError('publication-refused') + // Copy before the first lock wait. Never fill missing evidence from a later row. + const event = structuredClone(observation) + const context = event.context + if ( + !['persisted', 'validation-observed'].includes(event.status) || + event.bootstrap !== 'resolved' || + !('identity' in event) || + !accountUuid(event.identity) || + !isNativeLocalPoolBinding(event.binding) || + event.binding.storageId !== paths.storageId || + (event.binding.identity !== undefined && + event.binding.identity !== event.identity) || + !digest(event.credentialFingerprint) || + !dispatchVersion(event.credentialVersion) || + !context || + !record(context.subject) || + !isNativeLocalPoolBinding(context.subject.binding) || + !sameLocalBinding(context.subject.binding, event.binding) || + context.subject.credentialFingerprint !== event.credentialFingerprint || + !dispatchVersion(context.subject.version) || + !sameVersion(context.subject.version, event.credentialVersion) || + !isNativeLocalPoolBinding(context.runtimeBinding) || + context.runtimeBinding.identity !== undefined || + !sameLocalBinding(context.runtimeBinding, { + ...event.binding, + identity: undefined, + }) || + ![ + context.refreshErrorClearedAt, + context.quotaErrorClearedAt, + context.quotaErrorGeneration, + ].every((value) => value === null || counter(value)) + ) + throw refuse() + const destination = { ...context.runtimeBinding, identity: event.identity } + let proof: unknown + if (event.status === 'persisted') { + proof = { + binding: destination, + credentialFingerprint: event.successorFingerprint, + version: event.successorVersion, + } + if ( + !isNativeLocalCredentialValidation(proof) || + !record(event.committed) || + !keys(event.committed, ['credentialFingerprint', 'version']) || + event.committed.credentialFingerprint !== proof.credentialFingerprint || + !dispatchVersion(event.committed.version) || + !sameVersion(event.committed.version, { ...proof.version }) + ) + throw refuse() + } else if (event.status === 'validation-observed') { + if (event.binding.identity !== event.identity) throw refuse() + proof = { + binding: destination, + credentialFingerprint: event.credentialFingerprint, + version: event.credentialVersion, + } + } + if (!isNativeLocalCredentialValidation(proof)) throw refuse() + return { predecessor: context.runtimeBinding, context, proof } +} + +function firstIdentityEntry( + old: NativeRuntimeEntry | undefined, + publication: FirstIdentityPublication, +): NativeRuntimeEntry { + const { predecessor, context, proof } = publication + if ( + !old || + old.binding.kind !== 'local' || + !sameLocalBinding(old.binding, predecessor) || + (old.refreshErrorClearedAt ?? null) !== context.refreshErrorClearedAt || + (old.quotaErrorClearedAt ?? null) !== context.quotaErrorClearedAt || + (old.quotaErrorGeneration ?? null) !== context.quotaErrorGeneration + ) + throw new NativeRuntimeError('publication-refused') + const floor = ( + marker: number | undefined, + error: AccountOperationError | undefined, + ) => { + const value = + marker === undefined + ? error?.checkedAt + : Math.max(marker, error?.checkedAt ?? marker) + // Runtime error times can be fractional, but coordinator reset timestamps + // must be nonnegative safe integers. Refuse an unrepresentable reset floor + // rather than rounding it into a different error-clear boundary. + if (value !== undefined && !counter(value)) + throw new NativeRuntimeError('publication-refused') + return value + } + const refreshErrorClearedAt = floor( + old.refreshErrorClearedAt, + old.lastRefreshError, + ) + const quotaErrorClearedAt = floor( + old.quotaErrorClearedAt, + old.lastQuotaRefreshError, + ) + // The old profile, usage and errors have not been proven to belong to this + // account. Retain only evidence for the newly checked credentials and the + // reset timestamps/generation that prevent older results from restoring them. + return { + binding: proof.binding, + credentialValidation: proof, + ...(refreshErrorClearedAt !== undefined ? { refreshErrorClearedAt } : {}), + ...(quotaErrorClearedAt !== undefined ? { quotaErrorClearedAt } : {}), + ...(old.quotaErrorGeneration !== undefined + ? { quotaErrorGeneration: old.quotaErrorGeneration } + : {}), + } +} + +/** + * Core-internal reconciliation of a successfully checked first local UUID. + * Matching a response to an account disabled after dispatch does not give that + * account permission to make another model request; admission checks still apply. + * No caller mutator or publication callback can widen this transition. + */ +export async function publishNativeLocalFirstIdentity( + paths: NativePoolPaths, + observation: NativeRefreshObservation, +): Promise { + try { + paths = { ...paths } + const publication = captureFirstIdentity(paths, observation) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + const [config, state] = nativePoolStoreLocks(paths) + const checkPool = async () => { + const read = await store.read() + const row = + read.status === 'ready' + ? read.rows.find((row) => row.id === publication.proof.binding.rowId) + : undefined + if ( + !row || + !nativeLocalPoolBindingMatches(publication.proof.binding, paths, row) || + !nativeLocalCredentialValidationMatches( + publication.proof, + publication.proof.binding, + row.credential, + ) + ) + throw new NativeRuntimeError('publication-refused') + } + return await withLock( + config.path, + { ...POOL_LOCK_DEFAULTS, name: config.name }, + async (configLease) => + withLock( + state.path, + { ...POOL_LOCK_DEFAULTS, name: state.name }, + async (stateLease) => { + await checkPool() + return writeNativeRuntime( + paths.runtime, + paths.storageId, + (current) => { + const id = publication.predecessor.rowId + current.accounts[id] = firstIdentityEntry( + current.accounts[id], + publication, + ) + return current + }, + { + beforeRename: async () => { + // Strict reads do not take row locks, repair storage or schedule pulls. + // Keep both pool leases through the writer's runtime assertion/rename. + await checkPool() + await configLease.assertOwned() + await stateLease.assertOwned() + }, + }, + publication, + ) + }, + ), + ) + } catch (error) { + if (error instanceof NativeRuntimeError) throw error + // Pool/lease/parser exceptions can contain paths or input; do not relay them. + throw new NativeRuntimeError('publication-refused') + } +} diff --git a/packages/core/src/tests/native-first-identity.test.ts b/packages/core/src/tests/native-first-identity.test.ts new file mode 100644 index 000000000..5c7732174 --- /dev/null +++ b/packages/core/src/tests/native-first-identity.test.ts @@ -0,0 +1,1147 @@ +import { expect } from 'bun:test' +import { watch } from 'node:fs' +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { lockPathFor, withLock } from '@cortexkit/common-auth/fs' +import { + fingerprintOf, + openPoolStore, + POOL_LOCK_DEFAULTS, +} from '@cortexkit/common-auth/store' + +import type { ProviderAccountUuid } from '../claude-code.ts' +import { + type NativeLocalCredentialValidation, + nativeLocalCredentialValidationMatches, +} from '../native-credential-validation.ts' +import { nativeQuotaCodec } from '../native-quota-codec.ts' +import { + createNativeRefreshCoordinator, + type NativeRefreshObservation, + type NativeRefreshSubject, +} from '../native-refresh-coordinator.ts' +import { + isNativeRuntimeStagingName, + type NativeRuntimeEntry, + publishNativeLocalFirstIdentity, + readNativeRuntime, + updateNativeRuntime, +} from '../native-runtime.ts' +import { + captureNativeLocalPoolBinding, + type NativeLocalPoolBinding, +} from '../pool-binding.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore, nativePoolStoreLocks } from '../pool-store.ts' +import { tokenFingerprint } from '../token-fingerprint.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup, trackDetached } = createTestLifetimeSuite() +const uuid = '11111111-2222-4333-8444-555555555555' +const otherUuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const credential = { + type: 'oauth' as const, + access: 'synthetic-access', + refresh: 'synthetic-refresh', + expires: 4_000_000_000_000, +} + +async function fixture( + known = true, + metadata: Partial = {}, +) { + const root = await mkdtemp(join(tmpdir(), 'native-first-identity-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const paths = await resolveNativePoolPaths( + join(root, 'config.json'), + join(root, 'state.json'), + ) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + await store.initialize() + await store.add({ + id: 'row', + credential, + ...(known ? { identity: uuid } : {}), + }) + const read = await store.read() + if (read.status !== 'ready' || !read.rows[0]) + throw new Error('Missing fixture row') + const row = read.rows[0] + const binding = captureNativeLocalPoolBinding(paths, row) + const predecessor: NativeLocalPoolBinding = { + kind: 'local', + storageId: paths.storageId, + rowId: 'row', + credentialEpoch: binding.credentialEpoch, + } + const runtime = await updateNativeRuntime( + paths.runtime, + paths.storageId, + () => ({ + version: 1, + storageId: paths.storageId, + relay: { token: 'synthetic-relay' }, + accounts: { + row: { binding: predecessor, ...metadata }, + unrelated: { + binding: { ...predecessor, rowId: 'unrelated', identity: otherUuid }, + lastUsed: 5, + }, + }, + }), + ) + const c = row.credential + if (c?.type !== 'oauth' || !c.access || !c.expires) + throw new Error('Missing OAuth fixture') + const subject: NativeRefreshSubject = { + binding, + credentialFingerprint: fingerprintOf(c), + version: { + accessFingerprint: tokenFingerprint(c.access), + expires: c.expires, + ...(Object.hasOwn(c, 'lastRefreshedAt') + ? { lastRefreshedAt: c.lastRefreshedAt } + : {}), + }, + } + const context = { + subject, + runtimeBinding: predecessor, + refreshErrorClearedAt: metadata.refreshErrorClearedAt ?? null, + quotaErrorClearedAt: metadata.quotaErrorClearedAt ?? null, + quotaErrorGeneration: metadata.quotaErrorGeneration ?? null, + } + const event: NativeRefreshObservation = { + status: 'persisted', + binding, + identity: uuid, + bootstrap: 'resolved', + handoff: 'not-needed', + attempt: 1, + credentialFingerprint: subject.credentialFingerprint, + credentialVersion: subject.version, + context, + successorFingerprint: subject.credentialFingerprint, + successorVersion: subject.version, + committed: { + credentialFingerprint: subject.credentialFingerprint, + version: subject.version, + }, + } + return { root, paths, store, predecessor, subject, event, runtime } +} +type Fixture = Awaited> + +function proof(f: Fixture): NativeLocalCredentialValidation { + return { + binding: { ...f.predecessor, identity: uuid }, + credentialFingerprint: f.subject.credentialFingerprint, + version: { + accessFingerprint: tokenFingerprint(credential.access), + expires: credential.expires, + ...f.subject.version, + }, + } +} + +async function refused( + f: Fixture, + event: unknown = f.event, + code = 'publication-refused', + publisher = publishNativeLocalFirstIdentity, +) { + const before = await readFile(f.paths.runtime, 'utf8') + try { + await publisher(f.paths, event as NativeRefreshObservation) + throw new Error('Refusal did not occur') + } catch (error) { + expect(error).toMatchObject({ name: 'NativeRuntimeError', code }) + expect(String(error)).not.toContain('synthetic-access') + expect(String(error)).not.toContain('synthetic-refresh') + } + expect(await readFile(f.paths.runtime, 'utf8')).toBe(before) + expect( + (await readdir(f.root)).filter((name) => + isNativeRuntimeStagingName(f.paths.runtime, name), + ), + ).toEqual([]) +} + +test('first identity metadata reset keeps exact floors generation and unrelated state', async () => { + const f = await fixture(true, { + lastUsed: 1, + lastRefreshedAt: 2, + lastRefreshError: { message: 'old refresh', checkedAt: 11 }, + refreshErrorClearedAt: 0, + lastQuotaRefreshError: { message: 'old quota', checkedAt: 13 }, + quotaErrorClearedAt: 3, + quotaErrorGeneration: 7, + refreshLeaseId: 'old-lease', + refreshLeaseUntil: 99, + refreshLeaseTokenHash: 'a'.repeat(64), + profile: { tier: 'max', orgType: 'team', checkedAt: 5 }, + prime: { count: 1, inputTokens: 2, outputTokens: 3, since: 4 }, + authLineageId: 'old-lineage', + primeAuthLineageRefreshTokenFingerprint: tokenFingerprint('old-refresh'), + quotaCheckedAt: 6, + quotaToken: tokenFingerprint('old-access'), + }) + const next = await publishNativeLocalFirstIdentity(f.paths, f.event) + expect(next.accounts.row).toEqual({ + binding: proof(f).binding, + credentialValidation: proof(f), + refreshErrorClearedAt: 11, + quotaErrorClearedAt: 13, + quotaErrorGeneration: 7, + }) + expect(next.accounts.unrelated).toEqual(f.runtime.accounts.unrelated) + expect(next.relay).toEqual(f.runtime.relay) + expect(Object.keys(next.accounts)).toEqual(['row', 'unrelated']) + expect(await readNativeRuntime(f.paths.runtime, f.paths.storageId)).toEqual({ + status: 'ready', + state: next, + }) +}) + +test('first identity preserves absent versus zero fences without generation bump', async () => { + for (const metadata of [ + {}, + { + refreshErrorClearedAt: 0, + quotaErrorClearedAt: 0, + quotaErrorGeneration: 0, + }, + { refreshErrorClearedAt: 20, quotaErrorGeneration: 9 }, + ]) { + const f = await fixture(true, metadata) + expect( + (await publishNativeLocalFirstIdentity(f.paths, f.event)).accounts.row, + ).toEqual({ + binding: proof(f).binding, + credentialValidation: proof(f), + ...metadata, + }) + } +}) + +test('first identity permits sparse original dispatch but never sparse checked successor', async () => { + for (const version of [ + {}, + { accessFingerprint: undefined, expires: undefined }, + ]) { + const f = await fixture() + const event = structuredClone(f.event) + if (!event.context) throw new Error('Fixture context') + event.binding = f.predecessor + event.credentialVersion = version + event.context = { + ...event.context, + subject: { + binding: f.predecessor, + credentialFingerprint: f.subject.credentialFingerprint, + version, + }, + } + expect( + (await publishNativeLocalFirstIdentity(f.paths, event)).accounts.row + ?.credentialValidation, + ).toEqual(proof(f)) + } +}) + +test('first identity requires known captured subject for validation observation', async () => { + const f = await fixture() + if (!f.event.context) throw new Error('Fixture context') + const event = { + ...f.event, + status: 'validation-observed' as const, + identity: uuid, + context: f.event.context, + } + await refused(f, { + ...event, + binding: f.predecessor, + context: { + ...event.context, + subject: { ...f.subject, binding: f.predecessor }, + }, + }) + await refused(f, { ...event, bootstrap: 'unavailable' }) + await publishNativeLocalFirstIdentity(f.paths, event) +}) + +test('first identity refuses known identity clearing and retargeting observations', async () => { + const f = await fixture() + await writeFile( + f.paths.runtime, + JSON.stringify({ + ...f.runtime, + accounts: { + ...f.runtime.accounts, + row: { binding: { ...f.predecessor, identity: uuid } }, + }, + }), + { mode: 0o600 }, + ) + for (const identity of [undefined, otherUuid, uuid]) { + await refused(f, { ...f.event, identity }) + } +}) + +test('first identity rejects changed or incoherent current pool bindings', async () => { + for (const variant of [ + 'epoch', + 'identity', + 'missing', + 'kind', + 'unbound', + 'malformed', + ]) { + const f = await fixture() + if (variant === 'epoch') + await f.store.replace('row', credential, { identity: uuid }) + if (variant === 'identity') + await f.store.replace('row', credential, { identity: otherUuid }) + if (variant === 'missing') await f.store.remove('row') + if (variant === 'kind') { + const config = JSON.parse(await readFile(f.paths.config, 'utf8')) + config.accounts[0].type = 'api' + await writeFile(f.paths.config, JSON.stringify(config), { mode: 0o600 }) + } + if (variant === 'unbound') { + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + state.accounts.row.access = 'unstamped-access' + await writeFile(f.paths.state, JSON.stringify(state), { mode: 0o600 }) + } + if (variant === 'malformed') + await writeFile(f.paths.config, '{"secret":"synthetic-refresh",', { + mode: 0o600, + }) + await refused(f) + } +}) + +test('first identity attributes a disabled bound row without granting serving eligibility', async () => { + const f = await fixture() + await f.store.disable('row', 'synthetic-disable') + const next = await publishNativeLocalFirstIdentity(f.paths, f.event) + expect(next.accounts.row?.credentialValidation).toEqual(proof(f)) + const read = await f.store.read() + expect(read.status === 'ready' && read.rows[0]?.enabled).toBe(false) +}) + +test('first identity refuses ineligible and incomplete observations', async () => { + const f = await fixture() + const base = f.event + const negatives: unknown[] = [ + null, + {}, + ...['failed', 'adopted', 'refused', 'identity-contradicted'].map( + (status) => ({ ...base, status }), + ), + ] + for (const bootstrap of ['failed', 'unavailable']) + negatives.push({ ...base, bootstrap }) + for (const identity of [undefined, '', 'not-a-uuid', otherUuid]) + negatives.push({ ...base, identity }) + negatives.push( + { ...base, context: undefined }, + { ...base, committed: undefined }, + { ...base, successorVersion: {} }, + { + ...base, + committed: { + credentialFingerprint: f.subject.credentialFingerprint, + version: {}, + }, + }, + ) + if (base.status !== 'persisted') throw new Error('Fixture status') + negatives.push( + { ...base, successorFingerprint: 'b'.repeat(64) }, + { + ...base, + committed: { ...base.committed, credentialFingerprint: 'b'.repeat(64) }, + }, + ) + for (const key of ['accessFingerprint', 'expires', 'lastRefreshedAt']) + negatives.push({ + ...base, + committed: { + ...base.committed, + version: { + ...base.committed.version, + [key]: + key === 'accessFingerprint' ? tokenFingerprint('other-access') : 0, + }, + }, + }) + for (const event of negatives) await refused(f, event) + await publishNativeLocalFirstIdentity(f.paths, f.event) +}) + +test('first identity exact version rejects every material mismatch and timestamp presence direction', async () => { + for (const variant of [ + 'refresh', + 'access', + 'expires', + 'stamp-zero', + 'stamp-value', + 'stamp-absent', + ]) { + const f = await fixture() + const event = structuredClone(f.event) + if (event.status !== 'persisted') throw new Error('Fixture status') + const version = { ...event.successorVersion } + if (variant === 'refresh') + event.successorFingerprint = fingerprintOf({ + type: 'oauth', + refresh: 'other-refresh', + }) + if (variant === 'access') + version.accessFingerprint = tokenFingerprint('other-access') + if (variant === 'expires') version.expires = credential.expires - 1 + if (variant === 'stamp-zero') version.lastRefreshedAt = 0 + if (variant === 'stamp-value') version.lastRefreshedAt = 10 + if (variant === 'stamp-absent') { + // Change only the presence of lastRefreshedAt in saved credential state, + // retaining the same credential epoch. + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + state.accounts.row.lastRefreshedAt = 0 + await writeFile(f.paths.state, JSON.stringify(state), { mode: 0o600 }) + } + event.successorVersion = version + event.committed = { + credentialFingerprint: event.successorFingerprint, + version, + } + await refused(f, event) + } +}) + +test('first identity reset context rejects binding clear and generation drift', async () => { + for (const key of [ + 'refreshErrorClearedAt', + 'quotaErrorClearedAt', + 'quotaErrorGeneration', + ]) { + for (const [captured, current] of [ + [null, 0], + [0, null], + [1, 2], + ]) { + const f = await fixture(true, current === null ? {} : { [key]: current }) + const event = structuredClone(f.event) + if (!event.context) throw new Error('Fixture context') + event.context = { ...event.context, [key]: captured } + await refused(f, event) + } + } + const f = await fixture() + for (const changed of [ + { rowId: 'elsewhere' }, + { storageId: 'b'.repeat(64) }, + { credentialEpoch: 2 }, + { identity: uuid }, + { kind: 'custody' }, + ]) { + const event = structuredClone(f.event) + await refused(f, { + ...event, + context: { + ...event.context, + runtimeBinding: { ...f.predecessor, ...changed }, + }, + }) + } +}) + +test('first identity refuses missing known custody alternate row storage and epoch predecessors', async () => { + for (const variant of [ + 'missing', + 'known', + 'custody', + 'row', + 'storage', + 'epoch', + ]) { + const f = await fixture() + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { + const old = current.accounts.row + if (!old) throw new Error('Fixture predecessor') + if (variant === 'missing') delete current.accounts.row + if (variant === 'known') + old.binding = { ...f.predecessor, identity: otherUuid } + if (variant === 'custody') + old.binding = { + kind: 'custody', + storageId: f.paths.storageId, + routeId: 'row', + credentialId: 'cred', + accountIdentity: uuid, + recordVersion: 1, + } + if (variant === 'row') { + delete current.accounts.row + current.accounts.alternate = { + binding: { ...f.predecessor, rowId: 'alternate' }, + } + } + if (variant === 'epoch') + old.binding = { ...f.predecessor, credentialEpoch: 2 } + return current + }).catch(async (error) => { + // This runtime entry has no account UUID. A generic write cannot establish + // that identity at the same epoch because it has no checked pool observation. + if (variant !== 'known') throw error + const state = structuredClone(f.runtime) + state.accounts.row = { + binding: { ...f.predecessor, identity: otherUuid }, + } + await writeFile(f.paths.runtime, JSON.stringify(state), { mode: 0o600 }) + }) + if (variant === 'storage') { + const state = structuredClone(f.runtime) + state.accounts.row = { + binding: { ...f.predecessor, storageId: 'b'.repeat(64) }, + } + await writeFile(f.paths.runtime, JSON.stringify(state), { mode: 0o600 }) + } + await refused( + f, + f.event, + variant === 'storage' ? 'invalid-runtime' : 'publication-refused', + ) + } +}) + +test('first identity refuses absent runtime without creating a predecessor', async () => { + const f = await fixture() + await rm(f.paths.runtime) + await expect( + publishNativeLocalFirstIdentity(f.paths, f.event), + ).rejects.toMatchObject({ code: 'publication-refused' }) + expect(await readNativeRuntime(f.paths.runtime, f.paths.storageId)).toEqual({ + status: 'missing', + }) + expect( + (await readdir(f.root)).filter((name) => + isNativeRuntimeStagingName(f.paths.runtime, name), + ), + ).toEqual([]) +}) + +test('first identity refuses fractional and unsafe clear floors rather than rounding', async () => { + for (const checkedAt of [0.5, Number.MAX_SAFE_INTEGER + 1]) { + for (const key of ['lastRefreshError', 'lastQuotaRefreshError']) { + const f = await fixture(true, { + [key]: { message: 'old error', checkedAt }, + }) + await refused(f) + } + } +}) + +test('first identity strict decoder refuses invalid predecessor proof retry and quota metadata', async () => { + for (const metadata of [ + { credentialValidation: {} }, + { validationRetry: {} }, + { quota: {} }, + { profileToken: tokenFingerprint('old') }, + { quotaErrorGeneration: -1 }, + ]) { + const f = await fixture() + const state = { + ...f.runtime, + accounts: { + ...f.runtime.accounts, + row: { binding: f.predecessor, ...metadata }, + }, + } + await writeFile(f.paths.runtime, JSON.stringify(state), { mode: 0o600 }) + await refused(f, f.event, 'invalid-runtime') + } +}) + +// Owned module copies expose test controls without adding production knobs or +// replacing Bun's global module registry. Every lease and rename still uses the +// installed implementation; instrumentation observes or disrupts real files. +async function instrumented( + f: Fixture, + rewrite: (source: string) => string = (source) => source, +) { + const directory = join(f.root, 'instrumentation') + await mkdir(directory) + const sourcePath = new URL('../native-runtime.ts', import.meta.url) + let source = rewrite(await readFile(sourcePath, 'utf8')) + source = source.replace( + /from '(\.\/[^']+)'/g, + (_match, relative: string) => + `from '${new URL(relative, sourcePath).href}'`, + ) + source = source.replace("from 'node:fs/promises'", "from './controls.ts'") + source = source.replace( + "from '@cortexkit/common-auth/fs'", + "from './controls.ts'", + ) + source = source.replace( + "from '@cortexkit/common-auth/store'", + `from '${import.meta.resolve('@cortexkit/common-auth/store')}'`, + ) + source = source.replace( + `from '${new URL('../pool-store.ts', import.meta.url).href}'`, + "from './controls.ts'", + ) + await writeFile(join(directory, 'runtime.ts'), source) + const storeUrl = new URL('../pool-store.ts', import.meta.url).href + await writeFile( + join(directory, 'controls.ts'), + ` +import * as fs from 'node:fs/promises'; +import { withLock as realLock } from ${JSON.stringify(import.meta.resolve('@cortexkit/common-auth/fs'))}; +import { createNativePoolStore as realStore, nativePoolStoreLocks } from ${JSON.stringify(storeUrl)}; +export { nativePoolStoreLocks }; +export const events = []; export const held = new Set(); export const heldAtRenames = []; +export let reads = 0; export let renames = 0; export let forbidden = 0; +export const control = { afterSync: async () => {}, afterRename: async () => {}, afterAcquire: async () => {} }; +export const { lstat, mkdir, rm } = fs; +export async function open(...args) { + const file = await fs.open(...args); + if (args[1] !== 'wx') return file; + return new Proxy(file, { get(target, key) { + if (key === 'sync') return async () => { await target.sync(); await control.afterSync(); }; + const value = target[key]; return typeof value === 'function' ? value.bind(target) : value; + }}); +} +export async function rename(...args) { + events.push('rename'); renames++; heldAtRenames.push([...held]); + await fs.rename(...args); await control.afterRename(); +} +export async function withLock(path, options, callback) { + return realLock(path, options, async lock => { + events.push('acquire:' + options.name); held.add(options.name); + try { await control.afterAcquire(options.name); return await callback({ assertOwned: async () => { events.push('assert:' + options.name); await lock.assertOwned(); } }); } + finally { held.delete(options.name); events.push('release:' + options.name); } + }); +} +export function createNativePoolStore(options) { + const store = realStore(options); + return new Proxy(store, { get(target, key) { + if (key === 'read') return async () => { reads++; events.push('read'); return target.read(); }; + forbidden++; throw new Error('Only strict read is allowed during publication'); + }}); +} +`, + ) + const runtime: typeof import('../native-runtime.ts') = await import( + pathToFileURL(join(directory, 'runtime.ts')).href + ) + const controls: { + events: string[] + held: Set + heldAtRenames: string[][] + reads: number + renames: number + forbidden: number + control: { + afterSync: () => Promise + afterRename: () => Promise + afterAcquire: (name: string) => Promise + } + } = await import(pathToFileURL(join(directory, 'controls.ts')).href) + return { + runtime, + controls, + moduleUrl: pathToFileURL(join(directory, 'runtime.ts')).href, + controlsUrl: pathToFileURL(join(directory, 'controls.ts')).href, + } +} + +test('first identity real leases acquire config state runtime once and hold through one rename', async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + const beforePool = await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]) + const next = await runtime.publishNativeLocalFirstIdentity(f.paths, f.event) + expect(next.accounts.row?.credentialValidation).toEqual(proof(f)) + expect(controls.events).toEqual([ + 'acquire:pool-config', + 'acquire:pool-state', + 'read', + 'acquire:native-runtime', + 'read', + 'assert:pool-config', + 'assert:pool-state', + 'assert:native-runtime', + 'rename', + 'release:native-runtime', + 'release:pool-state', + 'release:pool-config', + ]) + expect(controls.reads).toBe(2) + expect(controls.renames).toBe(1) + expect(controls.heldAtRenames).toEqual([ + ['pool-config', 'pool-state', 'native-runtime'], + ]) + expect(controls.forbidden).toBe(0) + expect(controls.held.size).toBe(0) + expect( + await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]), + ).toEqual(beforePool) +}) + +test('first identity authorization still runs generic error transition fences', async () => { + const f = await fixture(true, { + lastRefreshError: { message: 'old-error', checkedAt: 5 }, + }) + const { runtime, controls } = await instrumented(f, (source) => { + const removed = + '...(refreshErrorClearedAt !== undefined ? { refreshErrorClearedAt } : {}),' + expect(source).toContain(removed) + return source.replace(removed, '') + }) + await refused( + f, + f.event, + 'runtime-conflict', + runtime.publishNativeLocalFirstIdentity, + ) + expect(controls.renames).toBe(0) +}) + +for (const name of ['pool-config', 'pool-state', 'native-runtime']) { + test(`first identity refuses actual ${name} lease loss with no stage leak`, async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + const before = await readFile(f.paths.runtime, 'utf8') + controls.control.afterSync = async () => { + const path = + name === 'pool-config' + ? f.paths.config + : name === 'pool-state' + ? f.paths.state + : f.paths.runtime + await rm(lockPathFor(path, name)) + } + let error: unknown + try { + await runtime.publishNativeLocalFirstIdentity(f.paths, f.event) + } catch (caught) { + error = caught + } + // Observe publication before checking its error classification, so removing + // a production lease assertion fails on the unsafe rename itself. + expect(controls.renames).toBe(0) + expect(await readFile(f.paths.runtime, 'utf8')).toBe(before) + expect( + (await readdir(f.root)).filter((leaf) => + isNativeRuntimeStagingName(f.paths.runtime, leaf), + ), + ).toEqual([]) + expect(error).toMatchObject({ + name: 'NativeRuntimeError', + code: name === 'native-runtime' ? 'runtime-io' : 'publication-refused', + }) + expect(controls.held.size).toBe(0) + }) +} + +test('first identity final strict version recheck rejects access-only same-epoch replacement', async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + const oldState = await readFile(f.paths.state, 'utf8') + const oldConfig = await readFile(f.paths.config, 'utf8') + // Prepare a valid credential-bound replacement through the store before taking + // publication locks. Inject its saved bytes later to simulate an external stale + // writer changing access without invoking a store operation under those locks. + await f.store.rotate('row', { ...credential, access: 'intervening-access' }) + const replacement = JSON.parse(await readFile(f.paths.state, 'utf8')) + delete replacement.accounts.row.lastRefreshedAt + await writeFile(f.paths.state, oldState, { mode: 0o600 }) + await writeFile(f.paths.config, oldConfig, { mode: 0o600 }) + controls.control.afterSync = async () => { + await writeFile(f.paths.state, JSON.stringify(replacement), { mode: 0o600 }) + const read = await f.store.read() + expect(read.status === 'ready' && read.rows[0]?.stamp).toBe('bound') + expect(read.status === 'ready' && read.rows[0]?.credentialEpoch).toBe( + f.predecessor.credentialEpoch, + ) + } + await refused( + f, + f.event, + 'publication-refused', + runtime.publishNativeLocalFirstIdentity, + ) + expect(controls.reads).toBe(2) + expect(controls.renames).toBe(0) +}) + +test('first identity copies checked proof and context before lock waits', async () => { + const f = await fixture() + const event = structuredClone(f.event) + const { runtime, controls } = await instrumented(f) + controls.control.afterAcquire = async (name) => { + if (name !== 'pool-config') return + if (event.status !== 'persisted' || !event.context) + throw new Error('Fixture event') + event.successorVersion = {} + event.context = { ...event.context, quotaErrorGeneration: 100 } + } + expect( + (await runtime.publishNativeLocalFirstIdentity(f.paths, event)).accounts.row + ?.credentialValidation, + ).toEqual(proof(f)) +}) + +test('strict native reader under both production pool leases performs zero writes pulls or HTTP', async () => { + const f = await fixture() + const specs = nativePoolStoreLocks(f.paths) + let writes = 0 + let pulls = 0 + const watcher = watch(f.root, (_event, leaf) => { + if ( + leaf === f.paths.config.split('/').at(-1) || + leaf === f.paths.state.split('/').at(-1) + ) + writes++ + }) + deferCleanup(() => watcher.close()) + const before = await Promise.all( + [f.paths.config, f.paths.state].map(async (path) => ({ + bytes: await readFile(path, 'utf8'), + info: await stat(path), + })), + ) + const observable = openPoolStore({ + provider: 'anthropic', + configPath: f.paths.config, + statePath: f.paths.state, + requireCredentialStamps: true, + storeLocks: specs, + quota: nativeQuotaCodec, + pull: async () => { + pulls++ + throw new Error('Pull forbidden') + }, + }) + await withLock( + specs[0].path, + { ...POOL_LOCK_DEFAULTS, name: specs[0].name }, + async (config) => + withLock( + specs[1].path, + { ...POOL_LOCK_DEFAULTS, name: specs[1].name }, + async (state) => { + expect(await f.store.read()).toEqual(await observable.read()) + await observable.pullsSettled() + await config.assertOwned() + await state.assertOwned() + }, + ), + ) + expect(pulls).toBe(0) + const after = await Promise.all( + [f.paths.config, f.paths.state].map(async (path) => ({ + bytes: await readFile(path, 'utf8'), + info: await stat(path), + })), + ) + expect( + after.map(({ bytes, info }) => [ + bytes, + info.ino, + info.mtimeMs, + info.ctimeMs, + ]), + ).toEqual( + before.map(({ bytes, info }) => [ + bytes, + info.ino, + info.mtimeMs, + info.ctimeMs, + ]), + ) + expect(writes).toBe(0) + // Intercept fetch only in the child to count unexpected HTTP attempts while + // createNativePoolStore.read follows the installed strict pool-file read path. + const result = await child(` + import { withLock } from ${JSON.stringify(import.meta.resolve('@cortexkit/common-auth/fs'))}; + import { createNativePoolStore } from ${JSON.stringify(new URL('../pool-store.ts', import.meta.url).href)}; + import { nativeQuotaCodec } from ${JSON.stringify(new URL('../native-quota-codec.ts', import.meta.url).href)}; + let http = 0; globalThis.fetch = async () => { http++; throw new Error('HTTP forbidden'); }; + const paths = ${JSON.stringify(f.paths)}; + await withLock(paths.config, { name: 'pool-config', ttlMs: 10000, timeoutMs: 15000, renew: true }, async a => withLock(paths.state, { name: 'pool-state', ttlMs: 10000, timeoutMs: 15000, renew: true }, async b => { + const read = await createNativePoolStore({ paths, quota: nativeQuotaCodec }).read(); + await a.assertOwned(); await b.assertOwned(); console.log(JSON.stringify({ status: read.status, http })); + })); + `) + expect(result.exit).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ status: 'ready', http: 0 }) + expect(result.stderr).toBe('') +}) + +async function child(source: string) { + const process = Bun.spawn([globalThis.process.execPath, '-e', source], { + stdout: 'pipe', + stderr: 'pipe', + }) + const output = Promise.all([ + process.exited, + new Response(process.stdout).text(), + new Response(process.stderr).text(), + ]) + trackDetached(output) + deferCleanup(() => { + if (process.exitCode === null) process.kill() + }) + const [exit, stdout, stderr] = await output + return { exit, stdout, stderr } +} + +async function expireExitedLeases(f: Fixture) { + // Expire dead lock records only after confirmed child exit and both output + // pipes have drained. Those abandoned markers then belong to an exited + // process, not to a test body that could still be writing its files. + for (const [path, name] of [ + ...nativePoolStoreLocks(f.paths).map((s) => [s.path, s.name] as const), + [f.paths.runtime, 'native-runtime'] as const, + ]) { + const markerPath = lockPathFor(path, name) + try { + const marker = JSON.parse(await readFile(markerPath, 'utf8')) + marker.expiresAt = 0 + await writeFile(markerPath, JSON.stringify(marker)) + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) + throw error + } + } +} + +test('first identity serialized 0.9.0 learning publishes same epoch exactly once', async () => { + const f = await fixture(false) + const { runtime, controls } = await instrumented(f) + let refreshes = 0 + let lookups = 0 + const coordinator = createNativeRefreshCoordinator({ + paths: f.paths, + quota: nativeQuotaCodec, + refreshToken: async () => { + refreshes++ + return { + ...credential, + access: 'new-access', + refresh: 'new-refresh', + expiresIn: 3600, + } + }, + resolveIdentity: async () => { + lookups++ + expect(controls.held.size).toBe(0) + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: uuid as ProviderAccountUuid, + } + }, + readRestrictions: async (subject) => ({ + restriction: { status: 'allowed' }, + context: { + subject, + runtimeBinding: f.predecessor, + refreshErrorClearedAt: null, + quotaErrorClearedAt: null, + quotaErrorGeneration: null, + }, + }), + readAdmission: async (subject) => { + const read = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const validation = + read.status === 'ready' + ? read.state.accounts.row?.credentialValidation + : undefined + return validation && + validation.credentialFingerprint === subject.credentialFingerprint + ? { status: 'proven', validation } + : { status: 'unproven' } + }, + reconcile: async (event) => { + expect(event.status).toBe('persisted') + await runtime.publishNativeLocalFirstIdentity(f.paths, event) + }, + }) + const result = await coordinator.authorize({ + binding: f.predecessor, + intent: 'serve', + mode: 'local', + }) + expect(result).toMatchObject({ + status: 'usable', + source: 'rotated', + binding: { ...f.predecessor, identity: uuid }, + }) + const read = await f.store.read() + if (read.status !== 'ready' || !read.rows[0]) + throw new Error('Missing committed row') + expect(read.rows[0].credentialEpoch).toBe(f.predecessor.credentialEpoch) + const saved = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (saved.status !== 'ready') throw new Error('Missing proof') + expect( + nativeLocalCredentialValidationMatches( + saved.state.accounts.row?.credentialValidation, + { ...f.predecessor, identity: uuid }, + read.rows[0].credential, + ), + ).toBe(true) + expect(Object.keys(saved.state.accounts.row ?? {})).toEqual([ + 'binding', + 'credentialValidation', + ]) + expect(controls.renames).toBe(1) + expect( + controls.events.filter((s) => s === 'acquire:native-runtime'), + ).toHaveLength(1) + expect(controls.forbidden).toBe(0) + expect(refreshes).toBe(1) + expect(lookups).toBe(1) +}) + +test('first identity child crash after pool commit recovers with known validation only', async () => { + const f = await fixture(false) + const before = await readFile(f.paths.runtime, 'utf8') + const result = await child(` + import { createNativeRefreshCoordinator } from ${JSON.stringify(new URL('../native-refresh-coordinator.ts', import.meta.url).href)}; + import { nativeQuotaCodec } from ${JSON.stringify(new URL('../native-quota-codec.ts', import.meta.url).href)}; + const coordinator = createNativeRefreshCoordinator({ paths: ${JSON.stringify(f.paths)}, quota: nativeQuotaCodec, + refreshToken: async () => (${JSON.stringify({ ...credential, access: 'new-access', refresh: 'new-refresh' })}), + resolveIdentity: async () => ({ deviceId: 'synthetic', accountUuid: ${JSON.stringify(uuid)} }), + readRestrictions: async subject => ({ restriction: { status: 'allowed' }, context: { subject, runtimeBinding: ${JSON.stringify(f.predecessor)}, refreshErrorClearedAt: null, quotaErrorClearedAt: null, quotaErrorGeneration: null } }), + readAdmission: async () => ({ status: 'unproven' }), + reconcile: async event => { if (event.status !== 'persisted') throw new Error('Expected commit'); process.exit(19); }, + }); + await coordinator.authorize({ binding: ${JSON.stringify(f.predecessor)}, intent: 'serve', mode: 'local' }); + `) + expect(result.exit).toBe(19) + expect(result.stderr).toBe('') + await expireExitedLeases(f) + expect(await readFile(f.paths.runtime, 'utf8')).toBe(before) + const read = await f.store.read() + if (read.status !== 'ready' || !read.rows[0]) + throw new Error('Missing committed row') + const known = captureNativeLocalPoolBinding(f.paths, read.rows[0]) + expect(known).toEqual({ ...f.predecessor, identity: uuid }) + const beforePool = await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]) + let refreshes = 0 + let lookups = 0 + const observations: string[] = [] + const coordinator = createNativeRefreshCoordinator({ + paths: f.paths, + quota: nativeQuotaCodec, + refreshToken: async () => { + refreshes++ + throw new Error('Redundant refresh forbidden') + }, + resolveIdentity: async () => { + lookups++ + return { + deviceId: 'synthetic', + sessionId: 'synthetic-session', + accountUuid: uuid as ProviderAccountUuid, + } + }, + readRestrictions: async (subject) => ({ + restriction: { status: 'allowed' }, + context: { + subject, + runtimeBinding: f.predecessor, + refreshErrorClearedAt: null, + quotaErrorClearedAt: null, + quotaErrorGeneration: null, + }, + }), + readAdmission: async () => { + const saved = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const validation = + saved.status === 'ready' + ? saved.state.accounts.row?.credentialValidation + : undefined + return validation + ? { status: 'proven', validation } + : { status: 'unproven' } + }, + reconcile: async (event) => { + observations.push(event.status) + await publishNativeLocalFirstIdentity(f.paths, event) + }, + }) + expect( + await coordinator.authorize({ + binding: known, + intent: 'serve', + mode: 'local', + }), + ).toMatchObject({ status: 'usable', source: 'validated' }) + expect(observations).toEqual(['validation-observed']) + expect(refreshes).toBe(0) + expect(lookups).toBe(1) + expect( + await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]), + ).toEqual(beforePool) +}) + +for (const seam of ['afterSync', 'afterRename']) { + test(`first identity child interruption ${seam} preserves old or committed proof`, async () => { + const f = await fixture() + const { moduleUrl, controlsUrl } = await instrumented(f) + const before = await readFile(f.paths.runtime, 'utf8') + const result = await child(` + const { control } = await import(${JSON.stringify(controlsUrl)}); + control.${seam} = async () => process.exit(19); + const { publishNativeLocalFirstIdentity } = await import(${JSON.stringify(moduleUrl)}); + await publishNativeLocalFirstIdentity(${JSON.stringify(f.paths)}, ${JSON.stringify(f.event)}); + `) + expect(result.exit).toBe(19) + expect(result.stderr).toBe('') + await expireExitedLeases(f) + const stages = (await readdir(f.root)).filter((name) => + isNativeRuntimeStagingName(f.paths.runtime, name), + ) + expect(stages).toHaveLength(seam === 'afterSync' ? 1 : 0) + if (seam === 'afterSync') + expect(await readFile(f.paths.runtime, 'utf8')).toBe(before) + else { + const saved = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + expect( + saved.status === 'ready' && + saved.state.accounts.row?.credentialValidation, + ).toEqual(proof(f)) + await refused(f) + } + }) +} diff --git a/packages/core/src/tests/native-runtime.test.ts b/packages/core/src/tests/native-runtime.test.ts index de0a2d147..d059ab1c8 100644 --- a/packages/core/src/tests/native-runtime.test.ts +++ b/packages/core/src/tests/native-runtime.test.ts @@ -13,6 +13,7 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { fingerprintOf } from '@cortexkit/common-auth/store' import { hashRefreshToken } from '../accounts.ts' import { toNativeQuotaMap } from '../native-quota-codec.ts' @@ -30,6 +31,79 @@ const storageId = 'a'.repeat(64) const vaultAccountIdentity = '11111111-2222-4333-8444-555555555555' const { test, deferCleanup } = createTestLifetimeSuite() +test('generic runtime writer refuses same-epoch identity changes even with exact proof and hooks', async () => { + const path = await fixture() + const unknown = { + kind: 'local' as const, + storageId, + rowId: 'row', + credentialEpoch: 1, + } + const known = { ...unknown, identity: vaultAccountIdentity } + await updateNativeRuntime(path, storageId, () => ({ + version: 1, + storageId, + accounts: { row: { binding: unknown } }, + })) + const before = await readFile(path, 'utf8') + let hookCalls = 0 + await expect( + updateNativeRuntime( + path, + storageId, + (current) => { + current.accounts.row = { + binding: known, + credentialValidation: { + binding: known, + credentialFingerprint: fingerprintOf({ + type: 'oauth', + refresh: 'synthetic-refresh', + }), + version: { + accessFingerprint: tokenFingerprint('synthetic-access'), + expires: 4_000_000_000_000, + }, + }, + } + return current + }, + { + beforeRename: async () => { + hookCalls++ + }, + }, + ), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) + expect(hookCalls).toBe(0) + expect(await readFile(path, 'utf8')).toBe(before) + // Seed an already-known predecessor independently. Without an epoch change, + // the writer must refuse to clear its account identity and later substitute + // another account, as well as refusing a direct substitution. + await writeFile( + path, + JSON.stringify({ + version: 1, + storageId, + accounts: { row: { binding: known } }, + }), + { mode: 0o600 }, + ) + const knownBytes = await readFile(path, 'utf8') + for (const destination of [ + unknown, + { ...known, identity: 'another-account' }, + ]) { + await expect( + updateNativeRuntime(path, storageId, (current) => { + current.accounts.row = { binding: destination } + return current + }), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) + expect(await readFile(path, 'utf8')).toBe(knownBytes) + } +}) + async function fixture() { const root = await mkdtemp(join(tmpdir(), 'anthropic-native-runtime-')) deferCleanup(() => rm(root, { recursive: true, force: true })) From 6e38aefac3276313781d1276951b92b621e6498a Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:02:46 +0200 Subject: [PATCH 024/169] style: simplify first identity entry guard --- packages/core/src/native-runtime.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index aec0960a6..02b2b2d82 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -823,8 +823,7 @@ function firstIdentityEntry( ): NativeRuntimeEntry { const { predecessor, context, proof } = publication if ( - !old || - old.binding.kind !== 'local' || + old?.binding.kind !== 'local' || !sameLocalBinding(old.binding, predecessor) || (old.refreshErrorClearedAt ?? null) !== context.refreshErrorClearedAt || (old.quotaErrorClearedAt ?? null) !== context.quotaErrorClearedAt || From be9e9570c390a6bb33e1b4a95730d094f94be45c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:46:32 +0200 Subject: [PATCH 025/169] mason: adopt common-auth 0.10.0 in core --- bun.lock | 4 +-- packages/core/package.json | 2 +- .../scripts/check-native-type-closure.test.ts | 29 ++++++++++++++++++- .../core/scripts/check-native-type-closure.ts | 4 +-- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index c809dfdfe..d471c9cf6 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "^0.9.0", + "@cortexkit/common-auth": "^0.10.0", }, }, "packages/e2e-tests": { @@ -218,7 +218,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.9.0", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.4.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-FiolaobIdq8feMJyGaiQm1skefsxixsSyasB+vrKVOkU/HFxkuJGHDo7cUSFrmfNZRs31fWFJbVLcfVGoAfIVQ=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.10.0", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-K369fRBlbilRrqtB0n+lkVjYWB1dkIE1MVoec3XurXnzGQq36sDQ/dRuRDbGDIIyWOX+VEkrZoFa/6/9momSlA=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], diff --git a/packages/core/package.json b/packages/core/package.json index 6e0de4386..f33ccb44d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "^0.9.0" + "@cortexkit/common-auth": "^0.10.0" }, "dependencies": { "@cortexkit/claustrum-client": "0.6.2", diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index 61d6a9f47..e1aafdf9f 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -76,7 +76,7 @@ test('recognizes import, export, inline import types and reference edges without ]) }) -test('copies the complete published relative declaration graph including cycles and directives', async () => { +test('rejects unsupported published producer versions before copying declarations', async () => { const root = await fixture() const dist = join(root, 'dist') const producer = join(root, 'producer') @@ -85,6 +85,33 @@ test('copies the complete published relative declaration graph including cycles 'package.json', JSON.stringify({ version: '0.9.0', + exports: { './store': { types: './dist/store.d.ts' } }, + }), + ) + await put(producer, 'LICENSE', 'Synthetic test license') + await put(producer, 'dist/store.d.ts', 'export interface Store {}') + await put(dist, 'index.js', 'export const ready = true;') + await put( + dist, + 'index.d.ts', + `export type { Store } from '@cortexkit/common-auth/store';`, + ) + + await expect(closeNativeTypeDeclarations(dist, producer)).rejects.toThrow( + 'Expected published common-auth 0.10.0, got 0.9.0', + ) + expect(await readdir(dist)).not.toContain('internal-types') +}) + +test('copies the complete published relative declaration graph including cycles and directives', async () => { + const root = await fixture() + const dist = join(root, 'dist') + const producer = join(root, 'producer') + await put( + producer, + 'package.json', + JSON.stringify({ + version: '0.10.0', exports: { './store': { types: './dist/store/index.d.ts' } }, }), ) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts index 3da7e447b..50f4779b3 100644 --- a/packages/core/scripts/check-native-type-closure.ts +++ b/packages/core/scripts/check-native-type-closure.ts @@ -185,9 +185,9 @@ export async function closeNativeTypeDeclarations( version: string exports: Record } - if (manifest.version !== '0.9.0') + if (manifest.version !== '0.10.0') throw new Error( - `Expected published common-auth 0.9.0, got ${manifest.version}`, + `Expected published common-auth 0.10.0, got ${manifest.version}`, ) const producerDist = join(producerRoot, 'dist') const assets = join(distRoot, TYPE_ASSETS) From 3b5b53647a30a299dc7f1f78e76124b0e83e869e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:57:54 +0200 Subject: [PATCH 026/169] mason: add pure native local runtime readers Require explicit mode/account/quota policy and exact token-free validation proof. Preserve reset markers and full native refresh-lineage restrictions without interpreting legacy hash namespaces. Validation-only retry fences unproven unexpired recovery, not already matching positive proof; use a strict required policy function property to admit sparse refresh subjects safely. The initial new retry fixture incorrectly claimed matching positive proof should be blocked. Parent review clarified that contract; the revised unproven-only fixture and real-coordinator coexistence regression preserve both claims. Actual strict compiler negative coverage rejects complete-only callbacks. Native first-identity publication, generic-writer refusal, quota and custody envelopes remain unchanged; reconciliation and host wiring are not implemented. Approved by parent source/comment review on patch 66d490e1154e83fd8896ab285e498a24b5a51b8ebb9d36fb6e7ac169b295d9c3 after fresh Sidekick review. Pinned Bun 1.3.14 / Node 24.16.0: 66 focused and 719 Core tests, six packed/type tests, workspace types/lock and Pi import gates passed. Distinct raw initial/revision evidence and 17 named mutation controls are retained in ignored node_modules/.cache/native-local-readers-evidence. --- .../core/src/native-local-runtime-readers.ts | 182 +++ packages/core/src/native-runtime.ts | 66 +- .../native-local-runtime-readers.test.ts | 1053 +++++++++++++++++ .../core/src/tests/native-runtime.test.ts | 183 +++ 4 files changed, 1466 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/native-local-runtime-readers.ts create mode 100644 packages/core/src/tests/native-local-runtime-readers.test.ts diff --git a/packages/core/src/native-local-runtime-readers.ts b/packages/core/src/native-local-runtime-readers.ts new file mode 100644 index 000000000..90a781fd7 --- /dev/null +++ b/packages/core/src/native-local-runtime-readers.ts @@ -0,0 +1,182 @@ +import { + isNativeLocalCredentialValidation, + type NativeLocalCredentialValidation, +} from './native-credential-validation.ts' +import type { + NativeRefreshHooks, + NativeRefreshRestriction, + NativeRefreshSubject, +} from './native-refresh-coordinator.ts' +import { + type NativeRuntimeEntry, + NativeRuntimeError, + readNativeRuntime, +} from './native-runtime.ts' +import { + isNativeLocalPoolBinding, + type NativeLocalPoolBinding, +} from './pool-binding.ts' +import type { NativePoolPaths } from './pool-paths.ts' + +/** The caller decides whether local OAuth mode, the account and quota allow the operation. */ +export type NativeLocalExternalPolicy = Exclude< + NativeRefreshRestriction, + { credentialFingerprint: string } +> + +export interface NativeLocalRuntimeReadersOptions { + paths: NativePoolPaths + /** + * Decide whether local OAuth mode, the account and quota allow the operation + * without I/O. Credential-validation evidence is checked separately. + */ + externalPolicy: (subject: NativeRefreshSubject) => NativeLocalExternalPolicy + /** Return the current time to evaluate stored retry deadlines; tests can supply a fixed clock. */ + now?: () => number +} + +function sameBinding(a: NativeLocalPoolBinding, b: NativeLocalPoolBinding) { + return ( + a.kind === b.kind && + a.storageId === b.storageId && + a.rowId === b.rowId && + a.credentialEpoch === b.credentialEpoch && + a.identity === b.identity + ) +} + +/** + * Require the same account, refresh credential, access fingerprint and expiry. + * A missing lastRefreshedAt must remain distinct from a recorded zero. + */ +function sameCredential( + a: NativeLocalCredentialValidation, + b: NativeLocalCredentialValidation, +) { + return ( + sameBinding(a.binding, b.binding) && + a.credentialFingerprint === b.credentialFingerprint && + a.version.accessFingerprint === b.version.accessFingerprint && + a.version.expires === b.version.expires && + Object.hasOwn(a.version, 'lastRefreshedAt') === + Object.hasOwn(b.version, 'lastRefreshedAt') && + a.version.lastRefreshedAt === b.version.lastRefreshedAt + ) +} + +function refreshRestriction( + entry: NativeRuntimeEntry | undefined, + subject: NativeRefreshSubject, + now: number, +): NativeRefreshRestriction { + const error = entry?.lastRefreshError + if ( + entry?.binding.kind === 'local' && + sameBinding(entry.binding, subject.binding) && + error?.credentialFingerprint === subject.credentialFingerprint + ) { + if (error.permanent === true) + return { + status: 'blocked', + reason: 'invalid-grant', + credentialFingerprint: subject.credentialFingerprint, + } + if (error.nextRetryAt !== undefined && error.nextRetryAt > now) + return { + status: 'blocked', + reason: 'refresh-backoff', + credentialFingerprint: subject.credentialFingerprint, + } + } + return { status: 'allowed' } +} + +/** + * Provide the coordinator's restriction and credential-validation reads without + * retrieving credentials or writing files. The caller separately provides + * reconciliation and route selection. + */ +export function createNativeLocalRuntimeReaders( + options: NativeLocalRuntimeReadersOptions, +): Pick { + const { paths, externalPolicy } = options + const now = options.now ?? Date.now + if (typeof externalPolicy !== 'function') + throw new TypeError('Native local external policy gate is required') + + function policy(subject: NativeRefreshSubject): NativeLocalExternalPolicy { + const gate = externalPolicy(subject) + if (gate?.status === 'allowed' && Object.keys(gate).length === 1) + return { status: 'allowed' } + if ( + gate?.status === 'blocked' && + Object.keys(gate).length === 2 && + [ + 'quota-ineligible', + 'account-disabled', + 'local-mode-unavailable', + ].includes(gate.reason) + ) + return { status: 'blocked', reason: gate.reason } + throw new TypeError('Native local external policy result is invalid') + } + + async function readEntry(subject: NativeRefreshSubject) { + if (!isNativeLocalPoolBinding(subject.binding)) + throw new NativeRuntimeError('invalid-runtime') + const read = await readNativeRuntime(paths.runtime, paths.storageId) + const entry = + read.status === 'ready' + ? read.state.accounts[subject.binding.rowId] + : undefined + // Vault-managed bindings are not local OAuth bindings. + if (entry && entry.binding.kind !== 'local') + throw new NativeRuntimeError('invalid-runtime') + return entry + } + + return { + async readRestrictions(subject) { + const entry = await readEntry(subject) + const gate = policy(subject) + return { + restriction: + gate.status === 'blocked' + ? gate + : refreshRestriction(entry, subject, now()), + context: { + subject, + runtimeBinding: + entry?.binding.kind === 'local' ? entry.binding : null, + refreshErrorClearedAt: entry?.refreshErrorClearedAt ?? null, + quotaErrorClearedAt: entry?.quotaErrorClearedAt ?? null, + quotaErrorGeneration: entry?.quotaErrorGeneration ?? null, + }, + } + }, + async readAdmission(subject) { + const entry = await readEntry(subject) + const gate = policy(subject) + if (gate.status === 'blocked') return gate + const time = now() + const restriction = refreshRestriction(entry, subject, time) + if (restriction.status === 'blocked') + return { status: 'blocked', reason: restriction.reason } + if (!isNativeLocalCredentialValidation(subject)) + throw new NativeRuntimeError('invalid-runtime') + const proof = entry?.credentialValidation + if (proof && sameCredential(proof, subject)) + return { status: 'proven', validation: proof } + const retry = entry?.validationRetry + // Expired access can refresh despite an account-validation lookup deadline. + if ( + retry && + subject.version.expires > time && + retry.nextRetryAt > time && + sameCredential(retry.subject, subject) + ) + return { status: 'blocked', reason: 'validation-backoff' } + return { status: 'unproven' } + }, + } +} diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index 02b2b2d82..bba7a315a 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -62,6 +62,17 @@ export interface NativeLocalValidationRetry { readonly nextRetryAt: number } +/** + * Match refresh failures to the coordinator's full OAuth-refresh credential + * fingerprint. The legacy tokenHash hashes the raw refresh token, and + * refreshTokenFingerprint is a short hash; neither is an alias for this value. + * Errors without the explicit full fingerprint preserve error-reset timestamps + * but cannot restrict a credential. + */ +export interface NativeLocalRefreshError extends AccountOperationError { + credentialFingerprint?: string +} + /** * Per-account retry errors, profiles, quota-priming usage and observation * identifiers stored separately from credentials. Vault quota is stored here; @@ -73,7 +84,7 @@ export interface NativeRuntimeEntry { validationRetry?: NativeLocalValidationRetry lastUsed?: number lastRefreshedAt?: number - lastRefreshError?: AccountOperationError + lastRefreshError?: NativeLocalRefreshError refreshErrorClearedAt?: number refreshLeaseId?: string refreshLeaseUntil?: number @@ -202,7 +213,10 @@ function binding(value: unknown): value is NativeRuntimeBinding { ) } -function operationError(value: unknown): value is AccountOperationError { +function operationError( + value: unknown, + extraKeys: readonly string[] = [], +): value is AccountOperationError { return ( record(value) && keys( @@ -216,6 +230,7 @@ function operationError(value: unknown): value is AccountOperationError { 'refreshTokenFingerprint', 'status', 'permanent', + ...extraKeys, ], ) && typeof value.message === 'string' && @@ -234,6 +249,14 @@ function operationError(value: unknown): value is AccountOperationError { ) } +function localRefreshError(value: unknown): value is NativeLocalRefreshError { + return ( + operationError(value, ['credentialFingerprint']) && + record(value) && + optional(value, 'credentialFingerprint', digest) + ) +} + function localValidationRetry( value: unknown, observedBinding: NativeRuntimeBinding, @@ -371,7 +394,11 @@ function entry( !optional(value, 'quotaErrorGeneration', counter) || !optional(value, 'profile', profile) || !optional(value, 'prime', prime) || - !optional(value, 'lastRefreshError', operationError) || + !optional( + value, + 'lastRefreshError', + observedBinding.kind === 'local' ? localRefreshError : operationError, + ) || !optional(value, 'lastQuotaRefreshError', operationError) ) return false @@ -410,7 +437,11 @@ function entry( for (const [errorKey, clearKey] of errorFences) { const error = value[errorKey] const clear = value[clearKey] - if (operationError(error) && time(clear) && error.checkedAt <= clear) + const checkError = + errorKey === 'lastRefreshError' && observedBinding.kind === 'local' + ? localRefreshError + : operationError + if (checkError(error) && time(clear) && error.checkedAt <= clear) return false } return true @@ -617,21 +648,20 @@ function assertTransitions( quotaSnapshotCheckedAt(fromNativeQuotaMap(old.quota)) ) throw new NativeRuntimeError('runtime-conflict') - for (const [older, newer, clear] of [ - [ - old.lastRefreshError, - incoming.lastRefreshError, - incoming.refreshErrorClearedAt, - ], - [ - old.lastQuotaRefreshError, - incoming.lastQuotaRefreshError, - incoming.quotaErrorClearedAt, - ], - ]) { + for (const [errorKey, clearKey] of [ + ['lastRefreshError', 'refreshErrorClearedAt'], + ['lastQuotaRefreshError', 'quotaErrorClearedAt'], + ] as const) { + const older = old[errorKey] + const newer = incoming[errorKey] + const clear = incoming[clearKey] + const checkError = + errorKey === 'lastRefreshError' && incoming.binding.kind === 'local' + ? localRefreshError + : operationError if ( - operationError(older) && - (!operationError(newer) + checkError(older) && + (!checkError(newer) ? !time(clear) || clear < older.checkedAt : newer.checkedAt < older.checkedAt) ) diff --git a/packages/core/src/tests/native-local-runtime-readers.test.ts b/packages/core/src/tests/native-local-runtime-readers.test.ts new file mode 100644 index 000000000..2ba26ce31 --- /dev/null +++ b/packages/core/src/tests/native-local-runtime-readers.test.ts @@ -0,0 +1,1053 @@ +import { expect, spyOn } from 'bun:test' +import { constants } from 'node:fs' +import * as files from 'node:fs/promises' +import { join } from 'node:path' +import * as lockFs from '@cortexkit/common-auth/fs' +import { fingerprintOf } from '@cortexkit/common-auth/store' + +import { hashRefreshToken } from '../accounts.ts' +import * as auth from '../auth.ts' +import * as identity from '../claude-code.ts' +import type { NativeLocalCredentialValidation } from '../native-credential-validation.ts' +import { + createNativeLocalRuntimeReaders, + type NativeLocalExternalPolicy, +} from '../native-local-runtime-readers.ts' +import { + createNativeRefreshCoordinator, + type NativeRefreshHooks, + type NativeRefreshObservation, + type NativeRefreshSubject, +} from '../native-refresh-coordinator.ts' +import { + type NativeLocalRefreshError, + type NativeRuntimeEntry, + NativeRuntimeError, +} from '../native-runtime.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import * as pools from '../pool-store.ts' +import { tokenFingerprint } from '../token-fingerprint.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() +const accountA = + 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' as identity.ProviderAccountUuid +const accountB = '11111111-2222-4333-8444-555555555555' +const now = 500 +const allowed: NativeLocalExternalPolicy = { status: 'allowed' } +const material = { + type: 'oauth' as const, + access: 'synthetic-access-A', + refresh: 'synthetic-refresh-A', + expires: 1_000, + lastRefreshedAt: 100, +} + +async function fixture( + externalPolicy: ( + subject: NativeRefreshSubject, + ) => NativeLocalExternalPolicy = () => allowed, +) { + const parent = join( + import.meta.dir, + '../../../../node_modules/.cache/native-local-runtime-readers', + ) + await files.mkdir(parent, { recursive: true, mode: 0o700 }) + const root = await files.mkdtemp(join(parent, 'runtime-')) + deferCleanup(() => files.rm(root, { recursive: true, force: true })) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + const subject: NativeLocalCredentialValidation = { + binding: { + kind: 'local', + storageId: paths.storageId, + rowId: 'synthetic-row-A', + credentialEpoch: 1, + identity: accountA, + }, + credentialFingerprint: fingerprintOf(material), + version: { + accessFingerprint: tokenFingerprint(material.access), + expires: material.expires, + lastRefreshedAt: material.lastRefreshedAt, + }, + } + const hooks = createNativeLocalRuntimeReaders({ + paths, + externalPolicy, + now: () => now, + }) + async function put(entry: NativeRuntimeEntry | undefined, value?: unknown) { + await files.writeFile( + paths.runtime, + JSON.stringify( + value ?? { + version: 1, + storageId: paths.storageId, + accounts: entry + ? { + [entry.binding.kind === 'local' + ? entry.binding.rowId + : entry.binding.routeId]: entry, + } + : {}, + }, + null, + 2, + ), + { mode: 0o600 }, + ) + } + return { root, paths, subject, hooks, put } +} + +function entry(subject: NativeLocalCredentialValidation): NativeRuntimeEntry { + return { binding: subject.binding, credentialValidation: subject } +} + +function retry(subject: NativeLocalCredentialValidation, nextRetryAt = 800) { + return { subject, checkedAt: 200, nextRetryAt } +} + +function refreshError( + subject: NativeLocalCredentialValidation, + fields: Partial = {}, +): NativeLocalRefreshError { + return { + message: 'synthetic provider detail that must never escape', + checkedAt: 200, + credentialFingerprint: subject.credentialFingerprint, + nextRetryAt: 800, + permanent: false, + ...fields, + } +} + +async function snapshot(root: string) { + const result: Record = {} + for (const name of (await files.readdir(root, { recursive: true })).sort()) { + const path = join(root, name) + const info = await files.lstat(path) + result[name] = { + mode: info.mode, + bytes: info.isFile() + ? (await files.readFile(path)).toString('hex') + : null, + } + } + return result +} + +test('admits exact known positive proof from a real runtime file', async () => { + const f = await fixture() + // These fixed expected values independently check the full OAuth-prefixed + // refresh-credential hash and the short access-token hash. + expect(f.subject.credentialFingerprint).toBe( + '008f37156dffc3a2d0fc5793e4115ced8dd87f9609aa0e14f11aa09f45daaf04', + ) + expect(f.subject.version.accessFingerprint).toBe('270863a0169db796') + await f.put(entry(f.subject)) + expect(await f.hooks.readAdmission(f.subject)).toEqual({ + status: 'proven', + validation: f.subject, + }) + expect(Object.keys(f.hooks).sort()).toEqual([ + 'readAdmission', + 'readRestrictions', + ]) +}) + +test('missing proof and account projections never imply positive admission', async () => { + const f = await fixture() + expect(await f.hooks.readAdmission(f.subject)).toEqual({ status: 'unproven' }) + await f.put({ + binding: f.subject.binding, + lastRefreshedAt: 100, + quotaToken: f.subject.version.accessFingerprint, + quotaCheckedAt: 100, + profile: { + tier: 'max', + orgType: 'individual', + checkedAt: 100, + accountIdentity: accountA, + providerAccountUuid: accountA, + tokenFingerprint: f.subject.version.accessFingerprint, + }, + prime: { count: 1, inputTokens: 2, outputTokens: 3, since: 0 }, + authLineageId: 'synthetic-adopted-lineage', + primeAuthLineageRefreshTokenFingerprint: tokenFingerprint(material.refresh), + }) + expect(await f.hooks.readAdmission(f.subject)).toEqual({ status: 'unproven' }) + expect((await f.hooks.readRestrictions(f.subject)).restriction).toEqual( + allowed, + ) +}) + +const mismatches: Array< + [ + string, + (s: NativeLocalCredentialValidation) => NativeLocalCredentialValidation, + ] +> = [ + [ + 'storage', + (s) => ({ ...s, binding: { ...s.binding, storageId: 'b'.repeat(64) } }), + ], + [ + 'row', + (s) => ({ ...s, binding: { ...s.binding, rowId: 'synthetic-row-B' } }), + ], + ['epoch', (s) => ({ ...s, binding: { ...s.binding, credentialEpoch: 2 } })], + ['account', (s) => ({ ...s, binding: { ...s.binding, identity: accountB } })], + [ + 'full refresh lineage', + (s) => ({ + ...s, + credentialFingerprint: fingerprintOf({ + type: 'oauth', + refresh: 'synthetic-refresh-B', + }), + }), + ], + [ + 'access fingerprint', + (s) => ({ + ...s, + version: { + ...s.version, + accessFingerprint: tokenFingerprint('synthetic-access-B'), + }, + }), + ], + ['expiry', (s) => ({ ...s, version: { ...s.version, expires: 1_001 } })], + [ + 'refresh timestamp', + (s) => ({ ...s, version: { ...s.version, lastRefreshedAt: 101 } }), + ], + [ + 'missing refresh timestamp', + (s) => ({ + ...s, + version: { + accessFingerprint: s.version.accessFingerprint, + expires: s.version.expires, + }, + }), + ], +] + +for (const [name, change] of mismatches) { + test(`positive proof rejects changed ${name}`, async () => { + const f = await fixture() + await f.put(entry(f.subject)) + expect(await f.hooks.readAdmission(change(f.subject))).toEqual({ + status: 'unproven', + }) + }) + test(`validation retry rejects changed ${name}`, async () => { + const f = await fixture() + await f.put({ + binding: f.subject.binding, + validationRetry: retry(f.subject), + }) + expect(await f.hooks.readAdmission(change(f.subject))).toEqual({ + status: 'unproven', + }) + }) +} + +test('proof and retry preserve absent versus zero timestamp in both directions', async () => { + const f = await fixture() + const absent = { + ...f.subject, + version: { + accessFingerprint: f.subject.version.accessFingerprint, + expires: 1_000, + }, + } + const zero = { ...absent, version: { ...absent.version, lastRefreshedAt: 0 } } + for (const [stored, presented] of [ + [absent, zero], + [zero, absent], + ] as const) { + await f.put(entry(stored)) + expect(await f.hooks.readAdmission(presented)).toEqual({ + status: 'unproven', + }) + expect(await f.hooks.readAdmission(stored)).toEqual({ + status: 'proven', + validation: stored, + }) + await f.put({ binding: stored.binding, validationRetry: retry(stored) }) + expect(await f.hooks.readAdmission(presented)).toEqual({ + status: 'unproven', + }) + expect(await f.hooks.readAdmission(stored)).toEqual({ + status: 'blocked', + reason: 'validation-backoff', + }) + } +}) + +for (const reason of [ + 'local-mode-unavailable', + 'account-disabled', + 'quota-ineligible', +] as const) { + test(`explicit external ${reason} policy blocks both hooks despite proof`, async () => { + const seen: NativeRefreshSubject[] = [] + const gate = { status: 'blocked' as const, reason } + const f = await fixture((s) => { + seen.push(s) + return gate + }) + await f.put(entry(f.subject)) + expect((await f.hooks.readRestrictions(f.subject)).restriction).toEqual( + gate, + ) + expect(await f.hooks.readAdmission(f.subject)).toEqual(gate) + expect(seen).toEqual([f.subject, f.subject]) + }) +} + +test('factory requires explicit general policy and rejects credential policy injection', async () => { + const f = await fixture() + expect(() => + Reflect.apply(createNativeLocalRuntimeReaders, undefined, [ + { paths: f.paths }, + ]), + ).toThrow('Native local external policy gate is required') + for (const gate of [ + undefined, + { status: 'blocked', reason: 'refresh-backoff' }, + { status: 'blocked', reason: 'validation-backoff' }, + { + status: 'allowed', + credentialFingerprint: f.subject.credentialFingerprint, + }, + { + status: 'blocked', + reason: 'quota-ineligible', + credentialFingerprint: f.subject.credentialFingerprint, + }, + ]) { + const hooks: Pick< + NativeRefreshHooks, + 'readRestrictions' | 'readAdmission' + > = Reflect.apply(createNativeLocalRuntimeReaders, undefined, [ + { paths: f.paths, externalPolicy: () => gate }, + ]) + await expect(hooks.readRestrictions(f.subject)).rejects.toThrow( + 'Native local external policy result is invalid', + ) + await expect(hooks.readAdmission(f.subject)).rejects.toThrow( + 'Native local external policy result is invalid', + ) + } +}) + +test('external policy callback must accept sparse refresh subjects under strict TypeScript', async () => { + const f = await fixture() + const tsc = join(import.meta.dir, '../../../../node_modules/.bin/tsc') + const config = join(f.root, 'tsconfig.json') + const path = join(f.root, 'policy.ts') + await files.writeFile( + config, + JSON.stringify({ + extends: join(import.meta.dir, '../../tsconfig.json'), + files: ['policy.ts'], + include: [], + }), + ) + const imports = ` + import { createNativeLocalRuntimeReaders, type NativeLocalRuntimeReadersOptions, type NativeLocalExternalPolicy } from ${JSON.stringify(join(import.meta.dir, '../native-local-runtime-readers.ts'))}; + import type { NativeRefreshSubject } from ${JSON.stringify(join(import.meta.dir, '../native-refresh-coordinator.ts'))}; + import type { NativeLocalCredentialValidation } from ${JSON.stringify(join(import.meta.dir, '../native-credential-validation.ts'))}; + import type { NativePoolPaths } from ${JSON.stringify(join(import.meta.dir, '../pool-paths.ts'))}; + declare const paths: NativePoolPaths; + ` + const version = Bun.spawnSync([tsc, '--version'], { + cwd: f.root, + stdout: 'pipe', + stderr: 'pipe', + }) + expect(version.exitCode).toBe(0) + expect(version.stdout.toString().trim()).toBe('Version 7.0.2') + console.log(version.stdout.toString().trim()) + await files.writeFile( + path, + `${imports} + const broad = (subject: NativeRefreshSubject): NativeLocalExternalPolicy => { + void subject; + return { status: 'allowed' }; + }; + const options: NativeLocalRuntimeReadersOptions = { paths, externalPolicy: broad }; + createNativeLocalRuntimeReaders(options); + `, + ) + const positive = Bun.spawnSync([tsc, '-p', config, '--listFiles'], { + cwd: f.root, + stdout: 'pipe', + stderr: 'pipe', + }) + expect(positive.exitCode).toBe(0) + expect(positive.stderr.toString()).toBe('') + const checked = positive.stdout.toString().trim().split('\n') + expect(checked).toContain(path) + expect(checked).toContain( + join(import.meta.dir, '../native-local-runtime-readers.ts'), + ) + console.log( + `External policy broad callback: ${checked.length} actual source/type files checked, exit ${positive.exitCode}`, + ) + await files.writeFile( + path, + `${imports} + const narrow = (subject: NativeLocalCredentialValidation): NativeLocalExternalPolicy => { + subject.binding.identity.toUpperCase(); + subject.version.expires.toFixed(); + return { status: 'allowed' }; + }; + const unsafe: NativeLocalRuntimeReadersOptions = { paths, externalPolicy: narrow }; + createNativeLocalRuntimeReaders({ paths, externalPolicy: narrow }); + void unsafe; + `, + ) + const negative = Bun.spawnSync([tsc, '-p', config], { + cwd: f.root, + stdout: 'pipe', + stderr: 'pipe', + }) + const diagnostics = negative.stdout.toString() + negative.stderr.toString() + console.log( + `External policy narrow callback: ${tsc} -p ${config}, exit ${negative.exitCode}\n${diagnostics}`, + ) + expect(negative.exitCode).toBe(1) + expect(diagnostics).toContain('policy.ts') + expect(diagnostics.match(/TS2322/g)).toHaveLength(2) + expect(diagnostics).toContain('NativeRefreshSubject') + expect(diagnostics).toContain('NativeLocalCredentialValidation') +}) + +test('restrictions echo captured partial subject and observe null zero and stale reset markers', async () => { + const f = await fixture() + const { identity: _identity, ...unknownBinding } = f.subject.binding + const captured: NativeRefreshSubject = { + binding: unknownBinding, + credentialFingerprint: f.subject.credentialFingerprint, + version: { lastRefreshedAt: 0 }, + } + const before = structuredClone(captured) + const empty = { + refreshErrorClearedAt: null, + quotaErrorClearedAt: null, + quotaErrorGeneration: null, + } + expect(await f.hooks.readRestrictions(captured)).toEqual({ + restriction: allowed, + context: { subject: captured, runtimeBinding: null, ...empty }, + }) + await f.put(entry(f.subject)) + expect((await f.hooks.readRestrictions(captured)).context).toEqual({ + subject: captured, + runtimeBinding: f.subject.binding, + ...empty, + }) + for (const value of [0, 7]) { + const observed = { + ...f.subject.binding, + credentialEpoch: 2, + identity: accountB, + } + await f.put({ + binding: observed, + refreshErrorClearedAt: value, + quotaErrorClearedAt: value, + quotaErrorGeneration: value, + }) + expect((await f.hooks.readRestrictions(captured)).context).toEqual({ + subject: captured, + runtimeBinding: observed, + refreshErrorClearedAt: value, + quotaErrorClearedAt: value, + quotaErrorGeneration: value, + }) + } + expect(captured).toEqual(before) + await f.put({ + binding: { ...f.subject.binding, rowId: 'synthetic-later-row' }, + }) + expect( + (await f.hooks.readRestrictions(captured)).context.runtimeBinding, + ).toBeNull() +}) + +test('native refresh errors require explicit full lineage never legacy hash aliases', async () => { + const f = await fixture() + const legacy = { + message: 'invalid_grant raw provider detail', + checkedAt: 200, + nextRetryAt: 800, + permanent: true, + } + expect(hashRefreshToken(material.refresh)).not.toBe( + f.subject.credentialFingerprint, + ) + for (const error of [ + legacy, + { ...legacy, tokenHash: hashRefreshToken(material.refresh) }, + { ...legacy, tokenHash: f.subject.credentialFingerprint }, + { ...legacy, refreshTokenFingerprint: tokenFingerprint(material.refresh) }, + { ...legacy, credentialFingerprint: hashRefreshToken(material.refresh) }, + { ...legacy, credentialFingerprint: 'b'.repeat(64) }, + ]) { + await f.put({ binding: f.subject.binding, lastRefreshError: error }) + expect((await f.hooks.readRestrictions(f.subject)).restriction).toEqual( + allowed, + ) + expect(await f.hooks.readAdmission(f.subject)).toEqual({ + status: 'unproven', + }) + } +}) + +test('exact native error honors only stored retry and explicit permanent flag', async () => { + const f = await fixture() + for (const [fields, reason] of [ + [{ nextRetryAt: 800, permanent: false, status: 503 }, 'refresh-backoff'], + [{ nextRetryAt: 0, permanent: true, status: 400 }, 'invalid-grant'], + ] as const) { + await f.put({ + ...entry(f.subject), + lastRefreshError: refreshError(f.subject, fields), + }) + expect((await f.hooks.readRestrictions(f.subject)).restriction).toEqual({ + status: 'blocked', + reason, + credentialFingerprint: f.subject.credentialFingerprint, + }) + expect(await f.hooks.readAdmission(f.subject)).toEqual({ + status: 'blocked', + reason, + }) + } + for (const fields of [ + { nextRetryAt: 499 }, + { nextRetryAt: 500 }, + { nextRetryAt: 0 }, + { nextRetryAt: 500, status: 400, permanent: false }, + ]) { + await f.put({ + ...entry(f.subject), + lastRefreshError: refreshError(f.subject, fields), + }) + expect((await f.hooks.readRestrictions(f.subject)).restriction).toEqual( + allowed, + ) + expect(await f.hooks.readAdmission(f.subject)).toEqual({ + status: 'proven', + validation: f.subject, + }) + } + await f.put({ + binding: f.subject.binding, + lastRefreshError: { + message: 'invalid_grant', + checkedAt: 200, + status: 400, + credentialFingerprint: f.subject.credentialFingerprint, + }, + }) + expect((await f.hooks.readRestrictions(f.subject)).restriction).toEqual( + allowed, + ) +}) + +test('stale native error binding and credential rotation cannot latch replacements', async () => { + const f = await fixture() + for (const change of mismatches.slice(0, 5)) { + await f.put({ + binding: f.subject.binding, + lastRefreshError: refreshError(f.subject, { permanent: true }), + }) + const changed = change[1](f.subject) + expect((await f.hooks.readRestrictions(changed)).restriction).toEqual( + allowed, + ) + expect(await f.hooks.readAdmission(changed)).toEqual({ status: 'unproven' }) + } + // Changing only the access fingerprint leaves the refresh credential unchanged, + // so an error bound to that refresh credential still applies. + await f.put({ + binding: f.subject.binding, + lastRefreshError: refreshError(f.subject), + }) + const changedAccess = mismatches[5]![1](f.subject) + expect((await f.hooks.readRestrictions(changedAccess)).restriction).toEqual({ + status: 'blocked', + reason: 'refresh-backoff', + credentialFingerprint: f.subject.credentialFingerprint, + }) + const { identity: _identity, ...unknown } = f.subject.binding + const captured = { ...f.subject, binding: unknown } + expect((await f.hooks.readRestrictions(captured)).restriction).toEqual( + allowed, + ) +}) + +test('unproven matching validation retry fences admission but never refresh restrictions', async () => { + const f = await fixture() + await f.put({ binding: f.subject.binding, validationRetry: retry(f.subject) }) + expect(await f.hooks.readAdmission(f.subject)).toEqual({ + status: 'blocked', + reason: 'validation-backoff', + }) + expect((await f.hooks.readRestrictions(f.subject)).restriction).toEqual( + allowed, + ) +}) + +test('due retry and expired material cannot inherit validation backoff', async () => { + const f = await fixture() + for (const due of [499, 500]) { + await f.put({ + binding: f.subject.binding, + validationRetry: retry(f.subject, due), + }) + expect(await f.hooks.readAdmission(f.subject)).toEqual({ + status: 'unproven', + }) + } + for (const expires of [499, 500]) { + const expired = { ...f.subject, version: { ...f.subject.version, expires } } + await f.put({ binding: expired.binding, validationRetry: retry(expired) }) + expect(await f.hooks.readAdmission(expired)).toEqual({ status: 'unproven' }) + expect((await f.hooks.readRestrictions(expired)).restriction).toEqual( + allowed, + ) + } + const changed = mismatches[5]![1](f.subject) + await f.put({ ...entry(changed), validationRetry: retry(f.subject) }) + expect(await f.hooks.readAdmission(changed)).toEqual({ + status: 'proven', + validation: changed, + }) +}) + +test('malformed mismatched and reset-cleared runtime files fail with redacted diagnostics', async () => { + const f = await fixture() + const secret = 'sk-ant-oat01-synthetic-secret-never-echo' + const good = { + version: 1, + storageId: f.paths.storageId, + accounts: { [f.subject.binding.rowId]: entry(f.subject) }, + } + for (const value of [ + { ...good, version: 2 }, + { ...good, storageId: 'b'.repeat(64) }, + { ...good, secret }, + { + ...good, + accounts: { + [f.subject.binding.rowId]: { + binding: { + kind: 'custody', + storageId: f.paths.storageId, + routeId: f.subject.binding.rowId, + credentialId: 'synthetic-custody-credential', + accountIdentity: accountA, + recordVersion: 0, + }, + }, + }, + }, + { + ...good, + accounts: { + [f.subject.binding.rowId]: { + binding: { ...f.subject.binding, rowId: 'different-row' }, + }, + }, + }, + { + ...good, + accounts: { + [f.subject.binding.rowId]: { + binding: f.subject.binding, + credentialValidation: { binding: f.subject.binding }, + }, + }, + }, + { + ...good, + accounts: { + [f.subject.binding.rowId]: { + binding: f.subject.binding, + lastRefreshError: refreshError(f.subject, { + credentialFingerprint: secret, + }), + }, + }, + }, + { + ...good, + accounts: { + [f.subject.binding.rowId]: { + binding: f.subject.binding, + lastRefreshError: refreshError(f.subject), + refreshErrorClearedAt: 200, + }, + }, + }, + ]) { + await f.put(undefined, value) + const before = await snapshot(f.root) + for (const read of [f.hooks.readRestrictions, f.hooks.readAdmission]) { + await expect(read(f.subject)).rejects.toBeInstanceOf(NativeRuntimeError) + await expect(read(f.subject)).rejects.toMatchObject({ + code: 'invalid-runtime', + message: 'Anthropic runtime state is invalid', + }) + } + expect(await snapshot(f.root)).toEqual(before) + } + await files.writeFile(f.paths.runtime, `{ "secret": "${secret}",`, { + mode: 0o600, + }) + await expect(f.hooks.readAdmission(f.subject)).rejects.toMatchObject({ + code: 'invalid-runtime', + message: 'Anthropic runtime state is invalid', + }) + await f.put(entry(f.subject)) + await files.chmod(f.paths.runtime, 0o644) + await expect(f.hooks.readRestrictions(f.subject)).rejects.toMatchObject({ + code: 'unsafe-runtime', + message: 'Anthropic runtime file is not owner-only regular storage', + }) +}) + +test('reader calls leave files byte-identical with no mutation locks or provider HTTP', async () => { + const f = await fixture() + for (const path of [ + f.paths.config, + f.paths.state, + f.paths.legacyConfig, + f.paths.legacyState, + f.paths.journal, + f.paths.roster, + ]) + await files.writeFile( + path, + 'opaque sentinel, not a readable credential store', + { mode: 0o600 }, + ) + await f.put(entry(f.subject)) + const before = await snapshot(f.root) + const write = spyOn(files, 'writeFile') + const rename = spyOn(files, 'rename') + const mkdir = spyOn(files, 'mkdir') + const rm = spyOn(files, 'rm') + const open = spyOn(files, 'open') + const lock = spyOn(lockFs, 'withLock') + const refreshLock = spyOn(lockFs, 'acquireRefreshFileLock') + const pool = spyOn(pools, 'createNativePoolStore') + const provider = spyOn(auth, 'refreshClaudeOAuthToken') + const bootstrap = spyOn(identity, 'resolveClaudeCodeIdentity') + const http = spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')) + try { + await f.hooks.readRestrictions(f.subject) + await f.hooks.readAdmission(f.subject) + expect(await snapshot(f.root)).toEqual(before) + for (const observation of [ + write, + rename, + mkdir, + rm, + lock, + refreshLock, + pool, + provider, + bootstrap, + http, + ]) + expect(observation).toHaveBeenCalledTimes(0) + expect(open.mock.calls).toHaveLength(2) + expect( + open.mock.calls.every( + (call) => call[1] === (constants.O_RDONLY | constants.O_NOFOLLOW), + ), + ).toBe(true) + } finally { + for (const observation of [ + write, + rename, + mkdir, + rm, + open, + lock, + refreshLock, + pool, + provider, + bootstrap, + http, + ]) + observation.mockRestore() + } +}) + +async function coordinatorFixture(expires = 1_000) { + const f = await fixture() + const store = pools.createNativePoolStore({ + paths: f.paths, + now: () => now, + quota: { validate: () => true, merge: (_, observation) => observation }, + }) + await store.add({ + id: f.subject.binding.rowId, + identity: accountA, + credential: { ...material, expires }, + }) + const read = await store.read() + if (read.status !== 'ready') + throw new Error('Native test pool is unavailable') + const row = read.rows[0]! + // Store.add records lastRefreshedAt from its current clock rather than copying + // the timestamp supplied with the incoming credential. + expect(row.credential).toMatchObject({ + ...material, + expires, + lastRefreshedAt: now, + }) + const subject = { + ...f.subject, + binding: { ...f.subject.binding, credentialEpoch: row.credentialEpoch! }, + version: { ...f.subject.version, expires, lastRefreshedAt: now }, + } + return { ...f, subject, store } +} + +test('exact positive proof survives coexisting validation retry without provider work', async () => { + const f = await coordinatorFixture() + await f.put({ ...entry(f.subject), validationRetry: retry(f.subject) }) + let providers = 0 + let lookups = 0 + const observations: NativeRefreshObservation[] = [] + const coordinator = createNativeRefreshCoordinator({ + paths: f.paths, + now: () => now, + quota: { validate: () => true, merge: (_, observation) => observation }, + ...f.hooks, + refreshToken: async () => { + providers++ + return { ...material, expiresIn: 3_600 } + }, + resolveIdentity: async () => { + lookups++ + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: accountA, + } + }, + reconcile: async (observation) => { + observations.push(observation) + }, + }) + const before = await snapshot(f.root) + expect(await f.hooks.readAdmission(f.subject)).toEqual({ + status: 'proven', + validation: f.subject, + }) + expect( + await coordinator.authorize({ + mode: 'local', + intent: 'serve', + binding: f.subject.binding, + }), + ).toMatchObject({ + status: 'usable', + source: 'current', + access: material.access, + validation: f.subject, + }) + expect([providers, lookups, observations.length]).toEqual([0, 0, 0]) + expect(await snapshot(f.root)).toEqual(before) +}) + +test('actual coordinator admits only real current proof without provider work', async () => { + const f = await coordinatorFixture() + await f.put(entry(f.subject)) + let providers = 0 + let lookups = 0 + const observations: NativeRefreshObservation[] = [] + const coordinator = createNativeRefreshCoordinator({ + paths: f.paths, + now: () => now, + quota: { validate: () => true, merge: (_, observation) => observation }, + ...f.hooks, + refreshToken: async () => { + providers++ + return { ...material, expiresIn: 3_600 } + }, + resolveIdentity: async () => { + lookups++ + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: accountA as identity.ProviderAccountUuid, + } + }, + reconcile: async (observation) => { + observations.push(observation) + }, + }) + const before = await snapshot(f.root) + const result = await coordinator.authorize({ + mode: 'local', + intent: 'serve', + binding: f.subject.binding, + }) + expect(result).toMatchObject({ + status: 'usable', + source: 'current', + access: material.access, + validation: f.subject, + }) + expect([providers, lookups, observations.length]).toEqual([0, 0, 0]) + expect(await snapshot(f.root)).toEqual(before) + await f.put({ binding: f.subject.binding, validationRetry: retry(f.subject) }) + expect( + await coordinator.authorize({ + mode: 'local', + intent: 'serve', + binding: f.subject.binding, + }), + ).toEqual({ + status: 'refused', + reason: 'validation-backoff', + persisted: false, + }) + expect([providers, lookups, observations.length]).toEqual([0, 0, 0]) +}) + +test('actual coordinator account lookup cannot admit missing runtime proof', async () => { + const f = await coordinatorFixture() + const observations: NativeRefreshObservation[] = [] + let providers = 0 + let lookups = 0 + const coordinator = createNativeRefreshCoordinator({ + paths: f.paths, + now: () => now, + quota: { validate: () => true, merge: (_, observation) => observation }, + ...f.hooks, + refreshToken: async () => { + providers++ + return { ...material, expiresIn: 3_600 } + }, + resolveIdentity: async () => { + lookups++ + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: accountA, + } + }, + reconcile: async (observation) => { + observations.push(observation) + }, + }) + expect( + await coordinator.authorize({ + mode: 'local', + intent: 'serve', + binding: f.subject.binding, + }), + ).toEqual({ status: 'refused', reason: 'proof-missing', persisted: false }) + expect([providers, lookups]).toEqual([0, 1]) + expect(observations.map((o) => o.status)).toEqual(['validation-observed']) + expect( + (await files.readdir(f.root)).includes('anthropic-auth-native-state.json'), + ).toBe(false) +}) + +for (const recovery of ['expired', 'rejected'] as const) { + test(`actual coordinator can refresh ${recovery} material despite absent proof and validation retry`, async () => { + const f = await coordinatorFixture(recovery === 'expired' ? now : 1_000) + await f.put({ + binding: f.subject.binding, + validationRetry: retry(f.subject), + }) + const observations: NativeRefreshObservation[] = [] + let providers = 0 + let lookups = 0 + let reads = 0 + const coordinator = createNativeRefreshCoordinator({ + paths: f.paths, + now: () => now, + quota: { validate: () => true, merge: (_, observation) => observation }, + readRestrictions: async (subject) => { + const before = await snapshot(f.root) + const policy = await f.hooks.readRestrictions(subject) + expect(await snapshot(f.root)).toEqual(before) + reads++ + return policy + }, + readAdmission: async (subject) => { + const before = await snapshot(f.root) + const admission = await f.hooks.readAdmission(subject) + expect(await snapshot(f.root)).toEqual(before) + return admission + }, + refreshToken: async () => { + providers++ + return { + ...material, + access: 'synthetic-new-access', + refresh: 'synthetic-new-refresh', + expires: 2_000, + expiresIn: 3_600, + } + }, + resolveIdentity: async () => { + lookups++ + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: accountA as identity.ProviderAccountUuid, + } + }, + reconcile: async (observation) => { + observations.push(observation) + }, + }) + const runtimeBefore = await files.readFile(f.paths.runtime, 'utf8') + const result = await coordinator.authorize({ + mode: 'local', + intent: 'serve', + binding: f.subject.binding, + ...(recovery === 'rejected' + ? { rejectedAccessToken: material.access } + : {}), + }) + // These readers cannot save validation for the newly rotated credential, + // so the observed refresh must still leave that credential unproven. + expect(result).toEqual({ + status: 'refused', + reason: 'proof-missing', + persisted: true, + }) + expect([providers, lookups]).toEqual([1, 1]) + expect(reads).toBeGreaterThan(0) + expect(observations.map((o) => o.status)).toEqual(['persisted']) + expect(await files.readFile(f.paths.runtime, 'utf8')).toBe(runtimeBefore) + const read = await f.store.read() + if (read.status !== 'ready') + throw new Error('Native test pool is unavailable') + expect(read.rows[0]!.credential).toMatchObject({ + type: 'oauth', + access: 'synthetic-new-access', + }) + }) +} diff --git a/packages/core/src/tests/native-runtime.test.ts b/packages/core/src/tests/native-runtime.test.ts index d059ab1c8..fcd58b4f6 100644 --- a/packages/core/src/tests/native-runtime.test.ts +++ b/packages/core/src/tests/native-runtime.test.ts @@ -210,6 +210,189 @@ function state(): NativeRuntimeState { return decodeNativeRuntime(structuredClone(full), storageId) } +test('native local refresh lineage round-trips explicitly without inferring legacy aliases', async () => { + const parent = join( + import.meta.dir, + '../../../../node_modules/.cache/native-local-runtime-readers', + ) + await mkdir(parent, { recursive: true, mode: 0o700 }) + const root = await mkdtemp(join(parent, 'refresh-errors-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'runtime.json') + const local = full.accounts['local-row'] + const credentialFingerprint = fingerprintOf({ + type: 'oauth', + refresh: 'synthetic-refresh', + }) + expect(credentialFingerprint).not.toBe(local.lastRefreshError.tokenHash) + const native = { + ...full, + accounts: { + 'local-row': { + ...local, + lastRefreshError: { ...local.lastRefreshError, credentialFingerprint }, + refreshErrorClearedAt: 0, + quotaErrorClearedAt: 0, + quotaErrorGeneration: 0, + }, + }, + } + const before = structuredClone(native) + expect( + await updateNativeRuntime(path, storageId, () => + decodeNativeRuntime(native, storageId), + ), + ).toEqual(native) + expect(await readNativeRuntime(path, storageId)).toEqual({ + status: 'ready', + state: native, + }) + expect(native).toEqual(before) + const old = decodeNativeRuntime(full, storageId).accounts['local-row']! + .lastRefreshError! + expect(Object.hasOwn(old, 'credentialFingerprint')).toBe(false) + expect(old.tokenHash).toBe(hashRefreshToken('synthetic-refresh')) + expect(old.refreshTokenFingerprint).toBe( + tokenFingerprint('synthetic-refresh'), + ) +}) + +test('native local refresh lineage rejects malformed fingerprints without widening quota or custody', () => { + const local = full.accounts['local-row'] + for (const credentialFingerprint of [ + undefined, + null, + 0, + [], + {}, + '', + 'a'.repeat(16), + 'A'.repeat(64), + ` ${'a'.repeat(64)}`, + 'sk-ant-oat01-synthetic-secret', + ]) { + expect(() => + decodeNativeRuntime( + { + ...full, + accounts: { + 'local-row': { + ...local, + lastRefreshError: { + ...local.lastRefreshError, + credentialFingerprint, + }, + }, + }, + }, + storageId, + ), + ).toThrow('Anthropic runtime state is invalid') + } + const credentialFingerprint = fingerprintOf({ + type: 'oauth', + refresh: 'synthetic-refresh', + }) + expect(() => + decodeNativeRuntime( + { + ...full, + accounts: { + 'local-row': { + ...local, + lastQuotaRefreshError: { + ...local.lastQuotaRefreshError, + credentialFingerprint, + }, + }, + }, + }, + storageId, + ), + ).toThrow('Anthropic runtime state is invalid') + expect(() => + decodeNativeRuntime( + { + ...full, + accounts: { + 'vault-route': { + ...full.accounts['vault-route'], + lastRefreshError: { + message: 'synthetic error', + checkedAt: 1, + credentialFingerprint, + }, + }, + }, + }, + storageId, + ), + ).toThrow('Anthropic runtime state is invalid') + for (const checkedAt of [0, 100]) { + expect(() => + decodeNativeRuntime( + { + ...full, + accounts: { + 'local-row': { + ...local, + refreshErrorClearedAt: checkedAt, + lastRefreshError: { + ...local.lastRefreshError, + credentialFingerprint, + checkedAt, + }, + }, + }, + }, + storageId, + ), + ).toThrow('Anthropic runtime state is invalid') + } +}) + +test('native local refresh lineage retains monotonic error reset floors', async () => { + const parent = join( + import.meta.dir, + '../../../../node_modules/.cache/native-local-runtime-readers', + ) + await mkdir(parent, { recursive: true, mode: 0o700 }) + const root = await mkdtemp(join(parent, 'refresh-floors-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'runtime.json') + const initial = state() + initial.accounts['local-row']!.lastRefreshError!.credentialFingerprint = + fingerprintOf({ type: 'oauth', refresh: 'synthetic-refresh' }) + await updateNativeRuntime(path, storageId, () => initial) + const before = await readFile(path, 'utf8') + for (const change of [ + 'older', + 'remove-without-clear', + 'remove-before-clear', + ] as const) { + await expect( + updateNativeRuntime(path, storageId, (current) => { + const local = current.accounts['local-row']! + if (change === 'older') local.lastRefreshError!.checkedAt-- + else { + delete local.lastRefreshError + if (change === 'remove-before-clear') + local.refreshErrorClearedAt = 332 + } + return current + }), + ).rejects.toMatchObject({ code: 'runtime-conflict' }) + expect(await readFile(path, 'utf8')).toBe(before) + } + const cleared = await updateNativeRuntime(path, storageId, (current) => { + delete current.accounts['local-row']!.lastRefreshError + current.accounts['local-row']!.refreshErrorClearedAt = 333 + return current + }) + expect(cleared.accounts['local-row']!.refreshErrorClearedAt).toBe(333) + expect(cleared.accounts['local-row']!.lastRefreshError).toBeUndefined() +}) + test('runtime full-field fixture round-trips and accounts for every owned field', async () => { const path = await fixture() expect(await readNativeRuntime(path, storageId)).toEqual({ From 47f0be85dd860cc389129e951f9cd329e82a300d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:43:11 +0200 Subject: [PATCH 027/169] mason: forward physical native handoff contention Forward only acquireRefreshFileLock onContended through the isolated native observer, leaving the producer callback undefined when no observer exists. Never infer contention from null, exceptions, or timeout; ownership and lease behavior remain unchanged. Real-lock child cases cover causal contention, release sequence, omitted observers, non-live refusal, and throw/promise/thenable/non-settling isolation while preserving durable tokens, identity, proof, and awaited lock steps. The held-timeout test intentionally changes from no diagnostics to genuine live-holder contention under common-auth 0.10.0, while forbidding fabricated acquired/released events. Fixture-owned child cancellation uses the unchanged runner boundary and joins exit and both pipes with allSettled before cleanup. Scoped callback and cleanup ordering negative controls and pinned gates are retained locally. Parent Sidekick comment review and exact two-file patch approval precede this commit. The non-atomic lease owner-read/unlink window remains unchanged. --- .../core/src/native-refresh-coordinator.ts | 14 +- .../tests/native-refresh-coordinator.test.ts | 365 +++++++++++++++++- 2 files changed, 372 insertions(+), 7 deletions(-) diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index e3183e434..e1209a43e 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -613,6 +613,14 @@ export function createNativeRefreshCoordinator( ttlMs: POOL_LOCK_DEFAULTS.ttlMs, renew: true, now, + onContended: onLockEvent + ? () => + onLockEvent({ + type: 'contended', + name: spec.name, + path: spec.path, + }) + : undefined, onStep: options.onLockStep ? (step) => options.onLockStep?.(spec, step) : undefined, @@ -636,9 +644,9 @@ export function createNativeRefreshCoordinator( }) return lock } - // The installed primitive's null result can mean refusals other than a - // live holder. Contention diagnostics need a producer acquisition event; - // guessing here would report ownership evidence the primitive never gave. + // acquireRefreshFileLock's onContended reports a live-holder refusal. A + // null can also mean a stale-eviction-marker refusal without a live holder; + // neither diagnostic is an ownership assertion. const remaining = POOL_LOCK_DEFAULTS.timeoutMs - (performance.now() - started) if (remaining <= 0) diff --git a/packages/core/src/tests/native-refresh-coordinator.test.ts b/packages/core/src/tests/native-refresh-coordinator.test.ts index aa25623bc..4a3dd1fb5 100644 --- a/packages/core/src/tests/native-refresh-coordinator.test.ts +++ b/packages/core/src/tests/native-refresh-coordinator.test.ts @@ -6,6 +6,7 @@ import { expect, } from 'bun:test' import { AsyncLocalStorage } from 'node:async_hooks' +import { spawn } from 'node:child_process' import { appendFileSync, existsSync, @@ -23,7 +24,7 @@ import { writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { LockOwnershipError, @@ -79,6 +80,7 @@ import { TestLifetime } from './test-lifetime.ts' const roots: string[] = [] const scenarioRoots = new WeakMap>() +const physicalChildRoots = new WeakMap>() const testLifetime = new AsyncLocalStorage() let currentLifetime: TestLifetime | undefined @@ -355,6 +357,9 @@ afterEach(async () => { ...(scenario ? (scenarioRoots.get(scenario) ?? []) : []), ].map((root) => rm(root, { recursive: true, force: true })), ) + for (const root of scenario ? (physicalChildRoots.get(scenario) ?? []) : []) { + tracePhysicalChild('fixture:deleted', root) + } if (timeoutProbePool && timeoutProbeLog) { appendFileSync( timeoutProbeLog, @@ -1700,6 +1705,350 @@ test('persisted reconciliation failure preserves committed fact and cleans learn ) }) +// The child owns rejection detection, outside Bun's test-runner handlers. The +// wrapper observes completion of the real acquisition attempt independently of +// onContended, so omitting the event fails an assertion while finally still +// releases the holder and awaits the refresh body. +const physicalHandoffProbe = ` + import assert from 'node:assert/strict'; + import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; + import { setImmediate } from 'node:timers/promises'; + import { mock } from 'bun:test'; + import * as physical from '@cortexkit/common-auth/fs'; + import { fingerprintOf } from '@cortexkit/common-auth/store'; + const { root, mode } = JSON.parse(process.env.NATIVE_PHYSICAL_HANDOFF_PROBE); + const unhandled = []; + process.on('unhandledRejection', (error) => { + unhandled.push(String(error)); + console.error('ESCAPED HANDOFF DIAGNOSTIC REJECTION', error); + process.exitCode = 1; + }); + const attempts = []; + let spec; + let attempted; + const firstAttempt = new Promise((resolve) => { attempted = resolve; }); + const acquire = physical.acquireRefreshFileLock; + mock.module('@cortexkit/common-auth/fs', () => ({ + ...physical, + acquireRefreshFileLock: async (options) => { + if (options.name !== spec?.name) return acquire(options); + const attempt = { callback: options.onContended }; + attempts.push(attempt); + try { + const lock = await acquire(options); + attempt.result = lock === null ? 'refused' : 'acquired'; + return lock; + } catch (error) { + attempt.result = 'exception'; + throw error; + } finally { + attempted(); + } + }, + })); + const { resolveNativePoolPaths } = await import('./packages/core/src/pool-paths.ts'); + const { createNativePoolStore } = await import('./packages/core/src/pool-store.ts'); + const { captureNativeLocalPoolBinding } = await import('./packages/core/src/pool-binding.ts'); + const { createNativeRefreshCoordinator, nativeAccountProviderLock } = await import('./packages/core/src/native-refresh-coordinator.ts'); + const { tokenFingerprint } = await import('./packages/core/src/token-fingerprint.ts'); + const paths = await resolveNativePoolPaths(root + '/account.json', root + '/state.json'); + const quota = { validate: () => true, merge: (_, value) => value }; + const store = createNativePoolStore({ paths, quota }); + await store.initialize(); + await store.add({ id: 'a', credential: { type: 'oauth', access: 'old', refresh: 'old-refresh', expires: 4000000000000 } }); + const read = await store.read(); + assert.equal(read.status, 'ready'); + const binding = captureNativeLocalPoolBinding(paths, read.rows[0]); + spec = nativeAccountProviderLock(paths, 'A'); + const lockPath = physical.lockPathFor(spec.path, spec.name); + const markerPath = lockPath + '.evicting'; + const nonLive = mode === 'non-live' || mode === 'exception'; + let holder; + if (nonLive) { + await writeFile(lockPath, JSON.stringify({ ownerId: 'expired-owner', expiresAt: 0 })); + if (mode === 'non-live') { + await mkdir(markerPath); + await writeFile(markerPath + '/owner.json', JSON.stringify({ ownerId: 'marker-holder', createdAt: Date.now() })); + } + } else { + holder = await acquire({ ...spec, renew: true }); + assert.ok(holder); + await holder.assertOwned(); + } + const events = []; + const active = new Set(holder ? [spec.name] : []); + let bootstraps = 0; + let providers = 0; + let staleObserved = 0; + let persisted; + let proof; + const coordinator = createNativeRefreshCoordinator({ + paths, quota, + ...(mode === 'absent' ? {} : { onLockEvent: (event) => { + if (event.name !== spec.name) return; + events.push(event); + if (event.type === 'acquired') active.add(event.name); + else if (event.type === 'released') active.delete(event.name); + if (mode === 'throw') throw new Error('diagnostic throw'); + if (mode === 'reject') return Promise.reject(new Error('diagnostic rejection')); + if (mode === 'thenable') return { then: (_, reject) => reject(new Error('diagnostic thenable rejection')) }; + if (mode === 'never') return new Promise(() => {}); + } }), + onLockStep: (lock, step) => { + if (lock.name === spec.name && step === 'stale-lock-observed') { + staleObserved++; + if (mode === 'exception') throw new Error('handoff acquisition barrier failed'); + } + }, + refreshToken: async (input) => { + assert.equal(input.maxRetries, 0); + providers++; + return { type: 'oauth', access: 'new', refresh: 'new-refresh', expires: 4000000000000, expiresIn: 3600 }; + }, + resolveIdentity: async () => { + bootstraps++; + return { deviceId: 'd', sessionId: 's', accountUuid: 'A' }; + }, + readRestrictions: async (subject) => ({ + restriction: { status: 'allowed' }, + context: { subject, runtimeBinding: subject.binding, refreshErrorClearedAt: null, quotaErrorClearedAt: null, quotaErrorGeneration: null }, + }), + reconcile: async (event) => { + if (event.status !== 'persisted') return; + persisted = event; + const current = await store.read(); + assert.equal(current.status, 'ready'); + const row = current.rows[0]; + proof = { binding: captureNativeLocalPoolBinding(paths, row), credentialFingerprint: row.fingerprint, version: event.committed.version }; + }, + readAdmission: async () => proof ? { status: 'proven', validation: proof } : { status: 'unproven' }, + }); + const operation = coordinator.authorize({ mode: 'local', intent: 'refresh', binding }); + try { + await Promise.race([firstAttempt, operation.then(() => { throw new Error('Refresh ended before the physical attempt'); })]); + assert.equal(attempts[0].result, mode === 'exception' ? 'exception' : 'refused'); + if (mode === 'absent') assert.equal(attempts[0].callback, undefined); + if (nonLive || mode === 'absent') assert.deepEqual(events, []); + else { + assert.deepEqual(events, [{ type: 'contended', name: spec.name, path: spec.path }]); + assert.equal(typeof attempts[0].callback, 'function'); + assert.equal(active.has(spec.name), true); + await holder.assertOwned(); + } + if (nonLive) assert.equal(staleObserved, 1); + await holder?.release(); + holder = undefined; + if (mode === 'non-live') await rm(markerPath, { recursive: true }); + const result = await operation; + assert.equal(result.status, 'usable'); + assert.equal(result.access, 'new'); + assert.equal(bootstraps, 1); + assert.equal(providers, 1); + assert.equal(persisted.handoff, mode === 'exception' ? 'skipped-loss' : 'acquired'); + assert.equal(persisted.bootstrap, 'resolved'); + assert.equal(persisted.identity, 'A'); + assert.equal(persisted.committed.version.accessFingerprint, tokenFingerprint('new')); + const loaded = await store.read(); + assert.equal(loaded.status, 'ready'); + assert.equal(loaded.rows[0].credential.access, 'new'); + assert.equal(loaded.rows[0].credential.refresh, 'new-refresh'); + assert.equal(loaded.rows[0].identity, 'A'); + assert.equal(loaded.rows[0].fingerprint, fingerprintOf(loaded.rows[0].credential)); + assert.equal(proof.credentialFingerprint, loaded.rows[0].fingerprint); + if (mode === 'absent') { + assert.ok(attempts.length >= 2); + assert.ok(attempts.every((attempt) => attempt.callback === undefined)); + assert.deepEqual(events, []); + } else if (mode === 'exception') assert.deepEqual(events, []); + else { + const contention = events.slice(0, -2); + if (nonLive) assert.deepEqual(contention, []); + else { + assert.ok(contention.length > 0); + for (const event of contention) assert.deepEqual(event, { type: 'contended', name: spec.name, path: spec.path }); + } + assert.deepEqual(events.slice(-2), [ + { type: 'acquired', name: spec.name, path: spec.path }, + { type: 'released', name: spec.name, path: spec.path }, + ]); + assert.equal(active.size, 0); + } + assert.equal((await coordinator.authorize({ mode: 'local', intent: 'serve', binding: proof.binding })).status, 'usable'); + assert.equal(providers, 1); + assert.equal(bootstraps, 1); + assert.equal(JSON.parse(await readFile(paths.state, 'utf8')).accounts.a.refresh, 'new-refresh'); + } finally { + await holder?.release(); + if (mode === 'non-live') await rm(markerPath, { recursive: true, force: true }); + await Promise.allSettled([operation]); + } + await setImmediate(); + await setImmediate(); + assert.deepEqual(unhandled, []); + console.log('physical handoff probe passed: ' + mode); +` + +function tracePhysicalChild( + event: string, + root: string, + details: Record = {}, +) { + const log = process.env.NATIVE_PHYSICAL_CHILD_LIFETIME_LOG + if (log) { + appendFileSync( + log, + `${JSON.stringify({ event, root, fixtureExists: existsSync(root), ...details })}\n`, + ) + } +} + +async function physicalHandoffChild( + root: string, + workspace: string, + mode: string, +): Promise< + | { status: 'cancelled' } + | { status: 'completed'; exit: number | null; stdout: string; stderr: string } +> { + const lifetime = testLifetime.getStore() + if (!lifetime) throw new Error('Physical child requires a fixture lifetime') + let ownedRoots = physicalChildRoots.get(lifetime) + if (!ownedRoots) { + ownedRoots = new Set() + physicalChildRoots.set(lifetime, ownedRoots) + } + ownedRoots.add(root) + // The fixture owns cancellation and reaping rather than relying on Bun's + // test-runner cleanup of dangling subprocesses. + const child = spawn(process.execPath, ['--eval', physicalHandoffProbe], { + cwd: workspace, + env: { + ...process.env, + NATIVE_PHYSICAL_HANDOFF_PROBE: JSON.stringify({ root, mode }), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + tracePhysicalChild('child:spawned', root, { pid: child.pid, mode }) + let drained = false + let cancellationReason: string | undefined + const cancel = lifetime.gate() + // Teardown opens this gate before waiting for the body. A stalled child must + // not keep TestLifetime.finish waiting forever with its fixture files alive. + const cancellation = cancel.wait.then(() => { + if (drained) return + cancellationReason ??= 'fixture teardown' + tracePhysicalChild('child:cancelled', root, { + pid: child.pid, + reason: cancellationReason, + }) + child.kill('SIGKILL') + }) + lifetime.trackDetached(cancellation) + async function drain( + stream: typeof child.stdout, + event: 'stdout:drained' | 'stderr:drained', + ) { + stream.setEncoding('utf8') + let output = '' + let readRejected = false + try { + for await (const chunk of stream) output += chunk + return output + } catch (error) { + readRejected = true + throw error + } finally { + tracePhysicalChild(event, root, { + [event === 'stdout:drained' ? 'stdout' : 'stderr']: output, + readRejected, + }) + } + } + const exited = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (exit, signal) => { + tracePhysicalChild('child:exited', root, { + pid: child.pid, + exit, + signal, + }) + resolve(exit) + }) + }) + const stdoutRead = drain(child.stdout, 'stdout:drained') + const stderrRead = drain(child.stderr, 'stderr:drained') + const completion = [exited, stdoutRead, stderrRead] as const + let cleanupFailure: unknown + const result = await (async () => { + try { + const [exit, stdout, stderr] = await Promise.all(completion) + drained = true + if (cancellationReason) return { status: 'cancelled' as const } + return { status: 'completed' as const, exit, stdout, stderr } + } finally { + try { + if (!drained) child.kill('SIGKILL') + } catch (error) { + cleanupFailure = error + } + // Promise.all fails fast on a read error. Join each pipe and the process + // separately before releasing the cancellation task or deleting fixtures. + await Promise.allSettled(completion) + drained = true + cancel.open() + try { + await cancellation + } catch (error) { + cleanupFailure ??= error + } + tracePhysicalChild('child:fully-drained', root, { pid: child.pid }) + } + })() + // Cleanup errors are raised only after a successful body, never in place of + // the original acquisition or pipe-read error. + if (cleanupFailure) throw cleanupFailure + return result +} + +for (const [mode, name] of [ + [ + 'observe', + 'physical handoff contention forwards exact producer diagnostics and release sequence', + ], + ['throw', 'physical handoff contention isolates throwing diagnostics'], + ['reject', 'physical handoff contention isolates rejected promises'], + ['thenable', 'physical handoff contention isolates rejected thenables'], + ['never', 'physical handoff contention ignores never-settling diagnostics'], + [ + 'absent', + 'physical handoff omits the producer callback without an observer', + ], + [ + 'non-live', + 'physical handoff non-live marker refusal does not fabricate contention', + ], + [ + 'exception', + 'physical handoff acquisition exception does not fabricate contention', + ], +] as const) { + test(name, async () => { + const f = await fixture() + const workspace = resolve(import.meta.dir, '../../../..') + try { + const result = await physicalHandoffChild(f.root, workspace, mode) + // Teardown cancellation follows an already reported runner timeout. End + // the body after joining the child, without producing late assertion errors. + if (result.status === 'cancelled') return + const { exit, stdout, stderr } = result + expect({ exit, stderr }).toEqual({ exit: 0, stderr: '' }) + expect(stdout).toContain(`physical handoff probe passed: ${mode}`) + } finally { + tracePhysicalChild('body:finally', f.root) + } + }) +} + test('throwing handoff diagnostics preserve handoff and identity validation', async () => { const events: NativeLockEvent[] = [] let bootstraps = 0 @@ -2049,9 +2398,17 @@ test( handoff: 'skipped-contention', }), ) - // The old acquisition primitive gives no diagnostic reason for null. - // A timed-out wait must not invent acquired, released or contended events. - expect(handoffEvents).toEqual([]) + // The held lease generates contended events, but timeout does not generate + // acquired or released events for the waiter. + expect(handoffEvents.length).toBeGreaterThan(0) + expect( + handoffEvents.every( + (event) => + event.type === 'contended' && + event.name === spec.name && + event.path === spec.path, + ), + ).toBe(true) } finally { finish.release() await holder From 06a143aac5055c308996cecd1f14f7041789d2c1 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:20:08 +0200 Subject: [PATCH 028/169] mason: publish guarded known local credential proof Share private successful-observation capture and config -> state -> runtime publication guards without widening first-identity authorization. Known publication preserves metadata and matches captured reset context and actual committed material; proof-only missing entries require genuinely null captured context. Cover known validation and saved-successor reconcile/admission, exact version/context refusals and physical lease loss. Independent omissions redden five production guards and the owned-child sibling join with healthy positives. Existing first-identity tests remain byte-identical. Owned children cancel through existing lifetime gates before body joins and drain each exit/stdout/stderr promise with allSettled. Preserve the real locked-pipe Error. Settlement assertions use joined exited result plus SIGTERM because Bun leaves exitCode null for signal termination; no existing first-identity/runtime test claim is rewritten. Natural default-timeout and pipe-rejection controls verify cleanup ordering. Parent approved the exact source/comment delta after child-lifetime revisions. Only two spaces of comment indentation were corrected to satisfy the commit hook, without JavaScript changes. Pinned Bun 1.3.14 / Node 24.16.0 / TypeScript 7.0.2 gates: 715 Core tests, Core/workspace types, build, seven packed/type-closure tests and workspace lock. Ignored raw evidence retained in node_modules/.cache/native-known-validation-evidence. --- packages/core/src/native-runtime.ts | 144 +- .../src/tests/native-known-validation.test.ts | 1265 +++++++++++++++++ 2 files changed, 1385 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/tests/native-known-validation.test.ts diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index 02b2b2d82..014d3819a 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -536,12 +536,15 @@ export function isNativeRuntimeStagingName( ) } -interface FirstIdentityPublication { - readonly predecessor: NativeLocalPoolBinding +interface LocalValidationPublication { readonly context: NativeRefreshContext readonly proof: NativeLocalCredentialValidation } +interface FirstIdentityPublication extends LocalValidationPublication { + readonly predecessor: NativeLocalPoolBinding +} + function sameLocalBinding( a: NativeLocalPoolBinding, b: NativeLocalPoolBinding, @@ -659,9 +662,9 @@ export async function updateNativeRuntime( return writeNativeRuntime(path, storageId, change, hooks) } -// Both publication paths use the same lock, strict decoder and atomic replacement. -// Only the dedicated publisher supplies checked pool/runtime evidence authorizing -// the first account UUID change; a generic write has no such authorization. +// All runtime writes use the same lock, strict decoder and atomic replacement. +// Only first-identity publication supplies evidence authorizing an account UUID +// change; known-account publication and generic writes have no such permission. async function writeNativeRuntime( path: string, storageId: string, @@ -749,10 +752,10 @@ function sameVersion(a: Record, b: Record) { ) } -function captureFirstIdentity( +function captureLocalValidation( paths: NativePoolPaths, observation: NativeRefreshObservation, -): FirstIdentityPublication { +): LocalValidationPublication { const refuse = () => new NativeRuntimeError('publication-refused') // Copy before the first lock wait. Never fill missing evidence from a later row. const event = structuredClone(observation) @@ -775,12 +778,6 @@ function captureFirstIdentity( context.subject.credentialFingerprint !== event.credentialFingerprint || !dispatchVersion(context.subject.version) || !sameVersion(context.subject.version, event.credentialVersion) || - !isNativeLocalPoolBinding(context.runtimeBinding) || - context.runtimeBinding.identity !== undefined || - !sameLocalBinding(context.runtimeBinding, { - ...event.binding, - identity: undefined, - }) || ![ context.refreshErrorClearedAt, context.quotaErrorClearedAt, @@ -788,7 +785,7 @@ function captureFirstIdentity( ].every((value) => value === null || counter(value)) ) throw refuse() - const destination = { ...context.runtimeBinding, identity: event.identity } + const destination = { ...event.binding, identity: event.identity } let proof: unknown if (event.status === 'persisted') { proof = { @@ -814,7 +811,36 @@ function captureFirstIdentity( } } if (!isNativeLocalCredentialValidation(proof)) throw refuse() - return { predecessor: context.runtimeBinding, context, proof } + return { context, proof } +} + +function captureFirstIdentity( + paths: NativePoolPaths, + observation: NativeRefreshObservation, +): FirstIdentityPublication { + const publication = captureLocalValidation(paths, observation) + const predecessor = publication.context.runtimeBinding + if ( + !isNativeLocalPoolBinding(predecessor) || + predecessor.identity !== undefined || + !sameLocalBinding(predecessor, { + ...publication.proof.binding, + identity: undefined, + }) + ) + throw new NativeRuntimeError('publication-refused') + return { ...publication, predecessor } +} + +function capturedResetContextMatches( + old: NativeRuntimeEntry | undefined, + context: NativeRefreshContext, +): boolean { + return ( + (old?.refreshErrorClearedAt ?? null) === context.refreshErrorClearedAt && + (old?.quotaErrorClearedAt ?? null) === context.quotaErrorClearedAt && + (old?.quotaErrorGeneration ?? null) === context.quotaErrorGeneration + ) } function firstIdentityEntry( @@ -825,9 +851,7 @@ function firstIdentityEntry( if ( old?.binding.kind !== 'local' || !sameLocalBinding(old.binding, predecessor) || - (old.refreshErrorClearedAt ?? null) !== context.refreshErrorClearedAt || - (old.quotaErrorClearedAt ?? null) !== context.quotaErrorClearedAt || - (old.quotaErrorGeneration ?? null) !== context.quotaErrorGeneration + !capturedResetContextMatches(old, context) ) throw new NativeRuntimeError('publication-refused') const floor = ( @@ -880,6 +904,81 @@ export async function publishNativeLocalFirstIdentity( try { paths = { ...paths } const publication = captureFirstIdentity(paths, observation) + return await publishLocalValidation( + paths, + publication, + (old) => firstIdentityEntry(old, publication), + publication, + ) + } catch (error) { + if (error instanceof NativeRuntimeError) throw error + throw new NativeRuntimeError('publication-refused') + } +} + +function knownValidationEntry( + old: NativeRuntimeEntry | undefined, + publication: LocalValidationPublication, +): NativeRuntimeEntry { + const { context, proof } = publication + if (!capturedResetContextMatches(old, context)) + throw new NativeRuntimeError('publication-refused') + if (!old) { + if (context.runtimeBinding !== null) + throw new NativeRuntimeError('publication-refused') + return { binding: proof.binding, credentialValidation: proof } + } + if ( + old.binding.kind !== 'local' || + !isNativeLocalPoolBinding(context.runtimeBinding) || + !sameLocalBinding(old.binding, context.runtimeBinding) || + !sameLocalBinding(old.binding, proof.binding) + ) + throw new NativeRuntimeError('publication-refused') + // These credentials were checked for the already bound account. Nothing here + // clears its errors, retry policy, usage or profile, even after token rotation. + return { ...old, credentialValidation: proof } +} + +/** + * Core-internal positive-proof publication for an already known local account. + * A successful bootstrap must carry its original runtime context and exact + * checked material. Unknown predecessors still require first-identity publication. + * Recording proof for a disabled row does not enable it or grant admission. + */ +export async function publishNativeLocalCredentialValidation( + paths: NativePoolPaths, + observation: NativeRefreshObservation, +): Promise { + try { + paths = { ...paths } + const publication = captureLocalValidation(paths, observation) + const { context, proof } = publication + if ( + context.subject.binding.identity !== proof.binding.identity || + (context.runtimeBinding !== null && + (!isNativeLocalPoolBinding(context.runtimeBinding) || + !sameLocalBinding(context.runtimeBinding, proof.binding))) + ) + throw new NativeRuntimeError('publication-refused') + return await publishLocalValidation(paths, publication, (old) => + knownValidationEntry(old, publication), + ) + } catch (error) { + if (error instanceof NativeRuntimeError) throw error + throw new NativeRuntimeError('publication-refused') + } +} + +// Keep both local publishers behind the same strict pool reads and final lease +// assertions. Entry selection cannot bypass the runtime transition fences. +async function publishLocalValidation( + paths: NativePoolPaths, + publication: LocalValidationPublication, + replacement: (old: NativeRuntimeEntry | undefined) => NativeRuntimeEntry, + firstIdentity?: FirstIdentityPublication, +): Promise { + try { const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) const [config, state] = nativePoolStoreLocks(paths) const checkPool = async () => { @@ -912,11 +1011,8 @@ export async function publishNativeLocalFirstIdentity( paths.runtime, paths.storageId, (current) => { - const id = publication.predecessor.rowId - current.accounts[id] = firstIdentityEntry( - current.accounts[id], - publication, - ) + const id = publication.proof.binding.rowId + current.accounts[id] = replacement(current.accounts[id]) return current }, { @@ -928,7 +1024,7 @@ export async function publishNativeLocalFirstIdentity( await stateLease.assertOwned() }, }, - publication, + firstIdentity, ) }, ), diff --git a/packages/core/src/tests/native-known-validation.test.ts b/packages/core/src/tests/native-known-validation.test.ts new file mode 100644 index 000000000..98c0d15e2 --- /dev/null +++ b/packages/core/src/tests/native-known-validation.test.ts @@ -0,0 +1,1265 @@ +import { expect } from 'bun:test' +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { lockPathFor } from '@cortexkit/common-auth/fs' +import { fingerprintOf, type PoolRow } from '@cortexkit/common-auth/store' + +import type { ProviderAccountUuid } from '../claude-code.ts' +import { + type NativeLocalCredentialValidation, + nativeLocalCredentialValidationMatches, +} from '../native-credential-validation.ts' +import { nativeQuotaCodec } from '../native-quota-codec.ts' +import { + createNativeRefreshCoordinator, + type NativeRefreshObservation, + type NativeRefreshSubject, +} from '../native-refresh-coordinator.ts' +import { + isNativeRuntimeStagingName, + type NativeRuntimeEntry, + publishNativeLocalCredentialValidation, + readNativeRuntime, + updateNativeRuntime, +} from '../native-runtime.ts' +import { captureNativeLocalPoolBinding } from '../pool-binding.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore } from '../pool-store.ts' +import { tokenFingerprint } from '../token-fingerprint.ts' +import { createTestLifetimeSuite, TestLifetime } from './test-lifetime.ts' + +const { test, deferCleanup, gate, trackDetached } = createTestLifetimeSuite() +const uuid = '11111111-2222-4333-8444-555555555555' +const otherUuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const credential = { + type: 'oauth' as const, + access: 'synthetic-known-access', + refresh: 'synthetic-known-refresh', + expires: 4_000_000_000_000, +} + +function subjectOf( + paths: Awaited>, + row: PoolRow, +): NativeLocalCredentialValidation { + const c = row.credential + const binding = captureNativeLocalPoolBinding(paths, row) + if (c?.type !== 'oauth' || !c.access || !c.expires || !binding.identity) + throw new Error('Incomplete fixture material') + return { + binding: { ...binding, identity: binding.identity }, + credentialFingerprint: fingerprintOf(c), + version: { + accessFingerprint: tokenFingerprint(c.access), + expires: c.expires, + ...(Object.hasOwn(c, 'lastRefreshedAt') + ? { lastRefreshedAt: c.lastRefreshedAt } + : {}), + }, + } +} + +async function fixture( + metadata: Partial = {}, + missing = false, +) { + const parent = new URL( + '../../../../node_modules/.cache/native-known-validation-evidence/', + import.meta.url, + ) + await mkdir(parent, { recursive: true }) + const root = await mkdtemp(join(parent.pathname, 'fixture-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const paths = await resolveNativePoolPaths( + join(root, 'config.json'), + join(root, 'state.json'), + ) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + await store.initialize() + await store.add({ id: 'row', identity: uuid, credential }) + // Adding an account sets its refresh timestamp. Remove that timestamp here so + // absence-versus-zero checks test field presence, not two numeric values. + // This timestamp is separate from the credential's integrity stamp. + const initial = JSON.parse(await readFile(paths.state, 'utf8')) + delete initial.accounts.row.lastRefreshedAt + await writeFile(paths.state, JSON.stringify(initial), { mode: 0o600 }) + const read = await store.read() + if (read.status !== 'ready' || !read.rows[0]) + throw new Error('Missing fixture row') + const subject = subjectOf(paths, read.rows[0]) + const runtime = await updateNativeRuntime( + paths.runtime, + paths.storageId, + () => ({ + version: 1, + storageId: paths.storageId, + accounts: { + ...(!missing ? { row: { binding: subject.binding, ...metadata } } : {}), + unrelated: { + binding: { + ...subject.binding, + rowId: 'unrelated', + identity: otherUuid, + }, + lastUsed: 5, + }, + }, + }), + ) + const event: NativeRefreshObservation = { + status: 'validation-observed', + binding: subject.binding, + identity: uuid, + bootstrap: 'resolved', + handoff: 'not-needed', + attempt: 1, + credentialFingerprint: subject.credentialFingerprint, + credentialVersion: subject.version, + context: { + subject, + runtimeBinding: missing ? null : subject.binding, + refreshErrorClearedAt: metadata.refreshErrorClearedAt ?? null, + quotaErrorClearedAt: metadata.quotaErrorClearedAt ?? null, + quotaErrorGeneration: metadata.quotaErrorGeneration ?? null, + }, + } + return { root, paths, store, subject, event, runtime } +} +type Fixture = Awaited> + +async function refused( + f: Fixture, + event: unknown = f.event, + code = 'publication-refused', + publisher = publishNativeLocalCredentialValidation, +) { + const before = await readFile(f.paths.runtime, 'utf8') + let error: unknown + try { + await publisher(f.paths, event as NativeRefreshObservation) + } catch (caught) { + error = caught + } + // Check the actual unsafe publication first, not only its error classification. + expect(await readFile(f.paths.runtime, 'utf8')).toBe(before) + expect( + (await readdir(f.root)).filter((name) => + isNativeRuntimeStagingName(f.paths.runtime, name), + ), + ).toEqual([]) + expect(error).toMatchObject({ name: 'NativeRuntimeError', code }) + expect(String(error)).not.toContain(credential.access) + expect(String(error)).not.toContain(credential.refresh) +} + +async function savedSuccessor(f: Fixture): Promise { + await f.store.rotate('row', { + ...credential, + access: 'synthetic-successor-access', + refresh: 'synthetic-successor-refresh', + }) + const read = await f.store.read() + if (read.status !== 'ready' || !read.rows[0]) + throw new Error('Missing successor') + const successor = subjectOf(f.paths, read.rows[0]) + expect(successor.binding).toEqual(f.subject.binding) + return { + ...f.event, + status: 'persisted', + successorFingerprint: successor.credentialFingerprint, + successorVersion: successor.version, + committed: { + credentialFingerprint: successor.credentialFingerprint, + version: successor.version, + }, + } +} + +test('known publication healthy positive control', async () => { + const f = await fixture() + const next = await publishNativeLocalCredentialValidation(f.paths, f.event) + expect(next.accounts.row).toEqual({ + binding: f.subject.binding, + credentialValidation: f.subject, + }) + const read = await f.store.read() + expect( + read.status === 'ready' && + nativeLocalCredentialValidationMatches( + next.accounts.row?.credentialValidation, + f.subject.binding, + read.rows[0]?.credential, + ), + ).toBe(true) + expect(await readNativeRuntime(f.paths.runtime, f.paths.storageId)).toEqual({ + status: 'ready', + state: next, + }) +}) + +test('known publication preserves every unrelated metadata field and replaces only proof', async () => { + const f = await fixture({ + lastUsed: 1, + lastRefreshedAt: 2, + lastRefreshError: { + message: 'old refresh', + checkedAt: 11, + accountIdentity: uuid, + }, + refreshErrorClearedAt: 0, + lastQuotaRefreshError: { + message: 'old quota', + checkedAt: 13, + accountIdentity: uuid, + }, + quotaErrorClearedAt: 3, + quotaErrorGeneration: 7, + refreshLeaseId: 'old-lease', + refreshLeaseUntil: 99, + refreshLeaseTokenHash: 'a'.repeat(64), + profile: { + tier: 'max', + orgType: 'team', + checkedAt: 5, + providerAccountUuid: uuid as ProviderAccountUuid, + }, + prime: { count: 1, inputTokens: 2, outputTokens: 3, since: 4 }, + authLineageId: 'old-lineage', + primeAuthLineageRefreshTokenFingerprint: tokenFingerprint('old-refresh'), + quotaCheckedAt: 6, + quotaToken: tokenFingerprint('old-access'), + }) + const oldProof = { ...f.subject, credentialFingerprint: 'b'.repeat(64) } + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { + const old = current.accounts.row + if (!old) throw new Error('Missing metadata') + old.credentialValidation = oldProof + old.validationRetry = { subject: oldProof, checkedAt: 20, nextRetryAt: 40 } + return current + }) + const before = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (before.status !== 'ready' || !before.state.accounts.row) + throw new Error('Missing runtime') + const event = await savedSuccessor(f) + const next = await publishNativeLocalCredentialValidation(f.paths, event) + if (!next.accounts.row) throw new Error('Missing published metadata') + expect({ + ...next.accounts.row, + credentialValidation: oldProof, + }).toEqual(before.state.accounts.row) + expect(next.accounts.unrelated).toEqual(before.state.accounts.unrelated) + expect(Object.keys(next.accounts)).toEqual(['row', 'unrelated']) + const read = await f.store.read() + expect( + read.status === 'ready' && + nativeLocalCredentialValidationMatches( + next.accounts.row?.credentialValidation, + f.subject.binding, + read.rows[0]?.credential, + ), + ).toBe(true) + expect(next.accounts.row?.credentialValidation).not.toEqual(f.subject) +}) + +test('known publication creates only proof for genuinely missing metadata including absent runtime', async () => { + for (const absentRuntime of [false, true]) { + const f = await fixture({}, true) + if (absentRuntime) await rm(f.paths.runtime) + const next = await publishNativeLocalCredentialValidation(f.paths, f.event) + expect(next.accounts.row).toEqual({ + binding: f.subject.binding, + credentialValidation: f.subject, + }) + expect(next.accounts.unrelated).toEqual( + absentRuntime ? undefined : f.runtime.accounts.unrelated, + ) + } +}) + +test('known publication persisted proof uses saved successor and permits sparse original dispatch', async () => { + for (const version of [ + {}, + { accessFingerprint: undefined, expires: undefined }, + ]) { + const f = await fixture() + const event = await savedSuccessor(f) + if (!event.context) throw new Error('Missing context') + event.credentialVersion = version + event.context = { ...event.context, subject: { ...f.subject, version } } + const next = await publishNativeLocalCredentialValidation(f.paths, event) + const read = await f.store.read() + expect( + read.status === 'ready' && + nativeLocalCredentialValidationMatches( + next.accounts.row?.credentialValidation, + f.subject.binding, + read.rows[0]?.credential, + ), + ).toBe(true) + expect( + next.accounts.row?.credentialValidation?.credentialFingerprint, + ).not.toBe(f.subject.credentialFingerprint) + } +}) + +test('known publication refuses ineligible incomplete and incoherent observations', async () => { + const f = await fixture() + const base = f.event + const negatives: unknown[] = [ + null, + {}, + ...[ + 'adopted', + 'failed', + 'refused', + 'identity-contradicted', + 'cancelled', + ].map((status) => ({ ...base, status })), + ...['failed', 'unavailable', undefined].map((bootstrap) => ({ + ...base, + bootstrap, + handoff: 'skipped-cancelled', + })), + ...[undefined, '', 'not-a-uuid', otherUuid].map((identity) => ({ + ...base, + identity, + })), + { ...base, context: undefined }, + { ...base, credentialFingerprint: undefined }, + { ...base, credentialVersion: {} }, + { ...base, credentialVersion: { ...f.subject.version, expires: 0 } }, + { ...base, binding: { ...base.binding, identity: undefined } }, + ] + for (const subject of [ + { ...f.subject, credentialFingerprint: 'a'.repeat(64) }, + { ...f.subject, binding: { ...f.subject.binding, identity: otherUuid } }, + { ...f.subject, version: {} }, + { ...f.subject, version: { ...f.subject.version, lastRefreshedAt: 0 } }, + ]) + negatives.push({ ...base, context: { ...base.context, subject } }) + for (const event of negatives) await refused(f, event) +}) + +test('known publication refuses incoherent committed successor and sparse successor material', async () => { + const f = await fixture() + const event = await savedSuccessor(f) + if (event.status !== 'persisted') + throw new Error('Expected persisted fixture') + for (const changed of [ + { successorFingerprint: f.subject.credentialFingerprint }, + { successorVersion: f.subject.version }, + { committed: undefined }, + { successorVersion: {} }, + { + committed: { + credentialFingerprint: event.successorFingerprint, + version: {}, + }, + }, + { + committed: { + ...event.committed, + credentialFingerprint: f.subject.credentialFingerprint, + }, + }, + { committed: { ...event.committed, version: f.subject.version } }, + { committed: { ...event.committed, extra: credential.access } }, + ]) + await refused(f, { ...event, ...changed }) +}) + +test('known publication exact matcher rejects refresh access expiry and timestamp presence mismatches', async () => { + for (const variant of [ + 'refresh', + 'access', + 'expires', + 'stamp-zero', + 'stamp-value', + 'stamp-absent', + ]) { + const f = await fixture() + const event = structuredClone(f.event) + const version = { ...f.subject.version } + expect(Object.hasOwn(version, 'lastRefreshedAt')).toBe(false) + if (variant === 'refresh') + event.credentialFingerprint = fingerprintOf({ + type: 'oauth', + refresh: 'other-refresh', + }) + if (variant === 'access') + version.accessFingerprint = tokenFingerprint('other-access') + if (variant === 'expires') version.expires-- + if (variant === 'stamp-zero') version.lastRefreshedAt = 0 + if (variant === 'stamp-value') version.lastRefreshedAt = 10 + if (variant === 'stamp-absent') { + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + state.accounts.row.lastRefreshedAt = 0 + await writeFile(f.paths.state, JSON.stringify(state), { mode: 0o600 }) + const read = await f.store.read() + const material = + read.status === 'ready' ? read.rows[0]?.credential : undefined + if (material?.type !== 'oauth') + throw new Error('Missing timestamp fixture') + expect(Object.hasOwn(material, 'lastRefreshedAt')).toBe(true) + expect(material.lastRefreshedAt).toBe(0) + } + event.credentialVersion = version + event.context = { + ...f.event.context, + subject: { + ...f.subject, + credentialFingerprint: event.credentialFingerprint ?? '', + version, + }, + } + await refused(f, event) + } +}) + +test('known publication refuses changed row storage epoch identity kind and unbound state', async () => { + for (const variant of [ + 'epoch', + 'identity', + 'missing', + 'kind', + 'unbound', + 'storage', + 'row', + ]) { + const f = await fixture() + if (variant === 'epoch') + await f.store.replace('row', credential, { identity: uuid }) + if (variant === 'identity') + await f.store.replace('row', credential, { identity: otherUuid }) + if (variant === 'missing') await f.store.remove('row') + if (variant === 'kind') { + const config = JSON.parse(await readFile(f.paths.config, 'utf8')) + config.accounts[0].type = 'api' + await writeFile(f.paths.config, JSON.stringify(config), { mode: 0o600 }) + } + if (variant === 'unbound') { + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + state.accounts.row.access = 'unstamped-access' + await writeFile(f.paths.state, JSON.stringify(state), { mode: 0o600 }) + } + let event = f.event + if (variant === 'storage' || variant === 'row') { + const binding = { + ...f.subject.binding, + ...(variant === 'storage' + ? { storageId: 'b'.repeat(64) } + : { rowId: 'elsewhere' }), + } + event = { + ...event, + binding, + context: { + ...f.event.context, + runtimeBinding: binding, + subject: { ...f.subject, binding }, + }, + } + } + await refused(f, event) + } +}) + +test('known publication refuses captured reset context drift', async () => { + const f = await fixture({ + refreshErrorClearedAt: 3, + quotaErrorClearedAt: 5, + quotaErrorGeneration: 7, + }) + await refused(f, { + ...f.event, + context: { ...f.event.context, refreshErrorClearedAt: 2 }, + }) +}) + +for (const key of [ + 'refreshErrorClearedAt', + 'quotaErrorClearedAt', + 'quotaErrorGeneration', +]) { + test(`known publication keeps null zero and safe ${key} semantics`, async () => { + for (const [captured, current] of [ + [null, 0], + [0, null], + [1, 2], + ]) { + const f = await fixture(current === null ? {} : { [key]: current }) + await refused(f, { + ...f.event, + context: { ...f.event.context, [key]: captured }, + }) + } + for (const invalid of [0.5, -1, Number.MAX_SAFE_INTEGER + 1, undefined]) { + const f = await fixture() + await refused(f, { + ...f.event, + context: { ...f.event.context, [key]: invalid }, + }) + } + const missing = await fixture({}, true) + await refused(missing, { + ...missing.event, + context: { ...missing.event.context, [key]: 0 }, + }) + }) +} +test('known publication preserves recorded zero floors and generation', async () => { + const f = await fixture({ + refreshErrorClearedAt: 0, + quotaErrorClearedAt: 0, + quotaErrorGeneration: 0, + }) + const next = await publishNativeLocalCredentialValidation(f.paths, f.event) + if (!f.runtime.accounts.row) throw new Error('Missing metadata') + expect(next.accounts.row).toEqual({ + ...f.runtime.accounts.row, + credentialValidation: f.subject, + }) +}) + +test('known publication rejects unknown custody retargeted and changed runtime predecessors', async () => { + for (const binding of [ + { identity: undefined }, + { identity: otherUuid }, + { credentialEpoch: 2 }, + { + kind: 'custody', + storageId: '', + routeId: 'row', + credentialId: 'cred', + accountIdentity: uuid, + recordVersion: 1, + }, + ]) { + const f = await fixture() + const predecessor = + binding.kind === 'custody' + ? { ...binding, storageId: f.paths.storageId } + : { ...f.subject.binding, ...binding } + const state = { + ...f.runtime, + accounts: { + ...f.runtime.accounts, + row: { binding: predecessor, lastUsed: 9 }, + }, + } + await writeFile(f.paths.runtime, JSON.stringify(state), { mode: 0o600 }) + await refused(f) + // Building new publication context from the current runtime entry cannot let + // this publisher learn a first account identity or change an existing one. + await refused(f, { + ...f.event, + context: { ...f.event.context, runtimeBinding: predecessor }, + }) + } + const f = await fixture() + for (const changed of [ + { rowId: 'elsewhere' }, + { storageId: 'b'.repeat(64) }, + { credentialEpoch: 2 }, + { identity: otherUuid }, + ]) + await refused(f, { + ...f.event, + context: { + ...f.event.context, + runtimeBinding: { ...f.subject.binding, ...changed }, + }, + }) + await refused(f, { + ...f.event, + context: { ...f.event.context, runtimeBinding: null }, + }) + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { + delete current.accounts.row + return current + }) + await refused(f) +}) + +for (const path of ['config', 'state', 'runtime'] as const) { + test(`known publication strict decoder refuses malformed ${path}`, async () => { + const f = await fixture() + await writeFile(f.paths[path], '{"secret":"synthetic-known-refresh",', { + mode: 0o600, + }) + await refused( + f, + f.event, + path === 'runtime' ? 'invalid-runtime' : 'publication-refused', + ) + }) +} +for (const metadata of [ + { credentialValidation: {} }, + { validationRetry: {} }, + { quota: {} }, + { + lastRefreshError: { + message: 'wrong account', + checkedAt: 5, + accountIdentity: otherUuid, + }, + }, + { + profile: { + tier: 'max', + orgType: 'team', + checkedAt: 5, + providerAccountUuid: otherUuid, + }, + }, + { profileToken: tokenFingerprint('old') }, + { quotaErrorGeneration: -1 }, +]) { + test(`known publication strict decoder refuses mismatched ${Object.keys(metadata)[0]} metadata`, async () => { + const f = await fixture() + await writeFile( + f.paths.runtime, + JSON.stringify({ + ...f.runtime, + accounts: { + ...f.runtime.accounts, + row: { binding: f.subject.binding, ...metadata }, + }, + }), + { mode: 0o600 }, + ) + await refused(f, f.event, 'invalid-runtime') + }) +} + +test('known publication refuses laundering valid proof and retry schemas with mismatched bindings', async () => { + for (const field of ['credentialValidation', 'validationRetry']) { + const f = await fixture() + const wrong = { + ...f.subject, + binding: { ...f.subject.binding, identity: otherUuid }, + } + const metadata = + field === 'credentialValidation' + ? wrong + : { subject: wrong, checkedAt: 5, nextRetryAt: 10 } + await writeFile( + f.paths.runtime, + JSON.stringify({ + ...f.runtime, + accounts: { + ...f.runtime.accounts, + row: { binding: f.subject.binding, [field]: metadata }, + }, + }), + { mode: 0o600 }, + ) + await refused(f, f.event, 'invalid-runtime') + } +}) + +test('known publication disabled bound rows get proof without enabling eligibility', async () => { + const f = await fixture() + await f.store.disable('row', 'synthetic-disable') + const before = await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]) + const next = await publishNativeLocalCredentialValidation(f.paths, f.event) + expect(next.accounts.row?.credentialValidation).toEqual(f.subject) + const read = await f.store.read() + expect(read.status === 'ready' && read.rows[0]?.enabled).toBe(false) + expect( + await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]), + ).toEqual(before) +}) + +// Instrumented copies of the runtime modules use the real installed file locks. +// They add no extra safety checks and do not replace Bun's module registry. +async function instrumented(f: Fixture) { + const directory = join(f.root, 'instrumentation') + await mkdir(directory) + const sourcePath = new URL('../native-runtime.ts', import.meta.url) + let source = await readFile(sourcePath, 'utf8') + source = source.replace( + /from '(\.\/[^']+)'/g, + (_match, relative: string) => + `from '${new URL(relative, sourcePath).href}'`, + ) + source = source.replace("from 'node:fs/promises'", "from './controls.ts'") + source = source.replace( + "from '@cortexkit/common-auth/fs'", + "from './controls.ts'", + ) + source = source.replace( + "from '@cortexkit/common-auth/store'", + `from '${import.meta.resolve('@cortexkit/common-auth/store')}'`, + ) + source = source.replace( + `from '${new URL('../pool-store.ts', import.meta.url).href}'`, + "from './controls.ts'", + ) + await writeFile(join(directory, 'runtime.ts'), source) + await writeFile( + join(directory, 'controls.ts'), + ` +import * as fs from 'node:fs/promises'; +import { withLock as realLock } from ${JSON.stringify(import.meta.resolve('@cortexkit/common-auth/fs'))}; +import { createNativePoolStore as realStore, nativePoolStoreLocks } from ${JSON.stringify(new URL('../pool-store.ts', import.meta.url).href)}; +export { nativePoolStoreLocks }; +export const events = []; export const held = new Set(); export const heldAtRenames = []; +export let reads = 0; export let renames = 0; export let forbidden = 0; +export const control = { afterSync: async () => {}, afterAcquire: async () => {} }; +export const { lstat, mkdir, rm } = fs; +export async function open(...args) { + const file = await fs.open(...args); + if (args[1] !== 'wx') return file; + return new Proxy(file, { get(target, key) { + if (key === 'sync') return async () => { await target.sync(); await control.afterSync(); }; + const value = target[key]; return typeof value === 'function' ? value.bind(target) : value; + }}); +} +export async function rename(...args) { + events.push('rename'); renames++; heldAtRenames.push([...held]); await fs.rename(...args); +} +export async function withLock(path, options, callback) { + return realLock(path, options, async lock => { + events.push('acquire:' + options.name); held.add(options.name); + try { await control.afterAcquire(options.name); return await callback({ assertOwned: async () => { events.push('assert:' + options.name); await lock.assertOwned(); } }); } + finally { held.delete(options.name); events.push('release:' + options.name); } + }); +} +export function createNativePoolStore(options) { + const store = realStore(options); + return new Proxy(store, { get(target, key) { + if (key === 'read') return async () => { reads++; events.push('read'); return target.read(); }; + forbidden++; throw new Error('Only strict read allowed'); + }}); +} +`, + ) + const runtime: typeof import('../native-runtime.ts') = await import( + pathToFileURL(join(directory, 'runtime.ts')).href + ) + const controls: { + events: string[] + held: Set + heldAtRenames: string[][] + reads: number + renames: number + forbidden: number + control: { + afterSync: () => Promise + afterAcquire: (name: string) => Promise + } + } = await import(pathToFileURL(join(directory, 'controls.ts')).href) + return { runtime, controls } +} + +test('known publication real locks order one rename owner-only and byte-unchanged pool files', async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + const before = await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]) + await runtime.publishNativeLocalCredentialValidation(f.paths, f.event) + expect(controls.events).toEqual([ + 'acquire:pool-config', + 'acquire:pool-state', + 'read', + 'acquire:native-runtime', + 'read', + 'assert:pool-config', + 'assert:pool-state', + 'assert:native-runtime', + 'rename', + 'release:native-runtime', + 'release:pool-state', + 'release:pool-config', + ]) + expect(controls.heldAtRenames).toEqual([ + ['pool-config', 'pool-state', 'native-runtime'], + ]) + expect(controls.reads).toBe(2) + expect(controls.renames).toBe(1) + expect(controls.forbidden).toBe(0) + expect(controls.held.size).toBe(0) + for (const path of [f.paths.config, f.paths.state, f.paths.runtime]) + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect( + await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]), + ).toEqual(before) + const bytes = await readFile(f.paths.runtime, 'utf8') + expect(bytes).not.toContain(credential.access) + expect(bytes).not.toContain(credential.refresh) +}) + +for (const name of ['pool-config', 'pool-state', 'native-runtime']) { + test(`known publication refuses actual ${name} lease loss with no rename`, async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + controls.control.afterSync = async () => { + const path = + name === 'pool-config' + ? f.paths.config + : name === 'pool-state' + ? f.paths.state + : f.paths.runtime + await rm(lockPathFor(path, name)) + } + await refused( + f, + f.event, + name === 'native-runtime' ? 'runtime-io' : 'publication-refused', + runtime.publishNativeLocalCredentialValidation, + ) + expect(controls.renames).toBe(0) + expect(controls.held.size).toBe(0) + }) +} + +test('known publication final version recheck rejects same-epoch access-only change', async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + const oldState = await readFile(f.paths.state, 'utf8') + const oldConfig = await readFile(f.paths.config, 'utf8') + await f.store.rotate('row', { ...credential, access: 'intervening-access' }) + const replacement = JSON.parse(await readFile(f.paths.state, 'utf8')) + delete replacement.accounts.row.lastRefreshedAt + await writeFile(f.paths.state, oldState, { mode: 0o600 }) + await writeFile(f.paths.config, oldConfig, { mode: 0o600 }) + controls.control.afterSync = async () => { + // Replace the state file with a valid integrity stamp before the runtime rename. + // No test-side check protects that rename. The production final read must + // detect the changed credential version. + await writeFile(f.paths.state, JSON.stringify(replacement), { mode: 0o600 }) + } + await refused( + f, + f.event, + 'publication-refused', + runtime.publishNativeLocalCredentialValidation, + ) + expect(controls.reads).toBe(2) + expect(controls.renames).toBe(0) + const read = await f.store.read() + expect(read.status === 'ready' && read.rows[0]?.stamp).toBe('bound') + expect(read.status === 'ready' && read.rows[0]?.credentialEpoch).toBe( + f.subject.binding.credentialEpoch, + ) +}) + +test('known publication captures proof paths and reset context before lock waits', async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + const event = structuredClone(f.event) + const paths = { ...f.paths } + controls.control.afterAcquire = async (name) => { + if (name !== 'pool-config') return + event.credentialVersion = {} + if (!event.context) throw new Error('Missing fixture context') + event.context = { ...event.context, quotaErrorGeneration: 100 } + paths.runtime = join(f.root, 'wrong-runtime') + } + expect( + (await runtime.publishNativeLocalCredentialValidation(paths, event)) + .accounts.row?.credentialValidation, + ).toEqual(f.subject) + expect((await readdir(f.root)).includes('wrong-runtime')).toBe(false) +}) + +function coordinator(f: Fixture, publish: boolean, refresh = false) { + let exchanges = 0 + let lookups = 0 + const observations: NativeRefreshObservation[] = [] + const instance = createNativeRefreshCoordinator({ + paths: f.paths, + quota: nativeQuotaCodec, + refreshToken: async () => { + exchanges++ + if (!refresh) throw new Error('Unnecessary exchange forbidden') + return { + ...credential, + access: 'coordinator-successor-access', + refresh: 'coordinator-successor-refresh', + expiresIn: 3600, + } + }, + resolveIdentity: async () => { + lookups++ + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: uuid as ProviderAccountUuid, + } + }, + readRestrictions: async (subject: NativeRefreshSubject) => ({ + restriction: { status: 'allowed' }, + context: { ...f.event.context, subject }, + }), + readAdmission: async (subject) => { + const saved = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const validation = + saved.status === 'ready' + ? saved.state.accounts.row?.credentialValidation + : undefined + const read = await f.store.read() + const row = + read.status === 'ready' + ? read.rows.find((row) => row.id === subject.binding.rowId) + : undefined + return validation && + nativeLocalCredentialValidationMatches( + validation, + subject.binding, + row?.credential, + ) && + nativeLocalCredentialValidationMatches( + subject, + subject.binding, + row?.credential, + ) + ? { status: 'proven', validation } + : { status: 'unproven' } + }, + reconcile: async (event) => { + observations.push(event) + if (publish) await publishNativeLocalCredentialValidation(f.paths, event) + }, + }) + return { instance, observations, counts: () => ({ exchanges, lookups }) } +} + +test('known coordinator validation reconcile and exact read admission serve without token exchange', async () => { + const f = await fixture() + const before = await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]) + const c = coordinator(f, true) + expect( + await c.instance.authorize({ + binding: f.subject.binding, + intent: 'serve', + mode: 'local', + }), + ).toMatchObject({ + status: 'usable', + source: 'validated', + binding: f.subject.binding, + }) + expect(c.observations.map((e) => e.status)).toEqual(['validation-observed']) + expect(c.counts()).toEqual({ exchanges: 0, lookups: 1 }) + expect( + await c.instance.authorize({ + binding: f.subject.binding, + intent: 'serve', + mode: 'local', + }), + ).toMatchObject({ status: 'usable', source: 'current' }) + expect(c.counts()).toEqual({ exchanges: 0, lookups: 1 }) + expect( + await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]), + ).toEqual(before) +}) + +test('known coordinator bootstrap alone without matching publication never serves', async () => { + const f = await fixture() + const c = coordinator(f, false) + const before = await readFile(f.paths.runtime, 'utf8') + expect( + await c.instance.authorize({ + binding: f.subject.binding, + intent: 'serve', + mode: 'local', + }), + ).toMatchObject({ status: 'refused' }) + expect(c.observations.map((e) => [e.status, e.bootstrap])).toEqual([ + ['validation-observed', 'resolved'], + ]) + expect(c.counts()).toEqual({ exchanges: 0, lookups: 1 }) + expect(await readFile(f.paths.runtime, 'utf8')).toBe(before) +}) + +test('known coordinator refresh publishes actual saved successor while preserving captured context', async () => { + const f = await fixture({ + refreshErrorClearedAt: 0, + quotaErrorGeneration: 2, + lastUsed: 5, + }) + const c = coordinator(f, true, true) + expect( + await c.instance.authorize({ + binding: f.subject.binding, + intent: 'serve', + mode: 'local', + rejectedAccessToken: credential.access, + }), + ).toMatchObject({ status: 'usable', source: 'rotated' }) + expect(c.counts()).toEqual({ exchanges: 1, lookups: 1 }) + expect(c.observations.map((e) => e.status)).toEqual(['persisted']) + const read = await f.store.read() + const saved = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + expect( + read.status === 'ready' && + saved.status === 'ready' && + nativeLocalCredentialValidationMatches( + saved.state.accounts.row?.credentialValidation, + f.subject.binding, + read.rows[0]?.credential, + ), + ).toBe(true) + expect( + saved.status === 'ready' && saved.state.accounts.row?.refreshErrorClearedAt, + ).toBe(0) + expect( + saved.status === 'ready' && saved.state.accounts.row?.quotaErrorGeneration, + ).toBe(2) + expect(saved.status === 'ready' && saved.state.accounts.row?.lastUsed).toBe(5) +}) + +// TestLifetime signals cancellation before waiting for the test body to finish. +// Stop the child on that signal; deferred cleanup runs too late if the body waits +// for the child. Join the process and both pipe reads, even when one read fails, +// before releasing the cancellation handler or deleting the fixture. +function joinOwnedChild( + child: Pick, + lifetime: Pick, + exited: Promise, + stdout: Promise, + stderr: Promise, +): Promise<{ exit: number; stdout: string; stderr: string }> { + const cancel = lifetime.gate() + const cancellation = cancel.wait.then(() => { + if (child.exitCode === null) child.kill() + }) + lifetime.trackDetached(cancellation) + const work = [exited, stdout, stderr] + const output = (async () => { + try { + const [exit, out, err] = await Promise.all([exited, stdout, stderr]) + return { exit, stdout: out, stderr: err } + } catch (error) { + cancel.open() + // Do not replace the original pipe/read failure with a sibling outcome. + throw error + } finally { + await Promise.allSettled(work) + cancel.open() + await cancellation + } + })() + // The body observes the original rejection; detached tracking only joins the + // complete owned operation, without reporting that same failure in teardown. + lifetime.trackDetached( + output.then( + () => {}, + () => {}, + ), + ) + return output +} + +test('known child lifetime gate cancels under real leases before joining body and pipes', async () => { + const f = await fixture() + const lifetime = new TestLifetime() + const entered = Promise.withResolvers() + const closing = gate() + trackDetached(closing.wait.then(() => lifetime.finish())) + let exitSettled = false + let stdoutSettled = false + let stderrSettled = false + let cleanupAfterAll = false + lifetime.deferCleanup(async () => { + cleanupAfterAll = exitSettled && stdoutSettled && stderrSettled + await rm(f.root, { recursive: true, force: true }) + }) + const script = ` + import { withLock } from ${JSON.stringify(import.meta.resolve('@cortexkit/common-auth/fs'))}; + const paths = ${JSON.stringify(f.paths)}; + await withLock(paths.config, { name: 'pool-config', ttlMs: 10000, timeoutMs: 15000, renew: true }, async () => + withLock(paths.state, { name: 'pool-state', ttlMs: 10000, timeoutMs: 15000, renew: true }, async () => + withLock(paths.runtime, { name: 'native-runtime', ttlMs: 10000, timeoutMs: 15000, renew: true }, async () => { + console.log('owned-leases-entered'); setInterval(() => {}, 1000); await new Promise(() => {}); + }))); + ` + const child = Bun.spawn([process.execPath, '-e', script], { + stdout: 'pipe', + stderr: 'pipe', + }) + const exited = child.exited.finally(() => { + exitSettled = true + }) + const stdoutPipe = child.stdout.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk) + entered.resolve() + }, + }), + ) + const stdout = new Response(stdoutPipe).text().finally(() => { + stdoutSettled = true + entered.resolve() + }) + const stderr = new Response(child.stderr).text().finally(() => { + stderrSettled = true + }) + const body = lifetime.runBody(() => + joinOwnedChild(child, lifetime, exited, stdout, stderr), + ) + const observedBody = body.then( + (result) => ({ result }), + (error) => ({ error }), + ) + await entered.promise + // Finish the lifetime while the child still holds the real file locks. This + // tests cancellation without relying on Bun's process cleanup after a timeout. + await lifetime.finish() + const output = await observedBody + if (!('result' in output)) throw output.error + expect(output.result.stdout).toContain('owned-leases-entered') + expect(output.result.stderr).toBe('') + // Bun leaves exitCode null for signal termination; the joined exited promise + // and recorded signal, not that nullable property, establish child settlement. + expect(output.result.exit).not.toBe(0) + expect(child.signalCode).toBe('SIGTERM') + expect(cleanupAfterAll).toBe(true) + closing.open() +}) + +test('known child pipe rejection joins exited and sibling read before cleanup preserving original failure', async () => { + const f = await fixture() + const lifetime = new TestLifetime() + const resumeSibling = lifetime.gate() + const entered = Promise.withResolvers() + const firstFailure = Promise.withResolvers() + const removed = Promise.withResolvers() + let exitSettled = false + let siblingSettled = false + let cleanupStarted = false + let cleanupAfterExit = false + let cleanupAfterSibling = false + let siblingRead = 'pending' + lifetime.deferCleanup(async () => { + cleanupStarted = true + cleanupAfterExit = exitSettled + cleanupAfterSibling = siblingSettled + try { + await rm(f.root, { recursive: true, force: true }) + } finally { + removed.resolve() + } + }) + const child = Bun.spawn( + [process.execPath, '-e', 'setInterval(() => {}, 1000)'], + { + stdout: 'pipe', + stderr: 'pipe', + }, + ) + const exited = child.exited.finally(() => { + exitSettled = true + }) + // Lock the real stdout pipe so its actual Response read rejects, rather than + // supplying a synthetic rejected promise in place of child/pipe work. + const reader = child.stdout.getReader() + const stdout = Promise.resolve() + .then(() => new Response(child.stdout).text()) + .catch((error: unknown) => { + firstFailure.resolve(error) + throw error + }) + const stderrPipe = child.stderr.pipeThrough( + new TransformStream({ + async flush() { + entered.resolve() + await resumeSibling.wait + if (cleanupStarted) await removed.promise + try { + await readFile(f.paths.runtime) + siblingRead = 'present' + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) + throw error + siblingRead = 'ENOENT' + } + }, + }), + ) + const stderr = new Response(stderrPipe).text().finally(() => { + siblingSettled = true + }) + const body = lifetime.runBody(() => + joinOwnedChild(child, lifetime, exited, stdout, stderr), + ) + const observedBody = body.catch((error: unknown) => error) + const originalError = await firstFailure.promise + await entered.promise + await lifetime.finish() + const bodyError = await observedBody + // The outer fixture also waits for the child and both pipe reads if the helper + // fails to join them, so a failing check cannot leave work running. + await Promise.allSettled([exited, stdout, stderr]) + reader.releaseLock() + expect(originalError).toBeInstanceOf(Error) + expect(originalError).toMatchObject({ + message: 'ReadableStream has already been used', + }) + expect(bodyError).toBe(originalError) + expect(cleanupAfterExit).toBe(true) + expect(cleanupAfterSibling).toBe(true) + expect(siblingRead).toBe('present') + expect(siblingSettled).toBe(true) +}) + +test('known publication child strict pool reads and publication perform no provider HTTP', async () => { + const f = await fixture() + const script = ` + import { publishNativeLocalCredentialValidation } from ${JSON.stringify(new URL('../native-runtime.ts', import.meta.url).href)}; + let http = 0; globalThis.fetch = async () => { http++; throw new Error('HTTP forbidden'); }; + const next = await publishNativeLocalCredentialValidation(${JSON.stringify(f.paths)}, ${JSON.stringify(f.event)}); + console.log(JSON.stringify({ proof: !!next.accounts.row?.credentialValidation, http })); + ` + const child = Bun.spawn([process.execPath, '-e', script], { + stdout: 'pipe', + stderr: 'pipe', + }) + const exited = child.exited + const out = new Response(child.stdout).text() + const err = new Response(child.stderr).text() + const { exit, stdout, stderr } = await joinOwnedChild( + child, + { gate, trackDetached }, + exited, + out, + err, + ) + expect(exit).toBe(0) + expect(JSON.parse(stdout)).toEqual({ proof: true, http: 0 }) + expect(stderr).toBe('') +}) From cae5a44ec99adb7a220eed81c32fcf45bb1cda23 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:40:37 +0200 Subject: [PATCH 029/169] mason: add bounded offline host-auth writer Use the shared source reader, entry inspector and activation marker for fixed OpenCode/Pi physical write recipes. Capture explicit input and awaited callback identities, require Pi removal consent, preserve current unrelated providers and modes, and recheck the complete snapshot with the process fence last. Report completed effects even when later hooks fail; stage exclusively and clean up only owned temporary files. Include real replacement/creation/removal crash children with cancellation before lifetime joins and individually drained process/stream promises. Four retained independent safety mutants redden only their intended target-byte assertions with a healthy companion. Parent source/comment and independent safety review approved this exact two-file change before commit. This does not implement a journal transaction, path-binding preflight, crash sweeping, host wiring or live migration. There is no compare-and-swap: a host change after final recheck can be overwritten without detection; host quiescence and the next-resume entry oracle remain required. --- packages/core/src/native-host-auth-write.ts | 239 ++++ .../src/tests/native-host-auth-write.test.ts | 1012 +++++++++++++++++ 2 files changed, 1251 insertions(+) create mode 100644 packages/core/src/native-host-auth-write.ts create mode 100644 packages/core/src/tests/native-host-auth-write.test.ts diff --git a/packages/core/src/native-host-auth-write.ts b/packages/core/src/native-host-auth-write.ts new file mode 100644 index 000000000..bd335c470 --- /dev/null +++ b/packages/core/src/native-host-auth-write.ts @@ -0,0 +1,239 @@ +import { randomUUID } from 'node:crypto' +import { lstat, open, rename, unlink } from 'node:fs/promises' +import { dirname, isAbsolute, join } from 'node:path' + +import { custodyTombstoneOAuth } from './claustrum.ts' +import { + NativeMigrationSourceError, + readNativeMigrationSource, + requireUnchangedNativeMigrationSource, +} from './migration-source.ts' +import { + inspectNativeHostAuthEntry, + NativeHostAuthError, + requireNoSupervisedAuthContentSnapshot, +} from './native-host-auth.ts' + +export interface NativeHostAuthWriteInput { + readonly host: 'opencode' | 'pi' + /** + * Use the configured auth path, not a symlink's target, so the source reader + * can reject a symlink at the auth file. Its directory must already exist. + */ + readonly authPath: string + readonly sourceDigest: string + readonly expectedDigest: string + readonly env: NodeJS.ProcessEnv + readonly removePiAnthropicAuth: boolean + /** + * Must reject if the caller no longer owns the offline operation. + * Awaited immediately before replacing or deleting the auth file. + */ + readonly processFence: () => Promise +} + +export interface NativeHostAuthWriteHooks { + readonly beforeRecheck?: () => Promise + readonly beforeRename?: () => Promise + readonly afterRename?: () => Promise + readonly afterUnlink?: () => Promise +} + +export type NativeHostAuthWriteOutcome = + | { readonly status: 'unchanged' } + | { readonly status: 'replaced' | 'created' } + | { readonly status: 'removed'; readonly file: 'replaced' | 'unlinked' } + +export class NativeHostAuthWriteError extends Error { + constructor( + public readonly code: + | 'invalid-source' + | 'unsafe-source' + | 'consent-required' + | 'process-fence-refused' + | 'write-io' + | 'hook-failed', + /** Records the file change already completed if a later callback throws. */ + public readonly effect: NativeHostAuthWriteOutcome | undefined = undefined, + ) { + super(`Native host auth write: ${code}`) + this.name = 'NativeHostAuthWriteError' + } +} + +/** Match this helper's temporary filenames so crash cleanup leaves other files alone. */ +export function isNativeHostAuthWriteStagingName(name: string): boolean { + return ( + name.endsWith('.tmp') && + /^\.native-host-auth-write\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/.test( + name, + ) + ) +} + +function isEntryDigest(value: unknown): value is string { + return ( + typeof value === 'string' && + (value === 'absent' || + (value.length === 64 && /^[a-f0-9]{64}$/.test(value))) + ) +} + +async function barrier( + callback: (() => Promise) | undefined, + code: 'hook-failed' | 'process-fence-refused', + effect?: NativeHostAuthWriteOutcome, +): Promise { + try { + await callback?.() + } catch { + // A caller's exception may contain credentials; never retain it as a cause. + throw new NativeHostAuthWriteError(code, effect) + } +} + +/** + * Physical offline recipes only: no journal transaction or host SDK writes. + * The entry digest fences phases; the complete fresh snapshot fences this write. + * There is NO compare-and-swap with host writers: a host change between the final + * recheck and rename/unlink can be overwritten without detection. Advisory locks + * do not stop unlocked host writers. Callers must quiesce hosts and use the entry + * oracle on the next resume, as well as bind paths and preflight selected hosts. + */ +export async function writeNativeHostAuth( + input: NativeHostAuthWriteInput, + hooks: NativeHostAuthWriteHooks = {}, +): Promise { + let stage: string | undefined + let ownedStage: { dev: number; ino: number } | undefined + let effect: NativeHostAuthWriteOutcome | undefined + try { + // Save the original values and callbacks before awaiting file I/O; caller + // mutations must not redirect the write or replace its checks. + const { + host, + authPath, + sourceDigest, + expectedDigest, + removePiAnthropicAuth, + processFence, + } = input + requireNoSupervisedAuthContentSnapshot(input.env) + const { beforeRecheck, beforeRename, afterRename, afterUnlink } = hooks + if ( + (host !== 'opencode' && host !== 'pi') || + typeof authPath !== 'string' || + !isAbsolute(authPath) || + /\p{Cc}/u.test(authPath) || + !isEntryDigest(sourceDigest) || + !isEntryDigest(expectedDigest) || + typeof processFence !== 'function' + ) + throw new NativeHostAuthWriteError('invalid-source') + + const snapshot = await readNativeMigrationSource('hostAuth', authPath) + if ( + snapshot.metadata && + process.getuid && + snapshot.metadata.uid !== process.getuid() + ) + throw new NativeHostAuthWriteError('unsafe-source') + const entry = inspectNativeHostAuthEntry(host, snapshot.data) + if (entry.digest !== sourceDigest) + throw new NativeHostAuthWriteError('invalid-source') + + // Copy own JSON keys into a null-prototype object so __proto__ stays data. + const next: Record = Object.assign( + Object.create(null), + snapshot.data, + ) + if (host === 'opencode') { + if (entry.kind !== 'api' && entry.kind !== 'activation') + next.anthropic = custodyTombstoneOAuth('anthropic') + } else { + delete next.anthropic + } + if (inspectNativeHostAuthEntry(host, next).digest !== expectedDigest) + throw new NativeHostAuthWriteError('invalid-source') + + if ( + (host === 'opencode' && + (entry.kind === 'api' || entry.kind === 'activation')) || + (host === 'pi' && entry.kind === 'absent') + ) + return Object.freeze({ status: 'unchanged' }) + // Pi activation markers are stored as OAuth entries; removing one still + // requires the caller's explicit consent. + if (host === 'pi' && removePiAnthropicAuth !== true) + throw new NativeHostAuthWriteError('consent-required') + + const removingFile = host === 'pi' && Object.keys(next).length === 0 + const outcome: NativeHostAuthWriteOutcome = Object.freeze( + host === 'pi' + ? { status: 'removed', file: removingFile ? 'unlinked' : 'replaced' } + : { status: snapshot.data === undefined ? 'created' : 'replaced' }, + ) + if (!removingFile) { + stage = join( + dirname(authPath), + `.native-host-auth-write.${randomUUID()}.tmp`, + ) + // Never retry a collision and never clean up a stage we did not create. + const handle = await open(stage, 'wx', 0o600) + try { + const identity = await handle.stat() + ownedStage = { dev: identity.dev, ino: identity.ino } + await handle.writeFile(`${JSON.stringify(next, null, 2)}\n`, 'utf8') + await handle.chmod( + snapshot.metadata ? snapshot.metadata.mode & 0o7777 : 0o600, + ) + await handle.sync() + } finally { + await handle.close() + } + } + + await barrier(beforeRecheck, 'hook-failed') + await requireUnchangedNativeMigrationSource(snapshot) + // The file can still change after the final snapshot check; no atomic + // comparison protects the rename or unlink. Run the caller's process check + // after the test hook, immediately before changing the file. + await barrier(beforeRename, 'hook-failed') + await barrier(processFence, 'process-fence-refused') + if (removingFile) { + await unlink(authPath) + effect = outcome + await barrier(afterUnlink, 'hook-failed', effect) + } else { + await rename(stage as string, authPath) + ownedStage = undefined + effect = outcome + await barrier(afterRename, 'hook-failed', effect) + } + return outcome + } catch (error) { + if ( + error instanceof NativeHostAuthWriteError || + error instanceof NativeHostAuthError + ) + throw error + if (error instanceof NativeMigrationSourceError) + throw new NativeHostAuthWriteError( + error.code === 'unsafe-source' ? 'unsafe-source' : 'invalid-source', + effect, + ) + throw new NativeHostAuthWriteError('write-io', effect) + } finally { + if (stage && ownedStage) { + try { + const current = await lstat(stage) + if (current.dev === ownedStage.dev && current.ino === ownedStage.ino) + await unlink(stage) + } catch { + // If temporary-file cleanup fails, keep the original error or completed + // file-change result. Later cleanup may remove recognized temporary files, + // but must never read them as host auth. + } + } + } +} diff --git a/packages/core/src/tests/native-host-auth-write.test.ts b/packages/core/src/tests/native-host-auth-write.test.ts new file mode 100644 index 000000000..6cc0efa46 --- /dev/null +++ b/packages/core/src/tests/native-host-auth-write.test.ts @@ -0,0 +1,1012 @@ +import { expect, spyOn } from 'bun:test' +import * as crypto from 'node:crypto' +import { + chmod, + type FileHandle, + lstat, + mkdir, + mkdtemp, + open, + readdir, + readFile, + realpath, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { inspect } from 'node:util' + +import { inspectNativeHostAuthEntry } from '../native-host-auth.ts' +import { + isNativeHostAuthWriteStagingName, + type NativeHostAuthWriteHooks, + type NativeHostAuthWriteInput, + writeNativeHostAuth, +} from '../native-host-auth-write.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup, gate, trackDetached } = createTestLifetimeSuite() +const oauth = { + type: 'oauth', + access: 'synthetic-access', + refresh: 'synthetic-refresh', + expires: 0, +} +// These SHA-256 digests are the oauth and activation fixtures in +// native-host-auth.test.ts, hashed from its hand-written canonical JSON. +const sourceDigest = + 'b006e78a28dd6ba18131eb83536a8be9919c684befbd2b30ac57df97ff8d11ed' +const activationDigest = + '34ff5181a0466836fec63f58d91bf4d04c46ec2090ab5ed3b6c554332c4bc669' +const activation = { + type: 'oauth', + access: '', + refresh: 'claustrum-tombstone:v1:anthropic', + expires: 0, +} + +async function fixture(text?: string) { + const created = await mkdtemp(join(tmpdir(), 'anthropic-host-auth-write-')) + deferCleanup(() => rm(created, { recursive: true, force: true })) + const root = await realpath(created) + const path = join(root, 'auth.json') + if (text !== undefined) await writeFile(path, text, { mode: 0o600 }) + return { root, path } +} + +function input( + path: string, + overrides: Partial = {}, +): NativeHostAuthWriteInput { + return { + host: 'opencode', + authPath: path, + sourceDigest, + expectedDigest: activationDigest, + env: {}, + removePiAnthropicAuth: false, + processFence: async () => {}, + ...overrides, + } +} + +async function bytes(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return undefined + throw error + } +} + +async function stages(root: string) { + return (await readdir(root)).filter(isNativeHostAuthWriteStagingName) +} + +async function attempt(work: Promise): Promise { + return work.then( + (result) => result, + (error: unknown) => error, + ) +} + +function safeError(error: unknown, code: string, effect?: unknown) { + expect(error).toMatchObject({ code, effect }) + expect(error).toBeInstanceOf(Error) + expect(Object.hasOwn(error as object, 'cause')).toBe(false) + expect(inspect(error, { showHidden: true })).not.toContain('synthetic-') + expect(JSON.stringify(error)).not.toContain('synthetic-') +} + +test('fresh snapshot preserves changed unrelated providers and dangerous own keys', async () => { + const original = { anthropic: oauth, other: { key: 'synthetic-old' } } + const { path, root } = await fixture(JSON.stringify(original)) + const captured = inspectNativeHostAuthEntry('opencode', original).digest + const current = JSON.parse( + `{"anthropic":${JSON.stringify(oauth)},"other":{"key":"synthetic-new"},"__proto__":{"polluted":true},"constructor":{"unknown":[1,null,"café"]},"unfamiliar":{"deep":{"enabled":true}}}`, + ) + await writeFile(path, JSON.stringify(current)) + expect(inspectNativeHostAuthEntry('opencode', current).digest).toBe(captured) + const result = await writeNativeHostAuth( + input(path, { sourceDigest: captured }), + ) + const actual = JSON.parse((await bytes(path))!) + expect(actual).toEqual({ + ...current, + anthropic: activation, + }) + expect(Object.keys(actual).sort()).toEqual(Object.keys(current).sort()) + expect(Object.hasOwn(actual, '__proto__')).toBe(true) + expect(Object.hasOwn(actual, 'constructor')).toBe(true) + expect(actual.constructor).toEqual(current.constructor) + expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false) + expect(result).toEqual({ status: 'replaced' }) + expect(Object.isFrozen(result)).toBe(true) + expect(JSON.stringify(result)).not.toContain('synthetic-') + expect(await stages(root)).toEqual([]) +}) + +test('missing OpenCode creates only the exact activation entry with mode 0600', async () => { + const { path, root } = await fixture() + expect( + await writeNativeHostAuth(input(path, { sourceDigest: 'absent' })), + ).toEqual({ status: 'created' }) + expect(JSON.parse((await bytes(path))!)).toEqual({ anthropic: activation }) + expect((await stat(path)).mode & 0o7777).toBe(0o600) + expect(await stages(root)).toEqual([]) +}) + +test('replacement preserves existing mode bits and unknown OAuth properties are fenced', async () => { + const original = { + anthropic: { ...oauth, extra: { labels: ['café', 1] } }, + other: false, + } + const { path } = await fixture(JSON.stringify(original)) + await chmod(path, 0o640) + const digest = inspectNativeHostAuthEntry('opencode', original).digest + expect( + await writeNativeHostAuth(input(path, { sourceDigest: digest })), + ).toEqual({ status: 'replaced' }) + expect((await stat(path)).mode & 0o7777).toBe(0o640) + expect(JSON.parse((await bytes(path))!)).toEqual({ + anthropic: activation, + other: false, + }) +}) + +test('stock OpenCode API and exact activation are byte-for-byte no-ops with E equal S', async () => { + for (const entry of [ + { type: 'api', key: 'synthetic-key', unknown: 9 }, + activation, + ]) { + const text = ` { "other" : {"key":"synthetic-other"}, "anthropic": ${JSON.stringify(entry)} }\n` + const { path, root } = await fixture(text) + const before = await stat(path) + const digest = inspectNativeHostAuthEntry('opencode', { + anthropic: entry, + }).digest + let fences = 0 + const result = await writeNativeHostAuth( + input(path, { + sourceDigest: digest, + expectedDigest: digest, + processFence: async () => { + fences++ + }, + }), + ) + expect(await bytes(path)).toBe(text) + expect((await stat(path)).ino).toBe(before.ino) + expect(result).toEqual({ status: 'unchanged' }) + expect(fences).toBe(0) + expect(await stages(root)).toEqual([]) + const refused = await attempt( + writeNativeHostAuth( + input(path, { + sourceDigest: digest, + expectedDigest: 'e'.repeat(64), + }), + ), + ) + expect(await bytes(path)).toBe(text) + safeError(refused, 'invalid-source') + } +}) + +test('absent OpenCode entry preserves the complete current object when installing activation', async () => { + const original = { other: { key: 'synthetic-other' }, metadata: [1, true] } + const { path } = await fixture(JSON.stringify(original)) + await writeNativeHostAuth(input(path, { sourceDigest: 'absent' })) + expect(JSON.parse((await bytes(path))!)).toEqual({ + ...original, + anthropic: activation, + }) +}) + +test('source and expected digest mismatches refuse before any stage or provider changes', async () => { + const text = JSON.stringify({ + anthropic: oauth, + other: { key: 'synthetic-other' }, + }) + const { path, root } = await fixture(text) + for (const overrides of [ + { sourceDigest: 'absent' }, + { sourceDigest: 'f'.repeat(64) }, + { expectedDigest: 'absent' }, + { expectedDigest: sourceDigest }, + { expectedDigest: 'f'.repeat(64) }, + ...[ + 'a'.repeat(63), + 'A'.repeat(64), + `${sourceDigest}\n`, + 'synthetic-key', + '', + null, + ].flatMap((digest) => [ + { sourceDigest: digest as string }, + { expectedDigest: digest as string }, + ]), + ]) { + let fences = 0 + const error = await attempt( + writeNativeHostAuth( + input(path, { + ...overrides, + processFence: async () => { + fences++ + }, + }), + ), + ) + expect(await bytes(path)).toBe(text) + expect(await stages(root)).toEqual([]) + expect(fences).toBe(0) + safeError(error, 'invalid-source') + } +}) + +test('Pi consent refusal actively retains every target byte', async () => { + const text = JSON.stringify({ + anthropic: oauth, + other: { key: 'synthetic-other' }, + }) + const { path, root } = await fixture(text) + const error = await attempt( + writeNativeHostAuth( + input(path, { + host: 'pi', + expectedDigest: 'absent', + removePiAnthropicAuth: false, + }), + ), + ) + expect(await bytes(path)).toBe(text) + expect(await stages(root)).toEqual([]) + safeError(error, 'consent-required') +}) + +test('Pi removes all supported consented credential shapes without normalizing or importing them', async () => { + for (const entry of [ + oauth, + { type: 'api_key', key: 'synthetic-key' }, + { type: 'api_key', env: { ANTHROPIC_API_KEY: 'synthetic-env' } }, + { type: 'api_key' }, + { type: 'api_key', key: '', env: {} }, + activation, + ]) { + const original = JSON.parse( + `{"anthropic":${JSON.stringify(entry)},"__proto__":{"polluted":true},"other":{"key":"synthetic-other"}}`, + ) + const { path, root } = await fixture(JSON.stringify(original)) + const result = await writeNativeHostAuth( + input(path, { + host: 'pi', + sourceDigest: inspectNativeHostAuthEntry('pi', original).digest, + expectedDigest: 'absent', + removePiAnthropicAuth: true, + }), + ) + delete original.anthropic + expect(JSON.parse((await bytes(path))!)).toEqual(original) + expect(result).toEqual({ status: 'removed', file: 'replaced' }) + expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false) + expect(await stages(root)).toEqual([]) + } +}) + +test('Pi consent requires boolean true, not yes flags or truthy values', async () => { + const text = JSON.stringify({ anthropic: { type: 'api_key' } }) + const { path } = await fixture(text) + for (const consent of [undefined, false, 'yes', '--yes', 1, {}]) { + const error = await attempt( + writeNativeHostAuth( + input(path, { + host: 'pi', + sourceDigest: + '8bdbf2b15baae9ecfba096133446a79e4055f9adb8a307ee863d1bc8eb6873ab', + expectedDigest: 'absent', + removePiAnthropicAuth: consent as boolean, + }), + ), + ) + expect(await bytes(path)).toBe(text) + safeError(error, 'consent-required') + } +}) + +test('Pi last-provider removal unlinks only after its mandatory fence and awaits afterUnlink', async () => { + const { path, root } = await fixture(JSON.stringify({ anthropic: oauth })) + const events: string[] = [] + const result = await writeNativeHostAuth( + input(path, { + host: 'pi', + expectedDigest: 'absent', + removePiAnthropicAuth: true, + processFence: async () => { + events.push('fence') + expect(await bytes(path)).toBeDefined() + }, + }), + { + beforeRecheck: async () => { + events.push('recheck') + expect(await stages(root)).toEqual([]) + }, + beforeRename: async () => { + events.push('before') + }, + afterUnlink: async () => { + events.push('after') + expect(await bytes(path)).toBeUndefined() + }, + afterRename: async () => { + throw new Error('wrong effect hook') + }, + }, + ) + expect(result).toEqual({ status: 'removed', file: 'unlinked' }) + expect(events).toEqual(['recheck', 'before', 'fence', 'after']) + expect(await bytes(path)).toBeUndefined() +}) + +test('Pi missing or absent entry does not create unlink or claim removal, even without consent', async () => { + for (const text of [ + undefined, + '{}', + ' {"other":{"key":"synthetic-other"}}\n', + ]) { + const { path, root } = await fixture(text) + let calls = 0 + const result = await writeNativeHostAuth( + input(path, { + host: 'pi', + sourceDigest: 'absent', + expectedDigest: 'absent', + processFence: async () => { + calls++ + }, + }), + { + beforeRecheck: async () => { + calls++ + }, + afterUnlink: async () => { + calls++ + }, + }, + ) + expect(await bytes(path)).toBe(text) + expect(result).toEqual({ status: 'unchanged' }) + expect(calls).toBe(0) + expect(await stages(root)).toEqual([]) + } +}) + +test('malformed and non-object files or invalid Anthropic entries refuse without changing other providers', async () => { + for (const host of ['opencode', 'pi'] as const) { + for (const text of [ + '', + '{synthetic-malformed', + 'null', + '[]', + '1', + '"synthetic-string"', + ...[ + null, + [], + {}, + { type: 'unknown' }, + { ...oauth, expires: '0' }, + { ...activation, extra: true }, + { ...oauth, refresh: 'claustrum-tombstone:anything' }, + { type: 'api', key: 'claustrum-tombstone:anything' }, + ].map((entry) => + JSON.stringify({ other: { key: 'synthetic-other' }, anthropic: entry }), + ), + ]) { + const { path, root } = await fixture(text) + const error = await attempt( + writeNativeHostAuth( + input(path, { + host, + expectedDigest: host === 'pi' ? 'absent' : activationDigest, + }), + ), + ) + expect(await bytes(path)).toBe(text) + expect(await stages(root)).toEqual([]) + expect(error).toMatchObject({ code: 'invalid-source' }) + expect(inspect(error, { showHidden: true })).not.toContain('synthetic-') + } + } +}) + +test('unsafe symlink and directory leaves preserve their targets', async () => { + const { path, root } = await fixture() + const target = join(root, 'target.json') + const text = JSON.stringify({ anthropic: oauth, other: true }) + await writeFile(target, text) + await symlink(target, path) + const linked = await attempt(writeNativeHostAuth(input(path))) + expect(await bytes(target)).toBe(text) + expect((await lstat(path)).isSymbolicLink()).toBe(true) + safeError(linked, 'unsafe-source') + await rm(path) + await mkdir(path) + const directory = await attempt(writeNativeHostAuth(input(path))) + expect((await lstat(path)).isDirectory()).toBe(true) + expect(await bytes(target)).toBe(text) + safeError(directory, 'unsafe-source') + expect(await stages(root)).toEqual([]) +}) + +test('foreign ownership refuses with getuid available', async () => { + const text = JSON.stringify({ anthropic: oauth }) + const { path } = await fixture(text) + if (!process.getuid) return + const uid = process.getuid() + const getuid = spyOn(process, 'getuid').mockReturnValue(uid + 1) + try { + const error = await attempt(writeNativeHostAuth(input(path))) + expect(await bytes(path)).toBe(text) + safeError(error, 'unsafe-source') + } finally { + getuid.mockRestore() + } +}) + +test('source I/O and size errors map to invalid-source without echoing raw input', async () => { + const { path, root } = await fixture('{}') + safeError( + await attempt(writeNativeHostAuth(input(join(path, 'auth.json')))), + 'invalid-source', + ) + const oversized = `{"padding":"${'x'.repeat(4 * 1024 * 1024)}"}` + await writeFile(path, oversized) + const error = await attempt(writeNativeHostAuth(input(path))) + expect((await stat(path)).size).toBe(Buffer.byteLength(oversized)) + safeError(error, 'invalid-source') + expect(await stages(root)).toEqual([]) +}) + +test('supervised snapshot presence including empty string refuses with existing safe guidance', async () => { + for (const content of [ + '', + 'synthetic-malformed', + '{"key":"synthetic-snapshot"}', + ]) { + const text = JSON.stringify({ anthropic: oauth }) + const { path, root } = await fixture(text) + const error = await attempt( + writeNativeHostAuth( + input(path, { env: { OPENCODE_AUTH_CONTENT: content } }), + ), + ) + expect(await bytes(path)).toBe(text) + expect(await stages(root)).toEqual([]) + expect(error).toMatchObject({ + code: 'supervised-auth-snapshot', + message: + 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set. Run setup without OPENCODE_AUTH_CONTENT in the environment.', + }) + expect(inspect(error, { showHidden: true })).not.toContain('synthetic-') + } + const { path } = await fixture() + const error = await attempt( + writeNativeHostAuth( + input(path, { + sourceDigest: 'absent', + env: { OPENCODE_AUTH_CONTENT: '' }, + }), + ), + ) + expect(await bytes(path)).toBeUndefined() + expect(error).toMatchObject({ code: 'supervised-auth-snapshot' }) +}) + +test('final snapshot recheck actively retains the changed unrelated provider bytes', async () => { + const text = JSON.stringify({ + anthropic: oauth, + other: { key: 'synthetic-original' }, + }) + const changed = JSON.stringify({ + anthropic: oauth, + other: { key: 'synthetic-changed' }, + }) + const { path, root } = await fixture(text) + let fences = 0 + const error = await attempt( + writeNativeHostAuth( + input(path, { + processFence: async () => { + fences++ + }, + }), + { + beforeRecheck: async () => { + await writeFile(path, changed) + }, + }, + ), + ) + expect(await bytes(path)).toBe(changed) + expect(await stages(root)).toEqual([]) + expect(fences).toBe(0) + safeError(error, 'invalid-source') +}) + +test('continued absence recheck retains a newly appeared OpenCode file', async () => { + const { path, root } = await fixture() + const appeared = '{"other":{"key":"synthetic-appeared"}}' + const error = await attempt( + writeNativeHostAuth(input(path, { sourceDigest: 'absent' }), { + beforeRecheck: async () => { + await writeFile(path, appeared) + }, + }), + ) + expect(await bytes(path)).toBe(appeared) + expect(await stages(root)).toEqual([]) + safeError(error, 'invalid-source') +}) + +test('Pi recheck refuses newly added providers before replacement or last-provider unlink', async () => { + for (const other of [false, true]) { + const { path, root } = await fixture( + JSON.stringify({ anthropic: oauth, ...(other ? { other: 1 } : {}) }), + ) + const changed = JSON.stringify({ + anthropic: oauth, + other: 2, + appeared: true, + }) + const error = await attempt( + writeNativeHostAuth( + input(path, { + host: 'pi', + expectedDigest: 'absent', + removePiAnthropicAuth: true, + }), + { + beforeRecheck: async () => { + await writeFile(path, changed) + }, + }, + ), + ) + expect(await bytes(path)).toBe(changed) + expect(await stages(root)).toEqual([]) + safeError(error, 'invalid-source') + } +}) + +test('recheck detects deletion byte-identical replacement and changed permissions', async () => { + const text = JSON.stringify({ anthropic: oauth }) + for (const change of ['deletion', 'replacement', 'mode'] as const) { + const { path, root } = await fixture(text) + const error = await attempt( + writeNativeHostAuth(input(path), { + beforeRecheck: async () => { + if (change === 'deletion') await rm(path) + if (change === 'mode') await chmod(path, 0o640) + if (change === 'replacement') { + const replacement = join(root, 'replacement.json') + await writeFile(replacement, text) + await rename(replacement, path) + } + }, + }), + ) + expect(await bytes(path)).toBe(change === 'deletion' ? undefined : text) + if (change === 'mode') expect((await stat(path)).mode & 0o777).toBe(0o640) + expect(await stages(root)).toEqual([]) + safeError(error, 'invalid-source') + } +}) + +test('process fence refusal actively retains all target bytes and removes the owned stage', async () => { + const text = JSON.stringify({ anthropic: oauth, other: true }) + const { path, root } = await fixture(text) + const error = await attempt( + writeNativeHostAuth( + input(path, { + processFence: async () => { + throw new Error('synthetic-fence-secret') + }, + }), + ), + ) + expect(await bytes(path)).toBe(text) + expect(await stages(root)).toEqual([]) + safeError(error, 'process-fence-refused') +}) + +test('mandatory fence absence rejects and fence refusal also prevents creation and Pi unlink', async () => { + for (const host of ['opencode', 'pi'] as const) { + const text = + host === 'pi' ? JSON.stringify({ anthropic: oauth }) : undefined + const { path, root } = await fixture(text) + const options = input(path, { + host, + sourceDigest: host === 'pi' ? sourceDigest : 'absent', + expectedDigest: host === 'pi' ? 'absent' : activationDigest, + removePiAnthropicAuth: true, + }) + for (const processFence of [undefined, null, 1]) { + const error = await attempt( + writeNativeHostAuth({ + ...options, + processFence: + processFence as unknown as NativeHostAuthWriteInput['processFence'], + }), + ) + expect(await bytes(path)).toBe(text) + safeError(error, 'invalid-source') + } + const error = await attempt( + writeNativeHostAuth({ + ...options, + processFence: async () => { + throw 'synthetic-fence-secret' + }, + }), + ) + expect(await bytes(path)).toBe(text) + expect(await stages(root)).toEqual([]) + safeError(error, 'process-fence-refused') + } +}) + +test('captured input and hook identities cannot be redirected after a lifecycle barrier', async () => { + const { path, root } = await fixture(JSON.stringify({ anthropic: oauth })) + const otherPath = join(root, 'other-auth.json') + const options = { ...input(path) } + const paused = gate() + const reached = gate() + let fences = 0 + let after = 0 + options.processFence = async () => { + fences++ + } + const hooks: { + -readonly [K in keyof NativeHostAuthWriteHooks]: NativeHostAuthWriteHooks[K] + } = { + beforeRecheck: async () => { + reached.open() + await paused.wait + }, + afterRename: async () => { + after++ + }, + } + const writing = writeNativeHostAuth(options, hooks) + trackDetached(writing) + await reached.wait + options.authPath = otherPath + options.host = 'pi' + options.sourceDigest = 'absent' + options.expectedDigest = 'absent' + options.removePiAnthropicAuth = true + options.env.OPENCODE_AUTH_CONTENT = 'synthetic-snapshot' + options.processFence = async () => { + throw new Error('synthetic-mutated-fence') + } + hooks.afterRename = async () => { + throw new Error('synthetic-mutated-hook') + } + paused.open() + expect(await writing).toEqual({ status: 'replaced' }) + expect(await bytes(otherPath)).toBeUndefined() + expect(JSON.parse((await bytes(path))!)).toEqual({ anthropic: activation }) + expect(fences).toBe(1) + expect(after).toBe(1) +}) + +test('Pi consent is captured before the first await rather than adopted from mutable caller input', async () => { + const text = JSON.stringify({ anthropic: oauth }) + const { path } = await fixture(text) + const options = { ...input(path, { host: 'pi', expectedDigest: 'absent' }) } + const writing = attempt(writeNativeHostAuth(options)) + options.removePiAnthropicAuth = true + const error = await writing + expect(await bytes(path)).toBe(text) + safeError(error, 'consent-required') +}) + +test('replacement awaits lifecycle barriers and runs the process fence last', async () => { + const { path, root } = await fixture(JSON.stringify({ anthropic: oauth })) + const events: string[] = [] + const reached = gate() + const resume = gate() + let settled = false + const writing = writeNativeHostAuth( + input(path, { + processFence: async () => { + events.push('fence') + }, + }), + { + beforeRecheck: async () => { + events.push('recheck') + expect(await stages(root)).toHaveLength(1) + }, + beforeRename: async () => { + events.push('before') + }, + afterRename: async () => { + events.push('after') + reached.open() + await resume.wait + }, + }, + ).then((value) => { + settled = true + return value + }) + trackDetached(writing) + await reached.wait + expect(settled).toBe(false) + expect(JSON.parse((await bytes(path))!)).toEqual({ anthropic: activation }) + resume.open() + expect(await writing).toEqual({ status: 'replaced' }) + expect(events).toEqual(['recheck', 'before', 'fence', 'after']) +}) + +test('throwing pre-effect hooks clean up stages without changing target bytes', async () => { + for (const hook of ['beforeRecheck', 'beforeRename'] as const) { + const text = JSON.stringify({ anthropic: oauth }) + const { path, root } = await fixture(text) + const error = await attempt( + writeNativeHostAuth(input(path), { + [hook]: async () => { + throw new Error('synthetic-hook-secret') + }, + }), + ) + expect(await bytes(path)).toBe(text) + expect(await stages(root)).toEqual([]) + safeError(error, 'hook-failed') + } +}) + +test('after-effect hook failures retain token-free replacement creation and removal provenance', async () => { + for (const recipe of [ + 'replaced', + 'created', + 'removed-replaced', + 'removed-unlinked', + ] as const) { + const removing = recipe.startsWith('removed') + const original = { + anthropic: oauth, + ...(recipe === 'removed-replaced' ? { other: true } : {}), + } + const { path, root } = await fixture( + recipe === 'created' ? undefined : JSON.stringify(original), + ) + const fail = async () => { + throw new Error('synthetic-after-effect-secret') + } + const error = await attempt( + writeNativeHostAuth( + input(path, { + host: removing ? 'pi' : 'opencode', + sourceDigest: recipe === 'created' ? 'absent' : sourceDigest, + expectedDigest: removing ? 'absent' : activationDigest, + removePiAnthropicAuth: true, + }), + { afterRename: fail, afterUnlink: fail }, + ), + ) + if (recipe === 'removed-unlinked') expect(await bytes(path)).toBeUndefined() + else + expect(JSON.parse((await bytes(path))!)).toEqual( + removing ? { other: true } : { anthropic: activation }, + ) + safeError( + error, + 'hook-failed', + removing + ? { + status: 'removed', + file: recipe === 'removed-unlinked' ? 'unlinked' : 'replaced', + } + : { status: recipe }, + ) + expect(await stages(root)).toEqual([]) + } +}) + +test('stage collisions never delete another writer file and partial stages are cleaned', async () => { + const text = JSON.stringify({ anthropic: oauth }) + const { path, root } = await fixture(text) + const uuid = '11111111-2222-4333-8444-555555555555' + const stage = join(root, `.native-host-auth-write.${uuid}.tmp`) + await writeFile(stage, 'synthetic-other-writer') + const random = spyOn(crypto, 'randomUUID').mockReturnValue(uuid) + try { + const error = await attempt(writeNativeHostAuth(input(path))) + expect(await bytes(path)).toBe(text) + expect(await bytes(stage)).toBe('synthetic-other-writer') + safeError(error, 'write-io') + await rm(stage) + // Write incomplete JSON to the actual temporary file, then throw to test + // cleanup after a failed write. Only writeFile is patched; the source reader + // still uses its normal read calls. + const handle = await open(path, 'r') + const prototype: Pick = + Object.getPrototypeOf(handle) + await handle.close() + const originalWrite = prototype.writeFile + const partialWrite = spyOn(prototype, 'writeFile').mockImplementation( + async function (this: FileHandle) { + await originalWrite.call(this, '{"partial":') + throw new Error('synthetic-write-secret') + }, + ) + let errorAfterOpen: unknown + try { + errorAfterOpen = await attempt(writeNativeHostAuth(input(path))) + } finally { + partialWrite.mockRestore() + } + expect(await bytes(path)).toBe(text) + expect(await stages(root)).toEqual([]) + safeError(errorAfterOpen, 'write-io') + } finally { + random.mockRestore() + } +}) + +test('stage-name predicate does not recognize arbitrary temp paths or sibling writers', () => { + const own = '.native-host-auth-write.11111111-2222-4333-8444-555555555555.tmp' + expect(isNativeHostAuthWriteStagingName(own)).toBe(true) + for (const name of [ + 'auth.json.tmp', + '.native-runtime.111.tmp', + `/tmp/${own}`, + `${own}\n`, + `${own}.bak`, + own.replace('4333', '1333'), + own.replace('8444', '7444'), + ]) + expect(isNativeHostAuthWriteStagingName(name)).toBe(false) +}) + +test('the documented recheck-to-rename window is not a compare-and-swap guarantee', async () => { + const { path } = await fixture(JSON.stringify({ anthropic: oauth })) + const result = await writeNativeHostAuth(input(path), { + beforeRename: async () => { + await writeFile( + path, + JSON.stringify({ anthropic: oauth, lateProvider: true }), + ) + }, + }) + expect(result).toEqual({ status: 'replaced' }) + expect(JSON.parse((await bytes(path))!)).toEqual({ anthropic: activation }) +}) + +for (const recipe of [ + 'replacement', + 'creation', + 'Pi-replacement', + 'Pi-unlink', +] as const) { + for (const point of [ + 'beforeRename', + recipe === 'Pi-unlink' ? 'afterUnlink' : 'afterRename', + ] as const) { + test(`real child crash ${point} at ${recipe} leaves old or complete new authority bytes`, async () => { + const removing = recipe.startsWith('Pi-') + const original = { + anthropic: oauth, + ...(recipe === 'Pi-replacement' || recipe === 'replacement' + ? { other: { key: 'synthetic-other' } } + : {}), + } + const text = recipe === 'creation' ? undefined : JSON.stringify(original) + const { path, root } = await fixture(text) + const options = input(path, { + host: removing ? 'pi' : 'opencode', + sourceDigest: recipe === 'creation' ? 'absent' : sourceDigest, + expectedDigest: removing ? 'absent' : activationDigest, + removePiAnthropicAuth: true, + }) + const { processFence: _fence, ...scalars } = options + const writer = fileURLToPath( + new URL('../native-host-auth-write.ts', import.meta.url), + ) + const script = `import { writeNativeHostAuth } from ${JSON.stringify(writer)}; + await writeNativeHostAuth({ ...${JSON.stringify(scalars)}, processFence: async () => {} }, + { ${point}: async () => { process.exit(19) } }); process.exit(2);` + const child = Bun.spawn([process.execPath, '--eval', script], { + stdout: 'pipe', + stderr: 'pipe', + }) + // TestLifetime opens this gate before waiting for the test body, so teardown + // can kill a child the body is still awaiting. Registering kill as a cleanup + // would wait for the body to finish and deadlock. + const cancellation = gate() + let exited = false + const exit = child.exited.then((value) => { + exited = true + return value + }) + const stdout = new Response(child.stdout).text() + const stderr = new Response(child.stderr).text() + const cancelled = cancellation.wait.then(() => { + if (!exited) child.kill() + }) + for (const work of [exit, stdout, stderr, cancelled]) trackDetached(work) + try { + const [code, out, err] = await Promise.all([exit, stdout, stderr]) + const evidence = fileURLToPath( + new URL( + '../../../../node_modules/.cache/native-host-auth-write-evidence/', + import.meta.url, + ), + ) + await mkdir(evidence, { recursive: true }) + const prefix = join( + evidence, + `child-${recipe}-${point}-${basename(root)}`, + ) + await writeFile( + `${prefix}.command.json`, + JSON.stringify([process.execPath, '--eval', script]), + ) + await writeFile(`${prefix}.stdout`, out) + await writeFile(`${prefix}.stderr`, err) + await writeFile(`${prefix}.exit`, `${code}\n`) + expect(code, err).toBe(19) + expect(out).toBe('') + expect(err).toBe('') + } finally { + cancellation.open() + // Join individual streams and process even after a fail-fast rejection. + await Promise.allSettled([exit, stdout, stderr, cancelled]) + } + if (point === 'beforeRename') expect(await bytes(path)).toBe(text) + else if (recipe === 'Pi-unlink') expect(await bytes(path)).toBeUndefined() + else + expect(JSON.parse((await bytes(path))!)).toEqual( + removing + ? { other: { key: 'synthetic-other' } } + : { ...original, anthropic: activation }, + ) + const abandoned = await stages(root) + expect(abandoned).toHaveLength( + point === 'beforeRename' && recipe !== 'Pi-unlink' ? 1 : 0, + ) + // A child can leave temporary files behind without replacing auth.json. + // Read only auth.json on resume and leave temporary files for cleanup that + // recognizes this helper's filenames. + const fresh = await bytes(path) + const digest = inspectNativeHostAuthEntry( + options.host, + fresh === undefined ? undefined : JSON.parse(fresh), + ).digest + const resumed = await writeNativeHostAuth({ + ...options, + sourceDigest: digest, + }) + expect(resumed.status).toBe( + point === 'beforeRename' + ? removing + ? 'removed' + : recipe === 'creation' + ? 'created' + : 'replaced' + : 'unchanged', + ) + expect(await stages(root)).toEqual(abandoned) + }) + } +} From 6d62ef249ff05e33a411861dc05d7c9ded695245 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:21:58 +0200 Subject: [PATCH 030/169] mason: add pure native menu model and inventory contracts Declare closed Core-owned menu metadata for the OpenCode 1/2 profile and Pi, with literal per-host inventory, immutable output, confirmations, headless refusals, and offline custody setup guidance. Preserve 11 OpenCode and 9 Pi command families, including status-only Pi Prime/quota and OpenCode-only request-producing actions. Pin enrollment-reset confirmation to clearing denied/blocked terminal metadata; it does not revoke an approved token. Keep SDK type projections and emitted-module closure assertions only in the test. Seven independent staged/restored mutation controls fail their intended assertions with healthy companions. Parent approved source/comments and exact staged patch 60c36005331dff2266bd0d2f2fbaa016eec5c364fcd8afc4fb76ef9e90ecca51 after pinned focused/Core/pack/type/lock gates. Worker full repository tests, build and typecheck passed; root lint is prevented by pre-existing hydrated context's nested Biome configs, while both fenced files lint cleanly. No dependency, barrel, host, runtime, or build-script changes. --- packages/core/src/native-menu-model.ts | 670 ++++++++++ .../core/src/tests/native-menu-model.test.ts | 1159 +++++++++++++++++ 2 files changed, 1829 insertions(+) create mode 100644 packages/core/src/native-menu-model.ts create mode 100644 packages/core/src/tests/native-menu-model.test.ts diff --git a/packages/core/src/native-menu-model.ts b/packages/core/src/native-menu-model.ts new file mode 100644 index 000000000..4f2a0709c --- /dev/null +++ b/packages/core/src/native-menu-model.ts @@ -0,0 +1,670 @@ +/** Public menu metadata only; host adapters collect input and apply commands. */ +export const NATIVE_MENU_GROUP_IDS = Object.freeze([ + 'Accounts', + 'Quota', + 'Routing', + 'Limits', + 'Cache', + 'Diagnostics', + 'Extras', +] as const) + +export type NativeMenuGroupId = (typeof NATIVE_MENU_GROUP_IDS)[number] +/** OpenCode 1 and OpenCode 2 share this action profile; host integration is separate. */ +export type NativeMenuHost = 'opencode' | 'pi' +export type NativeMenuCommandId = + | 'account' + | 'quota' + | 'routing' + | 'killswitch' + | 'cache' + | 'cachekeep' + | 'dump' + | 'logging' + | 'fast' + | 'prime' + | 'start' +export type NativeMenuRefusalCode = 'interactive-required' | 'offline-required' +export type NativeMenuCustodyMode = 'local' | 'claustrum' +export type NativeMenuRoutingMode = + | 'main-first' + | 'fallback-first' + | 'sticky-balanced' +export type NativeMenuCacheMode = 'explicit' | 'automatic' | 'hybrid' +export type NativeMenuLogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace' +export type NativeMenuParameterId = + | 'id' + | 'apiKey' + | 'label' + | 'baseURL' + | 'authHeader' + | 'code' + | 'mode' + | 'entries' + | 'startHour' + | 'endHour' + | 'enabled' + | 'level' + +export type NativeMenuChoiceParameter< + Id extends NativeMenuParameterId = NativeMenuParameterId, + Choice extends string = string, +> = { + readonly id: Id + readonly kind: 'choice' + readonly required: boolean + readonly choices: readonly Choice[] +} + +/** Identifiers describe inputs, not the inputs' values or credential objects. */ +export type NativeMenuParameter = + | { + readonly id: 'id' + readonly kind: 'account-id' + readonly required: true + } + | { + readonly id: 'apiKey' | 'label' | 'baseURL' | 'code' + readonly kind: 'text' + readonly required: boolean + readonly protectedEntry: boolean + } + | NativeMenuChoiceParameter<'mode', NativeMenuCustodyMode> + | NativeMenuChoiceParameter<'mode', NativeMenuRoutingMode> + | NativeMenuChoiceParameter<'mode', NativeMenuCacheMode> + | NativeMenuChoiceParameter< + 'authHeader', + 'authorization-bearer' | 'x-api-key' + > + | NativeMenuChoiceParameter<'enabled', 'on' | 'off'> + | NativeMenuChoiceParameter<'level', NativeMenuLogLevel> + | { + readonly id: 'startHour' | 'endHour' + readonly kind: 'number' + readonly required: true + } + | { + readonly id: 'entries' + readonly kind: 'limit-entries' + readonly required: true + readonly fields: readonly ['account', 'fh', 'sd', 'scoped?'] + } + +export type NativeMenuActionId = + | 'enable' + | 'disable' + | 'remove' + | 'move-up' + | 'move-down' + | 'reset-backoff' + | 'enrollment-reset' + | 'add-apikey' + | 'add-oauth-start' + | 'add-oauth-finish' + | 'custody-mode' + | 'quota-refresh' + | 'routing-mode' + | 'routing-reset' + | 'killswitch-on' + | 'killswitch-off' + | 'killswitch-set' + | 'cache-on' + | 'cache-off' + | 'cache-mode' + | 'cachekeep-always' + | 'cachekeep-off' + | 'cachekeep-window' + | 'cachekeep-subagents' + | 'dump-on' + | 'dump-off' + | 'logging-level' + | 'fast-on' + | 'fast-off' + | 'prime-on' + | 'prime-off' + | 'start-fire' + +type DestructiveId = 'remove' | 'enrollment-reset' +type InteractiveId = + | 'add-apikey' + | 'add-oauth-start' + | 'add-oauth-finish' + | 'custody-mode' + +type ActionSafety = (Id extends DestructiveId + ? { readonly destructive: true; readonly confirm: string } + : { readonly destructive: false; readonly confirm?: never }) & + (Id extends InteractiveId + ? { + readonly interactive: true + readonly headlessRefusal: NativeMenuRefusalCode + } + : { readonly interactive: false; readonly headlessRefusal?: never }) + +export type NativeMenuAction = { + [Id in NativeMenuActionId]: { + readonly id: Id + readonly label: string + } & ActionSafety & + (Id extends 'custody-mode' + ? { + readonly kind: 'setup-guidance' + readonly command: 'account' + readonly group: 'Accounts' + readonly parameters: readonly [ + NativeMenuChoiceParameter<'mode', NativeMenuCustodyMode>, + ] + readonly offlineRequired: true + readonly setupInstructions: readonly string[] + } + : { + readonly kind: 'action' + readonly command: NativeMenuCommandId + readonly group: NativeMenuGroupId + readonly parameters: readonly NativeMenuParameter[] + }) +}[NativeMenuActionId] + +export type NativeMenuStatus = { + readonly kind: 'status' + readonly id: `${Exclude}-status` + readonly command: Exclude + readonly group: NativeMenuGroupId + readonly label: string +} +export type NativeMenuGroup = { + readonly id: NativeMenuGroupId + readonly actions: readonly NativeMenuAction[] + readonly statuses: readonly NativeMenuStatus[] +} +export type NativeMenuModel = { + readonly host: NativeMenuHost + readonly groups: readonly NativeMenuGroup[] +} + +const ordinary = { + kind: 'action', + destructive: false, + interactive: false, +} as const +const accountId = { id: 'id', kind: 'account-id', required: true } as const +const label = { + id: 'label', + kind: 'text', + required: false, + protectedEntry: false, +} as const + +// Freeze only the closed metadata structure; there is no input to scrub or copy. +function freezeAction(action: NativeMenuAction): NativeMenuAction { + for (const parameter of action.parameters) { + if (parameter.kind === 'choice') Object.freeze(parameter.choices) + if (parameter.kind === 'limit-entries') Object.freeze(parameter.fields) + Object.freeze(parameter) + } + Object.freeze(action.parameters) + if (action.kind === 'setup-guidance') Object.freeze(action.setupInstructions) + return Object.freeze(action) +} + +const actions: readonly NativeMenuAction[] = Object.freeze( + ( + [ + { + ...ordinary, + id: 'enable', + command: 'account', + group: 'Accounts', + label: 'Enable account', + parameters: [accountId], + }, + { + ...ordinary, + id: 'disable', + command: 'account', + group: 'Accounts', + label: 'Disable account', + parameters: [accountId], + }, + { + ...ordinary, + id: 'remove', + command: 'account', + group: 'Accounts', + label: 'Remove account', + parameters: [accountId], + destructive: true, + confirm: 'Remove the selected account from this host?', + }, + { + ...ordinary, + id: 'move-up', + command: 'account', + group: 'Accounts', + label: 'Move account up', + parameters: [accountId], + }, + { + ...ordinary, + id: 'move-down', + command: 'account', + group: 'Accounts', + label: 'Move account down', + parameters: [accountId], + }, + { + ...ordinary, + id: 'reset-backoff', + command: 'account', + group: 'Accounts', + label: 'Reset account backoff', + parameters: [], + }, + { + ...ordinary, + id: 'enrollment-reset', + command: 'account', + group: 'Accounts', + label: 'Reset host enrollment', + parameters: [], + destructive: true, + confirm: 'Clear denied or blocked enrollment state for this host?', + }, + { + ...ordinary, + id: 'add-apikey', + command: 'account', + group: 'Accounts', + label: 'Add API-key fallback', + interactive: true, + headlessRefusal: 'interactive-required', + parameters: [ + { id: 'apiKey', kind: 'text', required: true, protectedEntry: true }, + label, + { + id: 'baseURL', + kind: 'text', + required: false, + protectedEntry: false, + }, + { + id: 'authHeader', + kind: 'choice', + required: false, + choices: ['authorization-bearer', 'x-api-key'], + }, + ], + }, + { + ...ordinary, + id: 'add-oauth-start', + command: 'account', + group: 'Accounts', + label: 'Start OAuth sign-in', + parameters: [], + interactive: true, + headlessRefusal: 'interactive-required', + }, + { + ...ordinary, + id: 'add-oauth-finish', + command: 'account', + group: 'Accounts', + label: 'Finish OAuth sign-in', + parameters: [ + { id: 'code', kind: 'text', required: true, protectedEntry: true }, + label, + ], + interactive: true, + headlessRefusal: 'interactive-required', + }, + { + id: 'custody-mode', + kind: 'setup-guidance', + command: 'account', + group: 'Accounts', + label: 'Custody setup guidance', + destructive: false, + interactive: true, + headlessRefusal: 'offline-required', + offlineRequired: true, + parameters: [ + { + id: 'mode', + kind: 'choice', + required: true, + choices: ['local', 'claustrum'], + }, + ], + setupInstructions: [ + 'Stop all selected hosts before changing credential authority.', + 'Use the explicit offline setup flow for the selected local or claustrum mode.', + 'This menu only provides guidance; it does not change settings, propose enrollment, or report setup success.', + ], + }, + { + ...ordinary, + id: 'quota-refresh', + command: 'quota', + group: 'Quota', + label: 'Refresh quota', + parameters: [], + }, + { + ...ordinary, + id: 'routing-mode', + command: 'routing', + group: 'Routing', + label: 'Routing mode', + parameters: [ + { + id: 'mode', + kind: 'choice', + required: true, + choices: ['main-first', 'fallback-first', 'sticky-balanced'], + }, + ], + }, + { + ...ordinary, + id: 'routing-reset', + command: 'routing', + group: 'Routing', + label: 'Reset routing session', + parameters: [], + }, + { + ...ordinary, + id: 'killswitch-on', + command: 'killswitch', + group: 'Limits', + label: 'Enable killswitch', + parameters: [], + }, + { + ...ordinary, + id: 'killswitch-off', + command: 'killswitch', + group: 'Limits', + label: 'Disable killswitch', + parameters: [], + }, + { + ...ordinary, + id: 'killswitch-set', + command: 'killswitch', + group: 'Limits', + label: 'Set killswitch thresholds', + parameters: [ + { + id: 'entries', + kind: 'limit-entries', + required: true, + fields: ['account', 'fh', 'sd', 'scoped?'], + }, + ], + }, + { + ...ordinary, + id: 'cache-on', + command: 'cache', + group: 'Cache', + label: 'Enable cache', + parameters: [], + }, + { + ...ordinary, + id: 'cache-off', + command: 'cache', + group: 'Cache', + label: 'Disable cache', + parameters: [], + }, + { + ...ordinary, + id: 'cache-mode', + command: 'cache', + group: 'Cache', + label: 'Cache mode', + parameters: [ + { + id: 'mode', + kind: 'choice', + required: true, + choices: ['explicit', 'automatic', 'hybrid'], + }, + ], + }, + { + ...ordinary, + id: 'cachekeep-always', + command: 'cachekeep', + group: 'Cache', + label: 'Keep cache warm always', + parameters: [], + }, + { + ...ordinary, + id: 'cachekeep-off', + command: 'cachekeep', + group: 'Cache', + label: 'Disable cache keep', + parameters: [], + }, + { + ...ordinary, + id: 'cachekeep-window', + command: 'cachekeep', + group: 'Cache', + label: 'Cache keep window', + parameters: [ + { id: 'startHour', kind: 'number', required: true }, + { id: 'endHour', kind: 'number', required: true }, + ], + }, + { + ...ordinary, + id: 'cachekeep-subagents', + command: 'cachekeep', + group: 'Cache', + label: 'Cache keep for subagents', + parameters: [ + { + id: 'enabled', + kind: 'choice', + required: true, + choices: ['on', 'off'], + }, + ], + }, + { + ...ordinary, + id: 'dump-on', + command: 'dump', + group: 'Diagnostics', + label: 'Enable request dumps', + parameters: [], + }, + { + ...ordinary, + id: 'dump-off', + command: 'dump', + group: 'Diagnostics', + label: 'Disable request dumps', + parameters: [], + }, + { + ...ordinary, + id: 'logging-level', + command: 'logging', + group: 'Diagnostics', + label: 'Logging level', + parameters: [ + { + id: 'level', + kind: 'choice', + required: true, + choices: ['error', 'warn', 'info', 'debug', 'trace'], + }, + ], + }, + { + ...ordinary, + id: 'fast-on', + command: 'fast', + group: 'Extras', + label: 'Enable fast mode', + parameters: [], + }, + { + ...ordinary, + id: 'fast-off', + command: 'fast', + group: 'Extras', + label: 'Disable fast mode', + parameters: [], + }, + { + ...ordinary, + id: 'prime-on', + command: 'prime', + group: 'Extras', + label: 'Enable Prime', + parameters: [], + }, + { + ...ordinary, + id: 'prime-off', + command: 'prime', + group: 'Extras', + label: 'Disable Prime', + parameters: [], + }, + { + ...ordinary, + id: 'start-fire', + command: 'start', + group: 'Extras', + label: 'Fire lane start', + parameters: [], + }, + ] satisfies NativeMenuAction[] + ).map(freezeAction), +) + +const statuses: readonly NativeMenuStatus[] = Object.freeze( + ( + [ + { + kind: 'status', + id: 'account-status', + command: 'account', + group: 'Accounts', + label: 'Account status and usage', + }, + { + kind: 'status', + id: 'quota-status', + command: 'quota', + group: 'Quota', + label: 'Quota summary', + }, + { + kind: 'status', + id: 'routing-status', + command: 'routing', + group: 'Routing', + label: 'Routing status', + }, + { + kind: 'status', + id: 'killswitch-status', + command: 'killswitch', + group: 'Limits', + label: 'Killswitch status', + }, + { + kind: 'status', + id: 'cache-status', + command: 'cache', + group: 'Cache', + label: 'Cache status', + }, + { + kind: 'status', + id: 'cachekeep-status', + command: 'cachekeep', + group: 'Cache', + label: 'Cache keep status', + }, + { + kind: 'status', + id: 'dump-status', + command: 'dump', + group: 'Diagnostics', + label: 'Dump status', + }, + { + kind: 'status', + id: 'logging-status', + command: 'logging', + group: 'Diagnostics', + label: 'Logging status', + }, + { + kind: 'status', + id: 'fast-status', + command: 'fast', + group: 'Extras', + label: 'Fast mode status', + }, + { + kind: 'status', + id: 'prime-status', + command: 'prime', + group: 'Extras', + label: 'Prime quota window status', + }, + ] satisfies NativeMenuStatus[] + ).map((status) => Object.freeze(status)), +) + +function buildModel(host: NativeMenuHost): NativeMenuModel { + return Object.freeze({ + host, + groups: Object.freeze( + NATIVE_MENU_GROUP_IDS.map((id) => + Object.freeze({ + id, + actions: Object.freeze( + actions.filter( + (action) => + action.group === id && + (host === 'opencode' || + !['quota', 'killswitch', 'prime', 'start'].includes( + action.command, + )), + ), + ), + statuses: Object.freeze( + statuses.filter( + (status) => + status.group === id && + (host === 'opencode' || status.command !== 'killswitch'), + ), + ), + }), + ), + ), + }) +} + +const models = Object.freeze({ + opencode: buildModel('opencode'), + pi: buildModel('pi'), +}) + +/** Stable frozen metadata, with no current settings or credential material. */ +export function getNativeMenuModel(host: NativeMenuHost): NativeMenuModel { + return models[host] +} diff --git a/packages/core/src/tests/native-menu-model.test.ts b/packages/core/src/tests/native-menu-model.test.ts new file mode 100644 index 000000000..5de148987 --- /dev/null +++ b/packages/core/src/tests/native-menu-model.test.ts @@ -0,0 +1,1159 @@ +import { expect, test } from 'bun:test' +import { readFile } from 'node:fs/promises' +import type { + MenuAction, + MenuKnob, + SECTION_SLOTS, +} from '@cortexkit/common-auth/commands' +import { moduleReferences } from '../../scripts/check-native-type-closure.ts' +import { + getNativeMenuModel, + NATIVE_MENU_GROUP_IDS, + type NativeMenuAction, + type NativeMenuActionId, + type NativeMenuHost, + type NativeMenuParameter, + type NativeMenuRefusalCode, +} from '../native-menu-model.ts' + +type Assert = T +type Equal = [A] extends [B] ? ([B] extends [A] ? true : false) : false +type Assignable = [A] extends [B] ? true : false +type Slot = Group extends 'Extras' + ? 'extra' + : Group extends string + ? Lowercase + : never +type Slots = { + [Index in keyof Groups]: Slot +} + +// These projections check compile-time compatibility with the published command +// SDK. They are not runtime renderer objects; a host adapter constructs its own payload. +type KnobProjection

= P extends { + kind: 'choice' + choices: readonly (infer Choice extends string)[] +} + ? { + kind: 'choice' + id: P['id'] + label: string + choices: { value: Choice; label: string }[] + } + : P extends { kind: 'number' } + ? { + kind: 'number' + id: P['id'] + label: string + required: P['required'] + } + : { + kind: 'text' + id: P['id'] + label: string + required: P['required'] + masked: boolean + } +type ActionProjection = Pick & { + knobs: KnobProjection[] + confirm?: { message: string; irreversible: boolean } +} +const producerParity: [ + Assert, typeof SECTION_SLOTS>>, + Assert, MenuKnob>>, + Assert>, + Assert< + Equal< + Extract['id'], + Exclude + > + >, + Assert< + Assignable< + Extract, + { destructive: true; confirm: string } + > + >, + Assert< + Assignable< + Extract< + NativeMenuAction, + { + id: + | 'add-apikey' + | 'add-oauth-start' + | 'add-oauth-finish' + | 'custody-mode' + } + >, + { interactive: true; headlessRefusal: NativeMenuRefusalCode } + > + >, +] = [true, true, true, true, true, true] + +// Parameter notation: ! required, ? optional, # protected entry; choice domains +// and structured entry field identifiers are spelled out rather than inferred. +// Each row is [id, family, group, parameters, destructive, interactive, provenance]. +type InventoryRow = readonly [ + string, + string, + string, + readonly string[], + boolean, + boolean, + string, +] +const fixture: Record< + NativeMenuHost, + { + actions: readonly InventoryRow[] + statuses: readonly InventoryRow[] + families: readonly string[] + destructive: readonly string[] + interactive: readonly string[] + } +> = { + opencode: { + actions: [ + [ + 'enable', + 'account', + 'Accounts', + ['id:account-id!'], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'disable', + 'account', + 'Accounts', + ['id:account-id!'], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'remove', + 'account', + 'Accounts', + ['id:account-id!'], + true, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'move-up', + 'account', + 'Accounts', + ['id:account-id!'], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'move-down', + 'account', + 'Accounts', + ['id:account-id!'], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'reset-backoff', + 'account', + 'Accounts', + [], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'enrollment-reset', + 'account', + 'Accounts', + [], + true, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'add-apikey', + 'account', + 'Accounts', + [ + 'apiKey:text!#', + 'label:text?', + 'baseURL:text?', + 'authHeader:choice?=authorization-bearer|x-api-key', + ], + false, + true, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'add-oauth-start', + 'account', + 'Accounts', + [], + false, + true, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'add-oauth-finish', + 'account', + 'Accounts', + ['code:text!#', 'label:text?'], + false, + true, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'custody-mode', + 'account', + 'Accounts', + ['mode:choice!=local|claustrum'], + false, + true, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/opencode/src/index.ts:claude-account:4387-4415', + ], + [ + 'quota-refresh', + 'quota', + 'Quota', + [], + false, + false, + 'packages/opencode/src/index.ts:claude-quota:3382,4368-4369', + ], + [ + 'routing-mode', + 'routing', + 'Routing', + ['mode:choice!=main-first|fallback-first|sticky-balanced'], + false, + false, + 'packages/core/src/routing.ts:RoutingCommandAction:21-25; packages/opencode/src/index.ts:claude-routing:4416-4420', + ], + [ + 'routing-reset', + 'routing', + 'Routing', + [], + false, + false, + 'packages/core/src/routing.ts:RoutingCommandAction:21-25; packages/opencode/src/index.ts:claude-routing:4416-4420', + ], + [ + 'killswitch-on', + 'killswitch', + 'Limits', + [], + false, + false, + 'packages/core/src/killswitch.ts:KillswitchCommandAction:9-22; packages/opencode/src/index.ts:claude-killswitch:4469', + ], + [ + 'killswitch-off', + 'killswitch', + 'Limits', + [], + false, + false, + 'packages/core/src/killswitch.ts:KillswitchCommandAction:9-22; packages/opencode/src/index.ts:claude-killswitch:4469', + ], + [ + 'killswitch-set', + 'killswitch', + 'Limits', + ['entries:limit-entries!=account|fh|sd|scoped?'], + false, + false, + 'packages/core/src/killswitch.ts:KillswitchCommandAction:9-22; packages/opencode/src/index.ts:claude-killswitch:4469', + ], + [ + 'cache-on', + 'cache', + 'Cache', + [], + false, + false, + 'packages/core/src/cache1h.ts:Cache1hCommandAction:15-20; packages/opencode/src/index.ts:claude-cache:4439-4450', + ], + [ + 'cache-off', + 'cache', + 'Cache', + [], + false, + false, + 'packages/core/src/cache1h.ts:Cache1hCommandAction:15-20; packages/opencode/src/index.ts:claude-cache:4439-4450', + ], + [ + 'cache-mode', + 'cache', + 'Cache', + ['mode:choice!=explicit|automatic|hybrid'], + false, + false, + 'packages/core/src/cache1h.ts:Cache1hCommandAction:15-20; packages/opencode/src/index.ts:claude-cache:4439-4450', + ], + [ + 'cachekeep-always', + 'cachekeep', + 'Cache', + [], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:32-38; packages/opencode/src/index.ts:claude-cachekeep:4451-4455', + ], + [ + 'cachekeep-off', + 'cachekeep', + 'Cache', + [], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:32-38; packages/opencode/src/index.ts:claude-cachekeep:4451-4455', + ], + [ + 'cachekeep-window', + 'cachekeep', + 'Cache', + ['startHour:number!', 'endHour:number!'], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:32-38; packages/opencode/src/index.ts:claude-cachekeep:4451-4455', + ], + [ + 'cachekeep-subagents', + 'cachekeep', + 'Cache', + ['enabled:choice!=on|off'], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:32-38; packages/opencode/src/index.ts:claude-cachekeep:4451-4455', + ], + [ + 'dump-on', + 'dump', + 'Diagnostics', + [], + false, + false, + 'packages/core/src/dump.ts:DumpCommandAction; packages/opencode/src/index.ts:claude-dump:4430-4438', + ], + [ + 'dump-off', + 'dump', + 'Diagnostics', + [], + false, + false, + 'packages/core/src/dump.ts:DumpCommandAction; packages/opencode/src/index.ts:claude-dump:4430-4438', + ], + [ + 'logging-level', + 'logging', + 'Diagnostics', + ['level:choice!=error|warn|info|debug|trace'], + false, + false, + 'packages/core/src/logging.ts:LoggingCommandAction:21-24; packages/opencode/src/index.ts:claude-logging:4378-4386', + ], + [ + 'fast-on', + 'fast', + 'Extras', + [], + false, + false, + 'packages/core/src/fast.ts:FastModeCommandAction; packages/opencode/src/index.ts:claude-fast:4421-4429', + ], + [ + 'fast-off', + 'fast', + 'Extras', + [], + false, + false, + 'packages/core/src/fast.ts:FastModeCommandAction; packages/opencode/src/index.ts:claude-fast:4421-4429', + ], + [ + 'prime-on', + 'prime', + 'Extras', + [], + false, + false, + 'packages/core/src/prime.ts:PrimeCommandAction; packages/opencode/src/index.ts:claude-prime:4456-4468', + ], + [ + 'prime-off', + 'prime', + 'Extras', + [], + false, + false, + 'packages/core/src/prime.ts:PrimeCommandAction; packages/opencode/src/index.ts:claude-prime:4456-4468', + ], + [ + 'start-fire', + 'start', + 'Extras', + [], + false, + false, + 'packages/core/src/start.ts:LaneStartCommandAction:8; packages/opencode/src/index.ts:claude-start:4370-4377', + ], + ], + statuses: [ + [ + 'account-status', + 'account', + 'Accounts', + [], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:14,32', + ], + [ + 'quota-status', + 'quota', + 'Quota', + [], + false, + false, + 'packages/opencode/src/index.ts:claude-quota:4368-4369', + ], + [ + 'routing-status', + 'routing', + 'Routing', + [], + false, + false, + 'packages/core/src/routing.ts:RoutingCommandAction:22,25', + ], + [ + 'killswitch-status', + 'killswitch', + 'Limits', + [], + false, + false, + 'packages/core/src/killswitch.ts:KillswitchCommandAction:10,22', + ], + [ + 'cache-status', + 'cache', + 'Cache', + [], + false, + false, + 'packages/core/src/cache1h.ts:Cache1hCommandAction:16,20', + ], + [ + 'cachekeep-status', + 'cachekeep', + 'Cache', + [], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:33,37', + ], + [ + 'dump-status', + 'dump', + 'Diagnostics', + [], + false, + false, + 'packages/core/src/dump.ts:DumpCommandAction', + ], + [ + 'logging-status', + 'logging', + 'Diagnostics', + [], + false, + false, + 'packages/core/src/logging.ts:LoggingCommandAction:22,24', + ], + [ + 'fast-status', + 'fast', + 'Extras', + [], + false, + false, + 'packages/core/src/fast.ts:FastModeCommandAction', + ], + [ + 'prime-status', + 'prime', + 'Extras', + [], + false, + false, + 'packages/core/src/prime.ts:PrimeCommandAction', + ], + ], + families: [ + 'account', + 'quota', + 'routing', + 'killswitch', + 'cache', + 'cachekeep', + 'dump', + 'logging', + 'fast', + 'prime', + 'start', + ], + destructive: ['remove', 'enrollment-reset'], + interactive: [ + 'add-apikey', + 'add-oauth-start', + 'add-oauth-finish', + 'custody-mode', + ], + }, + pi: { + actions: [ + [ + 'enable', + 'account', + 'Accounts', + ['id:account-id!'], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'disable', + 'account', + 'Accounts', + ['id:account-id!'], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'remove', + 'account', + 'Accounts', + ['id:account-id!'], + true, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'move-up', + 'account', + 'Accounts', + ['id:account-id!'], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'move-down', + 'account', + 'Accounts', + ['id:account-id!'], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'reset-backoff', + 'account', + 'Accounts', + [], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'enrollment-reset', + 'account', + 'Accounts', + [], + true, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'add-apikey', + 'account', + 'Accounts', + [ + 'apiKey:text!#', + 'label:text?', + 'baseURL:text?', + 'authHeader:choice?=authorization-bearer|x-api-key', + ], + false, + true, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'add-oauth-start', + 'account', + 'Accounts', + [], + false, + true, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'add-oauth-finish', + 'account', + 'Accounts', + ['code:text!#', 'label:text?'], + false, + true, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'custody-mode', + 'account', + 'Accounts', + ['mode:choice!=local|claustrum'], + false, + true, + 'packages/core/src/commands/account.ts:AccountCommandAction:13-32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'routing-mode', + 'routing', + 'Routing', + ['mode:choice!=main-first|fallback-first|sticky-balanced'], + false, + false, + 'packages/core/src/routing.ts:RoutingCommandAction:21-25; packages/pi/src/commands.ts:claude-routing:231-256', + ], + [ + 'routing-reset', + 'routing', + 'Routing', + [], + false, + false, + 'packages/core/src/routing.ts:RoutingCommandAction:21-25; packages/pi/src/commands.ts:claude-routing:231-256', + ], + [ + 'cache-on', + 'cache', + 'Cache', + [], + false, + false, + 'packages/core/src/cache1h.ts:Cache1hCommandAction:15-20; packages/pi/src/commands.ts:claude-cache:83-118', + ], + [ + 'cache-off', + 'cache', + 'Cache', + [], + false, + false, + 'packages/core/src/cache1h.ts:Cache1hCommandAction:15-20; packages/pi/src/commands.ts:claude-cache:83-118', + ], + [ + 'cache-mode', + 'cache', + 'Cache', + ['mode:choice!=explicit|automatic|hybrid'], + false, + false, + 'packages/core/src/cache1h.ts:Cache1hCommandAction:15-20; packages/pi/src/commands.ts:claude-cache:83-118', + ], + [ + 'cachekeep-always', + 'cachekeep', + 'Cache', + [], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:32-38; packages/pi/src/commands.ts:claude-cachekeep:120-163', + ], + [ + 'cachekeep-off', + 'cachekeep', + 'Cache', + [], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:32-38; packages/pi/src/commands.ts:claude-cachekeep:120-163', + ], + [ + 'cachekeep-window', + 'cachekeep', + 'Cache', + ['startHour:number!', 'endHour:number!'], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:32-38; packages/pi/src/commands.ts:claude-cachekeep:120-163', + ], + [ + 'cachekeep-subagents', + 'cachekeep', + 'Cache', + ['enabled:choice!=on|off'], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:32-38; packages/pi/src/commands.ts:claude-cachekeep:120-163', + ], + [ + 'dump-on', + 'dump', + 'Diagnostics', + [], + false, + false, + 'packages/core/src/dump.ts:DumpCommandAction; packages/pi/src/commands.ts:claude-dump:165-196', + ], + [ + 'dump-off', + 'dump', + 'Diagnostics', + [], + false, + false, + 'packages/core/src/dump.ts:DumpCommandAction; packages/pi/src/commands.ts:claude-dump:165-196', + ], + [ + 'logging-level', + 'logging', + 'Diagnostics', + ['level:choice!=error|warn|info|debug|trace'], + false, + false, + 'packages/core/src/logging.ts:LoggingCommandAction:21-24; packages/pi/src/commands.ts:claude-logging:368-385', + ], + [ + 'fast-on', + 'fast', + 'Extras', + [], + false, + false, + 'packages/core/src/fast.ts:FastModeCommandAction; packages/pi/src/commands.ts:claude-fast:198-229', + ], + [ + 'fast-off', + 'fast', + 'Extras', + [], + false, + false, + 'packages/core/src/fast.ts:FastModeCommandAction; packages/pi/src/commands.ts:claude-fast:198-229', + ], + ], + statuses: [ + [ + 'account-status', + 'account', + 'Accounts', + [], + false, + false, + 'packages/core/src/commands/account.ts:AccountCommandAction:14,32; packages/pi/src/commands.ts:claude-account:272-366', + ], + [ + 'quota-status', + 'quota', + 'Quota', + [], + false, + false, + 'packages/pi/src/commands.ts:claude-quota:258-270', + ], + [ + 'routing-status', + 'routing', + 'Routing', + [], + false, + false, + 'packages/core/src/routing.ts:RoutingCommandAction:22,25; packages/pi/src/commands.ts:claude-routing:231-256', + ], + [ + 'cache-status', + 'cache', + 'Cache', + [], + false, + false, + 'packages/core/src/cache1h.ts:Cache1hCommandAction:16,20; packages/pi/src/commands.ts:claude-cache:83-118', + ], + [ + 'cachekeep-status', + 'cachekeep', + 'Cache', + [], + false, + false, + 'packages/core/src/cachekeep.ts:CacheKeepCommandAction:33,37; packages/pi/src/commands.ts:claude-cachekeep:120-163', + ], + [ + 'dump-status', + 'dump', + 'Diagnostics', + [], + false, + false, + 'packages/core/src/dump.ts:DumpCommandAction; packages/pi/src/commands.ts:claude-dump:165-196', + ], + [ + 'logging-status', + 'logging', + 'Diagnostics', + [], + false, + false, + 'packages/core/src/logging.ts:LoggingCommandAction:22,24; packages/pi/src/commands.ts:claude-logging:368-385', + ], + [ + 'fast-status', + 'fast', + 'Extras', + [], + false, + false, + 'packages/core/src/fast.ts:FastModeCommandAction; packages/pi/src/commands.ts:claude-fast:198-229', + ], + [ + 'prime-status', + 'prime', + 'Extras', + [], + false, + false, + 'packages/pi/src/commands.ts:claude-prime:387-406', + ], + ], + families: [ + 'account', + 'quota', + 'routing', + 'cache', + 'cachekeep', + 'dump', + 'logging', + 'fast', + 'prime', + ], + destructive: ['remove', 'enrollment-reset'], + interactive: [ + 'add-apikey', + 'add-oauth-start', + 'add-oauth-finish', + 'custody-mode', + ], + }, +} + +function allActions(host: NativeMenuHost) { + return getNativeMenuModel(host).groups.flatMap((group) => group.actions) +} + +function parameterSignature(parameter: NativeMenuParameter): string { + const base = `${parameter.id}:${parameter.kind}${parameter.required ? '!' : '?'}` + if (parameter.kind === 'text') + return `${base}${parameter.protectedEntry ? '#' : ''}` + if (parameter.kind === 'choice') + return `${base}=${parameter.choices.join('|')}` + if (parameter.kind === 'limit-entries') + return `${base}=${parameter.fields.join('|')}` + return base +} + +function expectKeys(value: object, keys: string[]) { + expect(Object.keys(value).sort()).toEqual(keys.sort()) +} + +test('native group order is Accounts, Quota, Routing, Limits, Cache, Diagnostics, Extras', () => { + const order = [ + 'Accounts', + 'Quota', + 'Routing', + 'Limits', + 'Cache', + 'Diagnostics', + 'Extras', + ] as const + expect(NATIVE_MENU_GROUP_IDS).toEqual(order) + for (const host of ['opencode', 'pi'] as const) { + expect(getNativeMenuModel(host).groups.map((group) => group.id)).toEqual([ + ...order, + ]) + } + expect(producerParity).toEqual([true, true, true, true, true, true]) +}) + +for (const host of ['opencode', 'pi'] as const) { + test(`${host} preserves the literal pinned action and status inventory`, () => { + const model = getNativeMenuModel(host) + const actual: (string | boolean | readonly string[])[][] = model.groups + .flatMap((group) => group.actions) + .map((action) => [ + action.id, + action.command, + action.group, + action.parameters.map(parameterSignature), + action.destructive, + action.interactive, + ]) + expect(actual).toEqual(fixture[host].actions.map((row) => row.slice(0, 6))) + const statuses = model.groups.flatMap((group) => group.statuses) + const actualStatuses: (string | boolean | readonly string[])[][] = + statuses.map((status) => [ + status.id, + status.command, + status.group, + [], + false, + false, + ]) + expect(actualStatuses).toEqual( + fixture[host].statuses.map((row) => row.slice(0, 6)), + ) + expect(actual).toHaveLength(host === 'opencode' ? 32 : 25) + const families: string[] = [ + ...new Set( + [...allActions(host), ...statuses].map( + (descriptor) => descriptor.command, + ), + ), + ] + expect(families.sort()).toEqual([...fixture[host].families].sort()) + expect(families).toHaveLength(host === 'opencode' ? 11 : 9) + for (const row of [...fixture[host].actions, ...fixture[host].statuses]) { + expect(row[6]).toMatch(/packages\/.+\.ts:[A-Za-z]/) + } + for (const group of model.groups) { + for (const descriptor of [...group.actions, ...group.statuses]) + expect(descriptor.group).toBe(group.id) + } + }) +} + +test('Pi Prime and quota remain status-only with no Limits or start actions', () => { + const pi = getNativeMenuModel('pi') + expect(pi.groups.find((group) => group.id === 'Limits')).toEqual({ + id: 'Limits', + actions: [], + statuses: [], + }) + expect( + allActions('pi').filter((action) => + ['prime', 'quota', 'killswitch', 'start'].includes(action.command), + ), + ).toEqual([]) + expect( + pi.groups + .flatMap((group) => group.statuses) + .filter((status) => ['prime', 'quota'].includes(status.command)) + .map((status) => status.id), + ).toEqual(['quota-status', 'prime-status']) + expect( + allActions('opencode') + .filter((action) => + ['quota', 'killswitch', 'prime', 'start'].includes(action.command), + ) + .map((action) => action.id), + ).toEqual([ + 'quota-refresh', + 'killswitch-on', + 'killswitch-off', + 'killswitch-set', + 'prime-on', + 'prime-off', + 'start-fire', + ]) +}) + +test('destructive actions require non-empty confirmation for removal and enrollment reset', () => { + for (const host of ['opencode', 'pi'] as const) { + const actions = allActions(host) + const destructiveIds: string[] = actions + .filter((action) => action.destructive) + .map((action) => action.id) + expect(destructiveIds).toEqual([...fixture[host].destructive]) + for (const id of fixture[host].destructive) { + const action = actions.find((candidate) => candidate.id === id) + expect(action?.destructive).toBe(true) + expect(typeof action?.confirm).toBe('string') + expect(action?.confirm?.trim().length).toBeGreaterThan(0) + if (id === 'enrollment-reset') { + expect(action?.confirm).toBe( + 'Clear denied or blocked enrollment state for this host?', + ) + } + } + } +}) + +test('interactive actions require closed headless refusals and protected API-key entry', () => { + for (const host of ['opencode', 'pi'] as const) { + const actions = allActions(host) + const interactiveIds: string[] = actions + .filter((action) => action.interactive) + .map((action) => action.id) + expect(interactiveIds).toEqual([...fixture[host].interactive]) + for (const id of fixture[host].interactive) { + const action = actions.find((candidate) => candidate.id === id) + expect(action?.interactive).toBe(true) + expect(action?.headlessRefusal).toBe( + id === 'custody-mode' ? 'offline-required' : 'interactive-required', + ) + } + expect( + actions.find((action) => action.id === 'add-apikey')?.parameters[0], + ).toEqual({ + id: 'apiKey', + kind: 'text', + required: true, + protectedEntry: true, + }) + } +}) + +test('custody-mode is typed offline setup guidance, never an executable authority change', () => { + for (const host of ['opencode', 'pi'] as const) { + const action = allActions(host).find( + (candidate) => candidate.id === 'custody-mode', + ) + expect(action?.kind).toBe('setup-guidance') + if (action?.kind !== 'setup-guidance') + throw new Error('Missing custody guidance') + expect(action.command).toBe('account') + expect(action.group).toBe('Accounts') + expect(action.interactive).toBe(true) + expect(action.offlineRequired).toBe(true) + expect(action.headlessRefusal).toBe('offline-required') + expect(action.parameters).toEqual([ + { + id: 'mode', + kind: 'choice', + required: true, + choices: ['local', 'claustrum'], + }, + ]) + expect(action.setupInstructions).toEqual([ + 'Stop all selected hosts before changing credential authority.', + 'Use the explicit offline setup flow for the selected local or claustrum mode.', + 'This menu only provides guidance; it does not change settings, propose enrollment, or report setup success.', + ]) + } +}) + +test('every descriptor has only closed public metadata and parameter identifiers', () => { + for (const host of ['opencode', 'pi'] as const) { + const model = getNativeMenuModel(host) + expectKeys(model, ['host', 'groups']) + for (const group of model.groups) { + expectKeys(group, ['id', 'actions', 'statuses']) + for (const status of group.statuses) { + expectKeys(status, ['kind', 'id', 'command', 'group', 'label']) + expect(status.kind).toBe('status') + expect(typeof status.label).toBe('string') + } + for (const action of group.actions) { + const keys = [ + 'id', + 'kind', + 'command', + 'group', + 'label', + 'parameters', + 'destructive', + 'interactive', + ] + if (action.destructive) keys.push('confirm') + if (action.interactive) keys.push('headlessRefusal') + if (action.kind === 'setup-guidance') + keys.push('offlineRequired', 'setupInstructions') + expectKeys(action, keys) + expect(typeof action.label).toBe('string') + expect(action.label.trim().length).toBeGreaterThan(0) + expect(['action', 'setup-guidance']).toContain(action.kind) + for (const parameter of action.parameters) { + const parameterKeys = ['id', 'kind', 'required'] + if (parameter.kind === 'text') parameterKeys.push('protectedEntry') + if (parameter.kind === 'choice') parameterKeys.push('choices') + if (parameter.kind === 'limit-entries') parameterKeys.push('fields') + expectKeys(parameter, parameterKeys) + expect(typeof parameter.required).toBe('boolean') + expect([ + 'account-id', + 'text', + 'choice', + 'number', + 'limit-entries', + ]).toContain(parameter.kind) + if (parameter.kind === 'text') + expect(typeof parameter.protectedEntry).toBe('boolean') + if (parameter.kind === 'choice') { + expect(parameter.choices.length).toBeGreaterThan(0) + for (const choice of parameter.choices) + expect(typeof choice).toBe('string') + } + if (parameter.kind === 'limit-entries') + expect(parameter.fields).toEqual(['account', 'fh', 'sd', 'scoped?']) + } + } + } + // Exact descriptor keys permit public metadata and parameter names only, + // never supplied input values, account/credential objects, or callbacks. + expect(JSON.stringify(model)).not.toMatch(/claude-/) + } +}) + +function expectDeepFrozen(value: unknown) { + if (!value || typeof value !== 'object') return + expect(Object.isFrozen(value)).toBe(true) + for (const child of Object.values(value)) expectDeepFrozen(child) +} + +test('output is deterministic and deeply frozen including nested parameter domains', () => { + expectDeepFrozen(NATIVE_MENU_GROUP_IDS) + for (const host of ['opencode', 'pi'] as const) { + const model = getNativeMenuModel(host) + const before = JSON.stringify(model) + expectDeepFrozen(model) + expect(getNativeMenuModel(host)).toBe(model) + expect(Reflect.set(model, 'host', 'other')).toBe(false) + expect( + Reflect.set(model.groups, model.groups.length, { id: 'Accounts' }), + ).toBe(false) + const action = model.groups[0]!.actions[0]! + expect(Reflect.set(action, 'label', 'changed')).toBe(false) + expect(Reflect.set(action.parameters[0]!, 'id', 'changed')).toBe(false) + expect(JSON.stringify(getNativeMenuModel(host))).toBe(before) + expect(JSON.parse(before)).toEqual(model) + } +}) + +test('emitted native menu declaration has no producer or internal-types references', async () => { + // Parse only this module's emitted declaration, never the barrel or vendored graph. + // It must contain no common-auth reference or internal-types edge. + const declaration = await readFile( + new URL('../../dist/native-menu-model.d.ts', import.meta.url), + 'utf8', + ) + expect(declaration).toContain('export declare function getNativeMenuModel') + const forbidden = moduleReferences(declaration).filter( + ({ specifier }) => + specifier === '@cortexkit/common-auth' || + specifier.startsWith('@cortexkit/common-auth/') || + /(^|\/)internal-types(\/|$)/.test(specifier.replaceAll('\\', '/')), + ) + expect(forbidden.map(({ specifier }) => specifier)).toEqual([]) +}) From 0b30f26ff46e8550ed397ac1c88cfef15b093e3f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:38:44 +0200 Subject: [PATCH 031/169] mason: add native scoped custody adapter Compose common-auth scoped custody behind Core-owned types, lazy owned IPC, OAuth-only authorization, original-receipt reporting and strictly newer retries. Redact consumer/vault errors and logger data; recover on a later operation after a rejected connection without retrying failed dispatches. Cover 12 historical custody controls plus native lifecycle, host token paths, 19 error kinds and module declaration closure. The thirteenth historical imported-main lineage control remains migration work. Six independent mutations reddened their intended assertions and restored exactly. Parent source/comment review approved the patch; the only later authorized delta sorts two adjacent test imports for the pre-commit hook. --- packages/core/src/native-custody.ts | 399 ++++++ .../core/src/tests/native-custody.test.ts | 1169 +++++++++++++++++ 2 files changed, 1568 insertions(+) create mode 100644 packages/core/src/native-custody.ts create mode 100644 packages/core/src/tests/native-custody.test.ts diff --git a/packages/core/src/native-custody.ts b/packages/core/src/native-custody.ts new file mode 100644 index 000000000..acaaf2292 --- /dev/null +++ b/packages/core/src/native-custody.ts @@ -0,0 +1,399 @@ +import { + ClaustrumConsumerError, + ClaustrumScopedCustody, +} from '@cortexkit/common-auth/claustrum' +import { getHostClaustrumEnrollmentPaths } from './claustrum-enrollment.ts' + +/** Claustrum IPC operations for listing accounts, reading tokens, reporting rejection, and closing the connection. */ +export interface NativeCustodyClient { + listScoped: (enrollmentToken?: string) => Promise<{ + readonly view: string + readonly rows: readonly { + readonly id: string + readonly categories: readonly string[] + readonly credentialType: string + readonly serves: readonly string[] + readonly providerIds: readonly string[] + readonly authMethod?: 'apikey' | 'chatgpt' | 'antigravity' | 'oauth' + readonly refreshAdapter?: string + readonly state: string + readonly recordVersion: number + readonly operations: readonly string[] + readonly createdAtMs: number | null + readonly accountId?: string + readonly email?: string + readonly orgName?: string + }[] + }> + getScoped: (input: { + credentialId: string + enrollmentToken?: string + minTtlMs?: number + }) => Promise<{ + material: string + recordVersion: number + expiresAtMs: number | null + credentialId?: string + projectId?: string + accountId?: string + email?: string + orgName?: string + }> + reportAuthFailureScoped: (input: { + credentialId: string + enrollmentToken?: string + providerStatus: number + recordVersion: number + reporterSource: NativeCustodyReporterSource + }) => Promise + close: () => void +} + +export type NativeCustodyReporterSource = + | 'direct' + | 'relay_status_field' + | 'relay_message_parse' + +export interface NativeCustodyLogger { + warn: (message: string, data?: unknown) => void + debug: (message: string, data?: unknown) => void +} + +export interface NativeCustodyOptions { + host: 'opencode' | 'pi' + connect: () => Promise + readToken?: () => Promise<{ token: string; token_generation: number }> + env?: NodeJS.ProcessEnv + now?: () => number + logger?: NativeCustodyLogger +} + +export interface NativeCustodyIdentity { + readonly credentialId: string + readonly accountIdentity: string +} + +/** A common-auth receipt: retain its original object identity and non-enumerable accessToken. */ +export interface NativeCustodyReceipt { + readonly credentialId: string + readonly credentialType: 'oauth' + readonly accountIdentity?: string + readonly accountIdentitySource: 'asserted' | 'parsed' | 'expected' | 'none' + readonly expectedAccountIdentity?: string + readonly assertedCredentialId?: string + readonly assertedAccountIdentity?: string + readonly recordVersion: number + readonly expiresAtMs: number | null + readonly accessToken: string +} + +export interface NativeCustodyInventory { + readonly view: string + readonly credentials: readonly { + readonly credentialId: string + readonly credentialType: 'oauth' | 'api_key' + readonly accountIdentity?: string + readonly state: string + readonly email?: string + readonly orgName?: string + }[] + readonly skipped: readonly { + readonly credentialId?: string + readonly reason: + | 'empty credential id' + | 'duplicate credential id' + | 'blank account identity' + | 'empty state' + }[] +} + +export type NativeCustodyErrorCode = + | 'closed' + | 'not-enrolled' + | 'invalid-token' + | 'unavailable' + | 'identity-changed' + | 'identity-unasserted' + | 'insufficient-validity' + | 'invalid-material' + | 'not-active' + | 'route-unavailable' + | 'route-declined' + | 'no-receipt' + | 'unsafe-file' + | 'invalid-state' + | 'wrong-consumer' + | 'roster-busy' + | 'host-slot-login' + | 'host-slot-placeholder' + | 'placeholder-refresh' + +function nativeCode(value: unknown): NativeCustodyErrorCode { + switch (value) { + case 'closed': + case 'not-enrolled': + case 'invalid-token': + case 'unavailable': + case 'identity-changed': + case 'identity-unasserted': + case 'insufficient-validity': + case 'invalid-material': + case 'not-active': + case 'route-unavailable': + case 'route-declined': + case 'no-receipt': + case 'unsafe-file': + case 'invalid-state': + case 'wrong-consumer': + case 'roster-busy': + case 'host-slot-login': + case 'host-slot-placeholder': + case 'placeholder-refresh': + return value + default: + return 'unavailable' + } +} + +export class NativeCustodyError extends Error { + readonly code: NativeCustodyErrorCode + + constructor(code: NativeCustodyErrorCode) { + const safeCode = nativeCode(code) + super(`Native custody refused: ${safeCode}`) + this.name = 'NativeCustodyError' + this.code = safeCode + } +} + +export type NativeCustodyRetry = + | { retry: true; receipt: NativeCustodyReceipt } + | { + retry: false + reason: + | 'reauthorize-failed' + | 'credential-changed' + | 'account-changed' + | 'version-not-newer' + code?: NativeCustodyErrorCode + } + +export interface NativeCustody { + discover: (signal?: AbortSignal) => Promise + authorize: ( + identity: NativeCustodyIdentity, + signal?: AbortSignal, + ) => Promise + reportFailure: ( + receipt: NativeCustodyReceipt, + status: number, + reporterSource: NativeCustodyReporterSource, + ) => Promise + prepareRetry: ( + served: NativeCustodyReceipt, + signal?: AbortSignal, + ) => Promise + close: () => void +} + +/** Share a lazy Claustrum connection; each authorize call reads a fresh enrollment token and OAuth receipt. */ +export function createNativeCustody( + options: NativeCustodyOptions, +): NativeCustody { + const env = { ...(options.env ?? process.env) } + const connect = options.connect + const logger = options.logger + let closed = false + let client: NativeCustodyClient | undefined + let connecting: Promise | undefined + const onClose = new Set<() => void>() + // Weak membership rejects copied or foreign receipts without retaining them. + // Common-auth keeps the original receipt's send-time enrollment token private. + const receipts = new WeakSet() + + function check(signal?: AbortSignal): void { + if (closed) throw new NativeCustodyError('closed') + signal?.throwIfAborted() + } + + function connection(): Promise { + check() + connecting ??= Promise.resolve() + .then(connect) + .then((owned) => { + if (closed) { + owned.close() + throw new NativeCustodyError('closed') + } + client = owned + return owned + }) + .catch((error: unknown) => { + // A later operation can recover from a failed connection. Do not retry + // the failed operation, or discard a successfully connected owned client. + connecting = undefined + throw error + }) + return connecting + } + + async function scoped( + operation: (owned: NativeCustodyClient) => Promise, + ): Promise { + const owned = await connection() + check() + return operation(owned) + } + + const primitive = new ClaustrumScopedCustody({ + client: { + listScoped: (token) => scoped((owned) => owned.listScoped(token)), + getScoped: (input) => scoped((owned) => owned.getScoped(input)), + reportAuthFailureScoped: (input) => + scoped((owned) => owned.reportAuthFailureScoped(input)), + close: () => client?.close(), + }, + family: { + refreshAdapter: 'anthropic', + category: 'anthropic-native', + apiKeys: false, + }, + requireAssertion: true, + ...(options.readToken + ? { readToken: options.readToken } + : { + tokenPath: getHostClaustrumEnrollmentPaths(options.host, env) + .tokenPath, + }), + now: options.now, + // Supply logger callbacks so common-auth cannot select its default logger. + // Only the fixed messages below reach the caller; record details never do. + logger: { + warn: () => logger?.warn('Native custody inventory record skipped'), + debug: () => logger?.debug('Native custody failure reported'), + }, + }) + + async function run( + operation: () => Promise, + signal?: AbortSignal, + ): Promise { + check(signal) + let stop: () => void = () => {} + let abort: () => void = () => {} + const interrupted = new Promise((_resolve, reject) => { + stop = () => reject(new NativeCustodyError('closed')) + abort = () => reject(signal?.reason) + onClose.add(stop) + signal?.addEventListener('abort', abort, { once: true }) + }) + try { + const result = await Promise.race([operation(), interrupted]) + check(signal) + return result + } catch (error) { + check(signal) + // No vault error fields have an allowlisted native projection here. Discard + // their code/message/cause/data; only common-auth consumer kinds are mapped. + throw new NativeCustodyError( + error instanceof ClaustrumConsumerError + ? nativeCode(error.kind) + : error instanceof NativeCustodyError + ? nativeCode(error.code) + : 'unavailable', + ) + } finally { + onClose.delete(stop) + signal?.removeEventListener('abort', abort) + } + } + + function requireReceipt(receipt: NativeCustodyReceipt): void { + if (!receipts.has(receipt)) throw new NativeCustodyError('no-receipt') + } + + const custody: NativeCustody = { + discover: (signal) => run(() => primitive.discover(signal), signal), + authorize: (identity, signal) => + run(async () => { + // Capture only route fields before awaiting common-auth authorization. + // Caller extras cannot override OAuth, and later mutation cannot move the route. + const { credentialId, accountIdentity } = identity + if (typeof credentialId !== 'string') + throw new NativeCustodyError('unavailable') + if ( + accountIdentity !== undefined && + typeof accountIdentity !== 'string' + ) { + throw new NativeCustodyError('unavailable') + } + if (!accountIdentity?.trim()) + throw new NativeCustodyError('identity-unasserted') + const receipt = await primitive.authorize( + { + credentialId, + accountIdentity, + credentialType: 'oauth', + }, + signal, + ) + check(signal) + receipts.add(receipt) + // Fixed OAuth input narrows common-auth's two-credential-type declaration. + // Return the original object, preserving its non-enumerable accessToken. + return receipt as NativeCustodyReceipt + }, signal), + reportFailure: (receipt, status, reporterSource) => + run(async () => { + requireReceipt(receipt) + await primitive.reportFailure(receipt, status, reporterSource) + }), + prepareRetry: async (served, signal) => { + check(signal) + requireReceipt(served) + if (served.assertedAccountIdentity === undefined) { + throw new NativeCustodyError('no-receipt') + } + let current: NativeCustodyReceipt + try { + current = await custody.authorize( + { + credentialId: served.credentialId, + accountIdentity: served.assertedAccountIdentity, + }, + signal, + ) + } catch (error) { + check(signal) + if (error instanceof NativeCustodyError && error.code === 'closed') + throw error + return { + retry: false, + reason: 'reauthorize-failed', + code: + error instanceof NativeCustodyError + ? nativeCode(error.code) + : 'unavailable', + } + } + check(signal) + if (current.credentialId !== served.credentialId) { + return { retry: false, reason: 'credential-changed' } + } + if (current.assertedAccountIdentity !== served.assertedAccountIdentity) { + return { retry: false, reason: 'account-changed' } + } + if (!(current.recordVersion > served.recordVersion)) { + return { retry: false, reason: 'version-not-newer' } + } + return { retry: true, receipt: current } + }, + close: () => { + if (closed) return + closed = true + for (const stop of onClose) stop() + primitive.close() + }, + } + return custody +} diff --git a/packages/core/src/tests/native-custody.test.ts b/packages/core/src/tests/native-custody.test.ts new file mode 100644 index 000000000..3ae49a516 --- /dev/null +++ b/packages/core/src/tests/native-custody.test.ts @@ -0,0 +1,1169 @@ +import { describe, expect, spyOn, test } from 'bun:test' +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { dirname, join } from 'node:path' +import type { ClaustrumClient } from '@cortexkit/claustrum-client' +import { + ClaustrumConsumerError, + type ClaustrumConsumerFailureKind, + ClaustrumCredentialError, + type ClaustrumScopedAttempt, + ClaustrumScopedCustody, + type VaultInventory, +} from '@cortexkit/common-auth/claustrum' +import { + createLogger, + initLogger, + resetLoggerForTest, +} from '@cortexkit/common-auth/logger' +import { moduleReferences } from '../../scripts/check-native-type-closure.ts' +import { getHostClaustrumEnrollmentPaths } from '../claustrum-enrollment.ts' +import { + createNativeCustody, + type NativeCustodyClient, + NativeCustodyError, + type NativeCustodyErrorCode, + type NativeCustodyInventory, + type NativeCustodyOptions, + type NativeCustodyReceipt, +} from '../native-custody.ts' + +type Assert = T +type PublishedClient = Pick< + ClaustrumClient, + 'listScoped' | 'getScoped' | 'reportAuthFailureScoped' | 'close' +> +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false +// Compare Core's structural types with common-auth and the published client. +// Keep these dependencies in tests, outside Core's public type declarations. +export type ClientParity = Assert> +export type ErrorParity = Assert< + Same +> +export type InventoryParity = Assert< + Same +> +export type ReceiptParity = Assert< + NativeCustodyReceipt extends ClaustrumScopedAttempt ? true : false +> + +const identity = { + credentialId: 'oauth:anthropic:work', + accountIdentity: 'account-1', +} +const enrollmentToken = '01'.repeat(32) +const nextToken = '02'.repeat(32) +const bearer = 'synthetic-access' +type Material = Awaited> +type Row = Awaited< + ReturnType +>['rows'][number] +const row: Row = { + id: identity.credentialId, + accountId: identity.accountIdentity, + credentialType: 'oauth', + categories: ['anthropic-native'], + refreshAdapter: 'anthropic', + serves: ['anthropic'], + providerIds: [], + operations: ['read'], + state: 'active', + recordVersion: 7, + createdAtMs: null, +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function fixture(options: Partial = {}) { + const gets: Parameters[0][] = [] + const reports: Parameters< + NativeCustodyClient['reportAuthFailureScoped'] + >[0][] = [] + const lists: (string | undefined)[] = [] + const counts = { connects: 0, reads: 0, closes: 0 } + let token = { token: enrollmentToken, token_generation: 1 } + let rows: readonly Row[] = [row] + let material: Material = { + credentialId: identity.credentialId, + accountId: identity.accountIdentity, + material: bearer, + recordVersion: 7, + expiresAtMs: 1_000_000, + } + const client: NativeCustodyClient = { + listScoped: async (token) => { + lists.push(token) + return { view: 'view-1', rows } + }, + getScoped: async (input) => { + gets.push(input) + return material + }, + reportAuthFailureScoped: async (input) => { + reports.push(input) + }, + close: () => { + counts.closes++ + }, + } + const custody = createNativeCustody({ + host: 'opencode', + connect: async () => { + counts.connects++ + return client + }, + readToken: async () => { + counts.reads++ + return token + }, + now: () => 1_000, + ...options, + }) + return { + custody, + client, + gets, + reports, + lists, + counts, + token: (value: typeof token) => { + token = value + }, + rows: (value: readonly Row[]) => { + rows = value + }, + material: (patch: Partial) => { + material = { ...material, ...patch } + }, + } +} + +async function refusal( + pending: Promise, + code: NativeCustodyErrorCode, +) { + const error = await pending.then( + () => undefined, + (error: unknown) => error, + ) + expect(error).toBeInstanceOf(NativeCustodyError) + expect((error as NativeCustodyError).code).toBe(code) + expect((error as NativeCustodyError).message).toBe( + `Native custody refused: ${code}`, + ) + return error as NativeCustodyError +} + +describe('historical custody controls (12 B1 + 1 migration)', () => { + test('discovery inventory shape', async () => { + const f = fixture() + f.rows([ + row, + { + ...row, + id: 'arbitrary-label', + accountId: undefined, + state: 'needs_reauth', + }, + { ...row, id: 'wrong-category', categories: ['other'] }, + { ...row, id: 'proxy', refreshAdapter: 'cursor' }, + { + ...row, + id: 'key', + credentialType: 'api_key', + refreshAdapter: undefined, + }, + { ...row, id: 'no-read', operations: ['sign'] }, + { ...row, id: 'blank-account', accountId: '' }, + ]) + expect(await f.custody.discover()).toEqual({ + view: 'view-1', + credentials: [ + { + credentialId: identity.credentialId, + credentialType: 'oauth', + accountIdentity: 'account-1', + state: 'active', + }, + { + credentialId: 'arbitrary-label', + credentialType: 'oauth', + state: 'needs_reauth', + }, + ], + skipped: [ + { credentialId: 'blank-account', reason: 'blank account identity' }, + ], + }) + expect(f.lists).toEqual([enrollmentToken]) + expect(f.gets).toHaveLength(0) + }) + + test('actual served identity assertion', async () => { + const f = fixture() + expect(await f.custody.authorize(identity)).toMatchObject({ + ...identity, + credentialType: 'oauth', + accountIdentitySource: 'asserted', + expectedAccountIdentity: identity.accountIdentity, + assertedCredentialId: identity.credentialId, + assertedAccountIdentity: identity.accountIdentity, + recordVersion: 7, + expiresAtMs: 1_000_000, + }) + }) + + test('five-minute margin reaching getScoped', async () => { + const f = fixture() + await f.custody.authorize(identity) + expect(f.gets).toEqual([ + { + credentialId: identity.credentialId, + enrollmentToken, + minTtlMs: 300_000, + }, + ]) + }) + + test('bearer hidden from receipt JSON and spread', async () => { + const f = fixture() + const receipt = await f.custody.authorize(identity) + expect(receipt.accessToken).toBe(bearer) + expect(Object.getOwnPropertyDescriptor(receipt, 'accessToken')).toEqual({ + value: bearer, + enumerable: false, + writable: false, + configurable: false, + }) + expect(Object.isFrozen(receipt)).toBe(true) + expect(JSON.stringify(receipt)).not.toContain(bearer) + expect(JSON.stringify(receipt)).not.toContain(enrollmentToken) + expect({ ...receipt }).not.toHaveProperty('accessToken') + }) + + test('original failure version', async () => { + const f = fixture() + const initial = await f.custody.authorize(identity) + f.token({ token: nextToken, token_generation: 2 }) + f.material({ recordVersion: 8 }) + const retried = await f.custody.prepareRetry(initial) + expect(retried.retry).toBe(true) + if (!retried.retry) throw new Error('Expected a newer receipt') + for (const status of [403, 429, 500]) + await f.custody.reportFailure(retried.receipt, status, 'direct') + expect(f.reports).toHaveLength(0) + f.material({ recordVersion: 9 }) + f.token({ token: enrollmentToken, token_generation: 3 }) + await Promise.all([ + f.custody.reportFailure(retried.receipt, 401, 'relay_status_field'), + f.custody.reportFailure(retried.receipt, 401, 'relay_status_field'), + ]) + expect(f.reports).toEqual([ + { + credentialId: identity.credentialId, + enrollmentToken: nextToken, + providerStatus: 401, + recordVersion: 8, + reporterSource: 'relay_status_field', + }, + ]) + }) + + test('spread or copy cannot report', async () => { + const f = fixture() + const receipt = await f.custody.authorize(identity) + const foreign = fixture() + for (const copy of [ + { ...receipt }, + Object.assign({}, receipt), + structuredClone(receipt), + ]) { + await refusal(f.custody.reportFailure(copy, 401, 'direct'), 'no-receipt') + } + await refusal( + foreign.custody.reportFailure(receipt, 401, 'direct'), + 'no-receipt', + ) + expect(f.reports).toHaveLength(0) + expect(foreign.counts.connects).toBe(0) + expect(foreign.reports).toHaveLength(0) + }) + + test('unknown expected identity refuses before getScoped', async () => { + const f = fixture() + for (const accountIdentity of [undefined, '', ' ']) { + await refusal( + f.custody.authorize({ + ...identity, + accountIdentity, + } as typeof identity), + 'identity-unasserted', + ) + } + expect(f.gets).toHaveLength(0) + expect(f.counts).toEqual({ connects: 0, reads: 0, closes: 0 }) + }) + + test('missing served account assertion', async () => { + const f = fixture() + f.material({ accountId: undefined }) + await refusal(f.custody.authorize(identity), 'identity-unasserted') + expect(f.gets).toHaveLength(1) + expect(f.reports).toHaveLength(0) + }) + + test('missing served credential assertion', async () => { + const f = fixture() + f.material({ credentialId: undefined }) + await refusal(f.custody.authorize(identity), 'identity-unasserted') + expect(f.gets).toHaveLength(1) + }) + + test('wrong served account assertion', async () => { + const f = fixture() + f.material({ accountId: 'other-account' }) + await refusal(f.custody.authorize(identity), 'identity-changed') + expect(f.gets).toHaveLength(1) + }) + + test('wrong served credential assertion', async () => { + const f = fixture() + f.material({ credentialId: 'other-credential' }) + await refusal(f.custody.authorize(identity), 'identity-changed') + expect(f.gets).toHaveLength(1) + }) + + test('insufficient lifetime refusal', async () => { + const f = fixture() + f.material({ expiresAtMs: 300_999 }) + let receipts = 0 + let dispatched = 0 + await refusal( + f.custody.authorize(identity).then(() => { + receipts++ + dispatched++ + }), + 'insufficient-validity', + ) + expect(f.gets).toEqual([ + { + credentialId: identity.credentialId, + enrollmentToken, + minTtlMs: 300_000, + }, + ]) + expect(receipts).toBe(0) + expect(dispatched).toBe(0) + expect(f.reports).toHaveLength(0) + }) +}) + +describe('authorization and receipt ownership', () => { + test('forces OAuth despite malicious extra runtime fields and null expiry', async () => { + const f = fixture() + const malicious = { + ...identity, + credentialType: 'api_key', + minTtlMs: 0, + requireAssertion: false, + } + expect((await f.custody.authorize(malicious)).credentialType).toBe('oauth') + f.material({ expiresAtMs: null }) + await refusal(f.custody.authorize(malicious), 'insufficient-validity') + expect(f.gets).toHaveLength(2) + expect(f.gets.every((input) => input.minTtlMs === 300_000)).toBe(true) + }) + + test('rereads token and does not reuse successful authorization', async () => { + const f = fixture() + expect(f.counts).toEqual({ connects: 0, reads: 0, closes: 0 }) + const first = await f.custody.authorize(identity) + f.token({ token: nextToken, token_generation: 2 }) + const second = await f.custody.authorize(identity) + expect(second).not.toBe(first) + expect(f.counts.reads).toBe(2) + expect(f.gets.map((input) => input.enrollmentToken)).toEqual([ + enrollmentToken, + nextToken, + ]) + f.client.getScoped = async () => { + throw new Error(enrollmentToken) + } + await refusal(f.custody.authorize(identity), 'unavailable') + expect(f.counts.reads).toBe(3) + }) + + test.each([ + { token: '', token_generation: 1 }, + { token: 'bad', token_generation: 1 }, + { token: enrollmentToken, token_generation: 0 }, + { token: enrollmentToken, token_generation: 1.5 }, + { token: enrollmentToken, token_generation: NaN }, + {} as { token: string; token_generation: number }, + ])('invalid enrollment refuses without scoped IPC: %j', async (token) => { + const f = fixture() + f.token(token) + await refusal(f.custody.authorize(identity), 'invalid-token') + expect(f.gets).toHaveLength(0) + expect(f.counts.connects).toBe(0) + }) + + test('returns and reports the exact producer original', async () => { + const originalAuthorize = ClaustrumScopedCustody.prototype.authorize + const originals: ClaustrumScopedAttempt[] = [] + const authorized = spyOn( + ClaustrumScopedCustody.prototype, + 'authorize', + ).mockImplementation(async function ( + this: ClaustrumScopedCustody, + identity, + signal, + ) { + const original = await originalAuthorize.call(this, identity, signal) + originals.push(original) + return original + }) + const originalReport = ClaustrumScopedCustody.prototype.reportFailure + const reported: ClaustrumScopedAttempt[] = [] + const reporting = spyOn( + ClaustrumScopedCustody.prototype, + 'reportFailure', + ).mockImplementation(async function ( + this: ClaustrumScopedCustody, + receipt, + status, + source, + ) { + reported.push(receipt) + return originalReport.call(this, receipt, status, source) + }) + try { + const f = fixture() + const initial = await f.custody.authorize(identity) + f.material({ recordVersion: 8 }) + const retry = await f.custody.prepareRetry(initial) + if (!retry.retry) throw new Error('Expected a retry') + expect(initial as ClaustrumScopedAttempt).toBe(originals[0]!) + expect(retry.receipt as ClaustrumScopedAttempt).toBe(originals[1]!) + await f.custody.reportFailure(retry.receipt, 401, 'direct') + expect(reported).toHaveLength(1) + expect(reported[0]).toBe(originals[1]) + } finally { + authorized.mockRestore() + reporting.mockRestore() + } + }) + + test('unexpected input is unavailable without IPC', async () => { + const f = fixture() + for (const invalid of [ + null, + undefined, + { credentialId: 123, accountIdentity: 'known' }, + { credentialId: 'id', accountIdentity: 123 }, + ]) { + await refusal( + f.custody.authorize(invalid as unknown as typeof identity), + 'unavailable', + ) + } + expect(f.counts.connects).toBe(0) + }) +}) + +describe('bounded non-reporting retry', () => { + test.each([7, 6])( + 'rejects equal or older version %i with version-not-newer', + async (version) => { + const f = fixture() + const served = await f.custody.authorize(identity) + f.material({ recordVersion: version }) + const result = await f.custody.prepareRetry(served) + expect(result).toEqual({ retry: false, reason: 'version-not-newer' }) + expect(f.gets).toHaveLength(2) + expect(f.counts.reads).toBe(2) + expect(f.reports).toHaveLength(0) + expect(JSON.stringify(result)).not.toContain(bearer) + }, + ) + + test('strictly greater version retries once without reporting', async () => { + const f = fixture() + const served = await f.custody.authorize(identity) + f.material({ recordVersion: 8 }) + const result = await f.custody.prepareRetry(served) + expect(result.retry).toBe(true) + if (!result.retry) throw new Error('Expected newer version') + expect(result.receipt.recordVersion).toBe(8) + expect(f.gets).toHaveLength(2) + expect(f.counts.reads).toBe(2) + expect(f.reports).toHaveLength(0) + expect(JSON.stringify(result)).not.toContain(enrollmentToken) + expect(JSON.stringify(result)).not.toContain(bearer) + }) + + test('copied or foreign receipt refuses before reauthorization', async () => { + const f = fixture() + const served = await f.custody.authorize(identity) + const foreign = fixture() + await refusal(f.custody.prepareRetry({ ...served }), 'no-receipt') + await refusal(foreign.custody.prepareRetry(served), 'no-receipt') + expect(f.gets).toHaveLength(1) + expect(f.counts.reads).toBe(1) + expect(foreign.counts.connects).toBe(0) + expect(foreign.counts.reads).toBe(0) + expect(f.reports).toHaveLength(0) + }) + + test('reauthorize-failed retains only the refusal code', async () => { + const f = fixture() + const served = await f.custody.authorize(identity) + f.material({ accountId: 'replacement' }) + expect(await f.custody.prepareRetry(served)).toEqual({ + retry: false, + reason: 'reauthorize-failed', + code: 'identity-changed', + }) + expect(f.gets).toHaveLength(2) + expect(f.reports).toHaveLength(0) + }) + + test.each([ + { + patch: { credentialId: 'other-credential' }, + reason: 'credential-changed', + }, + { patch: { accountIdentity: 'other-account' }, reason: 'account-changed' }, + ])( + 'defensive retry reason $reason for a genuine alternate receipt', + async ({ patch, reason }) => { + const f = fixture() + const served = await f.custody.authorize(identity) + const alternate = { ...identity, ...patch } + f.material({ + credentialId: alternate.credentialId, + accountId: alternate.accountIdentity, + recordVersion: 8, + }) + const originalAuthorize = f.custody.authorize + // Common-auth refuses changed assertions before the retry policy sees them. + // The spy instead authorizes one other valid route, returning its genuine + // receipt so the retry policy's credential/account comparisons run independently. + const authorize = spyOn(f.custody, 'authorize').mockImplementation( + (_identity, signal) => originalAuthorize(alternate, signal), + ) + try { + expect(await f.custody.prepareRetry(served)).toEqual({ + retry: false, + reason, + }) + expect(authorize).toHaveBeenCalledTimes(1) + expect(authorize.mock.calls[0]?.[0]).toEqual(identity) + expect(f.gets).toHaveLength(2) + expect(f.reports).toHaveLength(0) + } finally { + authorize.mockRestore() + } + }, + ) + + test('abort and closed propagate instead of retry refusal', async () => { + const f = fixture() + const served = await f.custody.authorize(identity) + const aborted = new Error('synthetic cancellation') + await expect( + f.custody.prepareRetry(served, AbortSignal.abort(aborted)), + ).rejects.toBe(aborted) + f.custody.close() + await refusal(f.custody.prepareRetry(served), 'closed') + expect(f.gets).toHaveLength(1) + expect(f.reports).toHaveLength(0) + }) + + test('closed consumer refusal during fresh reauthorization propagates', async () => { + let closed = false + const f = fixture({ + readToken: async () => { + if (closed) throw new ClaustrumConsumerError('closed', enrollmentToken) + return { token: enrollmentToken, token_generation: 1 } + }, + }) + const served = await f.custody.authorize(identity) + closed = true + await refusal(f.custody.prepareRetry(served), 'closed') + expect(f.gets).toHaveLength(1) + expect(f.reports).toHaveLength(0) + }) + + test('abort during fresh reauthorization propagates without reporting', async () => { + const token = deferred<{ token: string; token_generation: number }>() + const reading = deferred() + let reads = 0 + const f = fixture({ + readToken: async () => { + if (++reads === 1) + return { token: enrollmentToken, token_generation: 1 } + reading.resolve() + return token.promise + }, + }) + const served = await f.custody.authorize(identity) + const controller = new AbortController() + const pending = f.custody.prepareRetry(served, controller.signal) + await reading.promise + const reason = new Error('synthetic retry cancellation') + controller.abort(reason) + await expect(pending).rejects.toBe(reason) + token.resolve({ token: nextToken, token_generation: 2 }) + await token.promise + expect(reads).toBe(2) + expect(f.gets).toHaveLength(1) + expect(f.reports).toHaveLength(0) + f.custody.close() + }) +}) + +const consumerKinds = [ + 'closed', + 'not-enrolled', + 'invalid-token', + 'unavailable', + 'identity-changed', + 'identity-unasserted', + 'insufficient-validity', + 'invalid-material', + 'not-active', + 'route-unavailable', + 'route-declined', + 'no-receipt', + 'unsafe-file', + 'invalid-state', + 'wrong-consumer', + 'roster-busy', + 'host-slot-login', + 'host-slot-placeholder', + 'placeholder-refresh', +] as const satisfies readonly ClaustrumConsumerFailureKind[] + +export type ErrorTestCoverage = Assert< + Same<(typeof consumerKinds)[number], NativeCustodyErrorCode> +> + +describe('closed redacted error and logger boundary', () => { + test.each([...consumerKinds])( + 'maps consumer kind %s to its own native code', + async (kind) => { + const producer = new ClaustrumConsumerError( + kind, + `consumer detail ${enrollmentToken} ${bearer}`, + ) + const f = fixture({ + readToken: async () => { + throw producer + }, + }) + const error = await refusal(f.custody.authorize(identity), kind) + expect(error).not.toHaveProperty('cause') + expect(error).not.toHaveProperty('data') + expect(error).not.toHaveProperty('kind') + expect(producer).not.toHaveProperty('code') + expect(JSON.stringify(error)).not.toContain(enrollmentToken) + expect(String(error)).not.toContain('consumer detail') + expect(String(error)).not.toContain(bearer) + expect(f.gets).toHaveLength(0) + }, + ) + + test('unexpected and vault errors retain no raw message cause or data', async () => { + const vault = new ClaustrumCredentialError( + enrollmentToken, + 'permanent', + 'gone', + ) + vault.message = `raw vault detail ${bearer}` + Object.assign(vault, { + cause: new Error(enrollmentToken), + data: { token: enrollmentToken }, + kind: 'invalid-token', + }) + const f = fixture() + f.client.getScoped = async () => { + throw vault + } + for (const error of [ + await refusal(f.custody.authorize(identity), 'unavailable'), + await refusal( + fixture({ + readToken: async () => { + throw new Error(enrollmentToken) + }, + }).custody.authorize(identity), + 'unavailable', + ), + await refusal( + fixture({ + readToken: async () => { + throw new ClaustrumConsumerError( + 'alien' as ClaustrumConsumerFailureKind, + enrollmentToken, + ) + }, + }).custody.authorize(identity), + 'unavailable', + ), + ]) { + expect(Object.keys(error).sort()).toEqual(['code', 'name']) + expect(error).not.toHaveProperty('cause') + expect(error).not.toHaveProperty('data') + expect(error).not.toHaveProperty('class') + expect(JSON.stringify(error)).not.toContain(enrollmentToken) + expect(String(error)).not.toContain(bearer) + } + }) + + test('explicit and no-op logger functions prevent the producer default logger', async () => { + const defaultRecords: unknown[] = [] + const records: unknown[] = [] + initLogger({ + level: 'debug', + captureSink: (record) => { + defaultRecords.push(record) + }, + }) + try { + // A direct common-auth log verifies the sink observes its default logger; + // custody operations must bypass that logger, not merely leave it unconfigured. + createLogger('claustrum').warn('capture companion') + expect(defaultRecords).toHaveLength(1) + defaultRecords.length = 0 + for (const logger of [ + undefined, + { + warn: (message: string, data?: unknown) => { + records.push({ message, data }) + }, + debug: (message: string, data?: unknown) => { + records.push({ message, data }) + }, + }, + ]) { + const f = fixture({ logger }) + f.rows([{ ...row, id: enrollmentToken, accountId: '' }]) + await f.custody.discover() + const receipt = await f.custody.authorize(identity) + await f.custody.reportFailure(receipt, 401, 'direct') + } + expect(defaultRecords).toEqual([]) + expect(records).toEqual([ + { message: 'Native custody inventory record skipped', data: undefined }, + { message: 'Native custody failure reported', data: undefined }, + ]) + expect(JSON.stringify(records)).not.toContain(enrollmentToken) + expect(JSON.stringify(records)).not.toContain(bearer) + } finally { + resetLoggerForTest() + } + }) +}) + +describe('owned lazy lifecycle', () => { + test('a later operation recovers from a rejected connection without retrying the failed operation', async () => { + let connects = 0 + const f = fixture({ + connect: async () => { + if (++connects === 1) + throw new Error(`synthetic unavailable ${enrollmentToken}`) + return f.client + }, + }) + await refusal(f.custody.authorize(identity), 'unavailable') + expect(connects).toBe(1) + expect(f.counts.reads).toBe(1) + expect(f.gets).toHaveLength(0) + const receipt = await f.custody.authorize(identity) + expect(receipt.accessToken).toBe(bearer) + expect(connects).toBe(2) + expect(f.counts.reads).toBe(2) + expect(f.gets).toHaveLength(1) + await f.custody.authorize(identity) + expect(connects).toBe(2) + expect(f.gets).toHaveLength(2) + expect(f.reports).toHaveLength(0) + f.custody.close() + expect(f.counts.closes).toBe(1) + }) + + test('concurrent failed operations share one attempt and the recovery wave shares one new connection', async () => { + const first = deferred() + const recovered = deferred() + const firstEntered = deferred() + const recoveryEntered = deferred() + let connects = 0 + const f = fixture({ + connect: () => { + if (++connects === 1) { + firstEntered.resolve() + return first.promise + } + recoveryEntered.resolve() + return recovered.promise + }, + }) + const failed = Promise.all([ + refusal(f.custody.authorize(identity), 'unavailable'), + refusal(f.custody.discover(), 'unavailable'), + refusal(f.custody.authorize(identity), 'unavailable'), + ]) + await firstEntered.promise + expect(connects).toBe(1) + first.reject(new Error('synthetic transport unavailable')) + await failed + expect(connects).toBe(1) + expect(f.gets).toHaveLength(0) + expect(f.lists).toHaveLength(0) + + const pending = Promise.all([ + f.custody.authorize(identity), + f.custody.discover(), + f.custody.authorize(identity), + ]) + await recoveryEntered.promise + expect(connects).toBe(2) + recovered.resolve(f.client) + const [one, inventory, two] = await pending + expect(one).not.toBe(two) + expect(one.accessToken).toBe(bearer) + expect(two.accessToken).toBe(bearer) + expect(inventory.credentials).toHaveLength(1) + expect(connects).toBe(2) + expect(f.counts.reads).toBe(6) + expect(f.gets).toHaveLength(2) + expect(f.lists).toHaveLength(1) + expect(f.reports).toHaveLength(0) + f.custody.close() + expect(f.counts.closes).toBe(1) + }) + + test('close after a failed connection is terminal and leaves another instance open', async () => { + let connects = 0 + const f = fixture({ + connect: async () => { + connects++ + throw new Error('synthetic connect failure') + }, + }) + const other = fixture() + await other.custody.authorize(identity) + await refusal(f.custody.authorize(identity), 'unavailable') + f.custody.close() + await refusal(f.custody.authorize(identity), 'closed') + await refusal(f.custody.discover(), 'closed') + expect(connects).toBe(1) + expect(f.counts.reads).toBe(1) + expect(f.counts.closes).toBe(0) + expect(other.counts.closes).toBe(0) + await other.custody.authorize(identity) + other.custody.close() + expect(other.counts.closes).toBe(1) + }) + + test('close during a recovering connection rejects the wave and closes only its late client', async () => { + const recovered = deferred() + const recoveryEntered = deferred() + const lateClosed = deferred() + let connects = 0 + const f = fixture({ + connect: async () => { + if (++connects === 1) throw new Error('synthetic connect failure') + recoveryEntered.resolve() + return recovered.promise + }, + }) + const nativeClose = f.client.close + f.client.close = () => { + nativeClose() + lateClosed.resolve() + } + const other = fixture() + await other.custody.authorize(identity) + await refusal(f.custody.authorize(identity), 'unavailable') + const authorizing = f.custody.authorize(identity) + const discovering = f.custody.discover() + await recoveryEntered.promise + expect(connects).toBe(2) + f.custody.close() + await Promise.all([ + refusal(authorizing, 'closed'), + refusal(discovering, 'closed'), + ]) + expect(f.counts.closes).toBe(0) + expect(other.counts.closes).toBe(0) + recovered.resolve(f.client) + await lateClosed.promise + expect(f.counts.closes).toBe(1) + expect(f.gets).toHaveLength(0) + expect(f.lists).toHaveLength(0) + await refusal(f.custody.authorize(identity), 'closed') + expect(connects).toBe(2) + await other.custody.authorize(identity) + other.custody.close() + expect(other.counts.closes).toBe(1) + }) + + test('concurrent first operations share only the connection', async () => { + const connection = deferred() + const entered = deferred() + let connects = 0 + const f = fixture({ + connect: () => { + connects++ + entered.resolve() + return connection.promise + }, + }) + expect(connects).toBe(0) + expect(f.counts.reads).toBe(0) + const pending = Promise.all([ + f.custody.discover(), + f.custody.authorize(identity), + f.custody.authorize(identity), + ]) + await entered.promise + expect(connects).toBe(1) + connection.resolve(f.client) + const results = await pending + expect(results[1]).not.toBe(results[2]) + expect(f.gets).toHaveLength(2) + expect(f.counts.reads).toBe(3) + f.custody.close() + f.custody.close() + expect(f.counts.closes).toBe(1) + }) + + test('close before connection settles rejects pending operations and closes the late owned client', async () => { + const connection = deferred() + const entered = deferred() + const lateClosed = deferred() + const f = fixture({ + connect: () => { + entered.resolve() + return connection.promise + }, + }) + const nativeClose = f.client.close + f.client.close = () => { + nativeClose() + lateClosed.resolve() + } + const first = f.custody.authorize(identity) + const second = f.custody.discover() + await entered.promise + f.custody.close() + // Closing must reject these operations while the connect promise is still + // unresolved. The lateClosed promise then confirms disposal of the returned client. + await Promise.all([refusal(first, 'closed'), refusal(second, 'closed')]) + expect(f.counts.closes).toBe(0) + connection.resolve(f.client) + await lateClosed.promise + expect(f.counts.closes).toBe(1) + expect(f.gets).toHaveLength(0) + expect(f.lists).toHaveLength(0) + await refusal(f.custody.authorize(identity), 'closed') + }) + + test('close before use never connects and cannot close another instance', async () => { + const first = fixture() + const other = fixture() + await other.custody.authorize(identity) + first.custody.close() + await refusal(first.custody.discover(), 'closed') + expect(first.counts).toEqual({ connects: 0, reads: 0, closes: 0 }) + expect(other.counts.closes).toBe(0) + await other.custody.authorize(identity) + other.custody.close() + expect(other.counts.closes).toBe(1) + }) + + test('close and abort fence in-flight token reads and scoped replies', async () => { + const token = deferred<{ token: string; token_generation: number }>() + const reading = deferred() + const f = fixture({ + readToken: () => { + reading.resolve() + return token.promise + }, + }) + const pending = f.custody.authorize(identity) + await reading.promise + f.custody.close() + await refusal(pending, 'closed') + token.resolve({ token: enrollmentToken, token_generation: 1 }) + await token.promise + expect(f.gets).toHaveLength(0) + + const reply = deferred() + const entered = deferred() + const other = fixture() + other.client.getScoped = () => { + entered.resolve() + return reply.promise + } + const controller = new AbortController() + const cancelled = other.custody.authorize(identity, controller.signal) + await entered.promise + const reason = new Error('synthetic abort') + controller.abort(reason) + await expect(cancelled).rejects.toBe(reason) + reply.resolve({ + material: bearer, + recordVersion: 7, + expiresAtMs: 1_000_000, + credentialId: identity.credentialId, + accountId: identity.accountIdentity, + }) + await reply.promise + other.custody.close() + }) +}) + +describe('synthetic default enrollment files', () => { + test('captured host paths use the primitive safe reader with zero construction reads', async () => { + const root = await mkdtemp(join(process.cwd(), '.native-custody-')) + const env = { XDG_STATE_HOME: root } + const opencode = fixture({ env, readToken: undefined }) + const pi = fixture({ env, host: 'pi', readToken: undefined }) + try { + const openPath = getHostClaustrumEnrollmentPaths( + 'opencode', + env, + ).tokenPath + const piPath = getHostClaustrumEnrollmentPaths('pi', env).tokenPath + expect(openPath).toBe( + join(root, 'cortexkit', 'anthropic-auth', 'opencode-enrollment.json'), + ) + expect(piPath).toBe( + join(root, 'cortexkit', 'anthropic-auth', 'pi-enrollment.json'), + ) + expect(openPath).not.toBe(piPath) + expect(opencode.counts.connects).toBe(0) + expect(pi.counts.connects).toBe(0) + // Construction precedes file creation. Only later authorization may read + // these synthetic enrollment files through common-auth's tokenPath reader. + await mkdir(dirname(openPath), { recursive: true, mode: 0o700 }) + await writeFile( + openPath, + JSON.stringify({ token: enrollmentToken, token_generation: 1 }), + { mode: 0o600 }, + ) + await writeFile( + piPath, + JSON.stringify({ token: nextToken, token_generation: 2 }), + { mode: 0o600 }, + ) + env.XDG_STATE_HOME = join(root, 'never-created') + await opencode.custody.authorize(identity) + await pi.custody.authorize(identity) + expect(opencode.gets[0]?.enrollmentToken).toBe(enrollmentToken) + expect(pi.gets[0]?.enrollmentToken).toBe(nextToken) + await writeFile( + openPath, + JSON.stringify({ token: nextToken, token_generation: 2 }), + { mode: 0o600 }, + ) + await opencode.custody.authorize(identity) + expect(opencode.gets[1]?.enrollmentToken).toBe(nextToken) + expect(opencode.counts.reads).toBe(0) + expect(pi.counts.reads).toBe(0) + } finally { + opencode.custody.close() + pi.custody.close() + await rm(root, { recursive: true, force: true }) + } + }) + + test('private-group paths accept owner-only secrets and refuse group-readable or unsafe files', async () => { + const root = await mkdtemp(join(process.cwd(), '.native-custody-')) + const parent = join(root, 'group-owned') + const path = join(parent, 'opencode-enrollment.json') + const f = fixture({ + env: { OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: path }, + readToken: undefined, + }) + try { + await mkdir(parent, { mode: 0o770 }) + await chmod(parent, 0o770) + await refusal(f.custody.authorize(identity), 'not-enrolled') + expect(f.counts.connects).toBe(0) + await writeFile( + path, + JSON.stringify({ token: enrollmentToken, token_generation: 1 }), + { mode: 0o600 }, + ) + await f.custody.authorize(identity) + expect(f.gets).toHaveLength(1) + await chmod(path, 0o640) + await refusal(f.custody.authorize(identity), 'unsafe-file') + await chmod(path, 0o600) + await writeFile(path, JSON.stringify({ token: '', token_generation: 1 })) + await refusal(f.custody.authorize(identity), 'invalid-token') + await writeFile(path, `{ malformed ${enrollmentToken}`) + const error = await refusal( + f.custody.authorize(identity), + 'invalid-state', + ) + expect(JSON.stringify(error)).not.toContain(enrollmentToken) + await rm(path) + const target = join(parent, 'synthetic-token.json') + await writeFile( + target, + JSON.stringify({ token: enrollmentToken, token_generation: 1 }), + { mode: 0o600 }, + ) + await symlink(target, path) + await refusal(f.custody.authorize(identity), 'unsafe-file') + expect(f.gets).toHaveLength(1) + } finally { + f.custody.close() + await rm(root, { recursive: true, force: true }) + } + }) +}) + +test('native custody declaration has no producer or internal-types edge', async () => { + const source = await readFile( + new URL('../../dist/native-custody.d.ts', import.meta.url), + 'utf8', + ) + const references = moduleReferences(source).map( + (reference) => reference.specifier, + ) + expect( + references.filter( + (specifier) => + specifier === '@cortexkit/common-auth' || + specifier.startsWith('@cortexkit/common-auth/'), + ), + ).toEqual([]) + expect( + references.filter( + (specifier) => + specifier.startsWith('.') && + specifier.split('/').includes('internal-types'), + ), + ).toEqual([]) +}) From 6e31d6bd634ea2f69040240fd91b49656b7e6d1c Mon Sep 17 00:00:00 2001 From: Alfonso Campaign Date: Wed, 7 Oct 2026 11:17:46 +0200 Subject: [PATCH 032/169] reclaim: auto-preserved uncommitted changes (NOT reviewed work product) --- .../core/src/native-refresh-coordinator.ts | 4 +- packages/core/src/native-runtime.ts | 476 ++++- .../tests/native-refresh-coordinator.test.ts | 42 +- .../src/tests/native-refresh-failure.test.ts | 1529 +++++++++++++++++ 4 files changed, 2024 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/tests/native-refresh-failure.test.ts diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index e1209a43e..f53fa67c8 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -331,7 +331,9 @@ function failureOf(error: Error): NativeRefreshFailure { const cause = error instanceof PoolOperationError ? error.cause : error const kind = error instanceof PoolOperationError ? error.kind : 'caller-hook' if (kind === 'provider' && cause instanceof ClaudeOAuthRefreshError) { - const invalidGrant = /\binvalid_grant\b/.test(cause.body) + // Only a 400 invalid_grant response establishes a dead refresh token. + const invalidGrant = + cause.status === 400 && /\binvalid_grant\b/.test(cause.body) return { kind, classification: invalidGrant diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index 7d180c0a4..89194b6fa 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -2,8 +2,9 @@ import { randomUUID } from 'node:crypto' import { constants } from 'node:fs' import { lstat, mkdir, open, rename, rm } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' +import { types as utilTypes } from 'node:util' import { withLock } from '@cortexkit/common-auth/fs' -import { POOL_LOCK_DEFAULTS } from '@cortexkit/common-auth/store' +import { fingerprintOf, POOL_LOCK_DEFAULTS } from '@cortexkit/common-auth/store' import type { AccountOperationError, @@ -25,6 +26,7 @@ import { import type { NativeRefreshContext, NativeRefreshObservation, + NativeRefreshSubject, } from './native-refresh-coordinator.ts' import { isNativeLocalPoolBinding, @@ -33,6 +35,7 @@ import { } from './pool-binding.ts' import type { NativePoolPaths } from './pool-paths.ts' import { createNativePoolStore, nativePoolStoreLocks } from './pool-store.ts' +import { tokenFingerprint } from './token-fingerprint.ts' /** Identifies the vault credential and Claude account that supplied an observation. This binding contains no access token. */ export interface NativeCustodyRuntimeBinding { @@ -934,9 +937,15 @@ export async function publishNativeLocalFirstIdentity( try { paths = { ...paths } const publication = captureFirstIdentity(paths, observation) - return await publishLocalValidation( + return await publishLocalEntry( paths, - publication, + publication.proof, + (material) => + nativeLocalCredentialValidationMatches( + publication.proof, + publication.proof.binding, + material, + ), (old) => firstIdentityEntry(old, publication), publication, ) @@ -992,42 +1001,463 @@ export async function publishNativeLocalCredentialValidation( !sameLocalBinding(context.runtimeBinding, proof.binding))) ) throw new NativeRuntimeError('publication-refused') - return await publishLocalValidation(paths, publication, (old) => - knownValidationEntry(old, publication), + return await publishLocalEntry( + paths, + proof, + (material) => + nativeLocalCredentialValidationMatches(proof, proof.binding, material), + (old) => knownValidationEntry(old, publication), + ) + } catch (error) { + if (error instanceof NativeRuntimeError) throw error + throw new NativeRuntimeError('publication-refused') + } +} + +export interface NativeLocalFailurePolicy { + readonly checkedAt: number + /** A nextRetryAt equal to checkedAt adds no delay beyond the recorded failure time. */ + readonly nextRetryAt?: number + readonly retryCount?: number +} + +export type NativeLocalFailureSkipReason = + | 'not-a-failure' + | 'no-context' + | 'not-attributable' + | 'missing-committed' + +export type NativeLocalFailureAttribution = + | { readonly kind: 'none'; readonly reason: NativeLocalFailureSkipReason } + | { + readonly kind: 'refresh-error' + readonly target: 'captured' | 'committed' + readonly subject: NativeRefreshSubject + readonly permanent: boolean + readonly message: + | 'Local refresh failed' + | 'Local account-check recovery required' + readonly status?: number + } + | { + readonly kind: 'validation-retry' + readonly target: 'captured' | 'committed' + readonly subject: NativeLocalValidationRetry['subject'] + } + +export type NativeLocalFailureSupersededReason = + | 'row-binding' + | 'row-credential' + | 'entry-binding' + | 'reset-context' + | 'already-proven' + | 'older-than-stored' + +export type NativeLocalFailurePublication = + | { + readonly status: 'published' + readonly attribution: Exclude< + NativeLocalFailureAttribution, + { kind: 'none' } + > + readonly state: NativeRuntimeState + } + | { + readonly status: 'skipped' + readonly reason: NativeLocalFailureSkipReason + } + | { + readonly status: 'superseded' + readonly reason: NativeLocalFailureSupersededReason + } + +function completeFailureVersion(value: unknown): boolean { + return ( + dispatchVersion(value) && + tokenFingerprintValue(value.accessFingerprint) && + counter(value.expires) && + value.expires > 0 + ) +} + +// Observations must be plain records throughout. Read property descriptors so +// caller-defined getters do not run while capturing them. Refuse proxies before +// inspecting them, and discard inspection errors that can contain caller data. +function capturePlainFailureData(input: T): T { + const active = new WeakSet() + const copy = (value: unknown, depth: number): unknown => { + if ( + value === null || + value === undefined || + ['string', 'number', 'boolean'].includes(typeof value) + ) + return value + if ( + typeof value !== 'object' || + utilTypes.isProxy(value) || + depth > 32 || + active.has(value) + ) + throw new NativeRuntimeError('publication-refused') + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) + throw new NativeRuntimeError('publication-refused') + active.add(value) + try { + const descriptors = Object.getOwnPropertyDescriptors(value) + const captured: Record = Object.create(null) + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== 'string') + throw new NativeRuntimeError('publication-refused') + const descriptor = descriptors[key] + if (!descriptor || !Object.hasOwn(descriptor, 'value')) + throw new NativeRuntimeError('publication-refused') + Object.defineProperty(captured, key, { + value: copy(descriptor.value, depth + 1), + enumerable: true, + writable: true, + configurable: true, + }) + } + return captured + } finally { + active.delete(value) + } + } + try { + return copy(input, 0) as T + } catch { + throw new NativeRuntimeError('publication-refused') + } +} + +/** + * Classify the captured observation only. Saved credentials identify which + * account needs recovery, but do not prove a failed refresh or a completed + * account check. Refuse malformed action inputs rather than infer them from disk. + */ +export function classifyNativeLocalFailure( + observation: NativeRefreshObservation, +): NativeLocalFailureAttribution { + try { + return classifyCapturedLocalFailure(capturePlainFailureData(observation)) + } catch { + // Return a new generic error if capture or classification fails. Do not + // retain an exception whose message or cause can contain caller data. + throw new NativeRuntimeError('publication-refused') + } +} + +function classifyCapturedLocalFailure( + event: NativeRefreshObservation, +): NativeLocalFailureAttribution { + if (event.status !== 'failed' && event.status !== 'persisted') + return { kind: 'none', reason: 'not-a-failure' } + if (event.context === undefined) return { kind: 'none', reason: 'no-context' } + if (!record(event.context)) + throw new NativeRuntimeError('publication-refused') + if ( + event.status === 'failed' && + (!record(event.failure) || + !keys( + event.failure, + ['kind', 'classification'], + ['status', 'retryAfter'], + ) || + !text(event.failure.kind) || + !['transient', 'invalid-grant', 'permanent'].includes( + event.failure.classification, + ) || + (event.failure.status !== undefined && + (!counter(event.failure.status) || + event.failure.status < 100 || + event.failure.status > 599)) || + (event.failure.retryAfter !== undefined && + !time(event.failure.retryAfter)) || + typeof event.persisted !== 'boolean') + ) + throw new NativeRuntimeError('publication-refused') + if (event.status === 'failed' && event.persisted && !event.committed) + return { kind: 'none', reason: 'missing-committed' } + if ( + event.bootstrap === 'resolved' || + (event.status === 'failed' && + (event.failure.kind === 'caller-hook' || + (!event.persisted && + !['provider', 'validation'].includes(event.failure.kind)))) + ) + return { kind: 'none', reason: 'not-attributable' } + const context = event.context + if ( + !isNativeLocalPoolBinding(event.binding) || + !digest(event.credentialFingerprint) || + !dispatchVersion(event.credentialVersion) || + !record(context.subject) || + !isNativeLocalPoolBinding(context.subject.binding) || + !sameLocalBinding(context.subject.binding, event.binding) || + context.subject.credentialFingerprint !== event.credentialFingerprint || + !dispatchVersion(context.subject.version) || + !sameVersion(context.subject.version, event.credentialVersion) || + (context.runtimeBinding !== null && + !isNativeLocalPoolBinding(context.runtimeBinding)) || + ![ + context.refreshErrorClearedAt, + context.quotaErrorClearedAt, + context.quotaErrorGeneration, + ].every((value) => value === null || counter(value)) + ) + throw new NativeRuntimeError('publication-refused') + const saved = event.status === 'persisted' || event.persisted + const committed = event.committed + if ( + saved && + (!record(committed) || + !keys(committed, ['credentialFingerprint', 'version']) || + !digest(committed.credentialFingerprint) || + !completeFailureVersion(committed.version)) + ) + throw new NativeRuntimeError('publication-refused') + if ( + event.status === 'persisted' && + (!dispatchVersion(event.successorVersion) || + event.successorFingerprint !== committed?.credentialFingerprint || + !sameVersion(event.successorVersion, { ...committed?.version })) + ) + throw new NativeRuntimeError('publication-refused') + if (event.status === 'failed' && !saved && event.committed !== undefined) + throw new NativeRuntimeError('publication-refused') + const subject: NativeRefreshSubject = { + binding: { ...event.binding }, + credentialFingerprint: saved + ? committed!.credentialFingerprint + : event.credentialFingerprint, + version: { ...(saved ? committed!.version : event.credentialVersion) }, + } + const target = saved ? 'committed' : 'captured' + if ( + (saved && subject.binding.identity !== undefined) || + (event.status === 'failed' && event.failure.kind === 'validation') + ) { + // A retry record identifies credentials still awaiting validation; + // it cannot grant permission to send requests. + if (!isNativeLocalCredentialValidation(subject)) + throw new NativeRuntimeError('publication-refused') + return { kind: 'validation-retry', target, subject } + } + if (saved) + return { + kind: 'refresh-error', + target, + subject, + permanent: false, + message: 'Local account-check recovery required', + } + if (event.status !== 'failed') + throw new NativeRuntimeError('publication-refused') + const status = event.failure.status + if ( + status !== undefined && + (!Number.isInteger(status) || status < 100 || status > 599) + ) + throw new NativeRuntimeError('publication-refused') + return { + kind: 'refresh-error', + target, + subject, + permanent: + event.failure.classification === 'invalid-grant' && status === 400, + message: 'Local refresh failed', + ...(status !== undefined ? { status } : {}), + } +} + +// Recovery can omit access or expiry, but a match does not validate credentials +// or permit requests. An undefined access fingerprint or expiry requires the +// credential field to be absent. An absent refresh timestamp is not a stored zero. +function failureMaterialMatches( + subject: NativeRefreshSubject, + material: Parameters[2], +): boolean { + if (!material || material.type !== 'oauth' || !text(material.refresh)) + return false + const version = subject.version + return ( + subject.credentialFingerprint === + fingerprintOf({ type: 'oauth', refresh: material.refresh }) && + (version.accessFingerprint === undefined + ? !Object.hasOwn(material, 'access') + : text(material.access) && + version.accessFingerprint === tokenFingerprint(material.access)) && + (version.expires === undefined + ? !Object.hasOwn(material, 'expires') + : version.expires === material.expires) && + Object.hasOwn(version, 'lastRefreshedAt') === + Object.hasOwn(material, 'lastRefreshedAt') && + version.lastRefreshedAt === material.lastRefreshedAt + ) +} + +/** + * Publish the failure only for its captured credential version or saved successor. + * This write neither validates credentials nor repairs identity, clears earlier + * errors, or changes their clear timestamps. Credentials and runtime metadata + * are saved separately; a thrown runtime write does not prove credential rollback. + */ +export async function publishNativeLocalRefreshFailure( + paths: NativePoolPaths, + observation: NativeRefreshObservation, + policy: NativeLocalFailurePolicy, +): Promise { + let reason: NativeLocalFailureSupersededReason | undefined + const superseded = (value: NativeLocalFailureSupersededReason): never => { + reason = value + throw new NativeRuntimeError('publication-refused') + } + try { + paths = capturePlainFailureData(paths) + const event = capturePlainFailureData(observation) + policy = capturePlainFailureData(policy) + const attribution = classifyNativeLocalFailure(event) + if (attribution.kind === 'none') + return { status: 'skipped', reason: attribution.reason } + const { subject } = attribution + const context = event.context! + if ( + subject.binding.storageId !== paths.storageId || + !counter(policy.checkedAt) || + (policy.nextRetryAt !== undefined && + (!counter(policy.nextRetryAt) || + policy.nextRetryAt < policy.checkedAt)) || + (policy.retryCount !== undefined && !counter(policy.retryCount)) || + ((attribution.kind === 'validation-retry' || !attribution.permanent) && + policy.nextRetryAt === undefined) + ) + throw new NativeRuntimeError('publication-refused') + const state = await publishLocalEntry( + paths, + subject, + (material) => failureMaterialMatches(subject, material), + (old) => { + if ( + (!old && context.runtimeBinding !== null) || + (old && + (old.binding.kind !== 'local' || + !isNativeLocalPoolBinding(context.runtimeBinding) || + !sameLocalBinding(old.binding, context.runtimeBinding) || + !sameLocalBinding(old.binding, subject.binding))) + ) + superseded('entry-binding') + if (!capturedResetContextMatches(old, context)) + superseded('reset-context') + if ( + old?.refreshErrorClearedAt !== undefined && + policy.checkedAt <= old.refreshErrorClearedAt + ) + throw new NativeRuntimeError('publication-refused') + const entry: NativeRuntimeEntry = old + ? { ...old } + : { binding: subject.binding } + if (attribution.kind === 'validation-retry') { + const sameSubject = (value: NativeLocalValidationRetry['subject']) => + sameLocalBinding(value.binding, subject.binding) && + value.credentialFingerprint === subject.credentialFingerprint && + sameVersion({ ...value.version }, { ...subject.version }) + if ( + old?.credentialValidation && + sameSubject(old.credentialValidation) + ) + superseded('already-proven') + if ( + old?.validationRetry && + sameSubject(old.validationRetry.subject) && + (old.validationRetry.checkedAt > policy.checkedAt || + (old.validationRetry.checkedAt === policy.checkedAt && + old.validationRetry.nextRetryAt !== policy.nextRetryAt)) + ) + superseded('older-than-stored') + entry.validationRetry = { + subject: attribution.subject, + checkedAt: policy.checkedAt, + nextRetryAt: policy.nextRetryAt!, + } + } else { + if ( + old?.lastRefreshError && + policy.checkedAt < old.lastRefreshError.checkedAt + ) + superseded('older-than-stored') + entry.lastRefreshError = { + message: attribution.message, + credentialFingerprint: subject.credentialFingerprint, + permanent: attribution.permanent, + checkedAt: policy.checkedAt, + ...(subject.binding.identity !== undefined + ? { accountIdentity: subject.binding.identity } + : {}), + ...(attribution.status !== undefined + ? { status: attribution.status } + : {}), + ...(policy.nextRetryAt !== undefined + ? { nextRetryAt: policy.nextRetryAt } + : {}), + ...(policy.retryCount !== undefined + ? { retryCount: policy.retryCount } + : {}), + } + } + return entry + }, + undefined, + superseded, ) + return { status: 'published', attribution, state } } catch (error) { + // The runtime writer replaces pre-rename exceptions with a generic error. + // Return a captured row-change reason only for that refusal; filesystem + // and lease faults must remain errors, not harmless supersession. + if ( + reason && + error instanceof NativeRuntimeError && + error.code === 'publication-refused' + ) + return { status: 'superseded', reason } if (error instanceof NativeRuntimeError) throw error throw new NativeRuntimeError('publication-refused') } } -// Both publication paths verify the pool and held locks before replacing state. -// Selecting an entry cannot skip the checks that prevent stale state writes. -async function publishLocalValidation( +// Hold pool config and state leases while checking the selected credential row +// and until runtime rename. All publishers share these checks; choosing another +// runtime entry must not bypass them. +async function publishLocalEntry( paths: NativePoolPaths, - publication: LocalValidationPublication, + subject: NativeRefreshSubject, + matches: ( + material: Parameters[2], + ) => boolean, replacement: (old: NativeRuntimeEntry | undefined) => NativeRuntimeEntry, firstIdentity?: FirstIdentityPublication, + superseded?: (reason: 'row-binding' | 'row-credential') => never, ): Promise { try { const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) const [config, state] = nativePoolStoreLocks(paths) const checkPool = async () => { const read = await store.read() - const row = - read.status === 'ready' - ? read.rows.find((row) => row.id === publication.proof.binding.rowId) - : undefined - if ( - !row || - !nativeLocalPoolBindingMatches(publication.proof.binding, paths, row) || - !nativeLocalCredentialValidationMatches( - publication.proof, - publication.proof.binding, - row.credential, - ) - ) + // Unreadable or invalid pool files do not prove a concurrent row change. + // Refuse the write as a storage fault and discard parser details that + // can contain credentials. + if (read.status !== 'ready') throw new NativeRuntimeError('publication-refused') + const row = read.rows.find((row) => row.id === subject.binding.rowId) + if (!row || !nativeLocalPoolBindingMatches(subject.binding, paths, row)) { + superseded?.('row-binding') + throw new NativeRuntimeError('publication-refused') + } + if (!matches(row.credential)) { + superseded?.('row-credential') + throw new NativeRuntimeError('publication-refused') + } } return await withLock( config.path, @@ -1042,7 +1472,7 @@ async function publishLocalValidation( paths.runtime, paths.storageId, (current) => { - const id = publication.proof.binding.rowId + const id = subject.binding.rowId current.accounts[id] = replacement(current.accounts[id]) return current }, diff --git a/packages/core/src/tests/native-refresh-coordinator.test.ts b/packages/core/src/tests/native-refresh-coordinator.test.ts index 4a3dd1fb5..406538806 100644 --- a/packages/core/src/tests/native-refresh-coordinator.test.ts +++ b/packages/core/src/tests/native-refresh-coordinator.test.ts @@ -2469,10 +2469,10 @@ for (const [name, cause, expectedCalls, classification] of [ 'invalid-grant', ], [ - 'invalid_grant even on 5xx', + '5xx invalid_grant body remains transient and non-dead', new ClaudeOAuthRefreshError(503, 'invalid_grant'), - 1, - 'invalid-grant', + 3, + 'transient', ], ['rate limit', new ClaudeOAuthRefreshError(429, 'busy', '7'), 1, 'transient'], [ @@ -2490,6 +2490,18 @@ for (const [name, cause, expectedCalls, classification] of [ }, }) await f.add('a', 'A') + if (name === '5xx invalid_grant body remains transient and non-dead') { + const { updateNativeRuntime } = await import('../native-runtime.ts') + const binding = await f.binding('a') + await updateNativeRuntime( + f.paths.runtime, + f.paths.storageId, + (current) => { + current.accounts.a = { binding } + return current + }, + ) + } let calls = 0 const c = f.coordinator({ refreshToken: async (input) => { @@ -2513,6 +2525,30 @@ for (const [name, cause, expectedCalls, classification] of [ failure: { kind: 'provider', classification }, }) expect((await f.row('a')).credential).toMatchObject(credential('a')) + if (name === '5xx invalid_grant body remains transient and non-dead') { + const { publishNativeLocalRefreshFailure } = await import( + '../native-runtime.ts' + ) + expect(result.failure.status).toBe(503) + const event = f.observations.at(-1) + if (!event) throw new Error('Missing provider failure observation') + const publication = await publishNativeLocalRefreshFailure( + f.paths, + event, + { + checkedAt: 100, + nextRetryAt: 100, + }, + ) + expect(publication).toMatchObject({ + status: 'published', + state: { + accounts: { + a: { lastRefreshError: { status: 503, permanent: false } }, + }, + }, + }) + } }) } diff --git a/packages/core/src/tests/native-refresh-failure.test.ts b/packages/core/src/tests/native-refresh-failure.test.ts new file mode 100644 index 000000000..93b619f1d --- /dev/null +++ b/packages/core/src/tests/native-refresh-failure.test.ts @@ -0,0 +1,1529 @@ +import { expect } from 'bun:test' +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { lockPathFor } from '@cortexkit/common-auth/fs' +import { fingerprintOf, type PoolRow } from '@cortexkit/common-auth/store' + +import { ClaudeOAuthRefreshError } from '../auth.ts' +import { nativeQuotaCodec } from '../native-quota-codec.ts' +import { createNativeLocalRuntimeReaders } from '../native-local-runtime-readers.ts' +import { + createNativeRefreshCoordinator, + type NativeRefreshObservation, + type NativeRefreshSubject, +} from '../native-refresh-coordinator.ts' +import { + classifyNativeLocalFailure, + isNativeRuntimeStagingName, + publishNativeLocalRefreshFailure, + readNativeRuntime, + updateNativeRuntime, + type NativeLocalFailurePolicy, + type NativeRuntimeEntry, +} from '../native-runtime.ts' +import { captureNativeLocalPoolBinding } from '../pool-binding.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore } from '../pool-store.ts' +import { tokenFingerprint } from '../token-fingerprint.ts' +import { createTestLifetimeSuite, TestLifetime } from './test-lifetime.ts' + +const { test, deferCleanup, gate, trackDetached } = createTestLifetimeSuite() +const uuid = '11111111-2222-4333-8444-555555555555' +const credential = { + type: 'oauth' as const, + access: 'synthetic-failure-access', + refresh: 'synthetic-failure-refresh', + expires: 4_000_000_000_000, +} + +function subjectOf( + paths: Awaited>, + row: PoolRow, +): NativeRefreshSubject { + const c = row.credential + if (c?.type !== 'oauth') throw new Error('Missing fixture credential') + return { + binding: captureNativeLocalPoolBinding(paths, row), + credentialFingerprint: fingerprintOf(c), + version: { + accessFingerprint: c.access ? tokenFingerprint(c.access) : undefined, + expires: c.expires, + ...(Object.hasOwn(c, 'lastRefreshedAt') + ? { lastRefreshedAt: c.lastRefreshedAt } + : {}), + }, + } +} + +async function fixture( + metadata: Partial = {}, + unknown = false, + sparse = false, + missing = false, +) { + const parent = new URL( + '../../../../node_modules/.cache/native-refresh-failure-evidence/', + import.meta.url, + ) + await mkdir(parent, { recursive: true }) + const root = await mkdtemp(join(parent.pathname, 'fixture-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const paths = await resolveNativePoolPaths( + join(root, 'config.json'), + join(root, 'state.json'), + ) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + await store.initialize() + await store.add({ + id: 'row', + ...(!unknown ? { identity: uuid } : {}), + credential: sparse + ? { type: 'oauth', refresh: credential.refresh } + : credential, + }) + const state = JSON.parse(await readFile(paths.state, 'utf8')) + delete state.accounts.row.lastRefreshedAt + await writeFile(paths.state, JSON.stringify(state), { mode: 0o600 }) + const read = await store.read() + if (read.status !== 'ready' || !read.rows[0]) + throw new Error('Missing fixture row') + const subject = subjectOf(paths, read.rows[0]) + const runtime = await updateNativeRuntime( + paths.runtime, + paths.storageId, + () => ({ + version: 1, + storageId: paths.storageId, + accounts: { + ...(!missing ? { row: { binding: subject.binding, ...metadata } } : {}), + unrelated: { + binding: { ...subject.binding, rowId: 'unrelated' }, + lastUsed: 5, + }, + }, + relay: { token: 'synthetic-relay-metadata' }, + }), + ) + const event: NativeRefreshObservation = { + status: 'failed', + failure: { kind: 'provider', classification: 'transient', status: 429 }, + persisted: false, + binding: subject.binding, + credentialFingerprint: subject.credentialFingerprint, + credentialVersion: subject.version, + attempt: 1, + bootstrap: 'unavailable', + handoff: 'not-needed', + context: { + subject, + runtimeBinding: missing ? null : subject.binding, + refreshErrorClearedAt: metadata.refreshErrorClearedAt ?? null, + quotaErrorClearedAt: metadata.quotaErrorClearedAt ?? null, + quotaErrorGeneration: metadata.quotaErrorGeneration ?? null, + }, + } + return { root, paths, store, subject, runtime, event } +} +type Fixture = Awaited> +const policy = { checkedAt: 100, nextRetryAt: 200, retryCount: 1 } + +async function unchanged( + f: Fixture, + run: () => Promise, + expected: unknown, +) { + const before = await readFile(f.paths.runtime, 'utf8') + expect(await run()).toEqual(expected) + expect(await readFile(f.paths.runtime, 'utf8')).toBe(before) + expect( + (await readdir(f.root)).filter((name) => + isNativeRuntimeStagingName(f.paths.runtime, name), + ), + ).toEqual([]) +} + +async function refused( + f: Fixture, + run: () => Promise, + code = 'publication-refused', +) { + const before = await readFile(f.paths.runtime, 'utf8') + let error: unknown + try { + await run() + } catch (caught) { + error = caught + } + expect(await readFile(f.paths.runtime, 'utf8')).toBe(before) + expect( + (await readdir(f.root)).filter((name) => + isNativeRuntimeStagingName(f.paths.runtime, name), + ), + ).toEqual([]) + expect(error).toMatchObject({ name: 'NativeRuntimeError', code }) + for (const secret of [ + credential.access, + credential.refresh, + 'synthetic-provider-body', + ]) + expect(String(error)).not.toContain(secret) +} + +async function savedSuccessor(f: Fixture): Promise { + await f.store.rotate('row', { + ...credential, + access: 'synthetic-successor-access', + refresh: 'synthetic-successor-refresh', + }) + const read = await f.store.read() + if (read.status !== 'ready' || !read.rows[0]) + throw new Error('Missing saved successor') + const successor = subjectOf(f.paths, read.rows[0]) + return { + ...f.event, + status: 'persisted', + successorFingerprint: successor.credentialFingerprint, + successorVersion: successor.version, + committed: { + credentialFingerprint: successor.credentialFingerprint, + version: successor.version, + }, + } +} + +// Copy runtime code to instrument production locks and file operations only. +// Rename has no extra test-side fence, so removing a production lease check +// must expose the write that the check should have refused. +async function instrumented(f: Fixture) { + const directory = join(f.root, 'instrumentation') + await mkdir(directory) + const sourcePath = new URL('../native-runtime.ts', import.meta.url) + let source = await readFile(sourcePath, 'utf8') + source = source + .replace( + /from '(\.\/[^']+)'/g, + (_match, relative: string) => + `from '${new URL(relative, sourcePath).href}'`, + ) + .replace("from 'node:fs/promises'", "from './controls.ts'") + .replace("from '@cortexkit/common-auth/fs'", "from './controls.ts'") + .replace( + "from '@cortexkit/common-auth/store'", + `from '${import.meta.resolve('@cortexkit/common-auth/store')}'`, + ) + .replace( + `from '${new URL('../pool-store.ts', import.meta.url).href}'`, + "from './controls.ts'", + ) + await writeFile(join(directory, 'runtime.ts'), source) + await writeFile( + join(directory, 'controls.ts'), + ` +import * as fs from 'node:fs/promises'; +import { withLock as realLock } from ${JSON.stringify(import.meta.resolve('@cortexkit/common-auth/fs'))}; +import { createNativePoolStore as realStore, nativePoolStoreLocks } from ${JSON.stringify(new URL('../pool-store.ts', import.meta.url).href)}; +export { nativePoolStoreLocks }; +export const events = []; export const held = new Set(); export const heldAtRenames = []; +export let reads = 0; export let renames = 0; export let forbidden = 0; export let fileIO = 0; +export const control = { afterSync: async () => {}, afterAcquire: async () => {}, failStageCleanup: false }; +export async function rm(...args) { + if (control.failStageCleanup && String(args[0]).endsWith('.partial')) throw new Error('synthetic-provider-body cleanup fault'); + return fs.rm(...args); +} +export async function lstat(...args) { fileIO++; return fs.lstat(...args); } +export async function mkdir(...args) { fileIO++; return fs.mkdir(...args); } +export async function open(...args) { + fileIO++; const file = await fs.open(...args); + if (args[1] !== 'wx') return file; + return new Proxy(file, { get(target, key) { + if (key === 'sync') return async () => { await target.sync(); await control.afterSync(); }; + const value = target[key]; return typeof value === 'function' ? value.bind(target) : value; + }}); +} +export async function rename(...args) { events.push('rename'); renames++; heldAtRenames.push([...held]); await fs.rename(...args); } +export async function withLock(path, options, callback) { + return realLock(path, options, async lock => { + events.push('acquire:' + options.name); held.add(options.name); + try { await control.afterAcquire(options.name); return await callback({ assertOwned: async () => { events.push('assert:' + options.name); await lock.assertOwned(); } }); } + finally { held.delete(options.name); events.push('release:' + options.name); } + }); +} +export function createNativePoolStore(options) { + const store = realStore(options); + return new Proxy(store, { get(target, key) { + if (key === 'read') return async () => { reads++; events.push('read'); return target.read(); }; + forbidden++; throw new Error('Only strict read allowed'); + }}); +} +`, + ) + const runtime: typeof import('../native-runtime.ts') = await import( + pathToFileURL(join(directory, 'runtime.ts')).href + ) + const controls: { + events: string[] + held: Set + heldAtRenames: string[][] + reads: number + renames: number + forbidden: number + fileIO: number + control: { + afterSync: () => Promise + afterAcquire: (name: string) => Promise + failStageCleanup: boolean + } + } = await import(pathToFileURL(join(directory, 'controls.ts')).href) + return { runtime, controls } +} + +for (const [status, classification, calls] of [ + [400, 'invalid-grant', 1], + [403, 'permanent', 1], + [429, 'transient', 1], + [500, 'transient', 3], +] as const) { + test(`public refresh status${status} invalid_grant classification and bounded retries`, async () => { + const f = await fixture() + let exchanges = 0 + const observations: NativeRefreshObservation[] = [] + const c = createNativeRefreshCoordinator({ + paths: f.paths, + quota: nativeQuotaCodec, + refreshToken: async (input) => { + expect(input.maxRetries).toBe(0) + exchanges++ + throw new ClaudeOAuthRefreshError( + status, + 'invalid_grant synthetic-provider-body', + '7', + ) + }, + resolveIdentity: async () => { + throw new Error('Lookup forbidden') + }, + readRestrictions: async (subject) => ({ + restriction: { status: 'allowed' }, + context: { ...f.event.context!, subject }, + }), + readAdmission: async () => ({ status: 'unproven' }), + reconcile: async (event) => { + observations.push(event) + }, + }) + expect( + await c.authorize({ + binding: f.subject.binding, + intent: 'serve', + mode: 'local', + rejectedAccessToken: credential.access, + }), + ).toMatchObject({ + status: 'failed', + persisted: false, + failure: { kind: 'provider', classification, status, retryAfter: 7 }, + }) + expect(exchanges).toBe(calls) + expect(observations).toHaveLength(calls) + }) +} + +test('failure real locks strict rereads metadata pool bytes and owner-only mode', async () => { + const metadata: Partial = { + lastUsed: 7, + lastRefreshedAt: 8, + refreshErrorClearedAt: 0, + quotaErrorClearedAt: 0, + quotaErrorGeneration: 0, + refreshLeaseId: 'synthetic-lease', + refreshLeaseUntil: 500, + refreshLeaseTokenHash: 'a'.repeat(64), + lastQuotaRefreshError: { + message: 'synthetic-quota', + checkedAt: 3, + permanent: false, + }, + quotaCheckedAt: 4, + quotaToken: 'b'.repeat(16), + profile: { tier: 'synthetic-tier', orgType: 'synthetic-org', checkedAt: 6 }, + prime: { count: 1, inputTokens: 2, outputTokens: 3, since: 4 }, + authLineageId: 'synthetic-lineage', + primeAuthLineageRefreshTokenFingerprint: 'c'.repeat(16), + } + const f = await fixture(metadata) + const { runtime, controls } = await instrumented(f) + const pool = await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]) + const result = await runtime.publishNativeLocalRefreshFailure( + f.paths, + f.event, + policy, + ) + expect(result.status).toBe('published') + if (result.status !== 'published') throw new Error('Not published') + const entry = { ...result.state.accounts.row } + delete entry.lastRefreshError + expect(entry).toEqual(f.runtime.accounts.row!) + expect(result.state.accounts.unrelated).toEqual(f.runtime.accounts.unrelated) + expect(result.state.relay).toEqual(f.runtime.relay) + expect(result.state.accounts.row?.lastRefreshError).toEqual({ + message: 'Local refresh failed', + credentialFingerprint: f.subject.credentialFingerprint, + accountIdentity: uuid, + permanent: false, + status: 429, + ...policy, + }) + expect(controls.events).toEqual([ + 'acquire:pool-config', + 'acquire:pool-state', + 'read', + 'acquire:native-runtime', + 'read', + 'assert:pool-config', + 'assert:pool-state', + 'assert:native-runtime', + 'rename', + 'release:native-runtime', + 'release:pool-state', + 'release:pool-config', + ]) + expect(controls.heldAtRenames).toEqual([ + ['pool-config', 'pool-state', 'native-runtime'], + ]) + expect(controls.forbidden).toBe(0) + expect(controls.renames).toBe(1) + expect( + await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]), + ).toEqual(pool) + for (const path of [f.paths.config, f.paths.state, f.paths.runtime]) + expect((await stat(path)).mode & 0o777).toBe(0o600) + const bytes = await readFile(f.paths.runtime, 'utf8') + expect(bytes).not.toContain(credential.access) + expect(bytes).not.toContain(credential.refresh) +}) + +for (const name of ['pool-config', 'pool-state', 'native-runtime']) { + test(`failure refuses actual ${name} ownership loss without test masks`, async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + controls.control.afterSync = async () => { + await rm( + lockPathFor( + name === 'pool-config' + ? f.paths.config + : name === 'pool-state' + ? f.paths.state + : f.paths.runtime, + name, + ), + ) + } + await refused( + f, + () => runtime.publishNativeLocalRefreshFailure(f.paths, f.event, policy), + name === 'native-runtime' ? 'runtime-io' : 'publication-refused', + ) + expect(controls.renames).toBe(0) + expect(controls.held.size).toBe(0) + }) +} + +test('failure captures event policy paths and original reset values before waits', async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + const event = structuredClone(f.event) + const paths = { ...f.paths } + const options = { ...policy } + controls.control.afterAcquire = async (name) => { + if (name !== 'pool-config') return + event.credentialVersion = {} + event.context = { ...event.context!, quotaErrorGeneration: 77 } + paths.runtime = join(f.root, 'wrong-runtime') + options.checkedAt = 2.5 + } + expect( + await runtime.publishNativeLocalRefreshFailure(paths, event, options), + ).toMatchObject({ + status: 'published', + state: { accounts: { row: { lastRefreshError: { checkedAt: 100 } } } }, + }) + expect((await readdir(f.root)).includes('wrong-runtime')).toBe(false) +}) + +test('failure sparse predecessor records exact absence without proof', async () => { + const f = await fixture({}, true, true) + const result = await publishNativeLocalRefreshFailure( + f.paths, + f.event, + policy, + ) + expect(result).toMatchObject({ + status: 'published', + state: { + accounts: { + row: { + binding: f.subject.binding, + lastRefreshError: { + permanent: false, + credentialFingerprint: f.subject.credentialFingerprint, + }, + }, + }, + }, + }) + const saved = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (saved.status !== 'ready') throw new Error('Missing runtime') + expect(saved.state.accounts.row?.credentialValidation).toBeUndefined() + expect(saved.state.accounts.row?.validationRetry).toBeUndefined() +}) + +for (const field of ['access', 'expires', 'lastRefreshedAt'] as const) { + test(`failure sparse exact presence rejects appearing ${field}`, async () => { + const f = await fixture({}, true, true) + const { runtime, controls } = await instrumented(f) + // Rotate through the store so the changed row retains its integrity stamp. + await f.store.rotate('row', { + type: 'oauth', + refresh: credential.refresh, + ...(field === 'access' + ? { access: credential.access } + : field === 'expires' + ? { expires: 0 } + : {}), + }) + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + if (field === 'lastRefreshedAt') state.accounts.row.lastRefreshedAt = 0 + else delete state.accounts.row.lastRefreshedAt + await writeFile(f.paths.state, JSON.stringify(state), { mode: 0o600 }) + await unchanged( + f, + () => runtime.publishNativeLocalRefreshFailure(f.paths, f.event, policy), + { status: 'superseded', reason: 'row-credential' }, + ) + expect(controls.renames).toBe(0) + }) +} + +for (const timing of ['first', 'final'] as const) { + test(`failure ${timing} guarded pool read rejects access-only rotation`, async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + const originalConfig = await readFile(f.paths.config, 'utf8') + const originalState = await readFile(f.paths.state, 'utf8') + await f.store.rotate('row', { ...credential, access: 'independent-access' }) + const rotated = JSON.parse(await readFile(f.paths.state, 'utf8')) + delete rotated.accounts.row.lastRefreshedAt + if (timing === 'first') + await writeFile(f.paths.state, JSON.stringify(rotated), { mode: 0o600 }) + else { + await writeFile(f.paths.config, originalConfig, { mode: 0o600 }) + await writeFile(f.paths.state, originalState, { mode: 0o600 }) + controls.control.afterSync = async () => { + await writeFile(f.paths.state, JSON.stringify(rotated), { mode: 0o600 }) + } + } + await unchanged( + f, + () => runtime.publishNativeLocalRefreshFailure(f.paths, f.event, policy), + { status: 'superseded', reason: 'row-credential' }, + ) + expect(controls.renames).toBe(0) + expect(controls.reads).toBe(timing === 'first' ? 1 : 2) + if (timing === 'first') + expect(controls.events).not.toContain('acquire:native-runtime') + }) +} + +test('failure committed unknown bootstrap is non-dead recovery not identity proof', async () => { + const f = await fixture({}, true) + const event = await savedSuccessor(f) + const result = await publishNativeLocalRefreshFailure(f.paths, event, policy) + expect(result.status).toBe('published') + if (result.status !== 'published') throw new Error('Not published') + expect(result.attribution.target).toBe('committed') + expect(result.state.accounts.row).toEqual({ + binding: f.subject.binding, + lastRefreshError: { + message: 'Local account-check recovery required', + permanent: false, + credentialFingerprint: + event.status === 'persisted' + ? event.committed.credentialFingerprint + : '', + ...policy, + }, + }) + expect( + result.state.accounts.row?.lastRefreshError?.credentialFingerprint, + ).not.toBe(f.subject.credentialFingerprint) + expect( + result.state.accounts.row?.lastRefreshError?.accountIdentity, + ).toBeUndefined() +}) + +test('failure committed known validation retry retains predecessor errors and metadata', async () => { + const previous = { + message: 'synthetic-predecessor', + checkedAt: 5, + credentialFingerprint: 'a'.repeat(64), + permanent: false, + } + const f = await fixture({ + lastRefreshError: previous, + lastUsed: 9, + refreshErrorClearedAt: 0, + }) + const event = await savedSuccessor(f) + const result = await publishNativeLocalRefreshFailure(f.paths, event, policy) + expect(result.status).toBe('published') + if (result.status !== 'published' || event.status !== 'persisted') + throw new Error('Not published') + expect(result.state.accounts.row as unknown).toEqual({ + ...f.runtime.accounts.row!, + validationRetry: { + subject: { + binding: { ...f.subject.binding, identity: uuid }, + ...event.committed, + }, + checkedAt: 100, + nextRetryAt: 200, + }, + }) + expect(result.state.accounts.row?.credentialValidation).toBeUndefined() + const failed: NativeRefreshObservation = { + ...f.event, + status: 'failed', + persisted: true, + committed: event.committed, + failure: { kind: 'after-persist-hook', classification: 'permanent' }, + } + expect(classifyNativeLocalFailure(failed)).toMatchObject({ + kind: 'validation-retry', + target: 'committed', + subject: event.committed, + }) +}) + +test('failure known pre-network validation retry leaves lastRefreshError untouched', async () => { + const f = await fixture({ + lastRefreshError: { + message: 'synthetic-existing', + checkedAt: 5, + permanent: false, + }, + }) + const event: NativeRefreshObservation = { + ...f.event, + status: 'failed', + persisted: false, + failure: { kind: 'validation', classification: 'permanent' }, + } + expect( + await publishNativeLocalRefreshFailure(f.paths, event, policy), + ).toMatchObject({ + status: 'published', + state: { + accounts: { + row: { + lastRefreshError: f.runtime.accounts.row?.lastRefreshError, + validationRetry: { + subject: f.subject, + checkedAt: 100, + nextRetryAt: 200, + }, + }, + }, + }, + }) +}) + +test('failure non-attributable events skip zero file lock and staging I/O preserving backoff', async () => { + const f = await fixture({ + lastRefreshError: { + message: 'synthetic-bound-backoff', + checkedAt: 5, + nextRetryAt: 500, + permanent: false, + credentialFingerprint: fingerprintOf(credential), + }, + }) + const { runtime, controls } = await instrumented(f) + const variants: [NativeRefreshObservation, string][] = [ + [{ ...f.event, context: undefined }, 'no-context'], + [ + { + ...f.event, + status: 'failed', + persisted: true, + failure: { kind: 'unexpected', classification: 'permanent' }, + }, + 'missing-committed', + ], + [ + { + ...f.event, + status: 'failed', + persisted: false, + failure: { kind: 'caller-hook', classification: 'permanent' }, + }, + 'not-attributable', + ], + [ + { + ...f.event, + status: 'failed', + persisted: false, + failure: { kind: 'load-error', classification: 'permanent' }, + }, + 'not-attributable', + ], + [{ ...f.event, bootstrap: 'resolved' }, 'not-attributable'], + [ + { ...f.event, status: 'refused', reason: 'synthetic', consumed: true }, + 'not-a-failure', + ], + [ + { + ...f.event, + status: 'adopted', + successorFingerprint: f.subject.credentialFingerprint, + successorVersion: f.subject.version, + }, + 'not-a-failure', + ], + [ + { + ...f.event, + status: 'identity-contradicted', + expectedIdentity: uuid, + returnedIdentity: 'other', + successorFingerprint: f.subject.credentialFingerprint, + successorVersion: f.subject.version, + committed: { + credentialFingerprint: f.subject.credentialFingerprint, + version: f.subject.version, + }, + }, + 'not-a-failure', + ], + ] + for (const [event, reason] of variants) + await unchanged( + f, + () => runtime.publishNativeLocalRefreshFailure(f.paths, event, policy), + { status: 'skipped', reason }, + ) + expect(controls.fileIO).toBe(0) + expect(controls.events).toEqual([]) + expect(controls.reads).toBe(0) +}) + +for (const key of [ + 'refreshErrorClearedAt', + 'quotaErrorClearedAt', + 'quotaErrorGeneration', +] as const) { + test(`failure captured ${key} null is not a concurrent zero reset`, async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + controls.control.afterAcquire = async (name) => { + if (name !== 'native-runtime') return + await writeFile( + f.paths.runtime, + JSON.stringify({ + ...f.runtime, + accounts: { + ...f.runtime.accounts, + row: { ...f.runtime.accounts.row, [key]: 0 }, + }, + }), + { mode: 0o600 }, + ) + } + // The runtime file now contains a concurrent reset to zero. Publication + // must retain that reset instead of restoring the original null context. + const result = await runtime.publishNativeLocalRefreshFailure( + f.paths, + f.event, + policy, + ) + expect(result).toEqual({ status: 'superseded', reason: 'reset-context' }) + const saved = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + expect(saved.status === 'ready' && saved.state.accounts.row?.[key]).toBe(0) + expect(controls.renames).toBe(0) + }) +} + +test('failure runtime entry epoch retargeting supersedes without repair', async () => { + const f = await fixture() + const entry = f.runtime.accounts.row! + await writeFile( + f.paths.runtime, + JSON.stringify({ + ...f.runtime, + accounts: { + ...f.runtime.accounts, + row: { ...entry, binding: { ...entry.binding, credentialEpoch: 2 } }, + }, + }), + { mode: 0o600 }, + ) + await unchanged( + f, + () => publishNativeLocalRefreshFailure(f.paths, f.event, policy), + { status: 'superseded', reason: 'entry-binding' }, + ) +}) + +test('failure missing entry allowed only for genuinely null captured binding', async () => { + const f = await fixture({}, false, false, true) + expect( + await publishNativeLocalRefreshFailure(f.paths, f.event, policy), + ).toMatchObject({ status: 'published' }) + await unchanged( + f, + () => publishNativeLocalRefreshFailure(f.paths, f.event, policy), + { status: 'superseded', reason: 'entry-binding' }, + ) +}) + +test('failure older refresh error ordering supersedes without clearing', async () => { + const f = await fixture({ + lastRefreshError: { + message: 'synthetic-newer', + checkedAt: 101, + permanent: false, + }, + }) + await unchanged( + f, + () => publishNativeLocalRefreshFailure(f.paths, f.event, policy), + { status: 'superseded', reason: 'older-than-stored' }, + ) +}) + +for (const field of ['credentialValidation', 'validationRetry'] as const) { + test(`failure ${field} exact subject ordering supersedes validation retry`, async () => { + const f = await fixture() + const stored = + field === 'credentialValidation' + ? f.subject + : { subject: f.subject, checkedAt: 101, nextRetryAt: 300 } + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { + current.accounts.row = { ...current.accounts.row!, [field]: stored } + return current + }) + const event: NativeRefreshObservation = { + ...f.event, + status: 'failed', + persisted: false, + failure: { kind: 'validation', classification: 'transient' }, + } + await unchanged( + f, + () => publishNativeLocalRefreshFailure(f.paths, event, policy), + { + status: 'superseded', + reason: + field === 'credentialValidation' + ? 'already-proven' + : 'older-than-stored', + }, + ) + }) +} + +test('failure policy refuses unsafe fractional missing deadlines and clear floor equality', async () => { + const f = await fixture({ refreshErrorClearedAt: 100 }) + const variants: NativeLocalFailurePolicy[] = [ + policy, + { checkedAt: 2.5, nextRetryAt: 200 }, + { checkedAt: -1 }, + { checkedAt: Number.MAX_SAFE_INTEGER + 1 }, + { checkedAt: 101 }, + { checkedAt: 101, nextRetryAt: 100 }, + { checkedAt: 101, nextRetryAt: 102.5 }, + { checkedAt: 101, nextRetryAt: 200, retryCount: 0.5 }, + ] + for (const options of variants) + await refused(f, () => + publishNativeLocalRefreshFailure(f.paths, f.event, options), + ) + expect( + await publishNativeLocalRefreshFailure(f.paths, f.event, { + checkedAt: 101, + nextRetryAt: 101, + retryCount: 0, + }), + ).toMatchObject({ status: 'published' }) +}) + +test('failure invalid context sparse validation and committed contradictions are refused', async () => { + const f = await fixture() + for (const event of [ + { ...f.event, context: { ...f.event.context!, quotaErrorGeneration: 0.5 } }, + { + ...f.event, + context: { ...f.event.context!, refreshErrorClearedAt: undefined }, + }, + { ...f.event, credentialFingerprint: 'legacy-short-hash' }, + { + ...f.event, + credentialVersion: { ...f.subject.version, lastRefreshedAt: 0 }, + }, + ]) + await refused(f, () => + publishNativeLocalRefreshFailure( + f.paths, + event as NativeRefreshObservation, + policy, + ), + ) + const sparse = await fixture({}, true, true) + await refused(sparse, () => + publishNativeLocalRefreshFailure( + sparse.paths, + { + ...sparse.event, + status: 'failed', + persisted: false, + failure: { kind: 'validation', classification: 'transient' }, + }, + policy, + ), + ) + const event = await savedSuccessor(f) + if (event.status !== 'persisted') throw new Error('Missing persisted fixture') + await refused(f, () => + publishNativeLocalRefreshFailure( + f.paths, + { ...event, successorFingerprint: 'a'.repeat(64) }, + policy, + ), + ) +}) + +for (const status of [400, 403, 429, 500] as const) { + test(`failure provider status${status} dead-token discriminator is exactly400`, async () => { + const f = await fixture() + const event: NativeRefreshObservation = { + ...f.event, + status: 'failed', + persisted: false, + failure: { kind: 'provider', classification: 'invalid-grant', status }, + } + const result = await publishNativeLocalRefreshFailure( + f.paths, + event, + status === 400 ? { checkedAt: 100 } : policy, + ) + expect(result).toMatchObject({ + status: 'published', + state: { + accounts: { + row: { lastRefreshError: { status, permanent: status === 400 } }, + }, + }, + }) + }) +} + +for (const field of ['identity', 'credentialEpoch', 'missing-row'] as const) { + test(`failure final guarded binding rejects changed ${field}`, async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + controls.control.afterSync = async () => { + const config = JSON.parse(await readFile(f.paths.config, 'utf8')) + if (field === 'missing-row') config.accounts = [] + else if (field === 'identity') + config.accounts[0].accountId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + else config.commonAuthPool.rows.row.credentialEpoch = 2 + await writeFile(f.paths.config, JSON.stringify(config), { mode: 0o600 }) + } + await unchanged( + f, + () => runtime.publishNativeLocalRefreshFailure(f.paths, f.event, policy), + { status: 'superseded', reason: 'row-binding' }, + ) + expect(controls.renames).toBe(0) + }) +} + +test('failure cannot repair unidentified runtime from later known pool identity', async () => { + const f = await fixture() + const entry = f.runtime.accounts.row! + const unknownBinding = { ...f.subject.binding, identity: undefined } + await writeFile( + f.paths.runtime, + JSON.stringify({ + ...f.runtime, + accounts: { + ...f.runtime.accounts, + row: { ...entry, binding: unknownBinding }, + }, + }), + { mode: 0o600 }, + ) + const event = { + ...f.event, + context: { ...f.event.context!, runtimeBinding: unknownBinding }, + } + await unchanged( + f, + () => publishNativeLocalRefreshFailure(f.paths, event, policy), + { status: 'superseded', reason: 'entry-binding' }, + ) +}) + +test('failure retry equal-time conflict and differing access subject ordering', async () => { + const f = await fixture() + const event: NativeRefreshObservation = { + ...f.event, + status: 'failed', + persisted: false, + failure: { kind: 'validation', classification: 'transient' }, + } + expect( + await publishNativeLocalRefreshFailure(f.paths, event, policy), + ).toMatchObject({ status: 'published' }) + await unchanged( + f, + () => + publishNativeLocalRefreshFailure(f.paths, event, { + checkedAt: 100, + nextRetryAt: 300, + }), + { status: 'superseded', reason: 'older-than-stored' }, + ) + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { + const entry = current.accounts.row! + entry.validationRetry = { + ...entry.validationRetry!, + checkedAt: 101, + subject: { + ...entry.validationRetry!.subject, + version: { + ...entry.validationRetry!.subject.version, + accessFingerprint: 'a'.repeat(16), + }, + }, + } + return current + }) + expect( + await publishNativeLocalRefreshFailure(f.paths, event, policy), + ).toMatchObject({ status: 'published' }) +}) + +test('failure validation retry readers deny matching unexpired access but permit expired exchange', async () => { + const f = await fixture() + const event: NativeRefreshObservation = { + ...f.event, + status: 'failed', + persisted: false, + failure: { kind: 'validation', classification: 'transient' }, + } + await publishNativeLocalRefreshFailure(f.paths, event, policy) + const readers = createNativeLocalRuntimeReaders({ + paths: f.paths, + now: () => 150, + externalPolicy: () => ({ status: 'allowed' }), + }) + const result = classifyNativeLocalFailure(event) + if (result.kind !== 'validation-retry') + throw new Error('Missing retry subject') + expect(await readers.readAdmission(result.subject)).toEqual({ + status: 'blocked', + reason: 'validation-backoff', + }) + const expired = createNativeLocalRuntimeReaders({ + paths: f.paths, + now: () => credential.expires, + externalPolicy: () => ({ status: 'allowed' }), + }) + expect(await expired.readAdmission(result.subject)).toEqual({ + status: 'unproven', + }) +}) + +for (const arming of [false, true]) { + test(`failure caller ${arming ? 'future' : 'non-arming'} deadline respects coordinator retry budget`, async () => { + const f = await fixture() + let exchanges = 0 + const readers = createNativeLocalRuntimeReaders({ + paths: f.paths, + now: () => 100, + externalPolicy: () => ({ status: 'allowed' }), + }) + const c = createNativeRefreshCoordinator({ + paths: f.paths, + quota: nativeQuotaCodec, + ...readers, + refreshToken: async () => { + exchanges++ + throw new ClaudeOAuthRefreshError(500, 'synthetic-provider-body') + }, + resolveIdentity: async () => { + throw new Error('Lookup forbidden') + }, + reconcile: async (event) => { + expect( + await publishNativeLocalRefreshFailure(f.paths, event, { + checkedAt: 100, + nextRetryAt: arming ? 200 : 100, + }), + ).toMatchObject({ + status: event.status === 'failed' ? 'published' : 'skipped', + }) + }, + }) + expect( + await c.authorize({ + binding: f.subject.binding, + intent: 'serve', + mode: 'local', + rejectedAccessToken: credential.access, + }), + ).toMatchObject( + arming + ? { status: 'refused', reason: 'refresh-backoff' } + : { status: 'failed', failure: { classification: 'transient' } }, + ) + expect(exchanges).toBe(arming ? 1 : 3) + }) +} + +// Cancellation must reach an owned child before teardown joins its body. Join +// the process and both pipes individually before fixtures or leases are removed. +function joinOwnedChild( + child: Pick, + lifetime: Pick, + exited: Promise, + stdout: Promise, + stderr: Promise, +) { + const cancel = lifetime.gate() + const cancellation = cancel.wait.then(() => { + if (child.exitCode === null) child.kill() + }) + lifetime.trackDetached(cancellation) + const work = [exited, stdout, stderr] + const output = (async () => { + try { + const [exit, out, err] = await Promise.all([exited, stdout, stderr]) + return { exit, stdout: out, stderr: err } + } catch (error) { + cancel.open() + throw error + } finally { + await Promise.allSettled(work) + cancel.open() + await cancellation + } + })() + lifetime.trackDetached( + output.then( + () => {}, + () => {}, + ), + ) + return output +} + +test('failure owned child publisher performs zero provider HTTP and drains pipes', async () => { + const f = await fixture({}, true, true) + const child = Bun.spawn( + [ + process.execPath, + '-e', + ` + import { publishNativeLocalRefreshFailure } from ${JSON.stringify(new URL('../native-runtime.ts', import.meta.url).href)}; + let http = 0; globalThis.fetch = async () => { http++; throw new Error('Provider HTTP forbidden'); }; + const result = await publishNativeLocalRefreshFailure(${JSON.stringify(f.paths)}, ${JSON.stringify(f.event)}, ${JSON.stringify(policy)}); + console.log(JSON.stringify({ status: result.status, http })); + `, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const result = await joinOwnedChild( + child, + { gate, trackDetached }, + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ) + expect(result).toEqual({ + exit: 0, + stdout: '{"status":"published","http":0}\n', + stderr: '', + }) +}) + +test('failure child cancellation precedes body join and all three drains', async () => { + const f = await fixture() + const lifetime = new TestLifetime() + const entered = Promise.withResolvers() + const closing = gate() + trackDetached(closing.wait.then(() => lifetime.finish())) + let exitSettled = false, + stdoutSettled = false, + stderrSettled = false, + cleanupAfterAll = false + lifetime.deferCleanup(() => { + cleanupAfterAll = exitSettled && stdoutSettled && stderrSettled + }) + const child = Bun.spawn( + [ + process.execPath, + '-e', + ` + import { withLock } from ${JSON.stringify(import.meta.resolve('@cortexkit/common-auth/fs'))}; + const paths = ${JSON.stringify(f.paths)}; + await withLock(paths.config, { name: 'pool-config', ttlMs: 10000, timeoutMs: 15000, renew: true }, async () => + withLock(paths.state, { name: 'pool-state', ttlMs: 10000, timeoutMs: 15000, renew: true }, async () => + withLock(paths.runtime, { name: 'native-runtime', ttlMs: 10000, timeoutMs: 15000, renew: true }, async () => { + console.log('owned-leases-entered'); setInterval(() => {}, 1000); await new Promise(() => {}); + }))); + `, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const exited = child.exited.finally(() => { + exitSettled = true + }) + const stdoutPipe = child.stdout.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk) + entered.resolve() + }, + }), + ) + const stdout = new Response(stdoutPipe).text().finally(() => { + stdoutSettled = true + entered.resolve() + }) + const stderr = new Response(child.stderr).text().finally(() => { + stderrSettled = true + }) + const body = lifetime.runBody(() => + joinOwnedChild(child, lifetime, exited, stdout, stderr), + ) + trackDetached(body) + await entered.promise + closing.open() + await lifetime.finish() + const result = await body + expect(result.stdout).toContain('owned-leases-entered') + expect(result.exit).not.toBe(0) + expect(cleanupAfterAll).toBe(true) + // The killed fixture child cannot release its config and state locks. + // Remove those owned fixture locks only after the child exits and its + // stdout and stderr readers finish, so cleanup cannot race active work. + for (const [path, name] of [ + [f.paths.config, 'pool-config'], + [f.paths.state, 'pool-state'], + [f.paths.runtime, 'native-runtime'], + ]) + await rm(lockPathFor(path!, name!), { force: true }) +}) + +test('failure pool storage faults remain redacted errors not supersession', async () => { + const f = await fixture() + await writeFile(f.paths.state, 'synthetic-provider-body not JSON', { + mode: 0o600, + }) + await refused(f, () => + publishNativeLocalRefreshFailure(f.paths, f.event, policy), + ) +}) + +test('failure validation retry preserves different-subject proof field-for-field', async () => { + const f = await fixture({ lastUsed: 9, quotaErrorGeneration: 0 }) + const prior = { + ...f.subject, + binding: { ...f.subject.binding, identity: uuid }, + version: { accessFingerprint: 'a'.repeat(16), expires: credential.expires }, + } + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { + current.accounts.row!.credentialValidation = prior + return current + }) + const event: NativeRefreshObservation = { + ...f.event, + status: 'failed', + persisted: false, + failure: { kind: 'validation', classification: 'transient' }, + } + expect( + await publishNativeLocalRefreshFailure(f.paths, event, policy), + ).toMatchObject({ + status: 'published', + state: { + accounts: { + row: { + credentialValidation: prior, + lastUsed: 9, + quotaErrorGeneration: 0, + }, + }, + }, + }) +}) + +for (const field of ['refresh', 'expires', 'lastRefreshedAt'] as const) { + test(`failure exact full subject rejects changed ${field}`, async () => { + const f = await fixture() + await f.store.rotate('row', { + ...credential, + ...(field === 'refresh' + ? { refresh: 'independent-refresh' } + : field === 'expires' + ? { expires: credential.expires + 1 } + : {}), + }) + const state = JSON.parse(await readFile(f.paths.state, 'utf8')) + if (field === 'lastRefreshedAt') state.accounts.row.lastRefreshedAt = 0 + else delete state.accounts.row.lastRefreshedAt + await writeFile(f.paths.state, JSON.stringify(state), { mode: 0o600 }) + await unchanged( + f, + () => publishNativeLocalRefreshFailure(f.paths, f.event, policy), + { status: 'superseded', reason: 'row-credential' }, + ) + }) +} + +test('failure captured zero reset cannot match a missing runtime floor', async () => { + const f = await fixture() + const event = { + ...f.event, + context: { ...f.event.context!, refreshErrorClearedAt: 0 }, + } + await unchanged( + f, + () => publishNativeLocalRefreshFailure(f.paths, event, policy), + { status: 'superseded', reason: 'reset-context' }, + ) +}) + +test('failure stage cleanup storage fault cannot become captured supersession', async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + controls.control.afterSync = async () => { + const config = JSON.parse(await readFile(f.paths.config, 'utf8')) + config.accounts = [] + await writeFile(f.paths.config, JSON.stringify(config), { mode: 0o600 }) + controls.control.failStageCleanup = true + } + const before = await readFile(f.paths.runtime, 'utf8') + let error: unknown + try { + await runtime.publishNativeLocalRefreshFailure(f.paths, f.event, policy) + } catch (caught) { + error = caught + } + expect(error).toMatchObject({ + name: 'NativeRuntimeError', + code: 'runtime-io', + }) + expect(String(error)).not.toContain('synthetic-provider-body') + expect(await readFile(f.paths.runtime, 'utf8')).toBe(before) + expect(controls.renames).toBe(0) + expect(controls.held.size).toBe(0) +}) + +for (const boundary of [ + 'status', + 'credential-version', + 'subject-version', + 'runtime-binding', + 'committed-version', +] as const) { + test(`failure plain capture rejects ${boundary} accessors without reading or leaking`, async () => { + const f = await fixture() + const { runtime, controls } = await instrumented(f) + const event: NativeRefreshObservation = structuredClone( + boundary === 'committed-version' ? await savedSuccessor(f) : f.event, + ) + let getterReads = 0 + let target: object = event + let key: string = 'status' + if (boundary === 'credential-version') { + target = event.credentialVersion! + key = 'accessFingerprint' + } + if (boundary === 'subject-version') { + target = event.context!.subject.version + key = 'expires' + } + if (boundary === 'runtime-binding') { + target = event.context!.runtimeBinding! + key = 'identity' + } + if (boundary === 'committed-version') { + if (event.status !== 'persisted') throw new Error('Missing saved fixture') + target = event.committed.version + key = 'accessFingerprint' + } + Object.defineProperty(target, key, { + enumerable: true, + configurable: true, + get() { + getterReads++ + throw new Error('synthetic-bearer-audit-only') + }, + }) + for (const invoke of [ + () => runtime.classifyNativeLocalFailure(event), + () => runtime.publishNativeLocalRefreshFailure(f.paths, event, policy), + ]) { + let error: unknown + try { + await invoke() + } catch (caught) { + error = caught + } + expect(error).toMatchObject({ + name: 'NativeRuntimeError', + code: 'publication-refused', + message: 'Anthropic runtime publication was refused', + }) + expect(String(error)).not.toContain('synthetic-bearer-audit-only') + expect(error instanceof Error && Object.hasOwn(error, 'cause')).toBe( + false, + ) + expect(getterReads).toBe(0) + } + expect(controls.fileIO).toBe(0) + expect(controls.events).toEqual([]) + expect(controls.renames).toBe(0) + }) +} + +test('failure plain capture closes proxy reflection faults without invoking traps', async () => { + const f = await fixture() + let reflections = 0 + const event = new Proxy(f.event, { + getPrototypeOf() { + reflections++ + throw new Error('synthetic-bearer-audit-only') + }, + }) + for (const invoke of [ + () => classifyNativeLocalFailure(event), + () => publishNativeLocalRefreshFailure(f.paths, event, policy), + ]) { + let error: unknown + try { + await invoke() + } catch (caught) { + error = caught + } + expect(error).toMatchObject({ + name: 'NativeRuntimeError', + code: 'publication-refused', + }) + expect(String(error)).not.toContain('synthetic-bearer-audit-only') + } + expect(reflections).toBe(0) +}) + +test('failure classifier malformed actionable plain records stay inside the closed public boundary', async () => { + const f = await fixture() + const malformed: unknown[] = [ + null, + undefined, + { ...f.event, failure: undefined }, + { ...f.event, failure: null }, + { ...f.event, failure: {} }, + { ...f.event, failure: { kind: null, classification: 'transient' } }, + { + ...f.event, + failure: { + kind: 'provider', + classification: 'synthetic-bearer-audit-only', + }, + }, + { + ...f.event, + failure: { + kind: 'provider', + classification: 'transient', + status: 'synthetic-bearer-audit-only', + }, + }, + { ...f.event, persisted: 'synthetic-bearer-audit-only' }, + { ...f.event, context: null }, + { ...f.event, context: 0 }, + { ...f.event, context: 'synthetic-bearer-audit-only' }, + { ...f.event, context: {} }, + { ...f.event, context: { ...f.event.context, subject: null } }, + { ...f.event, context: { ...f.event.context, subject: {} } }, + ] + const { runtime, controls } = await instrumented(f) + const alteredNativeError = new runtime.NativeRuntimeError( + 'publication-refused', + ) + alteredNativeError.message = 'synthetic-bearer-audit-only' + Object.defineProperty(alteredNativeError, 'cause', { + value: 'synthetic-bearer-audit-only', + }) + malformed.push({ ...f.event, failure: alteredNativeError }) + for (const input of malformed) { + const event = input as NativeRefreshObservation + for (const invoke of [ + () => runtime.classifyNativeLocalFailure(event), + () => runtime.publishNativeLocalRefreshFailure(f.paths, event, policy), + ]) { + let error: unknown + try { + await invoke() + } catch (caught) { + error = caught + } + expect(error).toMatchObject({ + name: 'NativeRuntimeError', + code: 'publication-refused', + message: 'Anthropic runtime publication was refused', + }) + expect(String(error)).not.toContain('synthetic-bearer-audit-only') + expect(error instanceof Error && Object.hasOwn(error, 'cause')).toBe( + false, + ) + expect(error).not.toBe(alteredNativeError) + } + } + expect(controls.fileIO).toBe(0) + expect(controls.events).toEqual([]) + expect(classifyNativeLocalFailure(f.event)).toMatchObject({ + kind: 'refresh-error', + target: 'captured', + }) + expect( + classifyNativeLocalFailure({ ...f.event, context: undefined }), + ).toEqual({ kind: 'none', reason: 'no-context' }) + expect( + classifyNativeLocalFailure({ + ...f.event, + status: 'failed', + persisted: false, + failure: { kind: 'caller-hook', classification: 'permanent' }, + }), + ).toEqual({ kind: 'none', reason: 'not-attributable' }) +}) From 068b69bc0bf83c8e0f0ebb3d4f4e7b7deb7ccb9b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:45:56 +0200 Subject: [PATCH 033/169] mason: finalize guarded native failure publication Finalize the parent-reviewed four-file tree preserved in safety ancestor 6e31d6bd634ea2f69040240fd91b49656b7e6d1c, without rewriting that ancestor or changing source. Add typed failure publication and descriptor-safe whole-boundary attribution; share the private guarded transaction with unchanged success signatures and preserve exact predecessor/committed lineage, resets, ownership and metadata. Only HTTP400 invalid_grant is dead-token permanent. The parent narrowly authorized correcting the existing503 body's one-call invalid-grant claim to transient/three-call behavior, original503 status and non-dead actual publication. That old claim contradicted AccountOperationError and the accounts writer; its coverage and the healthy400 one-call companion are retained. Pinned Bun1.3.14/Node24.16.0 verification passed 57 focused and 860 Core tests, seven packed/type-closure tests, Core/workspace tsc, build, closure, lock and formatting. Parent independently verified hostile observation/path boundaries, JavaScript parity and final Sidekick comments (16 blocks/43 lines). No source/gate cycle was rerun after approval. Campaign recovery deleted the original ignored evidence cache. Recreated only26 distinct copied-source controls against the exact approved source; each isolated its named red with a healthy companion, applied patch, empty restored diff and equal baseline/restored SHA. No historical failed/misanchored controls were regenerated. Parent independently verified and durably archived all raw evidence at .cortexkit/alfonso/reports/native-failure-recovered-parent-20261007/recovered-controls.tar.gz, SHA256 d64343391f94c10377e78ccb7416f030e054d3861b965619e4ee0facfd76d737. No host/service/migration/reader/barrel/dependency or unrelated timing changes. No claim of native cutover or filesystem compare-and-swap; a publication exception does not prove rollback. Main/live remain untouched and branch integration/final combined verification are parent-owned. From 61d066f60d0669d710f4961da31b147cbb908e7d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:01:20 +0200 Subject: [PATCH 034/169] mason: compose local OAuth credential service Add createNativeLocalCredentialService. It exposes authorize only. Each call first requires the committed migration journal, then returns the existing refresh coordinator's result. The coordinator is wired to the strict native local runtime readers and to a private reconciler. The reconciler chooses a publisher from a frozen copy of each observation. Resolved account lookups publish first-identity evidence when the captured runtime predecessor has no account UUID; otherwise they publish known-account validation evidence. Attributable failures go through the guarded failure publisher with the caller's failure policy. Publication storage faults stay reconciliation errors. The parent reviewed the source and comments, including its own comment-only clarifications. The approved patch SHA256 is 06f2d4cdbd0cb3e0d2756f67fb87245cf46fdef2986d8ef29ad9d77020752d60. --- .../src/native-local-credential-service.ts | 307 +++++ .../native-local-credential-service.test.ts | 1160 +++++++++++++++++ 2 files changed, 1467 insertions(+) create mode 100644 packages/core/src/native-local-credential-service.ts create mode 100644 packages/core/src/tests/native-local-credential-service.test.ts diff --git a/packages/core/src/native-local-credential-service.ts b/packages/core/src/native-local-credential-service.ts new file mode 100644 index 000000000..075c47423 --- /dev/null +++ b/packages/core/src/native-local-credential-service.ts @@ -0,0 +1,307 @@ +import { types as utilTypes } from 'node:util' + +import { + createNativeLocalRuntimeReaders, + type NativeLocalExternalPolicy, +} from './native-local-runtime-readers.ts' +import { nativeQuotaCodec } from './native-quota-codec.ts' +import { + createNativeRefreshCoordinator, + type NativeRefreshCoordinatorOptions, + type NativeRefreshObservation, + type NativeRefreshRequest, + type NativeRefreshResult, + type NativeRefreshSubject, +} from './native-refresh-coordinator.ts' +import { + classifyNativeLocalFailure, + type NativeLocalFailureAttribution, + type NativeLocalFailurePolicy, + publishNativeLocalCredentialValidation, + publishNativeLocalFirstIdentity, + publishNativeLocalRefreshFailure, +} from './native-runtime.ts' +import { + NativeAuthorityError, + requireNativePoolAuthority, +} from './pool-authority.ts' +import type { NativePoolPaths } from './pool-paths.ts' + +/** A failure that the runtime failure publisher can record for one credential. */ +export type NativeLocalAttributedFailure = Exclude< + NativeLocalFailureAttribution, + { kind: 'none' } +> + +/** + * Everything the failure policy may use. Both values are frozen copies taken + * from the coordinator's report before any lock wait. They describe the + * credential version the failed operation used, not the pool row or runtime + * entry as they are now. + */ +export interface NativeLocalFailurePolicyInput { + readonly observation: NativeRefreshObservation + readonly attribution: NativeLocalAttributedFailure +} + +export interface NativeLocalCredentialServiceOptions { + /** Storage resolved by resolveNativePoolPaths. The service keeps its own copy. */ + readonly paths: NativePoolPaths + /** + * Decide without I/O whether local OAuth mode, the account and its quota + * allow the operation. Credential-validation evidence is checked separately. + */ + readonly externalPolicy: ( + subject: NativeRefreshSubject, + ) => NativeLocalExternalPolicy + /** + * Choose when a recorded failure was observed and when its credential may + * be retried. The service calls this once per attributable failure report, + * including each failed attempt of the coordinator's bounded retry loop. + * + * The coordinator reads the stored retry deadline again before each further + * attempt. A nextRetryAt later than the current clock therefore stops that + * loop: the next attempt is refused as 'refresh-backoff' and the result no + * longer carries the provider failure. Return nextRetryAt equal to checkedAt + * to record the failure without delaying the coordinator's own retries. + * The service never adds retries or a retry schedule of its own, and it does + * not treat an attempt number as proof that the failure is final. + */ + readonly failurePolicy: ( + input: NativeLocalFailurePolicyInput, + ) => NativeLocalFailurePolicy + /** Clock for expiry, stored retry deadlines and pool writes; defaults to Date.now. */ + readonly now?: () => number + /** Token-exchange function; defaults to the Claude OAuth refresh endpoint. */ + readonly refreshToken?: NativeRefreshCoordinatorOptions['refreshToken'] + /** Account lookup for new access; defaults to the Claude Code identity lookup. */ + readonly resolveIdentity?: NativeRefreshCoordinatorOptions['resolveIdentity'] +} + +/** + * Hosts receive an access token only after its Claude account has been checked. + * That check can be reused while the refresh token, access token, expiry and + * stored refresh timestamp have not changed. + */ +export interface NativeLocalCredentialService { + authorize(request: NativeRefreshRequest): Promise +} + +const optionKeys = [ + 'paths', + 'externalPolicy', + 'failurePolicy', + 'now', + 'refreshToken', + 'resolveIdentity', +] as const +const pathKeys = [ + 'legacyConfig', + 'legacyState', + 'config', + 'state', + 'runtime', + 'journal', + 'roster', + 'storageId', +] as const +const policyKeys = ['checkedAt', 'nextRetryAt', 'retryCount'] as const + +function invalidOption(key: string): TypeError { + return new TypeError( + `Native local credential service option ${key} is invalid`, + ) +} + +function plainDescriptors( + value: unknown, + allowed: readonly string[], +): Record | undefined { + if ( + value === null || + typeof value !== 'object' || + utilTypes.isProxy(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) + return undefined + const descriptors = Object.getOwnPropertyDescriptors(value) + for (const key of Reflect.ownKeys(descriptors)) { + const descriptor = descriptors[key as string] + // Getters would run caller code each time the value is read, so a later + // read could return a different callback or path than the one checked. + if ( + typeof key !== 'string' || + !allowed.includes(key) || + !descriptor || + !Object.hasOwn(descriptor, 'value') + ) + return undefined + } + return descriptors +} + +function requiredFunction( + descriptors: Record, + key: string, +): T { + const value = descriptors[key]?.value + if (typeof value !== 'function') throw invalidOption(key) + return value as T +} + +function optionalFunction( + descriptors: Record, + key: string, +): T | undefined { + const value = descriptors[key]?.value + if (value === undefined) return undefined + if (typeof value !== 'function') throw invalidOption(key) + return value as T +} + +function capturePaths(value: unknown): NativePoolPaths { + const descriptors = plainDescriptors(value, pathKeys) + if (!descriptors) throw invalidOption('paths') + const copy: Record = {} + for (const key of pathKeys) { + const item = descriptors[key]?.value + if (typeof item !== 'string' || item.length === 0) + throw invalidOption('paths') + copy[key] = item + } + return Object.freeze(copy as unknown as NativePoolPaths) +} + +function frozenCopy(value: T): T { + const copy = structuredClone(value) + const freeze = (item: unknown) => { + if (item === null || typeof item !== 'object') return + for (const child of Object.values(item)) freeze(child) + Object.freeze(item) + } + freeze(copy) + return copy +} + +// The publisher checks the numeric values. This copy only makes sure that the +// values it checks are the values it writes, without getters or extra fields. +function capturePolicy(value: unknown): NativeLocalFailurePolicy { + const descriptors = plainDescriptors(value, policyKeys) + if (!descriptors || !Object.hasOwn(descriptors, 'checkedAt')) + throw new TypeError('Native local failure policy result is invalid') + const field = (key: (typeof policyKeys)[number]) => + descriptors[key]?.value as number | undefined + const nextRetryAt = field('nextRetryAt') + const retryCount = field('retryCount') + return Object.freeze({ + checkedAt: field('checkedAt') as number, + ...(nextRetryAt !== undefined ? { nextRetryAt } : {}), + ...(retryCount !== undefined ? { retryCount } : {}), + }) +} + +/** + * Authorize local OAuth only after offline migration commits the pool as the + * credential authority. Creating this service neither imports credentials nor + * completes migration. The coordinator owns exchange, account lookup, bounded + * retries and the final validation check; it refuses API-key rows. + * + * Successful account checks record validation for the exact credential version. + * An existing runtime entry with no account UUID loses its unproven metadata, + * but keeps the clear timestamps that prevent older errors from returning. + * Known-account entries keep their metadata; a missing entry can be created + * only when the observation captured that absence before the provider call. + * Attributed failures record their credential version and caller retry policy. + * Writers recheck credential versions, bindings and clear timestamps under + * pool/runtime locks. A write fault prevents credentials from being returned, + * but cannot undo a token exchange or file replacement already committed. + */ +export function createNativeLocalCredentialService( + options: NativeLocalCredentialServiceOptions, +): NativeLocalCredentialService { + const descriptors = plainDescriptors(options, optionKeys) + if (!descriptors) + throw new TypeError('Native local credential service options are invalid') + const paths = capturePaths(descriptors.paths?.value) + const externalPolicy = requiredFunction< + NativeLocalCredentialServiceOptions['externalPolicy'] + >(descriptors, 'externalPolicy') + const failurePolicy = requiredFunction< + NativeLocalCredentialServiceOptions['failurePolicy'] + >(descriptors, 'failurePolicy') + const now = optionalFunction<() => number>(descriptors, 'now') + const refreshToken = optionalFunction< + NativeLocalCredentialServiceOptions['refreshToken'] + >(descriptors, 'refreshToken') + const resolveIdentity = optionalFunction< + NativeLocalCredentialServiceOptions['resolveIdentity'] + >(descriptors, 'resolveIdentity') + + async function reconcile(observation: NativeRefreshObservation) { + // Select from the captured provider observation, never a later runtime read. + // Writers recheck its credential version, binding and error-clear timestamps + // under locks; changing the subject would misattribute the provider result. + const event = frozenCopy(observation) + if ( + (event.status === 'persisted' || + event.status === 'validation-observed') && + event.bootstrap === 'resolved' + ) { + const predecessor = event.context?.runtimeBinding + if (predecessor && predecessor.identity === undefined) + await publishNativeLocalFirstIdentity(paths, event) + else await publishNativeLocalCredentialValidation(paths, event) + return + } + if (event.status !== 'failed' && event.status !== 'persisted') return + const attribution = classifyNativeLocalFailure(event) + if (attribution.kind === 'none') return + const policy = capturePolicy( + failurePolicy( + Object.freeze({ + observation: event, + attribution: frozenCopy(attribution), + }), + ), + ) + // Recording a failure, skipping an inapplicable update or rejecting a stale + // update cannot validate credentials. Leave the authorization result to the + // coordinator's separate admission check. + await publishNativeLocalRefreshFailure(paths, event, policy) + } + + const coordinator = createNativeRefreshCoordinator({ + paths, + quota: nativeQuotaCodec, + ...(now ? { now } : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(resolveIdentity ? { resolveIdentity } : {}), + ...createNativeLocalRuntimeReaders({ + paths, + externalPolicy, + ...(now ? { now } : {}), + }), + reconcile, + }) + + async function authorize( + request: NativeRefreshRequest, + ): Promise { + try { + await requireNativePoolAuthority(paths) + } catch (error) { + return { + status: 'refused', + reason: + error instanceof NativeAuthorityError + ? error.code + : 'authority-unavailable', + persisted: false, + } + } + return coordinator.authorize(request) + } + + return Object.freeze({ authorize }) +} diff --git a/packages/core/src/tests/native-local-credential-service.test.ts b/packages/core/src/tests/native-local-credential-service.test.ts new file mode 100644 index 000000000..bbcc757ff --- /dev/null +++ b/packages/core/src/tests/native-local-credential-service.test.ts @@ -0,0 +1,1160 @@ +import { expect } from 'bun:test' +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { fingerprintOf, type PoolRow } from '@cortexkit/common-auth/store' + +import { + ClaudeOAuthRefreshError, + type refreshClaudeOAuthToken, +} from '../auth.ts' +import type { ProviderAccountUuid } from '../claude-code.ts' +import type { NativeLocalCredentialValidation } from '../native-credential-validation.ts' +import type { + NativeLocalCredentialServiceOptions, + NativeLocalFailurePolicyInput, +} from '../native-local-credential-service.ts' +import * as production from '../native-local-credential-service.ts' +import { nativeQuotaCodec } from '../native-quota-codec.ts' +import type { + NativeRefreshRequest, + NativeRefreshResult, +} from '../native-refresh-coordinator.ts' +import { + isNativeRuntimeStagingName, + type NativeLocalFailurePolicy, + type NativeRuntimeEntry, + readNativeRuntime, + updateNativeRuntime, +} from '../native-runtime.ts' +import { + captureNativeLocalPoolBinding, + type NativeLocalPoolBinding, +} from '../pool-binding.ts' +import { type NativePoolPaths, resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore } from '../pool-store.ts' +import { tokenFingerprint } from '../token-fingerprint.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup, gate } = createTestLifetimeSuite() +const accountA = '11111111-2222-4333-8444-555555555555' as ProviderAccountUuid +const original = { + type: 'oauth' as const, + access: 'synthetic-service-access', + refresh: 'synthetic-service-refresh', + expires: 4_000_000_000_000, +} +const successor = { + access: 'synthetic-service-successor-access', + refresh: 'synthetic-service-successor-refresh', + expires: 4_000_000_100_000, + expiresIn: 3_600, +} +const providerBody = 'synthetic-provider-body' +const start = 1_000_000 + +type ServiceModule = typeof production +type Exchange = Awaited> +type Authority = 'committed' | 'missing' | 'incomplete' | 'invalid' + +interface FixtureOptions { + /** Whether the pool row records the account UUID. */ + poolIdentity?: boolean + /** Runtime entry captured before network work: bound to the account UUID, identity-less, or absent. */ + runtime?: 'known' | 'unknown' | 'missing' + metadata?: + | Partial + | ((binding: NativeLocalPoolBinding) => Partial) + authority?: Authority + expires?: number +} + +function migrationInput(paths: NativePoolPaths) { + const root = dirname(paths.config) + return { + host: 'opencode' as const, + sources: { + config: 'a'.repeat(64), + state: 'b'.repeat(64), + hostAuth: 'absent', + routing: null, + }, + routingPaths: { + source: join(root, 'legacy-routing.json'), + destination: join(root, 'native-routing.json'), + }, + hostAuthPath: join(root, 'host-auth.json'), + } +} + +/** + * Write a migration journal directly in the requested phase. Each request + * still reads it through the production authority guard, which decodes and + * checks it; pool-authority.test.ts covers the journal API's phase changes. + */ +async function writeJournal(paths: NativePoolPaths, authority: Authority) { + if (authority === 'missing') return + const committed = authority === 'committed' + await writeFile( + paths.journal, + authority === 'invalid' + ? '{"version":2' + : JSON.stringify({ + version: 2, + storageId: paths.storageId, + ...migrationInput(paths), + phase: committed ? 'committed' : 'building', + expectedHostAuth: committed ? 'absent' : 'unprepared', + expectedRouting: committed ? null : 'unprepared', + }), + { mode: 0o600 }, + ) +} + +async function fixture(options: FixtureOptions = {}) { + const parent = new URL( + '../../../../node_modules/.cache/native-local-credential-service/', + import.meta.url, + ) + await mkdir(parent, { recursive: true, mode: 0o700 }) + const root = await mkdtemp(join(parent.pathname, 'fixture-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + const clock = { time: start } + const store = createNativePoolStore({ + paths, + quota: nativeQuotaCodec, + now: () => clock.time, + }) + async function row(): Promise { + const read = await store.read() + const found = read.status === 'ready' ? read.rows[0] : undefined + if (!found) throw new Error('Missing fixture row') + return found + } + /** Validation evidence computed independently from the row as stored now. */ + async function proof(): Promise { + const current = await row() + const c = current.credential + if (c?.type !== 'oauth' || !c.access || c.expires === undefined) + throw new Error('Fixture row has no OAuth access') + return { + binding: { + ...captureNativeLocalPoolBinding(paths, current), + identity: accountA, + }, + credentialFingerprint: fingerprintOf(c), + version: { + accessFingerprint: tokenFingerprint(c.access), + expires: c.expires, + ...(Object.hasOwn(c, 'lastRefreshedAt') + ? { lastRefreshedAt: c.lastRefreshedAt } + : {}), + }, + } + } + async function entry(): Promise { + const read = await readNativeRuntime(paths.runtime, paths.storageId) + return read.status === 'ready' ? read.state.accounts.row : undefined + } + await store.initialize() + await store.add({ + id: 'row', + ...(options.poolIdentity !== false ? { identity: accountA } : {}), + credential: { ...original, expires: options.expires ?? original.expires }, + }) + const binding = captureNativeLocalPoolBinding(paths, await row()) + const runtime = options.runtime ?? 'known' + if (runtime !== 'missing') { + const { identity: _identity, ...unknown } = binding + const metadata = + typeof options.metadata === 'function' + ? options.metadata(binding) + : options.metadata + await updateNativeRuntime(paths.runtime, paths.storageId, () => ({ + version: 1, + storageId: paths.storageId, + accounts: { + row: { + binding: + runtime === 'known' ? { ...binding, identity: accountA } : unknown, + ...metadata, + }, + }, + })) + } + await writeJournal(paths, options.authority ?? 'committed') + const f = { + root, + paths, + store, + binding, + row, + proof, + entry, + get time(): number { + return clock.time + }, + set time(value: number) { + clock.time = value + }, + exchanges: 0, + lookups: 0, + failures: [] as NativeLocalFailurePolicyInput[], + exchange: async (): Promise => successor, + lookup: async (): Promise => accountA, + policy: ( + _input: NativeLocalFailurePolicyInput, + ): NativeLocalFailurePolicy => ({ + checkedAt: clock.time, + nextRetryAt: clock.time, + }), + request(fields: Partial = {}): NativeRefreshRequest { + return { mode: 'local', intent: 'serve', binding, ...fields } + }, + } + return f +} +type Fixture = Awaited> + +function service( + f: Fixture, + module: ServiceModule = production, + overrides: Partial = {}, +) { + return module.createNativeLocalCredentialService({ + paths: f.paths, + now: () => f.time, + externalPolicy: () => ({ status: 'allowed' }), + failurePolicy: (input) => { + f.failures.push(input) + return f.policy(input) + }, + refreshToken: async (input) => { + expect(input.maxRetries).toBe(0) + f.exchanges++ + return f.exchange() + }, + resolveIdentity: async () => { + f.lookups++ + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: await f.lookup(), + } + }, + ...overrides, + }) +} + +async function snapshot(root: string) { + const result: Record = {} + for (const name of (await readdir(root, { recursive: true })).sort()) { + try { + result[name] = (await readFile(join(root, name))).toString('hex') + } catch { + result[name] = 'directory' + } + } + return result +} + +async function stages(f: Fixture) { + return (await readdir(f.root)).filter((name) => + isNativeRuntimeStagingName(f.paths.runtime, name), + ) +} + +function leaksNoSecret(value: unknown) { + const text = JSON.stringify(value) + for (const secret of [ + original.access, + original.refresh, + successor.refresh, + providerBody, + ]) + expect(text).not.toContain(secret) +} + +/** Report which safety property failed, so a broken module copy is identified by name. */ +async function named(name: string, check: () => unknown) { + try { + await check() + } catch (error) { + throw new Error( + `${name} failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +// Owned module copies import every other module from its production file, so +// leases, renames and the coordinator are the real ones. Only the copied +// service source differs, and only where a test removes one guard. +async function owned(rewrite?: { from: string; to: string }) { + const parent = new URL( + '../../../../node_modules/.cache/native-local-credential-service/', + import.meta.url, + ) + await mkdir(parent, { recursive: true, mode: 0o700 }) + const directory = await mkdtemp(join(parent.pathname, 'owned-')) + deferCleanup(() => rm(directory, { recursive: true, force: true })) + const sourcePath = new URL( + '../native-local-credential-service.ts', + import.meta.url, + ) + let source = await readFile(sourcePath, 'utf8') + if (rewrite) { + expect(source.split(rewrite.from)).toHaveLength(2) + source = source.replace(rewrite.from, rewrite.to) + } + source = source.replace( + /from '(\.\/[^']+)'/g, + (_match, relative: string) => + `from '${new URL(relative, sourcePath).href}'`, + ) + const path = join(directory, 'service.ts') + await writeFile(path, source) + const module: ServiceModule = await import(pathToFileURL(path).href) + return module +} + +const authorityCodes = { + missing: 'migration-required', + incomplete: 'migration-incomplete', + invalid: 'invalid-journal', +} as const + +for (const authority of ['missing', 'incomplete', 'invalid'] as const) { + test(`authority ${authority} journal refuses every request before provider dispatch or writes`, async () => { + const f = await fixture({ authority, expires: start - 1 }) + const s = service(f) + const before = await snapshot(f.root) + for (const fields of [ + {}, + { intent: 'refresh' as const }, + { rejectedAccessToken: original.access }, + ]) + expect(await s.authorize(f.request(fields))).toEqual({ + status: 'refused', + reason: authorityCodes[authority], + persisted: false, + }) + expect([f.exchanges, f.lookups, f.failures.length]).toEqual([0, 0, 0]) + expect(await snapshot(f.root)).toEqual(before) + }) +} + +async function authorityScenario(module: ServiceModule) { + const f = await fixture({ authority: 'missing' }) + const result = await service(f, module).authorize(f.request()) + await named('authority refusal precedes provider dispatch', () => { + expect(result).toEqual({ + status: 'refused', + reason: 'migration-required', + persisted: false, + }) + expect([f.exchanges, f.lookups]).toEqual([0, 0]) + }) + // The same request is served once the migration journal is committed. + await writeJournal(f.paths, 'committed') + const served = await service(f, module).authorize(f.request()) + await named('committed authority serves validated access', async () => { + expect(served).toMatchObject({ status: 'usable', source: 'validated' }) + expect([f.exchanges, f.lookups]).toEqual([0, 1]) + }) +} + +test('exact positive proof is published before current access is served', async () => { + const f = await fixture() + const s = service(f) + expect(await f.entry()).toEqual({ + binding: { ...f.binding, identity: accountA }, + }) + const result = await s.authorize(f.request()) + const proof = await f.proof() + expect(result).toEqual({ + status: 'usable', + source: 'validated', + binding: proof.binding, + access: original.access, + expires: original.expires, + subject: proof, + validation: proof, + }) + expect(await f.entry()).toEqual({ + binding: proof.binding, + credentialValidation: proof, + }) + expect([f.exchanges, f.lookups, f.failures.length]).toEqual([0, 1, 0]) + // The saved evidence now admits the same credential without another lookup. + expect(await s.authorize(f.request())).toMatchObject({ + status: 'usable', + source: 'current', + access: original.access, + validation: proof, + }) + expect([f.exchanges, f.lookups]).toEqual([0, 1]) + expect(await stages(f)).toEqual([]) +}) + +const knownMetadata = ( + binding: NativeLocalPoolBinding, +): Partial => ({ + lastUsed: 1, + lastRefreshedAt: 2, + refreshErrorClearedAt: 3, + lastRefreshError: { + message: 'older refresh failure', + checkedAt: 11, + accountIdentity: accountA, + credentialFingerprint: fingerprintOf({ + type: 'oauth', + refresh: 'synthetic-retired-refresh', + }), + }, + quotaErrorClearedAt: 4, + quotaErrorGeneration: 7, + profile: { + tier: 'max', + orgType: 'team', + checkedAt: 5, + providerAccountUuid: accountA, + }, + prime: { count: 1, inputTokens: 2, outputTokens: 3, since: 4 }, + authLineageId: 'synthetic-lineage', + validationRetry: { + subject: { + binding: { ...binding, identity: accountA }, + credentialFingerprint: fingerprintOf({ + type: 'oauth', + refresh: 'synthetic-retired-refresh', + }), + version: { accessFingerprint: tokenFingerprint('retired'), expires: 9 }, + }, + checkedAt: 20, + nextRetryAt: 40, + }, +}) + +async function knownRotationScenario(module: ServiceModule) { + const f = await fixture({ metadata: knownMetadata }) + const before = await f.entry() + if (!before?.profile || !before.validationRetry) + throw new Error('Missing fixture metadata') + const result = await service(f, module).authorize( + f.request({ rejectedAccessToken: original.access }), + ) + await named( + 'known rotation preserves metadata and records proof', + async () => { + const proof = await f.proof() + expect(proof.credentialFingerprint).toBe( + fingerprintOf({ type: 'oauth', refresh: successor.refresh }), + ) + expect(result).toMatchObject({ + status: 'usable', + source: 'rotated', + access: successor.access, + validation: proof, + }) + expect(await f.entry()).toEqual({ + ...before, + credentialValidation: proof, + }) + expect([f.exchanges, f.lookups, f.failures.length]).toEqual([1, 1, 0]) + }, + ) +} + +test('known-token rotation preserves every runtime metadata field and records successor proof', async () => { + await knownRotationScenario(production) +}) + +async function firstIdentityScenario(module: ServiceModule) { + const f = await fixture({ + runtime: 'unknown', + metadata: { + lastUsed: 1, + refreshErrorClearedAt: 3, + lastRefreshError: { message: 'unproven failure', checkedAt: 11 }, + quotaErrorClearedAt: 4, + quotaErrorGeneration: 7, + profile: { tier: 'max', orgType: 'team', checkedAt: 5 }, + authLineageId: 'synthetic-unproven-lineage', + }, + }) + const result = await service(f, module).authorize(f.request()) + await named('first-identity repair serves validated access', async () => { + const proof = await f.proof() + expect(result).toMatchObject({ + status: 'usable', + source: 'validated', + access: original.access, + validation: proof, + }) + // Unproven profile, usage and error details are dropped. The reset fence + // advances to the dropped error's time so that error cannot be restored. + expect(await f.entry()).toEqual({ + binding: proof.binding, + credentialValidation: proof, + refreshErrorClearedAt: 11, + quotaErrorClearedAt: 4, + quotaErrorGeneration: 7, + }) + }) +} + +test('identity-less runtime predecessor of a known pool row is repaired through first identity', async () => { + await firstIdentityScenario(production) +}) + +test('unknown pool row learns its account through rotation and first-identity publication', async () => { + const f = await fixture({ + poolIdentity: false, + runtime: 'unknown', + metadata: { lastUsed: 1, quotaErrorGeneration: 2 }, + }) + expect(f.binding.identity).toBeUndefined() + const result = await service(f).authorize(f.request()) + const proof = await f.proof() + expect(result).toMatchObject({ + status: 'usable', + source: 'rotated', + binding: proof.binding, + access: successor.access, + validation: proof, + }) + expect(await f.entry()).toEqual({ + binding: proof.binding, + credentialValidation: proof, + quotaErrorGeneration: 2, + }) + expect([f.exchanges, f.lookups]).toEqual([1, 1]) +}) + +for (const absentFile of [true, false]) { + test(`genuinely missing runtime entry is created by known publication with ${absentFile ? 'no runtime file' : 'other runtime entries'}`, async () => { + const f = await fixture({ runtime: 'missing' }) + if (!absentFile) + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, () => ({ + version: 1, + storageId: f.paths.storageId, + accounts: {}, + })) + const result = await service(f).authorize(f.request()) + const proof = await f.proof() + expect(result).toMatchObject({ status: 'usable', source: 'validated' }) + expect(await f.entry()).toEqual({ + binding: proof.binding, + credentialValidation: proof, + }) + }) +} + +test('unknown pool row without a captured runtime entry is never given a predecessor or served', async () => { + const f = await fixture({ poolIdentity: false, runtime: 'missing' }) + const before = await snapshot(f.root) + expect(Object.keys(before)).not.toContain('anthropic-auth-native-state.json') + const result = await service(f).authorize(f.request()) + expect(result).toMatchObject({ + status: 'failed', + persisted: true, + reconciliationFailed: true, + }) + // The replacement was saved before publication refused; nothing claims it + // was rolled back, and no runtime entry was invented for it. + expect((await f.row()).credential).toMatchObject({ access: successor.access }) + expect(await f.entry()).toBeUndefined() + expect(await stages(f)).toEqual([]) + expect([f.exchanges, f.lookups]).toEqual([1, 1]) +}) + +for (const change of ['reset', 'access', 'entry-binding'] as const) { + test(`publication refuses after an intervening ${change} change and serves nothing`, async () => { + const f = await fixture() + f.lookup = async () => { + if (change === 'reset') + await updateNativeRuntime( + f.paths.runtime, + f.paths.storageId, + (current) => { + const entry = current.accounts.row + if (entry) entry.refreshErrorClearedAt = 50 + return current + }, + ) + if (change === 'access') + await f.store.rotate('row', { + ...original, + access: 'synthetic-intervening-access', + }) + if (change === 'entry-binding') + await updateNativeRuntime( + f.paths.runtime, + f.paths.storageId, + (current) => { + current.accounts.row = { + binding: { + ...f.binding, + identity: accountA, + credentialEpoch: f.binding.credentialEpoch + 1, + }, + } + return current + }, + ) + return accountA + } + const result = await service(f).authorize(f.request()) + expect(result).toMatchObject({ + status: 'failed', + persisted: false, + reconciliationFailed: true, + }) + expect((await f.entry())?.credentialValidation).toBeUndefined() + expect(await stages(f)).toEqual([]) + expect([f.exchanges, f.lookups]).toEqual([0, 1]) + leaksNoSecret(result) + }) +} + +test('failure publication superseded by a reset is not validation and does not fail reconciliation', async () => { + const f = await fixture({ expires: start - 1 }) + f.exchange = async () => { + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { + const entry = current.accounts.row + if (entry) entry.refreshErrorClearedAt = 50 + return current + }) + throw new ClaudeOAuthRefreshError(429, providerBody, '7') + } + f.policy = () => ({ checkedAt: f.time, nextRetryAt: f.time + 60_000 }) + const result = await service(f).authorize(f.request()) + expect(result).toEqual({ + status: 'failed', + failure: { + kind: 'provider', + classification: 'transient', + status: 429, + retryAfter: 7, + }, + persisted: false, + reconciliationFailed: false, + }) + expect(f.failures).toHaveLength(1) + expect(await f.entry()).toEqual({ + binding: { ...f.binding, identity: accountA }, + refreshErrorClearedAt: 50, + }) + expect(f.exchanges).toBe(1) +}) + +for (const operation of ['validation', 'failure'] as const) { + test(`runtime storage fault during ${operation} publication is a reconciliation error`, async () => { + const f = await fixture() + // Change permissions during the provider callback, after admission read + // the runtime entry. The subsequent publication must reject the now + // group-readable runtime file instead of treating it as a row change. + const fault = () => chmod(f.paths.runtime, 0o644) + if (operation === 'validation') + f.lookup = async () => { + await fault() + return accountA + } + else + f.exchange = async () => { + await fault() + throw new ClaudeOAuthRefreshError(429, providerBody) + } + f.policy = () => ({ checkedAt: f.time, nextRetryAt: f.time + 1 }) + const result = await service(f).authorize( + f.request(operation === 'failure' ? { intent: 'refresh' } : {}), + ) + expect(result).toEqual( + operation === 'validation' + ? { + status: 'failed', + failure: { kind: 'caller-hook', classification: 'permanent' }, + persisted: false, + reconciliationFailed: true, + } + : { + status: 'failed', + failure: { + kind: 'provider', + classification: 'transient', + status: 429, + }, + persisted: false, + reconciliationFailed: true, + }, + ) + await chmod(f.paths.runtime, 0o600) + expect(await f.entry()).toEqual({ + binding: { ...f.binding, identity: accountA }, + }) + expect(await stages(f)).toEqual([]) + }) +} + +async function invalidGrantScenario(module: ServiceModule) { + const f = await fixture() + f.exchange = async () => { + throw new ClaudeOAuthRefreshError( + 400, + `{"error":"invalid_grant","detail":"${providerBody}"}`, + ) + } + f.policy = () => ({ checkedAt: f.time }) + const s = service(f, module) + const result = await s.authorize(f.request({ intent: 'refresh' })) + await named( + 'invalid grant is reported and recorded as permanent', + async () => { + expect(result).toEqual({ + status: 'failed', + failure: { + kind: 'provider', + classification: 'invalid-grant', + status: 400, + }, + persisted: false, + reconciliationFailed: false, + }) + expect((await f.entry())?.lastRefreshError).toEqual({ + message: 'Local refresh failed', + credentialFingerprint: fingerprintOf(original), + permanent: true, + checkedAt: start, + accountIdentity: accountA, + status: 400, + }) + }, + ) + const next = await s.authorize( + f.request({ rejectedAccessToken: original.access }), + ) + await named('invalid grant blocks the next exchange', () => { + expect(next).toEqual({ + status: 'refused', + reason: 'invalid-grant', + persisted: false, + }) + expect(f.exchanges).toBe(1) + }) + leaksNoSecret([result, next, await f.entry()]) +} + +test('provider 400 invalid_grant is recorded as permanent and blocks the next exchange', async () => { + await invalidGrantScenario(production) +}) + +for (const [status, body, classification] of [ + [400, `{"error":"invalid_request","detail":"${providerBody}"}`, 'permanent'], + [403, `{"error":"invalid_grant","detail":"${providerBody}"}`, 'permanent'], + [429, providerBody, 'transient'], +] as const) { + test(`provider ${status} ${classification} failure is recorded as retryable, never invalid grant`, async () => { + const f = await fixture() + f.exchange = async () => { + throw new ClaudeOAuthRefreshError(status, body) + } + f.policy = () => ({ checkedAt: f.time, nextRetryAt: f.time + 60_000 }) + const s = service(f) + expect(await s.authorize(f.request({ intent: 'refresh' }))).toEqual({ + status: 'failed', + failure: { kind: 'provider', classification, status }, + persisted: false, + reconciliationFailed: false, + }) + expect(f.exchanges).toBe(1) + expect(f.failures.map((input) => input.attribution)).toEqual([ + { + kind: 'refresh-error', + target: 'captured', + subject: { + binding: f.binding, + credentialFingerprint: fingerprintOf(original), + version: (await f.proof()).version, + }, + permanent: false, + message: 'Local refresh failed', + status, + }, + ]) + expect((await f.entry())?.lastRefreshError).toMatchObject({ + permanent: false, + status, + checkedAt: start, + nextRetryAt: start + 60_000, + }) + expect(await s.authorize(f.request({ intent: 'refresh' }))).toEqual({ + status: 'refused', + reason: 'refresh-backoff', + persisted: false, + }) + expect(f.exchanges).toBe(1) + }) +} + +test('failure policy equal to the failure time keeps the coordinator three bounded physical retries', async () => { + const f = await fixture({ expires: start - 1 }) + f.exchange = async () => { + f.time += 10 + throw new ClaudeOAuthRefreshError(503, providerBody) + } + f.policy = () => ({ checkedAt: f.time, nextRetryAt: f.time }) + const result = await service(f).authorize(f.request()) + expect(result).toEqual({ + status: 'failed', + failure: { kind: 'provider', classification: 'transient', status: 503 }, + persisted: false, + reconciliationFailed: false, + }) + expect(f.exchanges).toBe(3) + expect(f.failures.map((input) => input.observation.attempt)).toEqual([ + 1, 2, 3, + ]) + expect((await f.entry())?.lastRefreshError).toMatchObject({ + permanent: false, + status: 503, + checkedAt: start + 30, + nextRetryAt: start + 30, + }) +}) + +test('future failure deadline suppresses the coordinator retry loop until the clock passes it', async () => { + const f = await fixture({ expires: start - 1 }) + f.exchange = async () => { + throw new ClaudeOAuthRefreshError(503, providerBody) + } + f.policy = () => ({ checkedAt: f.time, nextRetryAt: f.time + 1_000 }) + const s = service(f) + // The stored deadline is read before attempt 2, so the job ends as a refusal + // and no longer reports the 503 that caused it. + expect(await s.authorize(f.request())).toEqual({ + status: 'refused', + reason: 'refresh-backoff', + persisted: false, + }) + expect(f.exchanges).toBe(1) + expect(f.failures.map((input) => input.observation.attempt)).toEqual([1]) + f.time += 999 + expect(await s.authorize(f.request())).toMatchObject({ + reason: 'refresh-backoff', + }) + expect(f.exchanges).toBe(1) + f.time += 1 + expect(await s.authorize(f.request())).toMatchObject({ + reason: 'refresh-backoff', + }) + expect(f.exchanges).toBe(2) +}) + +async function unresolvedScenario(module: ServiceModule) { + const f = await fixture() + f.lookup = async () => { + throw new Error('synthetic lookup outage') + } + f.policy = () => ({ checkedAt: f.time, nextRetryAt: f.time + 1_000 }) + const s = service(f, module) + const result = await s.authorize( + f.request({ rejectedAccessToken: original.access }), + ) + await named( + 'unresolved lookup records validation retry, not proof', + async () => { + const committed = await f.proof() + expect(result).toEqual({ + status: 'refused', + reason: 'validation-backoff', + persisted: true, + }) + expect(await f.entry()).toEqual({ + binding: committed.binding, + validationRetry: { + subject: committed, + checkedAt: start, + nextRetryAt: start + 1_000, + }, + }) + expect(f.failures.map((input) => input.attribution)).toEqual([ + { kind: 'validation-retry', target: 'committed', subject: committed }, + ]) + }, + ) + f.time += 1_000 + f.lookup = async () => accountA + await named( + 'due validation retry validates the saved successor', + async () => { + expect(await s.authorize(f.request())).toMatchObject({ + status: 'usable', + source: 'validated', + access: successor.access, + }) + expect([f.exchanges, f.lookups]).toEqual([1, 2]) + }, + ) +} + +test('persisted replacement with failed lookup records exact validation retry and no proof', async () => { + await unresolvedScenario(production) +}) + +test('failure policy receives only frozen original observation and attribution', async () => { + const f = await fixture() + f.exchange = async () => { + throw new ClaudeOAuthRefreshError(429, providerBody) + } + f.policy = () => ({ checkedAt: f.time, nextRetryAt: f.time + 1 }) + await service(f).authorize(f.request({ intent: 'refresh' })) + // Assert outside the callback: the coordinator deliberately swallows a + // reconciliation error, which would hide a failed assertion inside it. + const [input] = f.failures + if (!input || f.failures.length !== 1) + throw new Error('Failure policy was not called exactly once') + expect(() => { + ;(input.observation as { attempt: number }).attempt = 9 + }).toThrow(TypeError) + expect(() => { + ;(input.attribution.subject.binding as { rowId: string }).rowId = 'x' + }).toThrow(TypeError) + expect(Object.keys(input).sort()).toEqual(['attribution', 'observation']) + expect(Object.isFrozen(input.observation.context?.subject.version)).toBe(true) + expect(input.observation).toMatchObject({ + status: 'failed', + attempt: 1, + persisted: false, + credentialFingerprint: fingerprintOf(original), + failure: { kind: 'provider', classification: 'transient', status: 429 }, + }) + leaksNoSecret(input) +}) + +test('API-key rows fail closed in the coordinator without provider dispatch or runtime writes', async () => { + const f = await fixture() + await f.store.add({ + id: 'api', + credential: { + type: 'api', + apiKey: 'synthetic-api-key', + baseURL: 'https://api.anthropic.com', + }, + }) + const read = await f.store.read() + const row = + read.status === 'ready' + ? read.rows.find((candidate) => candidate.id === 'api') + : undefined + if (!row) throw new Error('Missing API-key row') + const binding = captureNativeLocalPoolBinding(f.paths, row) + const s = service(f) + const before = await snapshot(f.root) + const results: NativeRefreshResult[] = [] + for (const intent of ['serve', 'refresh'] as const) + results.push(await s.authorize({ mode: 'local', intent, binding })) + for (const result of results) { + expect(result.status).not.toBe('usable') + expect(JSON.stringify(result)).not.toContain('synthetic-api-key') + } + // An API-key row has no OAuth refresh token for the policy check to bind. + // Refuse it without calling either OAuth exchange or account lookup. + expect(results).toEqual([ + { + status: 'failed', + failure: { kind: 'caller-hook', classification: 'permanent' }, + persisted: false, + reconciliationFailed: false, + }, + { + status: 'failed', + failure: { kind: 'caller-hook', classification: 'permanent' }, + persisted: false, + reconciliationFailed: false, + }, + ]) + expect([f.exchanges, f.lookups, f.failures.length]).toEqual([0, 0, 0]) + expect(await snapshot(f.root)).toEqual(before) +}) + +test('cancellation before dispatch refuses without work and mid-exchange cancellation still saves and cleans up', async () => { + const f = await fixture({ expires: start - 1 }) + const s = service(f) + const aborted = new AbortController() + aborted.abort() + expect(await s.authorize(f.request({ signal: aborted.signal }))).toEqual({ + status: 'refused', + reason: 'cancelled', + persisted: false, + }) + expect([f.exchanges, f.lookups]).toEqual([0, 0]) + const entered = gate() + const release = gate() + f.exchange = async () => { + entered.open() + await release.wait + return successor + } + const controller = new AbortController() + const lockFiles = async () => + (await readdir(f.root)).filter((name) => name.endsWith('.lock')) + const pending = s.authorize(f.request({ signal: controller.signal })) + await entered.wait + // The paused exchange still owns its refresh/provider leases. Confirm their + // files exist so the later empty-lock assertion actually proves cleanup. + expect((await lockFiles()).length).toBeGreaterThan(0) + controller.abort() + release.open() + expect(await pending).toEqual({ + status: 'refused', + reason: 'cancelled', + persisted: true, + }) + // Cancellation cannot roll back the consumed refresh token. Keep the saved + // successor and its completed account-validation proof for the next caller. + const proof = await f.proof() + expect(await f.entry()).toEqual({ + binding: proof.binding, + credentialValidation: proof, + }) + expect(await stages(f)).toEqual([]) + expect(await lockFiles()).toEqual([]) + expect(await s.authorize(f.request())).toMatchObject({ + status: 'usable', + source: 'current', + access: successor.access, + }) + expect([f.exchanges, f.lookups]).toEqual([1, 1]) +}) + +test('service exposes only authorize and accepts only own data option properties', async () => { + const f = await fixture() + const s = service(f) + expect(Object.keys(s)).toEqual(['authorize']) + expect(Object.isFrozen(s)).toBe(true) + const base: Record = { + paths: f.paths, + now: () => f.time, + externalPolicy: () => ({ status: 'allowed' }), + failurePolicy: () => ({ checkedAt: 0 }), + refreshToken: async () => { + throw new Error('Exchange forbidden') + }, + resolveIdentity: async () => ({ + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: accountA, + }), + } + const create = (options: unknown) => + Reflect.apply(production.createNativeLocalCredentialService, undefined, [ + options, + ]) + expect(() => create(base)).not.toThrow() + for (const [label, options] of [ + ['missing external policy', { ...base, externalPolicy: undefined }], + ['missing failure policy', { ...base, failurePolicy: undefined }], + ['non-function now', { ...base, now: 5 }], + ['replaceable reconciliation', { ...base, reconcile: async () => {} }], + ['replaceable admission', { ...base, readAdmission: async () => {} }], + ['store hold hook', { ...base, hold: async () => {} }], + [ + 'incomplete paths', + { ...base, paths: { ...f.paths, runtime: undefined } }, + ], + [ + 'getter failure policy', + Object.defineProperty({ ...base }, 'failurePolicy', { + enumerable: true, + get: () => () => ({ checkedAt: 0 }), + }), + ], + ['null options', null], + ] as const) { + let error: unknown + try { + create(options) + } catch (caught) { + error = caught + } + expect([label, error instanceof TypeError]).toEqual([label, true]) + } + // Replacing a callback on the caller's object after construction has no effect. + const options = { ...base, externalPolicy: () => ({ status: 'allowed' }) } + const captured = create(options) + options.externalPolicy = () => { + throw new Error('replaced policy must not run') + } + expect(await captured.authorize(f.request())).toMatchObject({ + status: 'usable', + }) +}) + +// Each guard is removed from its own owned copy, independently of the others. +// The healthy companion runs the same scenario on an unchanged owned copy, so +// a failure of the broken copy is caused by the removed guard alone. +const guardBreaks: Array<{ + guard: string + scenario: (module: ServiceModule) => Promise + from: string + to: string + assertion: string +}> = [ + { + guard: 'authority journal guard', + scenario: authorityScenario, + from: ' await requireNativePoolAuthority(paths)\n', + to: '', + assertion: 'authority refusal precedes provider dispatch', + }, + { + guard: 'first-identity predecessor selection', + scenario: firstIdentityScenario, + from: 'if (predecessor && predecessor.identity === undefined)', + to: 'if (false)', + assertion: 'first-identity repair serves validated access', + }, + { + guard: 'known predecessor selection', + scenario: knownRotationScenario, + from: 'if (predecessor && predecessor.identity === undefined)', + to: 'if (predecessor)', + assertion: 'known rotation preserves metadata and records proof', + }, + { + guard: 'failure publisher dispatch', + scenario: invalidGrantScenario, + from: ' await publishNativeLocalRefreshFailure(paths, event, policy)\n', + to: '', + assertion: 'invalid grant is reported and recorded as permanent', + }, + { + guard: 'positive publication bootstrap gate', + scenario: unresolvedScenario, + from: " event.bootstrap === 'resolved'\n", + to: ' true\n', + assertion: 'unresolved lookup records validation retry, not proof', + }, +] + +for (const { guard, scenario, from, to, assertion } of guardBreaks) { + test(`${guard} healthy owned copy passes its named assertion`, async () => { + await scenario(await owned()) + }) + test(`${guard} removed from an owned copy fails ${assertion}`, async () => { + const broken = await owned({ from, to }) + await expect(scenario(broken)).rejects.toThrow(`${assertion} failed`) + }) +} From 5d2a2a615f9a63eb8287b740e72eccdc8f4f9ca2 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:27:20 +0200 Subject: [PATCH 035/169] mason: expose public migration path and routing source seams Export the existing canonicalPath without changing normalization or callers. Document that canonical identity can follow a leaf symlink and cannot replace the configured path used by source capture and recheck. Add routing to the existing source-role union and inline coverage for canonical aliases, routing capture/recheck/redaction, and configured-leaf refusal. Existing tests, deadlines and runtime behavior remain unchanged. Parent approved the unchanged four-file delta after source/comment review. Focused suites and Core types pass; parent post-diagnosis full Core passes 880/880. Earlier worker 879/1 and parent 876/4 full-suite failures remain historically unexplained, not fixed by this prerequisite. Raw evidence is retained in the parent-owned native-source-seams-bg_45ac51ccbfea39a5 report. --- packages/core/src/migration-source.ts | 8 ++- packages/core/src/pool-paths.ts | 8 ++- .../core/src/tests/migration-source.test.ts | 69 +++++++++++++++++++ packages/core/src/tests/pool-paths.test.ts | 19 ++++- 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/core/src/migration-source.ts b/packages/core/src/migration-source.ts index 248f8454e..fefed95c2 100644 --- a/packages/core/src/migration-source.ts +++ b/packages/core/src/migration-source.ts @@ -4,7 +4,11 @@ import { lstat, open } from 'node:fs/promises' import { parseJsonRedacted } from './json.ts' -export type NativeMigrationSourceRole = 'config' | 'state' | 'hostAuth' +export type NativeMigrationSourceRole = + | 'config' + | 'state' + | 'hostAuth' + | 'routing' export interface NativeMigrationSourceSnapshot { readonly role: NativeMigrationSourceRole @@ -74,6 +78,8 @@ interface SourceReadHooks { /** * Capture exact import bytes without the legacy loader's missing-entry recovery. * Ownership and legacy schema decisions still belong to the offline importer. + * Pass the configured path, not a canonicalized leaf: lstat must see any leaf + * symlink, and the snapshot retains that path for the same guard during recheck. */ export async function readNativeMigrationSource( role: NativeMigrationSourceRole, diff --git a/packages/core/src/pool-paths.ts b/packages/core/src/pool-paths.ts index a67b8d95a..d0b8708a9 100644 --- a/packages/core/src/pool-paths.ts +++ b/packages/core/src/pool-paths.ts @@ -15,8 +15,12 @@ export interface NativePoolPaths { storageId: string } -/** Resolve existing parent aliases without creating any migration files. */ -async function canonicalPath(path: string): Promise { +/** + * Resolve existing parent aliases and nonexistent leaves without creating files. + * This can follow a leaf symlink, so it does not establish configured-leaf safety. + * Source capture and recheck must retain the configured path for the lstat guard. + */ +export async function canonicalPath(path: string): Promise { const absolute = resolve(path) try { return await realpath(absolute) diff --git a/packages/core/src/tests/migration-source.test.ts b/packages/core/src/tests/migration-source.test.ts index 8f6a44ece..94e513f6e 100644 --- a/packages/core/src/tests/migration-source.test.ts +++ b/packages/core/src/tests/migration-source.test.ts @@ -15,9 +15,11 @@ import { join } from 'node:path' import { inspect } from 'node:util' import { + NativeMigrationSourceError, readNativeMigrationSource, requireUnchangedNativeMigrationSource, } from '../migration-source.ts' +import { canonicalPath } from '../pool-paths.ts' const roots: string[] = [] @@ -62,6 +64,73 @@ test('captures exact bytes, filesystem ownership information and frozen nested i expect(await readFile(path, 'utf8')).toBe(text) }) +test('captures and rechecks routing inputs with role-preserving changed-source refusal', async () => { + const { path } = await fixture() + const text = + '{"routes":{"session":"account-a"},"access":"synthetic-routing-access"}\n' + await writeFile(path, text, { mode: 0o600 }) + const snapshot = await readNativeMigrationSource('routing', path) + expect(snapshot.role).toBe('routing') + expect(snapshot.path).toBe(path) + expect(snapshot.digest).toBe(createHash('sha256').update(text).digest('hex')) + expect(snapshot.data).toEqual({ + routes: { session: 'account-a' }, + access: 'synthetic-routing-access', + }) + expect(Object.isFrozen(snapshot.data?.routes)).toBe(true) + expect(JSON.stringify(snapshot)).not.toContain('synthetic-routing-access') + expect(inspect(snapshot)).not.toContain('synthetic-routing-access') + await requireUnchangedNativeMigrationSource(snapshot) + expect(await readFile(path, 'utf8')).toBe(text) + + const changed = + '{"routes":{"session":"account-b"},"access":"synthetic-routing-replacement"}\n' + await writeFile(path, changed) + let caught: unknown + try { + await requireUnchangedNativeMigrationSource(snapshot) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(NativeMigrationSourceError) + expect(caught).toMatchObject({ role: 'routing', code: 'source-changed' }) + expect(inspect(caught)).not.toContain('synthetic-routing-access') + expect(inspect(caught)).not.toContain('synthetic-routing-replacement') + expect(await readFile(path, 'utf8')).toBe(changed) +}) + +test('configured leaf symlinks remain refused during capture and recheck after canonicalization', async () => { + const { root, path } = await fixture() + const alias = join(root, 'directory-alias') + await symlink(root, alias, 'dir') + const configured = join(alias, 'source.json') + const text = '{"routes":{"session":"account-a"}}\n' + await writeFile(path, text, { mode: 0o600 }) + const snapshot = await readNativeMigrationSource('routing', configured) + expect(snapshot.path).toBe(configured) + expect(await canonicalPath(configured)).toBe(path) + await requireUnchangedNativeMigrationSource(snapshot) + + const target = join(root, 'target.json') + await writeFile(target, text, { mode: 0o600 }) + await rm(path) + await symlink(target, path) + expect(await canonicalPath(configured)).toBe(target) + let opened = false + await expect( + readNativeMigrationSource('routing', configured, { + onOpened: async () => { + opened = true + }, + }), + ).rejects.toMatchObject({ role: 'routing', code: 'unsafe-source' }) + expect(opened).toBe(false) + await expect( + requireUnchangedNativeMigrationSource(snapshot), + ).rejects.toMatchObject({ role: 'routing', code: 'unsafe-source' }) + expect(await readFile(target, 'utf8')).toBe(text) +}) + test('records missing inputs distinctly and rejects an input that appears after capture', async () => { const { path } = await fixture() const snapshot = await readNativeMigrationSource('state', path) diff --git a/packages/core/src/tests/pool-paths.test.ts b/packages/core/src/tests/pool-paths.test.ts index 6735756fb..1489ed16c 100644 --- a/packages/core/src/tests/pool-paths.test.ts +++ b/packages/core/src/tests/pool-paths.test.ts @@ -10,7 +10,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' -import { resolveNativePoolPaths } from '../pool-paths.ts' +import { canonicalPath, resolveNativePoolPaths } from '../pool-paths.ts' const roots: string[] = [] @@ -85,6 +85,23 @@ test('storage identity includes both paths and normalizes directory aliases', as expect(same).toEqual(paths) }) +test('canonicalPath resolves directory aliases for existing and nonexistent leaves', async () => { + const root = await fixture() + const aliasRoot = await fixture() + const alias = join(aliasRoot, 'link') + await symlink(root, alias, 'dir') + const existing = join(root, 'source.json') + await writeFile(existing, '{}') + expect(await canonicalPath(existing)).toBe(existing) + expect(await canonicalPath(join(alias, 'source.json'))).toBe(existing) + const missing = join(root, 'missing', 'nested', 'source.json') + expect(await canonicalPath(missing)).toBe(missing) + expect( + await canonicalPath(join(alias, 'missing', 'nested', 'source.json')), + ).toBe(missing) + expect(await readdir(root)).toEqual(['source.json']) +}) + test('refuses source or derived file collisions without creating a file', async () => { const root = await fixture() const config = join(root, 'anthropic-auth.json') From 4f61a0e1b6ba57ba12adcad64f0d518914fc9c5b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:34:11 +0200 Subject: [PATCH 036/169] Narrow failure publication invariants without non-null assertions --- packages/core/src/native-runtime.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index 89194b6fa..034e34b58 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -1226,12 +1226,17 @@ function classifyCapturedLocalFailure( throw new NativeRuntimeError('publication-refused') if (event.status === 'failed' && !saved && event.committed !== undefined) throw new NativeRuntimeError('publication-refused') + const material = saved + ? committed + : { + credentialFingerprint: event.credentialFingerprint, + version: event.credentialVersion, + } + if (!material) throw new NativeRuntimeError('publication-refused') const subject: NativeRefreshSubject = { binding: { ...event.binding }, - credentialFingerprint: saved - ? committed!.credentialFingerprint - : event.credentialFingerprint, - version: { ...(saved ? committed!.version : event.credentialVersion) }, + credentialFingerprint: material.credentialFingerprint, + version: { ...material.version }, } const target = saved ? 'committed' : 'captured' if ( @@ -1278,8 +1283,7 @@ function failureMaterialMatches( subject: NativeRefreshSubject, material: Parameters[2], ): boolean { - if (!material || material.type !== 'oauth' || !text(material.refresh)) - return false + if (material?.type !== 'oauth' || !text(material.refresh)) return false const version = subject.version return ( subject.credentialFingerprint === @@ -1321,8 +1325,9 @@ export async function publishNativeLocalRefreshFailure( if (attribution.kind === 'none') return { status: 'skipped', reason: attribution.reason } const { subject } = attribution - const context = event.context! + const context = event.context if ( + !context || subject.binding.storageId !== paths.storageId || !counter(policy.checkedAt) || (policy.nextRetryAt !== undefined && @@ -1358,6 +1363,9 @@ export async function publishNativeLocalRefreshFailure( ? { ...old } : { binding: subject.binding } if (attribution.kind === 'validation-retry') { + const nextRetryAt = policy.nextRetryAt + if (nextRetryAt === undefined) + throw new NativeRuntimeError('publication-refused') const sameSubject = (value: NativeLocalValidationRetry['subject']) => sameLocalBinding(value.binding, subject.binding) && value.credentialFingerprint === subject.credentialFingerprint && @@ -1378,7 +1386,7 @@ export async function publishNativeLocalRefreshFailure( entry.validationRetry = { subject: attribution.subject, checkedAt: policy.checkedAt, - nextRetryAt: policy.nextRetryAt!, + nextRetryAt, } } else { if ( From 50be123b70a8d354279d50e1b7f8a5c17ff9adda Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:57:06 +0200 Subject: [PATCH 037/169] mason: add pure native vault roster seed and resume validation buildNativeVaultRosterSeed builds the first complete vault roster seed (version 1, complete, verified view) from the verified inventory, the captured primary pair, the orchestrator-resolved primary route id, the legacy custody rows, the disabled account identities and the imported API route ids. validateNativeVaultRosterSeed checks an existing seed against the same captured input and returns nothing, so a valid seed is kept byte for byte when only the view or record states changed. Refusals use the closed NativeRosterSeedError codes roster-incomplete, unenrolled-local-row, roster-route-conflict and seed-conflict, and carry no caller data. Caller input is read without running caller code: proxies are refused before any reflection, only own data properties are read, and arrays are copied by index. The module is pure: no listing, reading, refresh, network or file write. Its emitted declaration has no producer type import. --- packages/core/src/native-roster-seed.ts | 647 ++++++++ .../core/src/tests/native-roster-seed.test.ts | 1468 +++++++++++++++++ 2 files changed, 2115 insertions(+) create mode 100644 packages/core/src/native-roster-seed.ts create mode 100644 packages/core/src/tests/native-roster-seed.test.ts diff --git a/packages/core/src/native-roster-seed.ts b/packages/core/src/native-roster-seed.ts new file mode 100644 index 000000000..fc53083cb --- /dev/null +++ b/packages/core/src/native-roster-seed.ts @@ -0,0 +1,647 @@ +import { types } from 'node:util' +import type { NativeCustodyInventory } from './native-custody.ts' + +/** + * Builds and re-checks the vault roster seed written while a legacy custody + * install migrates to the native pool. It is pure: it never lists, reads, + * refreshes or writes anything. The caller lists the vault, reads the legacy + * config, resolves the primary route id and owns every file write. + * + * The seed keeps the route ids the legacy install already used, so later + * discovery (which only adds new routes under its own prefix) keeps them. + * It carries no token, refresh token or other credential material. + */ + +/** Allowed build/check failure codes. Diagnostics contain no caller data. */ +export type NativeRosterSeedErrorCode = + | 'roster-incomplete' + | 'unenrolled-local-row' + | 'roster-route-conflict' + | 'seed-conflict' + +function seedCode(value: unknown): NativeRosterSeedErrorCode { + switch (value) { + case 'roster-incomplete': + case 'unenrolled-local-row': + case 'roster-route-conflict': + case 'seed-conflict': + return value + default: + return 'seed-conflict' + } +} + +export class NativeRosterSeedError extends Error { + readonly code: NativeRosterSeedErrorCode + + constructor(code: NativeRosterSeedErrorCode) { + const safeCode = seedCode(code) + super(`Native roster seed refused: ${safeCode}`) + this.name = 'NativeRosterSeedError' + this.code = safeCode + } +} + +/** One OAuth account row from the legacy custody config. No secrets. */ +export interface NativeRosterSeedLegacyRow { + /** The legacy account id; it becomes the seeded route id unchanged. */ + readonly routeId: string + /** Vault credential ID associated with this enrolled legacy row, if present. */ + readonly scopedCredentialId?: string + readonly anthropicAccountUuid?: string + readonly label?: string + readonly addedAt?: number + /** The user's enable/disable choice for this row. */ + readonly enabled: boolean +} + +export interface NativeRosterSeedInput { + /** The vault list, already verified by the caller's discovery. */ + readonly inventory: NativeCustodyInventory + /** The primary credential and account captured from the legacy config. */ + readonly primary: { + readonly credentialId: string + readonly accountIdentity: string + } + /** Route ID assigned by offline migration to the legacy primary Claude account. */ + readonly primaryRouteId: string + readonly legacyRows: readonly NativeRosterSeedLegacyRow[] + /** Accounts the user disabled, by Anthropic account UUID. */ + readonly disabledAccountIdentities: readonly string[] + /** Route ids taken by imported API-key rows; a seeded route never takes one. */ + readonly reservedRouteIds: readonly string[] +} + +export interface NativeVaultRosterSeedRow { + routeId: string + credentialId: string + credentialType: 'oauth' + accountIdentity: string + state: string + label: string + enabled: boolean + addedAt: number +} + +export interface NativeVaultRosterSeedDecline { + credentialId: string + accountIdentity: string +} + +/** Fresh secret-free roster, compatible with common-auth's readVaultRoster. */ +export interface NativeVaultRosterSeed { + version: 1 + view: string + complete: true + rows: NativeVaultRosterSeedRow[] + declined: NativeVaultRosterSeedDecline[] +} + +/** + * Stored roster returned by common-auth's readVaultRoster. Account quota, + * email and organisation metadata do not participate in seed matching. + */ +export interface NativeVaultRosterDocument { + readonly version: 1 + readonly view?: string + readonly complete: boolean + readonly rejected?: readonly { + readonly credentialId?: string + readonly reason: string + }[] + readonly rows: readonly { + readonly routeId: string + readonly credentialId: string + readonly credentialType: 'oauth' | 'api_key' + readonly accountIdentity?: string + readonly aliases?: readonly string[] + readonly state: string + readonly label: string + readonly email?: string + readonly orgName?: string + readonly enabled: boolean + readonly addedAt: number + readonly quota?: unknown + readonly stale?: true + readonly unclaimed?: true + }[] + readonly declined: readonly { + readonly credentialId: string + readonly accountIdentity?: string + }[] +} + +interface ExpectedDecline extends NativeVaultRosterSeedDecline { + /** + * A decline for a seeded row must name that row's credential. A decline for + * an account the seed has no row for is matched by account only, because + * which listed credential represents that account may change between runs. + */ + readonly exactCredential: boolean +} + +interface Expected { + readonly view: string + readonly rows: readonly NativeVaultRosterSeedRow[] + readonly declined: readonly ExpectedDecline[] + /** Disabled accounts the seed has no row for, listed now or not. */ + readonly unseeded: ReadonlySet +} + +type Refuse = (code: NativeRosterSeedErrorCode) => never + +/** + * The refusal state of one public call. Every failure leaves the call as a + * fresh closed error chosen from this record alone, never from the thrown + * value: a caller getter or malformed input can throw anything, and copying + * or even inspecting that value could carry caller data out. + */ +interface Boundary { + /** + * The code this module's own refusal check chose, when one refused. It is + * never taken from a caught exception, which may have come from the caller. + */ + refused?: NativeRosterSeedErrorCode + /** + * The generic code to report when an unexpected failure happens while one + * section of the input is being checked, set as each section begins. + */ + fallback: NativeRosterSeedErrorCode +} + +function guarded(body: (boundary: Boundary, refuse: Refuse) => T): T { + const boundary: Boundary = { fallback: 'seed-conflict' } + const refuse: Refuse = (code) => { + boundary.refused = code + throw new NativeRosterSeedError(code) + } + try { + return body(boundary, refuse) + } catch { + throw new NativeRosterSeedError(boundary.refused ?? boundary.fallback) + } +} + +function filled(value: unknown): value is string { + return typeof value === 'string' && value.trim() !== '' +} + +/** + * Caller input is read without running caller code: a proxy is refused before + * any reflection (reflection on a proxy runs its traps), only own data + * properties are read through their descriptors (an accessor is refused, never + * called), and arrays are copied by index (a custom iterator is never used). + * An inherited property reads as absent. + */ +function target( + value: unknown, + code: NativeRosterSeedErrorCode, + refuse: Refuse, +): object { + if (typeof value !== 'object' || value === null || types.isProxy(value)) + refuse(code) + return value +} + +function dataField( + owner: object, + key: string, + code: NativeRosterSeedErrorCode, + refuse: Refuse, +): unknown { + const descriptor = Object.getOwnPropertyDescriptor(owner, key) + if (descriptor === undefined) return undefined + if (Object.hasOwn(descriptor, 'get') || Object.hasOwn(descriptor, 'set')) + refuse(code) + return descriptor.value +} + +/** Copy the named own data fields of a caller object into a plain object. */ +function capture( + value: unknown, + keys: readonly K[], + code: NativeRosterSeedErrorCode, + refuse: Refuse, +): Record { + const owner = target(value, code, refuse) + const copy = {} as Record + for (const key of keys) copy[key] = dataField(owner, key, code, refuse) + return copy +} + +/** The length of a caller array, read without touching its elements. */ +function count( + value: unknown, + code: NativeRosterSeedErrorCode, + refuse: Refuse, +): number { + const owner = target(value, code, refuse) + if (!Array.isArray(owner)) refuse(code) + const length = dataField(owner, 'length', code, refuse) + if (typeof length !== 'number') refuse(code) + return length +} + +/** Copy a caller array by index, so later checks never re-read caller state. */ +function items( + value: unknown, + code: NativeRosterSeedErrorCode, + refuse: Refuse, +): unknown[] { + const owner = target(value, code, refuse) + const length = count(owner, code, refuse) + const copy: unknown[] = [] + for (let index = 0; index < length; index++) + copy.push(dataField(owner, String(index), code, refuse)) + return copy +} + +/** + * Validate the complete vault list and every legacy row first, and only then + * derive the route rows and the declines (the markers for disabled accounts). + * Nothing is filtered: one bad record refuses the whole seed rather than + * seeding the rest as complete. Each caller field is read once into owned + * plain data before it is judged. + */ +function expectedSeed( + input: NativeRosterSeedInput, + boundary: Boundary, + refuse: Refuse, +): Expected { + const source = target(input, 'seed-conflict', refuse) + const top = (key: string, code: NativeRosterSeedErrorCode) => + dataField(source, key, code, refuse) + + // A vault list or primary credential/account pair that cannot be read must + // not count as a verified complete list, so failures here report + // roster-incomplete. + boundary.fallback = 'roster-incomplete' + const inventory = capture( + top('inventory', 'roster-incomplete'), + ['view', 'credentials', 'skipped'], + 'roster-incomplete', + refuse, + ) + const view = inventory.view + const skipped = items(inventory.skipped, 'roster-incomplete', refuse) + const credentialRecords = items( + inventory.credentials, + 'roster-incomplete', + refuse, + ).map((record) => + capture( + record, + ['credentialId', 'credentialType', 'accountIdentity', 'state'], + 'roster-incomplete', + refuse, + ), + ) + + // The seed is written as complete, so the list must be complete: no + // skipped records, a view to record, and every record naming its account. + // A record without an account could log into a seeded account, so the + // seed's account membership could not be proven. + if (!filled(view) || skipped.length > 0) refuse('roster-incomplete') + const listed = new Map< + string, + { + credentialType: unknown + accountIdentity: string + state: string + } + >() + for (const record of credentialRecords) { + const { credentialId, credentialType, accountIdentity, state } = record + if ( + !filled(credentialId) || + !filled(accountIdentity) || + !filled(state) || + listed.has(credentialId) + ) { + refuse('roster-incomplete') + } + listed.set(credentialId, { credentialType, accountIdentity, state }) + } + + const primary = capture( + top('primary', 'roster-incomplete'), + ['credentialId', 'accountIdentity'], + 'roster-incomplete', + refuse, + ) + const primaryCredentialId = primary.credentialId + const primaryIdentity = primary.accountIdentity + + boundary.fallback = 'seed-conflict' + const primaryRouteId = top('primaryRouteId', 'seed-conflict') + const legacyRecords = items( + top('legacyRows', 'seed-conflict'), + 'seed-conflict', + refuse, + ).map((record) => + capture( + record, + [ + 'routeId', + 'scopedCredentialId', + 'anthropicAccountUuid', + 'label', + 'addedAt', + 'enabled', + ], + 'seed-conflict', + refuse, + ), + ) + const disabledList = items( + top('disabledAccountIdentities', 'seed-conflict'), + 'seed-conflict', + refuse, + ) + const reservedList = items( + top('reservedRouteIds', 'seed-conflict'), + 'seed-conflict', + refuse, + ) + for (const value of [...disabledList, ...reservedList]) { + if (typeof value !== 'string') refuse('seed-conflict') + } + const disabled = new Set(disabledList as string[]) + const reserved = new Set(reservedList as string[]) + + // A credential and account pair is seeded only when the vault lists exactly + // that OAuth credential logging into exactly that account. + function listedState(credentialId: string, accountIdentity: string): string { + const credential = listed.get(credentialId) + if ( + credential?.credentialType !== 'oauth' || + credential.accountIdentity !== accountIdentity + ) { + refuse('roster-incomplete') + } + return credential.state + } + + if (!filled(primaryCredentialId) || !filled(primaryIdentity)) + refuse('roster-incomplete') + const primaryState = listedState(primaryCredentialId, primaryIdentity) + + for (const row of legacyRecords) { + if (!filled(row.scopedCredentialId)) refuse('unenrolled-local-row') + } + const legacy = legacyRecords.map((row) => { + const credentialId = row.scopedCredentialId + const identity = row.anthropicAccountUuid + if (!filled(credentialId) || !filled(identity)) refuse('roster-incomplete') + return { + row, + credentialId, + identity, + state: listedState(credentialId, identity), + } + }) + + // `main` is the host's own slot name, never a route. A route id may not + // repeat or take an imported API-key row's id. + const routes = new Set() + for (const routeId of [ + primaryRouteId, + ...legacyRecords.map((row) => row.routeId), + ]) { + if ( + !filled(routeId) || + routeId === 'main' || + reserved.has(routeId) || + routes.has(routeId) + ) { + refuse('roster-route-conflict') + } + routes.add(routeId) + } + if (!filled(primaryRouteId)) refuse('roster-route-conflict') + + // The published projection folds every credential of one account into a + // single row, so two seeded rows for one account (or one credential) would + // be collapsed on the next discovery, losing a route. + const credentials = new Set([primaryCredentialId]) + const identities = new Set([primaryIdentity]) + const legacyRows = legacy.map( + ({ row, credentialId, identity, state }): NativeVaultRosterSeedRow => { + if (credentials.has(credentialId) || identities.has(identity)) + refuse('seed-conflict') + credentials.add(credentialId) + identities.add(identity) + const { routeId, label, addedAt, enabled } = row + if ( + !filled(routeId) || + typeof enabled !== 'boolean' || + (addedAt !== undefined && + (typeof addedAt !== 'number' || !Number.isFinite(addedAt))) || + (label !== undefined && typeof label !== 'string') + ) { + refuse('seed-conflict') + } + return { + routeId, + credentialId, + credentialType: 'oauth', + accountIdentity: identity, + state, + label: filled(label) ? label : routeId, + enabled, + addedAt: addedAt ?? 0, + } + }, + ) + + // A fallback row's own `enabled` flag decides its account, because the legacy + // runtime gated fallback routes on that flag alone and applied the disabled + // account list only to the primary. + const rows: NativeVaultRosterSeedRow[] = [ + { + routeId: primaryRouteId, + credentialId: primaryCredentialId, + credentialType: 'oauth', + accountIdentity: primaryIdentity, + state: primaryState, + label: primaryRouteId, + enabled: !disabled.has(primaryIdentity), + addedAt: 0, + }, + ...legacyRows, + ] + + const declined: ExpectedDecline[] = rows + .filter((row) => !row.enabled) + .map((row) => ({ + credentialId: row.credentialId, + accountIdentity: row.accountIdentity, + exactCredential: true, + })) + // A disabled account the vault lists but the seed has no row for is declined + // through its lowest listed credential id, so discovery adds it disabled. + // A disabled account the vault does not list cannot be declined here (a + // decline needs a credential id); the pool settings keep that choice. + const unseeded = [...disabled] + .filter((identity) => !identities.has(identity)) + .sort() + for (const identity of unseeded) { + const credentialId = [...listed] + .filter(([, credential]) => credential.accountIdentity === identity) + .map(([credentialId]) => credentialId) + .sort()[0] + if (credentialId !== undefined) + declined.push({ + credentialId, + accountIdentity: identity, + exactCredential: false, + }) + } + + return { view, rows, declined, unseeded: new Set(unseeded) } +} + +/** + * Build the first seed from the current verified vault list and the captured + * legacy state. The result is a complete roster the published reader accepts, + * built only from owned copies, so it shares no object with the input. + */ +export function buildNativeVaultRosterSeed( + input: NativeRosterSeedInput, +): NativeVaultRosterSeed { + return guarded((boundary: Boundary, refuse: Refuse) => { + const expected = expectedSeed(input, boundary, refuse) + return { + version: 1, + view: expected.view, + complete: true, + rows: expected.rows.map((row) => ({ ...row })), + declined: expected.declined.map(({ credentialId, accountIdentity }) => ({ + credentialId, + accountIdentity, + })), + } + }) +} + +/** + * Check a seed already on disk against the captured legacy state, so a resumed + * migration can keep it. Returns nothing: a valid seed is kept exactly as it + * is, with its original view and states. The current vault list must still + * pass every refusal, but a changed view or record state alone is not a + * conflict. Route, credential and account membership, labels, added times and + * enable/decline choices must match exactly; any difference refuses. + */ +export function validateNativeVaultRosterSeed( + existing: NativeVaultRosterDocument, + input: NativeRosterSeedInput, +): void { + guarded((boundary: Boundary, refuse: Refuse) => { + const expected = expectedSeed(input, boundary, refuse) + boundary.fallback = 'seed-conflict' + // Check schema, completeness, membership and imported user preferences. + // The stored view must be a string but need not match the current cursor. + // Row state, display metadata and quota are not read or rewritten. + const document = capture( + existing, + ['version', 'complete', 'view', 'rejected', 'rows', 'declined'], + 'seed-conflict', + refuse, + ) + const { version, complete, view, rejected } = document + const rows = items(document.rows, 'seed-conflict', refuse).map((row) => + capture( + row, + [ + 'routeId', + 'credentialId', + 'credentialType', + 'accountIdentity', + 'aliases', + 'label', + 'addedAt', + 'enabled', + 'stale', + 'unclaimed', + ], + 'seed-conflict', + refuse, + ), + ) + const declinedEntries = items( + document.declined, + 'seed-conflict', + refuse, + ).map((entry) => + capture( + entry, + ['credentialId', 'accountIdentity'], + 'seed-conflict', + refuse, + ), + ) + if ( + version !== 1 || + complete !== true || + typeof view !== 'string' || + (rejected !== undefined && + count(rejected, 'seed-conflict', refuse) > 0) || + rows.length !== expected.rows.length + ) { + refuse('seed-conflict') + } + + const wanted = new Map(expected.rows.map((row) => [row.routeId, row])) + const seen = new Set() + for (const row of rows) { + const match = + typeof row.routeId === 'string' ? wanted.get(row.routeId) : undefined + if ( + !match || + seen.has(match.routeId) || + row.credentialId !== match.credentialId || + row.credentialType !== 'oauth' || + row.accountIdentity !== match.accountIdentity || + (row.aliases !== undefined && + count(row.aliases, 'seed-conflict', refuse) > 0) || + row.label !== match.label || + row.addedAt !== match.addedAt || + row.enabled !== match.enabled || + row.stale !== undefined || + row.unclaimed !== undefined + ) { + refuse('seed-conflict') + } + seen.add(match.routeId) + } + + // Keep all current disabled-account markers. A disabled account with no + // seed row can also keep its earlier marker after disappearing from the + // vault, so credential removal alone does not prevent migration resume. + const declines = new Map( + expected.declined.map((entry) => [entry.accountIdentity, entry]), + ) + const declinedSeen = new Set() + for (const { credentialId, accountIdentity: identity } of declinedEntries) { + if ( + typeof credentialId !== 'string' || + typeof identity !== 'string' || + declinedSeen.has(identity) + ) { + refuse('seed-conflict') + } + const match = declines.get(identity) + if ( + match + ? match.exactCredential && credentialId !== match.credentialId + : !expected.unseeded.has(identity) + ) { + refuse('seed-conflict') + } + declinedSeen.add(identity) + } + for (const identity of declines.keys()) { + if (!declinedSeen.has(identity)) refuse('seed-conflict') + } + }) +} diff --git a/packages/core/src/tests/native-roster-seed.test.ts b/packages/core/src/tests/native-roster-seed.test.ts new file mode 100644 index 000000000..ac30affa3 --- /dev/null +++ b/packages/core/src/tests/native-roster-seed.test.ts @@ -0,0 +1,1468 @@ +import { describe, expect, spyOn, test } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + DEFAULT_ROUTE_PREFIX, + mutateVaultRoster, + projectVaultRoster, + readVaultRoster, + type VaultRosterFile, +} from '@cortexkit/common-auth/claustrum' +import { moduleReferences } from '../../scripts/check-native-type-closure.ts' +import { + buildNativeVaultRosterSeed, + NativeRosterSeedError, + type NativeRosterSeedErrorCode, + type NativeRosterSeedInput, + type NativeVaultRosterDocument, + type NativeVaultRosterSeed, + validateNativeVaultRosterSeed, +} from '../native-roster-seed.ts' + +type Assert = T +// Compile-only type checks, no runtime assertion: NativeVaultRosterSeed must +// be assignable to the published VaultRosterFile (a built seed can be written +// as a roster file), and VaultRosterFile must be assignable to +// NativeVaultRosterDocument (a file the published reader returns can be +// passed to the seed check). The producer types stay in tests only. +export type SeedIsRosterFile = Assert< + NativeVaultRosterSeed extends VaultRosterFile ? true : false +> +export type RosterFileIsDocument = Assert< + VaultRosterFile extends NativeVaultRosterDocument ? true : false +> + +type Mutable = { -readonly [K in keyof T]: Mutable } +type Input = Mutable + +/** A fresh, independently owned healthy input for every use. */ +function healthy(): Input { + return { + inventory: { + view: 'view-1', + credentials: [ + { + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: 'acct-primary', + state: 'active', + email: 'primary@example.test', + }, + { + credentialId: 'oauth:anthropic:work', + credentialType: 'oauth', + accountIdentity: 'acct-work', + state: 'active', + }, + { + credentialId: 'oauth:anthropic:old', + credentialType: 'oauth', + accountIdentity: 'acct-old', + state: 'cold', + }, + { + credentialId: 'oauth:anthropic:spare', + credentialType: 'oauth', + accountIdentity: 'acct-spare', + state: 'active', + }, + ], + skipped: [], + }, + primary: { + credentialId: 'oauth:anthropic', + accountIdentity: 'acct-primary', + }, + primaryRouteId: 'former-main-route', + legacyRows: [ + { + routeId: 'work-route', + scopedCredentialId: 'oauth:anthropic:work', + anthropicAccountUuid: 'acct-work', + label: 'Work account', + addedAt: 1_700_000_000_000, + enabled: true, + }, + { + routeId: 'old-route', + scopedCredentialId: 'oauth:anthropic:old', + anthropicAccountUuid: 'acct-old', + enabled: false, + }, + ], + disabledAccountIdentities: ['acct-old'], + reservedRouteIds: ['api-route'], + } +} + +/** + * Run a public call and report 'accepted' or the NativeRosterSeedError code. + * Any other error is rethrown, so a raw exception fails the test. + */ +function refusal(run: () => unknown): NativeRosterSeedErrorCode | 'accepted' { + try { + run() + return 'accepted' + } catch (error) { + if (error instanceof NativeRosterSeedError) return error.code + throw error + } +} + +/** Collect identifiers, labels and view text that error diagnostics must not reveal. */ +function capturedStrings(input: Input): string[] { + return [ + input.inventory.view, + input.primaryRouteId, + input.primary.credentialId, + input.primary.accountIdentity, + ...input.legacyRows.flatMap((row) => [ + row.routeId, + row.scopedCredentialId ?? '', + row.anthropicAccountUuid ?? '', + row.label ?? '', + ]), + ...input.reservedRouteIds, + ].filter(Boolean) +} + +describe('buildNativeVaultRosterSeed', () => { + test('builds a deterministic complete seed the published reader accepts', async () => { + const root = await mkdtemp(join(tmpdir(), 'native-roster-seed-')) + try { + const seed = buildNativeVaultRosterSeed(healthy()) + expect(buildNativeVaultRosterSeed(healthy())).toEqual(seed) + expect(JSON.stringify(buildNativeVaultRosterSeed(healthy()))).toBe( + JSON.stringify(seed), + ) + expect(seed).toEqual({ + version: 1, + view: 'view-1', + complete: true, + rows: [ + { + routeId: 'former-main-route', + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: 'acct-primary', + state: 'active', + label: 'former-main-route', + enabled: true, + addedAt: 0, + }, + { + routeId: 'work-route', + credentialId: 'oauth:anthropic:work', + credentialType: 'oauth', + accountIdentity: 'acct-work', + state: 'active', + label: 'Work account', + enabled: true, + addedAt: 1_700_000_000_000, + }, + { + routeId: 'old-route', + credentialId: 'oauth:anthropic:old', + credentialType: 'oauth', + accountIdentity: 'acct-old', + state: 'cold', + label: 'old-route', + enabled: false, + addedAt: 0, + }, + ], + declined: [ + { credentialId: 'oauth:anthropic:old', accountIdentity: 'acct-old' }, + ], + }) + + const path = join(root, 'roster.json') + await writeFile(path, JSON.stringify(seed)) + expect(await readVaultRoster(path)).toEqual(seed) + + // The next discovery over the same complete list keeps every seeded + // route id and only adds the unseeded account under the default prefix. + const projected = projectVaultRoster(seed, healthy().inventory, { + reservedRouteIds: new Set(['api-route']), + now: 5, + }) + const added = projected.rows[3]?.routeId ?? '' + expect(added.startsWith(DEFAULT_ROUTE_PREFIX)).toBe(true) + expect( + projected.rows.map((row) => [row.routeId, row.enabled, row.addedAt]), + ).toEqual([ + ['former-main-route', true, 0], + ['work-route', true, 1_700_000_000_000], + ['old-route', false, 0], + [added, true, 5], + ]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('keeps primary and fallback route ids, labels, added times and disable intent', () => { + const input = healthy() + input.disabledAccountIdentities = ['acct-primary', 'acct-work'] + input.legacyRows[0] = { ...input.legacyRows[0]!, label: ' ' } + input.legacyRows[1] = { + ...input.legacyRows[1]!, + label: 'Old account', + addedAt: 42, + } + const seed = buildNativeVaultRosterSeed(input) + expect( + seed.rows.map((row) => [ + row.routeId, + row.label, + row.addedAt, + row.enabled, + ]), + ).toEqual([ + ['former-main-route', 'former-main-route', 0, false], + // The legacy row's own enable choice wins over the disabled list. + ['work-route', 'work-route', 1_700_000_000_000, true], + ['old-route', 'Old account', 42, false], + ]) + expect(seed.declined).toEqual([ + { credentialId: 'oauth:anthropic', accountIdentity: 'acct-primary' }, + { credentialId: 'oauth:anthropic:old', accountIdentity: 'acct-old' }, + ]) + }) + + test('declines a disabled listed account without a seed row by its lowest credential id', () => { + const input = healthy() + input.inventory.credentials.push({ + credentialId: 'oauth:anthropic:a-spare', + credentialType: 'oauth', + accountIdentity: 'acct-spare', + state: 'active', + }) + input.disabledAccountIdentities = ['acct-spare', 'acct-gone', 'acct-old'] + const seed = buildNativeVaultRosterSeed(input) + expect(seed.rows.map((row) => row.routeId)).toEqual([ + 'former-main-route', + 'work-route', + 'old-route', + ]) + // An account the vault does not list cannot be named by a credential. + expect(seed.declined).toEqual([ + { credentialId: 'oauth:anthropic:old', accountIdentity: 'acct-old' }, + { + credentialId: 'oauth:anthropic:a-spare', + accountIdentity: 'acct-spare', + }, + ]) + const projected = projectVaultRoster(seed, input.inventory, { now: 1 }) + expect( + projected.rows.find((row) => row.accountIdentity === 'acct-spare') + ?.enabled, + ).toBe(false) + }) + + const refusals: [ + string, + NativeRosterSeedErrorCode, + (input: Input) => void, + ][] = [ + [ + 'a skipped record makes the list incomplete', + 'roster-incomplete', + (input) => { + input.inventory.skipped.push({ + credentialId: 'oauth:anthropic:bad', + reason: 'empty state', + }) + }, + ], + [ + 'a skipped record with no id makes the list incomplete', + 'roster-incomplete', + (input) => { + input.inventory.skipped.push({ reason: 'empty credential id' }) + }, + ], + [ + 'a blank view is not a verified list', + 'roster-incomplete', + (input) => { + input.inventory.view = '' + }, + ], + [ + 'an unrelated record without an account is unasserted', + 'roster-incomplete', + (input) => { + delete input.inventory.credentials[3]!.accountIdentity + }, + ], + [ + 'a duplicated credential id is ambiguous', + 'roster-incomplete', + (input) => { + input.inventory.credentials.push({ + ...input.inventory.credentials[3]!, + accountIdentity: 'acct-other', + }) + }, + ], + [ + 'the primary credential is not listed', + 'roster-incomplete', + (input) => { + input.inventory.credentials.shift() + }, + ], + [ + 'the primary is listed for another account', + 'roster-incomplete', + (input) => { + input.primary.accountIdentity = 'acct-elsewhere' + }, + ], + [ + 'the captured primary has no account', + 'roster-incomplete', + (input) => { + input.primary.accountIdentity = '' + }, + ], + [ + 'the primary is listed as an API key', + 'roster-incomplete', + (input) => { + input.inventory.credentials[0]!.credentialType = 'api_key' + }, + ], + [ + 'a legacy row has no account', + 'roster-incomplete', + (input) => { + delete input.legacyRows[0]!.anthropicAccountUuid + }, + ], + [ + 'a legacy credential is not listed', + 'roster-incomplete', + (input) => { + input.inventory.credentials.splice(1, 1) + }, + ], + [ + 'a legacy credential is listed for another account', + 'roster-incomplete', + (input) => { + input.inventory.credentials[1]!.accountIdentity = 'acct-moved' + }, + ], + [ + 'a legacy OAuth row was never enrolled', + 'unenrolled-local-row', + (input) => { + delete input.legacyRows[1]!.scopedCredentialId + }, + ], + [ + 'a legacy OAuth row has a blank enrollment', + 'unenrolled-local-row', + (input) => { + input.legacyRows[0]!.scopedCredentialId = ' ' + }, + ], + [ + 'the primary route is main', + 'roster-route-conflict', + (input) => { + input.primaryRouteId = 'main' + }, + ], + [ + 'a legacy route is main', + 'roster-route-conflict', + (input) => { + input.legacyRows[0]!.routeId = 'main' + }, + ], + [ + 'a legacy route repeats the primary route', + 'roster-route-conflict', + (input) => { + input.legacyRows[1]!.routeId = 'former-main-route' + }, + ], + [ + 'two legacy rows share a route', + 'roster-route-conflict', + (input) => { + input.legacyRows[1]!.routeId = 'work-route' + }, + ], + [ + 'a legacy route takes an imported API route', + 'roster-route-conflict', + (input) => { + input.legacyRows[0]!.routeId = 'api-route' + }, + ], + [ + 'the primary route takes an imported API route', + 'roster-route-conflict', + (input) => { + input.primaryRouteId = 'api-route' + }, + ], + [ + 'a legacy row is a second login of the primary account', + 'seed-conflict', + (input) => { + input.inventory.credentials[1]!.accountIdentity = 'acct-primary' + input.legacyRows[0]!.anthropicAccountUuid = 'acct-primary' + }, + ], + [ + 'two legacy rows log into one account', + 'seed-conflict', + (input) => { + input.inventory.credentials[2]!.accountIdentity = 'acct-work' + input.legacyRows[1]!.anthropicAccountUuid = 'acct-work' + }, + ], + [ + 'two legacy rows name one credential', + 'seed-conflict', + (input) => { + input.legacyRows[1]!.scopedCredentialId = 'oauth:anthropic:work' + input.legacyRows[1]!.anthropicAccountUuid = 'acct-work' + }, + ], + [ + 'a legacy added time is not a finite number', + 'seed-conflict', + (input) => { + input.legacyRows[0]!.addedAt = Number.NaN + }, + ], + ] + + for (const [name, code, mutate] of refusals) { + test(`refuses ${code}: ${name}`, () => { + // The unmodified healthy input must still build, so the refusal comes + // from the one mutation and not from a broken fixture. + expect(refusal(() => buildNativeVaultRosterSeed(healthy()))).toBe( + 'accepted', + ) + const input = healthy() + mutate(input) + expect(refusal(() => buildNativeVaultRosterSeed(input))).toBe(code) + expect(refusal(() => buildNativeVaultRosterSeed(healthy()))).toBe( + 'accepted', + ) + }) + } + + test('refusals carry only a fixed code, never captured values or a cause', () => { + const input = healthy() + input.legacyRows[0]!.anthropicAccountUuid = 'acct-secretive' + let caught: unknown + try { + buildNativeVaultRosterSeed(input) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(NativeRosterSeedError) + const error = caught as NativeRosterSeedError + expect(error.code).toBe('roster-incomplete') + expect(error.message).toBe('Native roster seed refused: roster-incomplete') + expect(error.cause).toBeUndefined() + const surfaces = [ + error.message, + JSON.stringify(error), + Bun.inspect(error), + String(error), + ] + for (const surface of surfaces) { + for (const value of [...capturedStrings(input), 'acct-secretive']) { + expect(surface.includes(value)).toBe(false) + } + } + // A code outside the closed set falls back to seed-conflict. + expect( + new NativeRosterSeedError('oauth:anthropic' as NativeRosterSeedErrorCode) + .code, + ).toBe('seed-conflict') + }) + + test('performs no provider request and imports no runtime dependency', async () => { + const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((() => { + throw new Error('provider request attempted') + }) as unknown as typeof fetch) + try { + const input = healthy() + const seed = buildNativeVaultRosterSeed(input) + validateNativeVaultRosterSeed(seed, input) + expect( + refusal(() => + buildNativeVaultRosterSeed({ ...input, primaryRouteId: 'main' }), + ), + ).toBe('roster-route-conflict') + expect(fetchSpy).not.toHaveBeenCalled() + } finally { + fetchSpy.mockRestore() + } + // The only runtime import is the proxy detector from node:util; the + // NativeCustodyInventory import is type-only and compiles away. Nothing + // the module imports can list, read, refresh or reach the network. + const source = await readFile( + new URL('../native-roster-seed.ts', import.meta.url), + 'utf8', + ) + expect( + new Bun.Transpiler({ loader: 'ts' }) + .scan(source) + .imports.map((edge) => edge.path), + ).toEqual(['node:util']) + expect(source.match(/^import .*$/gm)).toEqual([ + "import { types } from 'node:util'", + "import type { NativeCustodyInventory } from './native-custody.ts'", + ]) + expect(source).not.toMatch(/\b(?:require|import)\s*\(/) + }) +}) + +describe('validateNativeVaultRosterSeed', () => { + test('keeps an existing seed byte for byte across a changed view and state', async () => { + const root = await mkdtemp(join(tmpdir(), 'native-roster-seed-')) + try { + const path = join(root, 'roster.json') + const original = buildNativeVaultRosterSeed(healthy()) + // Email and organisation metadata the reader retains are not judged by + // the seed check and must not cause a refusal. + const stored = { + ...original, + rows: original.rows.map((row, index) => + index === 0 + ? { ...row, email: 'primary@example.test', orgName: 'Org' } + : row, + ), + } + const bytes = `${JSON.stringify(stored, null, 2)}\n` + await writeFile(path, bytes) + + const later = healthy() + later.inventory.view = 'view-2' + later.inventory.credentials[0]!.state = 'cold' + later.inventory.credentials[2]!.state = 'active' + later.inventory.credentials.push({ + credentialId: 'oauth:anthropic:new', + credentialType: 'oauth', + accountIdentity: 'acct-new', + state: 'active', + }) + // A fresh seed from the later list differs, so a byte comparison with a + // rebuilt seed would refuse this harmless change. + expect(JSON.stringify(buildNativeVaultRosterSeed(later))).not.toBe( + JSON.stringify(original), + ) + + const existing = await readVaultRoster(path) + expect(existing).toBeDefined() + const snapshot = structuredClone(existing) + expect(validateNativeVaultRosterSeed(existing!, later)).toBeUndefined() + expect(existing).toEqual(snapshot!) + + // mutateVaultRoster leaves the file unchanged when the callback returns + // no next roster. + const outcome = await mutateVaultRoster(path, (current) => { + if (!current) throw new Error('seed missing') + validateNativeVaultRosterSeed(current, later) + return { result: 'kept' as const } + }) + expect(outcome).toBe('kept') + expect(await readFile(path, 'utf8')).toBe(bytes) + expect((await readVaultRoster(path))?.view).toBe('view-1') + expect((await readVaultRoster(path))?.rows[0]?.state).toBe('active') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('judges a decline for an account without a seed row by account and disable intent', () => { + const input = healthy() + input.disabledAccountIdentities = ['acct-old', 'acct-spare'] + const seed = buildNativeVaultRosterSeed(input) + const later = healthy() + later.disabledAccountIdentities = ['acct-old', 'acct-spare'] + later.inventory.credentials.push({ + credentialId: 'oauth:anthropic:0-spare', + credentialType: 'oauth', + accountIdentity: 'acct-spare', + state: 'active', + }) + expect(refusal(() => validateNativeVaultRosterSeed(seed, later))).toBe( + 'accepted', + ) + // The disabled account's credential was removed from the vault: its + // decline is still accepted. + const gone = healthy() + gone.disabledAccountIdentities = ['acct-old', 'acct-spare'] + gone.inventory.credentials.pop() + expect(refusal(() => validateNativeVaultRosterSeed(seed, gone))).toBe( + 'accepted', + ) + // The decline must be present while the later inventory still lists the + // disabled account, and must be absent once the input no longer disables + // that account (it is enabled). + const dropped = structuredClone(seed) + dropped.declined.pop() + expect(refusal(() => validateNativeVaultRosterSeed(dropped, later))).toBe( + 'seed-conflict', + ) + expect(refusal(() => validateNativeVaultRosterSeed(seed, healthy()))).toBe( + 'seed-conflict', + ) + }) + + type Seed = Mutable + const conflicts: [string, (seed: Seed) => void][] = [ + [ + 'a changed account identity', + (seed) => { + seed.rows[1]!.accountIdentity = 'acct-other' + }, + ], + [ + 'a changed credential', + (seed) => { + seed.rows[2]!.credentialId = 'oauth:anthropic:spare' + }, + ], + [ + 'a changed route id', + (seed) => { + seed.rows[1]!.routeId = 'vault:work' + }, + ], + ['a missing row', (seed) => void seed.rows.pop()], + [ + 'an extra row', + (seed) => { + seed.rows.push({ + routeId: 'vault:spare', + credentialId: 'oauth:anthropic:spare', + credentialType: 'oauth', + accountIdentity: 'acct-spare', + state: 'active', + label: 'spare', + enabled: true, + addedAt: 3, + }) + }, + ], + [ + 'a repeated row in place of another', + (seed) => { + seed.rows[2] = { ...seed.rows[1]! } + }, + ], + [ + 'an alias added to a seeded row', + (seed) => { + seed.rows[1]!.aliases = ['oauth:anthropic:spare'] + }, + ], + [ + 'a changed label', + (seed) => { + seed.rows[1]!.label = 'Renamed' + }, + ], + [ + 'a changed added time', + (seed) => { + seed.rows[1]!.addedAt = 1 + }, + ], + [ + 'a changed enable flag', + (seed) => { + seed.rows[2]!.enabled = true + }, + ], + ['a removed decline', (seed) => void seed.declined.pop()], + [ + 'an added decline', + (seed) => { + seed.declined.push({ + credentialId: 'oauth:anthropic:work', + accountIdentity: 'acct-work', + }) + }, + ], + [ + 'a decline naming another credential of a seeded account', + (seed) => { + seed.declined[0]!.credentialId = 'oauth:anthropic:other' + }, + ], + [ + 'a decline without an account', + (seed) => { + delete seed.declined[0]!.accountIdentity + }, + ], + [ + 'an incomplete file', + (seed) => { + seed.complete = false + }, + ], + [ + 'rejected records', + (seed) => { + seed.rejected = [{ reason: 'empty state' }] + }, + ], + [ + 'a stale row', + (seed) => { + seed.rows[0]!.stale = true + }, + ], + [ + 'a missing view', + (seed) => { + delete seed.view + }, + ], + ] + + for (const [name, mutate] of conflicts) { + test(`refuses a retained seed with ${name}`, () => { + const input = healthy() + const healthySeed = buildNativeVaultRosterSeed(input) + expect( + refusal(() => validateNativeVaultRosterSeed(healthySeed, input)), + ).toBe('accepted') + const seed = structuredClone(healthySeed) as Seed + mutate(seed) + expect(refusal(() => validateNativeVaultRosterSeed(seed, input))).toBe( + 'seed-conflict', + ) + expect( + refusal(() => validateNativeVaultRosterSeed(healthySeed, healthy())), + ).toBe('accepted') + }) + } + + const capturedChanges: [ + string, + NativeRosterSeedErrorCode, + (input: Input) => void, + ][] = [ + [ + 'the vault now lists a legacy credential for another account', + 'roster-incomplete', + (input) => { + input.inventory.credentials[1]!.accountIdentity = 'acct-moved' + }, + ], + [ + 'the current list is incomplete', + 'roster-incomplete', + (input) => { + input.inventory.skipped.push({ reason: 'empty credential id' }) + }, + ], + [ + 'the captured label changed', + 'seed-conflict', + (input) => { + input.legacyRows[0]!.label = 'Another label' + }, + ], + [ + 'the captured added time changed', + 'seed-conflict', + (input) => { + input.legacyRows[0]!.addedAt = 7 + }, + ], + [ + 'the captured disable intent changed', + 'seed-conflict', + (input) => { + input.legacyRows[1]!.enabled = true + input.disabledAccountIdentities = [] + }, + ], + [ + 'the primary was disabled', + 'seed-conflict', + (input) => { + input.disabledAccountIdentities.push('acct-primary') + }, + ], + [ + 'a captured legacy row was added', + 'seed-conflict', + (input) => { + input.legacyRows.push({ + routeId: 'spare-route', + scopedCredentialId: 'oauth:anthropic:spare', + anthropicAccountUuid: 'acct-spare', + enabled: true, + }) + }, + ], + [ + 'a captured legacy row was removed', + 'seed-conflict', + (input) => { + input.legacyRows.pop() + input.disabledAccountIdentities = [] + }, + ], + [ + 'the primary route id changed', + 'seed-conflict', + (input) => { + input.primaryRouteId = 'other-main-route' + }, + ], + [ + 'an imported API row now takes a seeded route', + 'roster-route-conflict', + (input) => { + input.reservedRouteIds.push('work-route') + }, + ], + ] + + for (const [name, code, mutate] of capturedChanges) { + test(`refuses ${code} when ${name}`, () => { + const seed = buildNativeVaultRosterSeed(healthy()) + expect( + refusal(() => validateNativeVaultRosterSeed(seed, healthy())), + ).toBe('accepted') + const input = healthy() + mutate(input) + expect(refusal(() => validateNativeVaultRosterSeed(seed, input))).toBe( + code, + ) + }) + } +}) + +describe('public input boundary', () => { + const MARKER = 'synthetic-bearer-roster-probe' + + interface Probe { + /** Getter, proxy trap and iterator calls; the boundary must make none. */ + calls: number + thrown?: unknown + } + + /** Install a getter that counts calls and throws a caller-chosen value. */ + function accessor( + owner: object, + key: PropertyKey, + probe: Probe, + thrown: unknown = new Error(MARKER, { cause: MARKER }), + ): void { + probe.thrown = thrown + Object.defineProperty(owner, key, { + configurable: true, + enumerable: true, + get() { + probe.calls++ + throw thrown + }, + }) + } + + const TRAPS = [ + 'apply', + 'construct', + 'defineProperty', + 'deleteProperty', + 'get', + 'getOwnPropertyDescriptor', + 'getPrototypeOf', + 'has', + 'isExtensible', + 'ownKeys', + 'preventExtensions', + 'set', + 'setPrototypeOf', + ] as const + + /** Wrap a value in a proxy whose every trap counts its call. */ + function trapped(value: T, probe: Probe): T { + const handler: Record = {} + for (const trap of TRAPS) { + handler[trap] = (...args: unknown[]) => { + probe.calls++ + return (Reflect[trap] as (...forwarded: unknown[]) => unknown)(...args) + } + } + return new Proxy(value, handler as ProxyHandler) + } + + /** Give an array an own iterator that counts calls. */ + function counted(array: unknown[], probe: Probe): void { + const original = array[Symbol.iterator].bind(array) + Object.defineProperty(array, Symbol.iterator, { + configurable: true, + value: () => { + probe.calls++ + return original() + }, + }) + } + + /** + * The public call must fail with a new NativeRosterSeedError that holds only + * an allowed code and no caller details (no message text, cause or marker). + */ + function closedFailure( + run: () => unknown, + probe: Probe, + ): NativeRosterSeedErrorCode { + let caught: unknown + try { + run() + } catch (error) { + caught = error + } + expect(probe.calls).toBe(0) + expect(caught).toBeInstanceOf(NativeRosterSeedError) + expect(caught).not.toBe(probe.thrown) + const error = caught as NativeRosterSeedError + expect(error.cause).toBeUndefined() + expect(Object.keys(error).sort()).toEqual(['code', 'name']) + for (const surface of [ + error.message, + JSON.stringify(error), + Bun.inspect(error), + String(error), + ]) { + expect(surface.includes(MARKER)).toBe(false) + } + return error.code + } + + const hostileBuilds: [ + string, + NativeRosterSeedErrorCode, + (input: Input, probe: Probe) => unknown, + ][] = [ + [ + 'a getter on the inventory', + 'roster-incomplete', + (input, probe) => { + accessor(input, 'inventory', probe) + return input + }, + ], + [ + 'an inherited inventory getter', + 'roster-incomplete', + (input, probe) => { + const { inventory: _inventory, ...rest } = input + const prototype = {} + accessor(prototype, 'inventory', probe) + return Object.assign(Object.create(prototype), rest) + }, + ], + [ + 'a getter on the inventory credentials', + 'roster-incomplete', + (input, probe) => { + accessor(input.inventory, 'credentials', probe) + return input + }, + ], + [ + 'a getter on a credentials array index', + 'roster-incomplete', + (input, probe) => { + accessor(input.inventory.credentials, '1', probe) + return input + }, + ], + [ + 'a getter on a listed account identity', + 'roster-incomplete', + (input, probe) => { + accessor(input.inventory.credentials[1]!, 'accountIdentity', probe) + return input + }, + ], + [ + 'a getter on the primary pair', + 'roster-incomplete', + (input, probe) => { + accessor(input, 'primary', probe) + return input + }, + ], + [ + 'a getter on a legacy label', + 'seed-conflict', + (input, probe) => { + accessor(input.legacyRows[0]!, 'label', probe) + return input + }, + ], + [ + 'a getter on the legacy rows throwing a forged closed error', + 'seed-conflict', + (input, probe) => { + accessor( + input, + 'legacyRows', + probe, + new NativeRosterSeedError('unenrolled-local-row'), + ) + return input + }, + ], + [ + 'a getter on the reserved route ids throwing a plain string', + 'seed-conflict', + (input, probe) => { + accessor(input, 'reservedRouteIds', probe, MARKER) + return input + }, + ], + [ + 'a proxied input', + 'seed-conflict', + (input, probe) => trapped(input, probe), + ], + [ + 'a proxied inventory', + 'roster-incomplete', + (input, probe) => ({ + ...input, + inventory: trapped(input.inventory, probe), + }), + ], + [ + 'a revoked proxy inventory', + 'roster-incomplete', + (input) => { + const revocable = Proxy.revocable(input.inventory, {}) + revocable.revoke() + return { ...input, inventory: revocable.proxy } + }, + ], + [ + 'a proxied credentials array', + 'roster-incomplete', + (input, probe) => { + input.inventory.credentials = trapped( + input.inventory.credentials, + probe, + ) + return input + }, + ], + [ + 'a proxied credential', + 'roster-incomplete', + (input, probe) => { + input.inventory.credentials[2] = trapped( + input.inventory.credentials[2]!, + probe, + ) + return input + }, + ], + [ + 'a proxied primary pair', + 'roster-incomplete', + (input, probe) => ({ ...input, primary: trapped(input.primary, probe) }), + ], + [ + 'proxied legacy rows', + 'seed-conflict', + (input, probe) => ({ + ...input, + legacyRows: trapped(input.legacyRows, probe), + }), + ], + [ + 'a proxied legacy row', + 'seed-conflict', + (input, probe) => { + input.legacyRows[1] = trapped(input.legacyRows[1]!, probe) + return input + }, + ], + [ + 'a proxied disabled list', + 'seed-conflict', + (input, probe) => ({ + ...input, + disabledAccountIdentities: trapped( + input.disabledAccountIdentities, + probe, + ), + }), + ], + ] + + for (const [name, code, install] of hostileBuilds) { + test(`the builder refuses ${name} without running caller code`, () => { + // The unmodified healthy input builds, so the refusal comes from the + // hostile part. + expect(refusal(() => buildNativeVaultRosterSeed(healthy()))).toBe( + 'accepted', + ) + const probe: Probe = { calls: 0 } + const input = install(healthy(), probe) as NativeRosterSeedInput + expect( + closedFailure(() => buildNativeVaultRosterSeed(input), probe), + ).toBe(code) + // The validator checks its input before the stored document, so the + // same malformed input is refused there with the same code. + expect( + closedFailure( + () => + validateNativeVaultRosterSeed( + buildNativeVaultRosterSeed(healthy()), + input, + ), + probe, + ), + ).toBe(code) + }) + } + + type Seed = Mutable + const hostileSeeds: [string, (seed: Seed, probe: Probe) => unknown][] = [ + [ + 'a getter on the rows', + (seed, probe) => { + accessor(seed, 'rows', probe) + return seed + }, + ], + [ + 'a getter on a row label', + (seed, probe) => { + accessor(seed.rows[1]!, 'label', probe) + return seed + }, + ], + [ + 'a getter on the declines', + (seed, probe) => { + accessor(seed, 'declined', probe) + return seed + }, + ], + [ + 'a getter on a decline account throwing a forged closed error', + (seed, probe) => { + accessor( + seed.declined[0]!, + 'accountIdentity', + probe, + new NativeRosterSeedError('roster-incomplete'), + ) + return seed + }, + ], + ['a proxied document', (seed, probe) => trapped(seed, probe)], + [ + 'a proxied rows array', + (seed, probe) => ({ ...seed, rows: trapped(seed.rows, probe) }), + ], + [ + 'a proxied row', + (seed, probe) => { + seed.rows[0] = trapped(seed.rows[0]!, probe) + return seed + }, + ], + [ + 'a proxied decline', + (seed, probe) => { + seed.declined[0] = trapped(seed.declined[0]!, probe) + return seed + }, + ], + [ + 'a proxied empty alias list', + (seed, probe) => { + seed.rows[1]!.aliases = trapped([], probe) + return seed + }, + ], + [ + 'a proxied empty rejected list', + (seed, probe) => ({ ...seed, rejected: trapped([], probe) }), + ], + ] + + for (const [name, install] of hostileSeeds) { + test(`the validator refuses ${name} without running caller code`, () => { + const input = healthy() + const healthySeed = buildNativeVaultRosterSeed(input) + expect( + refusal(() => validateNativeVaultRosterSeed(healthySeed, input)), + ).toBe('accepted') + const probe: Probe = { calls: 0 } + const seed = install( + structuredClone(healthySeed) as Seed, + probe, + ) as NativeVaultRosterDocument + expect( + closedFailure(() => validateNativeVaultRosterSeed(seed, input), probe), + ).toBe('seed-conflict') + }) + } + + test('custom array iterators are never called', () => { + const probe: Probe = { calls: 0 } + const input = healthy() + counted(input.inventory.credentials, probe) + counted(input.inventory.skipped, probe) + counted(input.legacyRows, probe) + counted(input.disabledAccountIdentities, probe) + counted(input.reservedRouteIds, probe) + const seed = buildNativeVaultRosterSeed(input) + expect(seed).toEqual(buildNativeVaultRosterSeed(healthy())) + const stored = structuredClone(seed) as Seed + counted(stored.rows, probe) + counted(stored.declined, probe) + const aliases: string[] = [] + counted(aliases, probe) + stored.rows[1]!.aliases = aliases + expect(refusal(() => validateNativeVaultRosterSeed(stored, input))).toBe( + 'accepted', + ) + expect(probe.calls).toBe(0) + // Control: the counter does see an ordinary iteration of the same arrays. + void [...input.legacyRows] + expect(probe.calls).toBe(1) + }) + + const malformedInputs: [ + string, + NativeRosterSeedErrorCode, + (input: Record) => unknown, + ][] = [ + ['a null input', 'seed-conflict', () => null], + [ + 'a null inventory', + 'roster-incomplete', + (input) => ({ ...input, inventory: null }), + ], + [ + 'credentials that are not an array', + 'roster-incomplete', + (input) => { + ;(input.inventory as Record).credentials = 'oauth' + return input + }, + ], + [ + 'skipped records that are not an array', + 'roster-incomplete', + (input) => { + ;(input.inventory as Record).skipped = null + return input + }, + ], + [ + 'a null credential', + 'roster-incomplete', + (input) => { + ;(input.inventory as { credentials: unknown[] }).credentials[3] = null + return input + }, + ], + [ + 'a numeric listed state', + 'roster-incomplete', + (input) => { + ;(input.inventory as { credentials: Record[] }) + .credentials[3]!.state = 1 + return input + }, + ], + [ + 'a null primary', + 'roster-incomplete', + (input) => ({ ...input, primary: null }), + ], + [ + 'legacy rows that are not an array', + 'seed-conflict', + (input) => ({ ...input, legacyRows: {} }), + ], + [ + 'a null legacy row', + 'seed-conflict', + (input) => { + ;(input.legacyRows as unknown[])[1] = null + return input + }, + ], + [ + 'a numeric legacy route id', + 'roster-route-conflict', + (input) => { + ;(input.legacyRows as Record[])[0]!.routeId = 7 + return input + }, + ], + [ + 'a numeric primary route id', + 'roster-route-conflict', + (input) => ({ ...input, primaryRouteId: 7 }), + ], + [ + 'a string enable flag', + 'seed-conflict', + (input) => { + ;(input.legacyRows as Record[])[0]!.enabled = 'yes' + return input + }, + ], + [ + 'a numeric label', + 'seed-conflict', + (input) => { + ;(input.legacyRows as Record[])[0]!.label = 3 + return input + }, + ], + [ + 'a numeric disabled identity', + 'seed-conflict', + (input) => ({ ...input, disabledAccountIdentities: [1] }), + ], + [ + 'reserved route ids given as one string', + 'seed-conflict', + (input) => ({ ...input, reservedRouteIds: 'api-route' }), + ], + ] + + for (const [name, code, malform] of malformedInputs) { + test(`the builder refuses ${code} for ${name}`, () => { + expect(refusal(() => buildNativeVaultRosterSeed(healthy()))).toBe( + 'accepted', + ) + const input = malform( + healthy() as unknown as Record, + ) as NativeRosterSeedInput + // `refusal` rethrows any error other than NativeRosterSeedError. + expect(refusal(() => buildNativeVaultRosterSeed(input))).toBe(code) + expect( + refusal(() => + validateNativeVaultRosterSeed( + buildNativeVaultRosterSeed(healthy()), + input, + ), + ), + ).toBe(code) + }) + } + + const malformedSeeds: [string, (seed: Record) => unknown][] = + [ + ['a null document', () => null], + ['rows that are not an array', (seed) => ({ ...seed, rows: 'rows' })], + [ + 'declines that are not an array', + (seed) => ({ ...seed, declined: null }), + ], + [ + 'rejected records that are not an array', + (seed) => ({ ...seed, rejected: 'empty state' }), + ], + [ + 'a null row', + (seed) => { + ;(seed.rows as unknown[])[0] = null + return seed + }, + ], + [ + 'a numeric route id', + (seed) => { + ;(seed.rows as Record[])[0]!.routeId = 1 + return seed + }, + ], + [ + 'aliases that are not an array', + (seed) => { + ;(seed.rows as Record[])[1]!.aliases = 'alias' + return seed + }, + ], + [ + 'a null decline', + (seed) => { + ;(seed.declined as unknown[])[0] = null + return seed + }, + ], + [ + 'a numeric decline credential', + (seed) => { + ;(seed.declined as Record[])[0]!.credentialId = 1 + return seed + }, + ], + ] + + for (const [name, malform] of malformedSeeds) { + test(`the validator refuses seed-conflict for ${name}`, () => { + const input = healthy() + const healthySeed = buildNativeVaultRosterSeed(input) + expect( + refusal(() => validateNativeVaultRosterSeed(healthySeed, input)), + ).toBe('accepted') + const seed = malform( + structuredClone(healthySeed) as unknown as Record, + ) as NativeVaultRosterDocument + expect(refusal(() => validateNativeVaultRosterSeed(seed, input))).toBe( + 'seed-conflict', + ) + }) + } + + test('a built seed shares no object with its input', () => { + const input = healthy() + const seed = buildNativeVaultRosterSeed(input) + const before = JSON.stringify(seed) + input.inventory.view = 'changed' + input.inventory.credentials[0]!.state = 'changed' + input.legacyRows[0]!.label = 'changed' + input.disabledAccountIdentities.push('acct-work') + expect(JSON.stringify(seed)).toBe(before) + }) +}) + +test('native roster seed declaration has no producer or internal-types edge', async () => { + const source = await readFile( + new URL('../../dist/native-roster-seed.d.ts', import.meta.url), + 'utf8', + ) + const references = moduleReferences(source).map( + (reference) => reference.specifier, + ) + expect(references).toEqual(['./native-custody.ts']) + expect(source).toContain('export declare function buildNativeVaultRosterSeed') + expect(source).toContain( + 'export declare function validateNativeVaultRosterSeed', + ) +}) From 682a1efef1863c73187bcd56b3036c7b8377f06b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:16:30 +0200 Subject: [PATCH 038/169] mason: add OpenCode 2 native Anthropic HTTP adapter Add createNativeAnthropicAdapter (packages/opencode/src/v2/adapter.ts): the provider half of the common-auth OpenCode 2 hooks recipe, over a strict injected Core source (chooseAccount, authorizeAccount, request options, response observers and limit/end classification). It imports the common-auth/opencode2 contract as types only. It adds no host entry, no relay or proxy, and never sends anything itself. - OAuth sends reuse rewriteRequestBody/setOAuthHeaders/rewriteUrl and map tool names back with createStrippedStream. - API sends get no persona, prefix or signature. They also drop the plugin's own OAuth/fallback betas and the fallbacks field and markers. - Internal correlation headers are stripped before upstream; AbortSignal, method and unrelated headers are kept. - A malformed assistant tail is refused locally with zero sends and is never reported as an account limit. - A missing account or credential is refused with no host fallback. - One authorization serves one send; pending sends are bounded at 512. - forgetSession revokes choices, authorizations and rewrites still in progress, including at their settlement boundary. - The source's attempt-end callback is awaited for at most 5 seconds. Known limitation, outside this adapter: in the published installer a choose -> accountHeaders handoff can authorize again after forgetSession, because the installer creates no attempt until both awaits finish. The fix belongs to installer-owned lifetime checks. Add @opencode/plugin 2.0.22 as an exact devDependency of the opencode package. Its effect 4 types mark Error's reporter-ignore key read-only, so cleanAbort() in index.ts now builds its sentinel with Object.assign at construction (parent-approved fence extension). Key order, descriptors and runtime behaviour are unchanged. Tests: 25 adapter tests plus compile-time contract checks. Mutations fail their named tests. The rewrite's own final revocation check is redundant with the settlement check and has no failing control of its own. --- bun.lock | 457 ++++- packages/opencode/package.json | 3 +- packages/opencode/src/index.ts | 38 +- .../src/tests/opencode2-adapter.test.ts | 1540 +++++++++++++++++ packages/opencode/src/v2/adapter.ts | 918 ++++++++++ 5 files changed, 2928 insertions(+), 28 deletions(-) create mode 100644 packages/opencode/src/tests/opencode2-adapter.test.ts create mode 100644 packages/opencode/src/v2/adapter.ts diff --git a/bun.lock b/bun.lock index d471c9cf6..35d30003d 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ }, "devDependencies": { "@ai-sdk/anthropic": "4.0.58", + "@opencode/plugin": "2.0.22", }, "peerDependencies": { "@opencode-ai/plugin": "*", @@ -84,10 +85,24 @@ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.124.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/eventstream-handler-node": "^3.972.34", "@aws-sdk/middleware-eventstream": "^3.972.29", "@aws-sdk/middleware-websocket": "^3.972.52", "@aws-sdk/token-providers": "3.1127.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA=="], + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1057.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg=="], + "@aws-sdk/core": ["@aws-sdk/core@3.978.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA=="], + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.71", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-rsxsi8tF3vMGdWdaHQNXceHBdb49442yR2Pe08K+Phoc/yICbptdsHwL25Wf0z5+hjhSS5a+hDJHP7Xul4zvHQ=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ=="], "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg=="], @@ -104,6 +119,8 @@ "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ=="], + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1057.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1057.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw=="], + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.34", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw=="], "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.29", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ=="], @@ -118,6 +135,8 @@ "@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.10", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], @@ -240,6 +259,12 @@ "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.86.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-FU/zU/zG4RWokcZt+BVXXcieWi5ggvYnWP2kkB5XXjMaHRoy5BDhcZJ9JAnLTN9MwrCRoXgPQxOI0bFqwYeZkQ=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="], + + "@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], @@ -294,6 +319,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + "@google/genai": ["@google/genai@2.21.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -350,6 +377,10 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -370,10 +401,78 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], + + "@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="], + + "@npmcli/config": ["@npmcli/config@10.8.1", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ=="], + + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], + + "@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="], + + "@npmcli/installed-package-contents": ["@npmcli/installed-package-contents@4.0.0", "", { "dependencies": { "npm-bundled": "^5.0.0", "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" } }, "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA=="], + + "@npmcli/map-workspaces": ["@npmcli/map-workspaces@5.0.3", "", { "dependencies": { "@npmcli/name-from-folder": "^4.0.0", "@npmcli/package-json": "^7.0.0", "glob": "^13.0.0", "minimatch": "^10.0.3" } }, "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw=="], + + "@npmcli/metavuln-calculator": ["@npmcli/metavuln-calculator@9.0.3", "", { "dependencies": { "cacache": "^20.0.0", "json-parse-even-better-errors": "^5.0.0", "pacote": "^21.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5" } }, "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg=="], + + "@npmcli/name-from-folder": ["@npmcli/name-from-folder@4.0.0", "", {}, "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg=="], + + "@npmcli/node-gyp": ["@npmcli/node-gyp@5.0.0", "", {}, "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ=="], + + "@npmcli/package-json": ["@npmcli/package-json@7.0.5", "", { "dependencies": { "@npmcli/git": "^7.0.0", "glob": "^13.0.0", "hosted-git-info": "^9.0.0", "json-parse-even-better-errors": "^5.0.0", "proc-log": "^6.0.0", "semver": "^7.5.3", "spdx-expression-parse": "^4.0.0" } }, "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ=="], + + "@npmcli/promise-spawn": ["@npmcli/promise-spawn@9.0.1", "", { "dependencies": { "which": "^6.0.0" } }, "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q=="], + + "@npmcli/query": ["@npmcli/query@5.0.0", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ=="], + + "@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="], + + "@npmcli/run-script": ["@npmcli/run-script@10.0.4", "", { "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", "proc-log": "^6.0.0" } }, "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.31", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-Rdc1bPK06PByaGyGd0kf7JUZ4pTkexz2OOUNlqZWLpHOiMEZ+/rFGjt46ypZ3QwA697gNdWwwwQbHbKG5NMwGA=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.31", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w=="], + "@opencode/ai": ["@opencode/ai@2.0.22", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.0.22", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-wdD2ut4G/areDkA9bYizYiho6C1sRQ6mPqMSji93XzadavbkIcbhU/VyodOmExtZtOg2IjheJCUVCpDXQ8n8FA=="], + + "@opencode/client": ["@opencode/client@2.0.22", "", { "dependencies": { "@opencode/protocol": "2.0.22", "@opencode/schema": "2.0.22" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-FJKugH301QHlV/rGL8TvgL3ly+toVlww+TP+YdVTKQWytQvjLoUdYtvvm+Iq5k6pN6Ki3ybE/08E8Cg+e6Opbg=="], + + "@opencode/plugin": ["@opencode/plugin@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.0.22", "@opencode/client": "2.0.22", "@opencode/protocol": "2.0.22", "@opencode/schema": "2.0.22", "@opencode/util": "2.0.22", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { "@opencode/theme": "2.0.22", "@opentui/core": ">=0.5.14", "@opentui/solid": ">=0.5.14", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-EJyr+qA+I41rfJqE2zfyKL2qNez+wE8QkNwXf5TddEKIPmL8FUvYMVT5lLhbnXyOGTDxsEc0/8bX7ErQfX5/Rg=="], + + "@opencode/protocol": ["@opencode/protocol@2.0.22", "", { "dependencies": { "@opencode/schema": "2.0.22", "effect": "4.0.0-rc.112" } }, "sha512-Ws3DKQW/rlBDwNz2cPE9+nGwbw7pgnZ+5bzgoHWCWxgTZmxdFulmLP05lxR+PPwMklUwzOGoUXFIzODPZ27WfA=="], + + "@opencode/schema": ["@opencode/schema@2.0.22", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-PTqTrx3QHhlRL5ypfoMnSzXDU8XB2+vtKX8QKwIHP7B1F1yw90LN0XTmGP82k9rYoEcERq0TLuL+ykAsYJXJdA=="], + + "@opencode/util": ["@opencode/util@2.0.22", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "open": "11.0.4", "pacote": "21.5.1" } }, "sha512-nipMu7ZAM9gCOKwH06eqAUVgATR+5XbqXEKSN+ECYLgm9PStsmxraxDN93gJLV3bYzpR+9Ow0Be/s0ZYKUxPKg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.8.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-transformer": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.8.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@opentui/core": ["@opentui/core@0.5.11", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.11", "@opentui/core-darwin-x64": "0.5.11", "@opentui/core-linux-arm64": "0.5.11", "@opentui/core-linux-arm64-musl": "0.5.11", "@opentui/core-linux-x64": "0.5.11", "@opentui/core-linux-x64-musl": "0.5.11", "@opentui/core-win32-arm64": "0.5.11", "@opentui/core-win32-x64": "0.5.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-pImMfjCNx7JUp9Df1LRZBDLisWqgzOLXZKXO+hh3jA9ujBFPQnZOqbG+/N5uVAAX8IZGaqhYSQRwSZIFBmcfbQ=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DRXY5ioq+n1ZNAMAcaFaBunr0cmi2gqucjbTW7lgFp8t9uN3fNZnLTDOqkCTQtT2XhrU4GXqySaPSQ4qFG/EAQ=="], @@ -420,6 +519,28 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@redis/bloom": ["@redis/bloom@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA=="], + + "@redis/client": ["@redis/client@6.3.0", "", { "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg=="], + + "@redis/json": ["@redis/json@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw=="], + + "@redis/search": ["@redis/search@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw=="], + + "@redis/time-series": ["@redis/time-series@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A=="], + + "@sigstore/bundle": ["@sigstore/bundle@4.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A=="], + + "@sigstore/core": ["@sigstore/core@3.2.1", "", {}, "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g=="], + + "@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.5.2", "", {}, "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw=="], + + "@sigstore/sign": ["@sigstore/sign@4.1.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^15.0.4", "proc-log": "^6.1.0" } }, "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ=="], + + "@sigstore/tuf": ["@sigstore/tuf@4.0.2", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^4.1.0" } }, "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ=="], + + "@sigstore/verify": ["@sigstore/verify@3.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], @@ -428,14 +549,24 @@ "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], "@smithy/types": ["@smithy/types@4.18.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A=="], + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], @@ -444,12 +575,18 @@ "@tsconfig/bun": ["@tsconfig/bun@1.0.11", "", {}, "sha512-DQA3HOKFQ+/yPTUa73k5QGcuEM1ExtL6uWQMR0QrfOTitm0oLHqHd1tI74WtEtMqlIu+tvSMELJ6p5uNxVE7lw=="], + "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], + + "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], @@ -492,10 +629,14 @@ "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], @@ -510,6 +651,8 @@ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], @@ -522,16 +665,32 @@ "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "bundle-name": ["bundle-name@4.1.1", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA=="], + + "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], "chalk": ["chalk@6.0.0", "", {}, "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="], + + "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], + + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -540,6 +699,12 @@ "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], @@ -554,6 +719,8 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -564,6 +731,8 @@ "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], @@ -576,6 +745,8 @@ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], @@ -586,6 +757,8 @@ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -598,7 +771,7 @@ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], @@ -608,6 +781,8 @@ "grok-mermaid": ["grok-mermaid@0.2.3", "", {}, "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg=="], + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], @@ -616,16 +791,32 @@ "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + "http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="], + "http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], "https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], + "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + "ip-address": ["ip-address@10.7.3", "", {}, "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], @@ -636,14 +827,24 @@ "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@5.0.0", "", {}, "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + + "just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="], + + "just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], @@ -680,13 +881,33 @@ "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], + "marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "miniflare": ["miniflare@4.20260730.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.28.0", "workerd": "1.20260730.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-1Z9SB9r/o//80UA02Re3QhtcecSHAyAjf5EcKBfQVlQrCg7Miy79hl2PvtkwFLIaJ5rcrOPdDcRr577okwZPsg=="], "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -696,24 +917,52 @@ "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + + "npm-bundled": ["npm-bundled@5.0.0", "", { "dependencies": { "npm-normalize-package-bin": "^5.0.0" } }, "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw=="], + + "npm-install-checks": ["npm-install-checks@8.0.0", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA=="], + + "npm-normalize-package-bin": ["npm-normalize-package-bin@5.0.0", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="], + + "npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], + + "npm-packlist": ["npm-packlist@10.0.4", "", { "dependencies": { "ignore-walk": "^8.0.0", "proc-log": "^6.0.0" } }, "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng=="], + + "npm-pick-manifest": ["npm-pick-manifest@11.0.3", "", { "dependencies": { "npm-install-checks": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "npm-package-arg": "^13.0.0", "semver": "^7.3.5" } }, "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ=="], + + "npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="], + + "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], + "openai": ["openai@6.40.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + "p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="], + + "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], @@ -724,12 +973,26 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], + + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "proggy": ["proggy@4.0.0", "", {}, "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ=="], + + "promise-all-reject-late": ["promise-all-reject-late@1.0.1", "", {}, "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw=="], + + "promise-call-limit": ["promise-call-limit@3.0.2", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "protobufjs": ["protobufjs@7.5.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg=="], @@ -738,16 +1001,24 @@ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], + + "redis": ["redis@6.3.0", "", { "dependencies": { "@redis/bloom": "6.3.0", "@redis/client": "6.3.0", "@redis/json": "6.3.0", "@redis/search": "6.3.0", "@redis/time-series": "6.3.0" } }, "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw=="], + "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], @@ -762,10 +1033,26 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "sigstore": ["sigstore@4.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], + "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], + + "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], + + "spdx-license-ids": ["spdx-license-ids@3.0.24", "", {}, "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ=="], + + "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -776,12 +1063,20 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], + "typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], @@ -792,8 +1087,14 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], @@ -802,11 +1103,15 @@ "workerd": ["workerd@1.20260730.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260730.1", "@cloudflare/workerd-darwin-arm64": "1.20260730.1", "@cloudflare/workerd-linux-64": "1.20260730.1", "@cloudflare/workerd-linux-arm64": "1.20260730.1", "@cloudflare/workerd-windows-64": "1.20260730.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA=="], + "write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -824,6 +1129,20 @@ "@ampproject/remapping/@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=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.974.6", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.46", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/signature-v4-multi-region": "^3.996.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/types": ["@aws-sdk/types@3.974.6", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg=="], + + "@aws-sdk/credential-provider-cognito-identity/@smithy/core": ["@smithy/core@3.35.1", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w=="], + + "@aws-sdk/credential-provider-cognito-identity/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1129.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -838,32 +1157,150 @@ "@cortexkit/claustrum-client/@cortexkit/subc-client": ["@cortexkit/subc-client@0.16.1", "", {}, "sha512-TJqOcfMVxhCfZMANmEH/9w+L+zYxFSy5p5Zl/shDErtGSeUF6HFiwZ7OR/P2qFvpSg/yxCbsdenZ7rs5SE6fhA=="], + "@effect/opentelemetry/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@effect/platform-node/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@effect/platform-node-shared/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@effect/platform-node-shared/ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + "@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=="], + "@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "@npmcli/agent/http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + + "@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + + "@npmcli/git/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@npmcli/promise-spawn/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@opencode/ai/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@opencode/ai/google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "@opencode/plugin/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@opencode/protocol/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@opencode/schema/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@opencode/util/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@opencode/util/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "@smithy/util-buffer-from/@smithy/core": ["@smithy/core@3.35.1", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w=="], + + "@smithy/util-hex-encoding/@smithy/core": ["@smithy/core@3.35.1", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - "glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "node-gyp/undici": ["undici@6.29.0", "", {}, "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/core": ["@aws-sdk/core@3.978.1", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.35.0", "@smithy/signature-v4": "^5.7.3", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.47", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/signature-v4": "^5.7.3", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g=="], + + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@effect/opentelemetry/effect/fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], - "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "@effect/opentelemetry/effect/msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "@effect/platform-node-shared/effect/fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + + "@effect/platform-node-shared/effect/msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "@effect/platform-node/effect/fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + + "@effect/platform-node/effect/msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "@opencode/ai/effect/fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + + "@opencode/ai/effect/msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "@opencode/plugin/effect/fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + + "@opencode/plugin/effect/msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "@opencode/protocol/effect/fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + + "@opencode/protocol/effect/msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "@opencode/schema/effect/fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + + "@opencode/schema/effect/msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "@opencode/util/effect/fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + + "@opencode/util/effect/msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "@smithy/util-buffer-from/@smithy/core/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@smithy/util-hex-encoding/@smithy/core/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.41", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ=="], + + "babel-plugin-module-resolver/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + + "babel-plugin-module-resolver/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 70e1281c1..263c56009 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -70,6 +70,7 @@ "solid-js": "1.9.12" }, "devDependencies": { - "@ai-sdk/anthropic": "4.0.58" + "@ai-sdk/anthropic": "4.0.58", + "@opencode/plugin": "2.0.22" } } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 711569965..c749b1836 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -722,23 +722,27 @@ async function sendIgnoredMessage( } function cleanAbort(): never { - // OpenCode currently has no handled/cancel return contract for - // command.execute.before. Throw an Error for legacy hosts, but duck-type an - // Effect HttpServerResponse.empty({ status: 204 }) so OpenCode 1.17+ treats - // handled slash commands as a clean no-content response instead of logging a - // plugin error. - const sentinel = new Error(HANDLED_SENTINEL) as Error & - Record - sentinel[HTTP_SERVER_RESPONSE_TYPE_ID] = HTTP_SERVER_RESPONSE_TYPE_ID - sentinel[ERROR_REPORTER_IGNORE] = true - sentinel.status = 204 - sentinel.statusText = undefined - sentinel.headers = {} - sentinel.cookies = { - [HTTP_COOKIES_TYPE_ID]: HTTP_COOKIES_TYPE_ID, - cookies: {}, - } - sentinel.body = { [HTTP_BODY_TYPE_ID]: HTTP_BODY_TYPE_ID, _tag: 'Empty' } + // OpenCode has no return value that says "this slash command was handled" + // in command.execute.before, so the plugin throws. Older hosts only see an + // Error. OpenCode 1.17+ recognises the fields below as Effect's empty HTTP + // response (status 204, body tag `Empty`) and answers the command with no + // content; the reporter-ignore marker keeps Effect's error reporter from + // logging the handled command as a plugin failure. + // The fields are set while the error is built rather than assigned + // afterwards, because Effect's typing of the global Error makes the + // reporter-ignore marker read-only. + const sentinel = Object.assign(new Error(HANDLED_SENTINEL), { + [HTTP_SERVER_RESPONSE_TYPE_ID]: HTTP_SERVER_RESPONSE_TYPE_ID, + [ERROR_REPORTER_IGNORE]: true, + status: 204, + statusText: undefined, + headers: {}, + cookies: { + [HTTP_COOKIES_TYPE_ID]: HTTP_COOKIES_TYPE_ID, + cookies: {}, + }, + body: { [HTTP_BODY_TYPE_ID]: HTTP_BODY_TYPE_ID, _tag: 'Empty' }, + }) throw sentinel } diff --git a/packages/opencode/src/tests/opencode2-adapter.test.ts b/packages/opencode/src/tests/opencode2-adapter.test.ts new file mode 100644 index 000000000..c65d1a97d --- /dev/null +++ b/packages/opencode/src/tests/opencode2-adapter.test.ts @@ -0,0 +1,1540 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + CLAUDE_CODE_IDENTITY, + CLAUDE_OPUS_5_5_ADAPTIVE_THINKING, + CLAUDE_SONNET_5_ADAPTIVE_THINKING, + type ClaudeCodeIdentity, + FAST_MODE_BETA, + type ProviderAccountUuid, + signRequestBody, + TRAILING_ASSISTANT_HISTORY_MESSAGE, + USER_AGENT, +} from '@cortexkit/anthropic-auth-core' +import type { + AccountHeadersResult, + Attempt, + HeaderEdits, + LimitSignal, + OpenCode2AuthAdapter, + RequestScope, +} from '@cortexkit/common-auth/opencode2' +import { BILLING_LINEAGE_REQUEST_HEADER } from '../billing-lineage' +import { EFFORT_PLAN_REQUEST_HEADER } from '../effort-history' +import { LANE_START_REQUEST_HEADER } from '../lane-start' +import { + SERVER_FALLBACK_MARKER_TEXT, + SERVER_FALLBACK_SIGNATURE_PREFIX, + SERVER_SIDE_FALLBACK_BETA, +} from '../server-fallback' +import { + hasPinnedFirstUserTextForTest, + resetPinnedFirstUserTextsForTest, +} from '../transform' +import { + createNativeAnthropicAdapter, + INTERNAL_REQUEST_HEADERS, + type NativeAnthropicAdapter, + type NativeAnthropicAttemptContext, + type NativeAnthropicAttemptData, + type NativeAnthropicAttemptOutcome, + type NativeAnthropicAuthorization, + type NativeAnthropicBodyOptions, + NativeAnthropicLocalRefusal, + NativeAnthropicNoAccountError, + type NativeAnthropicRequestContext, + type NativeAnthropicResponseContext, + NativeAnthropicRevokedError, + type NativeAnthropicSource, + NO_ACCOUNT_REFUSAL, +} from '../v2/adapter' + +// A synthetic Core source: every account, credential and policy answer comes +// from here, and every call the adapter makes into it is recorded. + +type Provenance = { + readonly custody: 'local' | 'vault' | 'api' + readonly receipt: string + readonly version?: number +} +type Quota = { readonly unified: string | null; readonly scoped: string | null } +type Data = NativeAnthropicAttemptData + +const ENV_KEYS = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_CUSTOM_HEADERS', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', +] +const savedEnv = new Map() +beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv.set(key, process.env[key]) + delete process.env[key] + } +}) +afterEach(() => { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +const UPSTREAM = 'https://api.anthropic.com/v1/messages' +const ACCOUNT_UUID = + '6f1d1c2e-0000-4000-8000-00000000a11c' as ProviderAccountUuid +const IDENTITY: ClaudeCodeIdentity = { + deviceId: 'd'.repeat(64), + accountUuid: ACCOUNT_UUID, + sessionId: '11111111-2222-4333-8444-555555555555', +} + +const scopeOf = (over: Partial = {}): RequestScope => ({ + providerID: 'anthropic', + modelID: 'claude-sonnet-5', + sessionID: 'ses_alpha', + agent: 'build', + kind: 'primary', + ...over, +}) + +function oauth( + token: string, + provenance: Provenance, + extra: Partial< + Extract, { route: 'oauth' }> + > = {}, +): NativeAnthropicAuthorization { + return { + route: 'oauth', + accessToken: token, + identity: IDENTITY, + provenance, + ...extra, + } +} + +function apiKey( + key: string, + provenance: Provenance, + baseURL?: string, +): NativeAnthropicAuthorization { + return { + route: 'api', + headers: { 'X-Api-Key': key }, + ...(baseURL ? { baseURL } : {}), + provenance, + } +} + +interface Recorder { + requestOptions: NativeAnthropicRequestContext[] + hostErrors: NativeAnthropicAttemptContext[] + ends: { + context: NativeAnthropicAttemptContext + outcome: NativeAnthropicAttemptOutcome + }[] + forgotten: string[] +} + +function makeSource( + answers: { + choose?: (scope: RequestScope) => string | undefined + authorize?: ( + scope: RequestScope, + accountId: string, + ) => NativeAnthropicAuthorization | undefined + options?: NativeAnthropicBodyOptions + } & Partial> = {}, +) { + const seen: Recorder = { + requestOptions: [], + hostErrors: [], + ends: [], + forgotten: [], + } + const source: NativeAnthropicSource = { + chooseAccount: (input) => answers.choose?.(input) ?? 'acct-local', + authorizeAccount: (scope, accountId) => + answers.authorize + ? answers.authorize(scope, accountId) + : oauth('sk-ant-oat01-local-token', { + custody: 'local', + receipt: `local:${accountId}`, + }), + requestOptions: (input) => { + seen.requestOptions.push(input) + return answers.options ?? {} + }, + limitFromHostError: (error, input) => { + seen.hostErrors.push(input) + return error.status === 429 + ? { reason: 'host-429', status: 429 } + : undefined + }, + onAttemptEnd: (context, outcome) => { + seen.ends.push({ context, outcome }) + }, + forgetSession: (sessionID) => { + seen.forgotten.push(sessionID) + }, + ...answers, + } + return { source, seen } +} + +/** + * Drives one physical send in the order the common-auth installer does: + * `chooseAccount`, `accountHeaders`, then in `http.request` the adapter's + * `rewriteRequest` followed by the account header edits, which win on the + * wire. Every request that would reach the network lands in `sent`. + */ +class Host { + readonly sent: Request[] = [] + private seq = 0 + + constructor(readonly adapter: OpenCode2AuthAdapter) {} + + async begin(scope: RequestScope) { + const accountId = await this.adapter.chooseAccount(scope) + if (accountId === undefined) throw new Error('installer: no-account') + const result = (await this.adapter.accountHeaders({ + ...scope, + accountId, + })) as AccountHeadersResult + const attempt: Attempt = { + ...scope, + accountId, + attemptId: `attempt-${++this.seq}`, + transport: 'http', + data: result.attempt, + } + return { attempt, edits: result.headers } + } + + async rewrite( + started: { attempt: Attempt; edits: HeaderEdits }, + request: Request, + ) { + const { attempt, edits } = started + const rewritten = + (await this.adapter.rewriteRequest?.({ ...attempt, request, attempt })) ?? + request + const headers = new Headers(rewritten.headers) + for (const [name, value] of Object.entries(edits)) { + if (value === null) headers.delete(name) + else headers.set(name, value) + } + return new Request(rewritten, { headers }) + } + + async send( + started: { attempt: Attempt; edits: HeaderEdits }, + request: Request, + ) { + const final = await this.rewrite(started, request) + this.sent.push(final) + return final + } + + async run(scope: RequestScope, request: Request) { + const started = await this.begin(scope) + const final = await this.send(started, request) + return { ...started, final } + } +} + +/** Waits a bounded number of turns for in-progress work to reach the source. */ +async function until(predicate: () => boolean) { + for (let turn = 0; turn < 100 && !predicate(); turn++) await Bun.sleep(1) + expect(predicate()).toBe(true) +} + +const sonnetPayload = () => ({ + model: 'claude-sonnet-5', + max_tokens: 32000, + stream: true, + system: [ + { + type: 'text', + text: 'You are OpenCode, the best coding agent on the planet.\nWork carefully.', + }, + ], + tools: [ + { + name: 'bash', + description: 'Run a shell command', + input_schema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, + }, + { + name: 'read', + description: 'Read a file', + input_schema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + }, + ], + tool_choice: { type: 'tool', name: 'bash' }, + thinking: { type: 'enabled', budget_tokens: 16000 }, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'List the repo files.' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Listing them.' }, + { + type: 'tool_use', + id: 'toolu_01', + name: 'bash', + input: { command: 'ls' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_01', + content: 'README.md', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], +}) + +const opus55Payload = () => ({ + ...sonnetPayload(), + model: 'claude-opus-5-5', + tool_choice: { type: 'tool', name: 'StructuredOutput' }, + thinking: { type: 'disabled' }, +}) + +function hostRequest( + payload: unknown, + init: { headers?: Record; signal?: AbortSignal } = {}, +) { + return new Request(UPSTREAM, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': 'opencode-host-placeholder', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', + 'user-agent': 'opencode/2.0.22 ai-sdk/anthropic', + 'x-host-trace': 'trace-7', + ...init.headers, + }, + body: JSON.stringify(payload), + ...(init.signal ? { signal: init.signal } : {}), + }) +} + +const sse = (event: string, data: unknown) => + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + +const PREFIXED_STREAM = [ + sse('message_start', { + type: 'message_start', + message: { + id: 'msg_01', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-5', + content: [], + usage: { + input_tokens: 120, + cache_read_input_tokens: 64, + output_tokens: 1, + }, + }, + }), + sse('ping', { type: 'ping' }), + sse('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_02', + name: 'mcp_Bash', + input: {}, + }, + }), + sse('content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"command":"ls"}' }, + }), + sse('content_block_stop', { type: 'content_block_stop', index: 0 }), + sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'tool_use' }, + usage: { output_tokens: 18 }, + }), + sse('message_stop', { type: 'message_stop' }), +] + +/** Splits a stream into deliberately awkward chunks, mid-name included. */ +function streamOf(text: string, onCancel?: (reason: unknown) => void) { + const bytes = new TextEncoder().encode(text) + let offset = 0 + return new ReadableStream({ + pull(controller) { + if (offset >= bytes.length) { + controller.close() + return + } + const end = Math.min(bytes.length, offset + 37) + controller.enqueue(bytes.slice(offset, end)) + offset = end + }, + cancel(reason) { + onCancel?.(reason) + }, + }) +} + +function sseResponse(text: string, onCancel?: (reason: unknown) => void) { + return new Response(streamOf(text, onCancel), { + status: 200, + headers: { + 'content-type': 'text/event-stream', + 'request-id': 'req_011CTestUpstream000000001', + 'anthropic-ratelimit-unified-status': 'allowed', + 'anthropic-ratelimit-unified-5h-utilization': '0.42', + }, + }) +} + +const bodyOf = async (request: Request) => + JSON.parse(await request.clone().text()) as Record + +describe('OpenCode 2 native Anthropic adapter: request shape', () => { + test('OAuth send carries the Claude Code wire shape, signed body and betas', async () => { + const { source } = makeSource() + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + + const { final } = await host.run(scopeOf(), hostRequest(sonnetPayload())) + + expect(final.method).toBe('POST') + expect(new URL(final.url).searchParams.get('beta')).toBe('true') + expect(final.headers.get('authorization')).toBe( + 'Bearer sk-ant-oat01-local-token', + ) + expect(final.headers.get('x-api-key')).toBeNull() + expect(final.headers.get('user-agent')).toBe(USER_AGENT) + expect(final.headers.get('x-app')).toBe('cli') + expect(final.headers.get('x-claude-code-session-id')).toBe( + IDENTITY.sessionId, + ) + const betas = final.headers.get('anthropic-beta')?.split(',') ?? [] + expect(betas).toContain('oauth-2025-04-20') + expect(betas).toContain('fine-grained-tool-streaming-2025-05-14') + + const text = await final.clone().text() + const body = JSON.parse(text) + expect(body.tools.map((tool: { name: string }) => tool.name)).toEqual([ + 'mcp_Bash', + 'mcp_Read', + ]) + expect(body.tool_choice).toEqual({ type: 'tool', name: 'mcp_Bash' }) + expect(body.messages[1].content[1].name).toBe('mcp_Bash') + expect(body.thinking).toEqual({ ...CLAUDE_SONNET_5_ADAPTIVE_THINKING }) + expect(body.system[0].text).toMatch( + /^x-anthropic-billing-header: .*cch=[0-9a-f]{5};/, + ) + expect(body.system[1].text).toBe(CLAUDE_CODE_IDENTITY) + expect(JSON.parse(body.metadata.user_id).account_uuid).toBe(ACCOUNT_UUID) + // Re-signing a correctly signed body reproduces it exactly; the tampered + // companion shows the comparison can fail. + expect(await signRequestBody(text)).toBe(text) + const tampered = text.replace('List the repo files.', 'List all files.') + expect(await signRequestBody(tampered)).not.toBe(tampered) + }) + + test('Opus 5.5 drops the forced tool choice and keeps adaptive thinking', async () => { + const { source } = makeSource() + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + + const { final } = await host.run( + scopeOf({ modelID: 'claude-opus-5-5' }), + hostRequest(opus55Payload()), + ) + const body = await bodyOf(final) + + expect(body.tool_choice).toBeUndefined() + expect(body.thinking).toEqual({ ...CLAUDE_OPUS_5_5_ADAPTIVE_THINKING }) + expect(body.tools[0].name).toBe('mcp_Bash') + }) + + test('API route gains no OAuth persona, prefix, betas or signature', async () => { + const { source, seen } = makeSource({ + authorize: () => + apiKey( + 'sk-ant-api03-row-key', + { custody: 'api', receipt: 'api:row-2' }, + 'https://gateway.example.com/anthropic', + ), + }) + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + const payload = sonnetPayload() + + const { final } = await host.run( + scopeOf(), + hostRequest(payload, { + headers: { + 'anthropic-beta': + 'fine-grained-tool-streaming-2025-05-14,fine-grained-tool-streaming-2025-05-14', + }, + }), + ) + + expect(final.url).toBe( + 'https://gateway.example.com/anthropic/v1/messages?beta=true', + ) + expect(final.headers.get('x-api-key')).toBe('sk-ant-api03-row-key') + expect(final.headers.get('authorization')).toBeNull() + expect(final.headers.get('user-agent')).toBe( + 'opencode/2.0.22 ai-sdk/anthropic', + ) + expect(final.headers.get('x-app')).toBeNull() + expect(final.headers.get('x-claude-code-session-id')).toBeNull() + expect(final.headers.get('anthropic-beta')).toBe( + 'fine-grained-tool-streaming-2025-05-14', + ) + expect(await final.clone().text()).toBe(JSON.stringify(payload)) + expect(seen.requestOptions).toHaveLength(0) + }) + + test('API route strips incoming OAuth-only betas, fallback opt-in and fallback markers', async () => { + const { source } = makeSource({ + authorize: () => + apiKey( + 'sk-ant-api03-row-key', + { custody: 'api', receipt: 'api:row-2' }, + 'https://gateway.example.com/anthropic', + ), + }) + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + const clean = sonnetPayload() + // The same conversation as it looks after an OAuth turn of this plugin: + // the server-side fallback opt-in on the body and a stored fallback + // marker in the assistant turn. + const contaminated = sonnetPayload() as Record + contaminated.fallbacks = 'default' + contaminated.messages[1].content.unshift({ + type: 'thinking', + thinking: SERVER_FALLBACK_MARKER_TEXT, + signature: `${SERVER_FALLBACK_SIGNATURE_PREFIX}claude-fable-5|claude-opus-5`, + }) + + const { final } = await host.run( + scopeOf(), + hostRequest(contaminated, { + headers: { + 'anthropic-beta': [ + SERVER_SIDE_FALLBACK_BETA, + 'oauth-2025-04-20', + 'claude-code-20250219', + 'fine-grained-tool-streaming-2025-05-14', + 'context-1m-2025-08-07', + ].join(','), + }, + }), + ) + + expect(final.headers.get('anthropic-beta')).toBe( + 'fine-grained-tool-streaming-2025-05-14,context-1m-2025-08-07', + ) + expect(await bodyOf(final)).toEqual(clean) + expect(final.headers.get('x-api-key')).toBe('sk-ant-api03-row-key') + expect(final.headers.get('authorization')).toBeNull() + expect(final.url).toBe( + 'https://gateway.example.com/anthropic/v1/messages?beta=true', + ) + + // Only OAuth-only betas: the header is dropped rather than sent empty. + const onlyOAuth = await host.run( + scopeOf(), + hostRequest(clean, { + headers: { + 'anthropic-beta': `oauth-2025-04-20,${SERVER_SIDE_FALLBACK_BETA}`, + }, + }), + ) + expect(onlyOAuth.final.headers.get('anthropic-beta')).toBeNull() + }) + + test('caller-provided billing, effort, cache and fast options reach the body rewrite', async () => { + const options: NativeAnthropicBodyOptions = { + cache1hEnabled: true, + cache1hMode: 'explicit', + fastModeEnabled: true, + billingLineage: { + previousRequestId: 'req_011111111111111111111111', + promptId: '00000000-0000-4000-8000-000000000001', + }, + } + const { source, seen } = makeSource({ options }) + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + const payload = { ...sonnetPayload(), model: 'claude-opus-4-8' } + + const { final } = await host.run( + scopeOf({ modelID: 'claude-opus-4-8' }), + hostRequest(payload), + ) + const body = await bodyOf(final) + + expect(body.speed).toBe('fast') + expect(final.headers.get('anthropic-beta')?.split(',')).toContain( + FAST_MODE_BETA, + ) + expect(body.system[0].text).toContain( + 'cc_prev_req=req_011111111111111111111111', + ) + expect(body.system[0].text).toContain( + 'cc_prompt_id=00000000-0000-4000-8000-000000000001', + ) + expect(JSON.stringify(body)).toContain('"ttl":"1h"') + expect(seen.requestOptions).toHaveLength(1) + expect(seen.requestOptions[0]?.provenance).toEqual({ + custody: 'local', + receipt: 'local:acct-local', + }) + + // Healthy companion: the same request through a source that returns no + // options carries no `speed`, no fast-mode beta, no cc_prev_req or + // cc_prompt_id lineage and no 1h cache TTL. Those values above therefore + // came from the source's options, not from the wire request. + const plain = new Host( + createNativeAnthropicAdapter({ source: makeSource().source }).adapter, + ) + const bare = await plain.run( + scopeOf({ modelID: 'claude-opus-4-8' }), + hostRequest(payload), + ) + const bareBody = await bodyOf(bare.final) + expect(bareBody.speed).toBeUndefined() + expect(bare.final.headers.get('anthropic-beta')?.split(',')).not.toContain( + FAST_MODE_BETA, + ) + expect(bareBody.system[0].text).not.toContain('cc_prev_req=') + expect(JSON.stringify(bareBody)).not.toContain('"ttl":"1h"') + }) + + test('strips internal correlation headers before upstream and hands them to the source', async () => { + const internal = { + 'x-session-affinity': 'ses_alpha', + 'x-opencode-session': 'ses_alpha', + 'x-parent-session-id': 'ses_parent', + [EFFORT_PLAN_REQUEST_HEADER]: 'plan-1', + [BILLING_LINEAGE_REQUEST_HEADER]: 'lineage-1', + [LANE_START_REQUEST_HEADER]: '1', + } + expect(Object.keys(internal).sort()).toEqual( + INTERNAL_REQUEST_HEADERS.filter( + (name) => name !== 'x-common-auth-attempt', + ).sort(), + ) + const oauthSource = makeSource() + const apiSource = makeSource({ + authorize: () => + apiKey('sk-ant-api03-row-key', { custody: 'api', receipt: 'api:1' }), + }) + for (const { source } of [oauthSource, apiSource]) { + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + const { final } = await host.run( + scopeOf(), + hostRequest(sonnetPayload(), { headers: internal }), + ) + for (const name of INTERNAL_REQUEST_HEADERS) { + expect(final.headers.get(name)).toBeNull() + } + } + expect(oauthSource.seen.requestOptions[0]?.correlation).toEqual(internal) + }) + + test('preserves method, AbortSignal and unrelated headers', async () => { + const { source } = makeSource() + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + const controller = new AbortController() + + const { final } = await host.run( + scopeOf(), + hostRequest(sonnetPayload(), { signal: controller.signal }), + ) + + expect(final.method).toBe('POST') + expect(final.headers.get('x-host-trace')).toBe('trace-7') + expect(final.signal.aborted).toBe(false) + controller.abort(new Error('user stopped the turn')) + expect(final.signal.aborted).toBe(true) + }) +}) + +describe('OpenCode 2 native Anthropic adapter: refusals', () => { + test('a meaningful assistant tail is refused locally with zero sends and no reroute', async () => { + const { source, seen } = makeSource() + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + const payload = sonnetPayload() + payload.messages.push({ + role: 'assistant', + content: [{ type: 'text', text: 'Here is the partial answer' }], + }) + const started = await host.begin(scopeOf()) + + const failure = await host + .send(started, hostRequest(payload)) + .catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(NativeAnthropicLocalRefusal) + expect((failure as NativeAnthropicLocalRefusal).message).toBe( + TRAILING_ASSISTANT_HISTORY_MESSAGE, + ) + expect((failure as NativeAnthropicLocalRefusal).check).toBe( + 'meaningful_trailing_assistant', + ) + expect(host.sent).toHaveLength(0) + const hostError = { type: 'api', message: 'refused', status: 429 } + expect( + native.adapter.limitFromError?.(hostError, started.attempt), + ).toBeUndefined() + expect(seen.hostErrors).toHaveLength(0) + await native.adapter.onAttemptEnd?.(started.attempt, { + outputStarted: false, + error: { reason: 'failed', message: TRAILING_ASSISTANT_HISTORY_MESSAGE }, + }) + expect(seen.ends[0]?.outcome.localRefusal).toEqual({ + check: 'meaningful_trailing_assistant', + }) + expect(seen.ends[0]?.outcome.status).toBeUndefined() + + // Healthy companion: an attempt that was sent leaves the host error to + // the source's own policy, and an upstream 400 is no local refusal. + const sent = await host.run(scopeOf(), hostRequest(sonnetPayload())) + expect(native.adapter.limitFromError?.(hostError, sent.attempt)).toEqual({ + reason: 'host-429', + status: 429, + }) + await native.adapter.onAttemptEnd?.(sent.attempt, { + status: 400, + outputStarted: false, + }) + expect(seen.ends[1]?.outcome.localRefusal).toBeUndefined() + expect(seen.ends[1]?.outcome.status).toBe(400) + }) + + test('the API route refuses a meaningful assistant tail too', async () => { + const { source } = makeSource({ + authorize: () => + apiKey('sk-ant-api03-row-key', { custody: 'api', receipt: 'api:1' }), + }) + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + const payload = sonnetPayload() + payload.messages.push({ + role: 'assistant', + content: [{ type: 'text', text: 'trailing' }], + }) + + const failure = await host + .run(scopeOf(), hostRequest(payload)) + .catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(NativeAnthropicLocalRefusal) + expect(host.sent).toHaveLength(0) + }) + + test('refuses with zero sends when the source has no account or credential', async () => { + const cases: { + name: string + answers: Parameters[0] + error: unknown + }[] = [ + { + name: 'no account chosen', + answers: { choose: () => undefined, chooseAccount: () => undefined }, + error: 'installer: no-account', + }, + { + name: 'authorization refused', + answers: { authorize: () => undefined }, + error: NativeAnthropicNoAccountError, + }, + { + name: 'empty OAuth token', + answers: { + authorize: () => oauth('', { custody: 'local', receipt: 'x' }), + }, + error: NativeAnthropicNoAccountError, + }, + { + name: 'API authorization without a credential header', + answers: { + authorize: () => ({ + route: 'api', + headers: { 'x-api-key': null }, + provenance: { custody: 'api', receipt: 'api:1' }, + }), + }, + error: NativeAnthropicNoAccountError, + }, + ] + for (const { name, answers, error } of cases) { + const { source } = makeSource(answers) + const host = new Host(createNativeAnthropicAdapter({ source }).adapter) + const failure = await host + .run(scopeOf(), hostRequest(sonnetPayload())) + .catch((caught: unknown) => caught) + if (typeof error === 'string') { + expect((failure as Error).message, name).toBe(error) + } else { + expect(failure, name).toBeInstanceOf(error as typeof Error) + expect((failure as Error).message, name).toBe(NO_ACCOUNT_REFUSAL) + } + expect(host.sent, name).toHaveLength(0) + } + }) + + test('a source refusal raised while authorizing stops the request before dispatch', async () => { + // A source that cannot serve the selected transport (a WebSocket relay on + // OpenCode 2, say) refuses with its own error; the adapter neither + // replaces it nor switches to another route. + const unsupported = new Error( + 'websocket relay is not available on OpenCode 2', + ) + const { source } = makeSource({ + authorizeAccount: () => { + throw unsupported + }, + }) + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + + const failure = await host + .run(scopeOf(), hostRequest(sonnetPayload())) + .catch((error: unknown) => error) + + expect(failure).toBe(unsupported) + expect(host.sent).toHaveLength(0) + expect(native.pendingSendCount).toBe(0) + }) +}) + +describe('OpenCode 2 native Anthropic adapter: responses', () => { + test('maps tool names back, keeps upstream headers and reports observations with provenance', async () => { + const starts: { + message: Record + provenance: Provenance + }[] = [] + const completions: string[] = [] + const quotaReads: { + quota: Quota + context: NativeAnthropicResponseContext + }[] = [] + const vault: Provenance = { + custody: 'vault', + receipt: 'rcpt-9', + version: 7, + } + const { source } = makeSource({ + authorize: () => oauth('sk-ant-oat01-vault-token', vault), + responseOptions: (context) => ({ + onMessageStart: (message) => + starts.push({ message, provenance: context.provenance }), + onComplete: (reason) => completions.push(reason), + }), + quotaFromHeaders: (headers, context) => { + const quota = { + unified: headers.get('anthropic-ratelimit-unified-5h-utilization'), + scoped: headers.get( + 'anthropic-ratelimit-unified-7d_opus-utilization', + ), + } + quotaReads.push({ quota, context }) + return quota + }, + }) + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + const { attempt } = await host.run(scopeOf(), hostRequest(sonnetPayload())) + const upstream = sseResponse(PREFIXED_STREAM.join('')) + + const quota = native.adapter.quotaFromHeaders?.( + upstream.headers, + 200, + attempt, + ) + const response = await native.adapter.rewriteResponse?.({ + ...attempt, + request: host.sent[0] as Request, + response: upstream, + attempt, + }) + const text = await (response as Response).text() + + expect(quota).toEqual({ unified: '0.42', scoped: null }) + expect(quotaReads).toHaveLength(1) + expect(quotaReads[0]?.context.provenance).toBe(vault) + expect(quotaReads[0]?.context.status).toBe(200) + expect(text).toContain('"name": "bash"') + expect(text).not.toContain('mcp_Bash') + expect((response as Response).headers.get('request-id')).toBe( + 'req_011CTestUpstream000000001', + ) + expect( + (response as Response).headers.get('anthropic-ratelimit-unified-status'), + ).toBe('allowed') + expect(starts).toHaveLength(1) + expect(starts[0]?.provenance).toBe(vault) + expect(starts[0]?.message.usage).toMatchObject({ input_tokens: 120 }) + expect(completions).toEqual(['tool_use']) + }) + + test('an API response reaches the host unchanged', async () => { + const { source } = makeSource({ + authorize: () => + apiKey('sk-ant-api03-row-key', { custody: 'api', receipt: 'api:1' }), + }) + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + const { attempt } = await host.run(scopeOf(), hostRequest(sonnetPayload())) + // An API request's tools were never renamed, so a tool genuinely called + // `search` must come back as `search`. + const stream = sse('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_03', + name: 'search', + input: {}, + }, + }) + const upstream = sseResponse(stream) + + const response = + (await native.adapter.rewriteResponse?.({ + ...attempt, + request: host.sent[0] as Request, + response: upstream, + attempt, + })) ?? upstream + + expect(await response.text()).toBe(stream) + }) + + test('forwards genuine error responses to the source policy with provenance', async () => { + const vault: Provenance = { + custody: 'vault', + receipt: 'rcpt-3', + version: 12, + } + const classified: { + status: number + retryAfter: string | null + body: string + provenance: Provenance + }[] = [] + const { source } = makeSource({ + authorize: () => oauth('sk-ant-oat01-vault-token', vault), + limitFromResponse: async ({ status, headers, body, provenance }) => { + classified.push({ + status, + retryAfter: headers.get('retry-after'), + body: await body(), + provenance, + }) + return status === 429 + ? { reason: 'model-scoped:7d_opus', status, retryAfterMs: 30_000 } + : undefined + }, + }) + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + const { attempt } = await host.run(scopeOf(), hostRequest(sonnetPayload())) + const errorBody = JSON.stringify({ + type: 'error', + error: { type: 'rate_limit_error', message: 'Opus weekly limit' }, + }) + const upstream = new Response(errorBody, { + status: 429, + headers: { 'retry-after': '30', 'content-type': 'application/json' }, + }) + + const limit = await native.adapter.limitFromResponse?.({ + status: upstream.status, + headers: upstream.headers, + body: () => upstream.clone().text(), + attempt, + }) + + expect(limit).toEqual({ + reason: 'model-scoped:7d_opus', + status: 429, + retryAfterMs: 30_000, + }) + expect(classified).toEqual([ + { status: 429, retryAfter: '30', body: errorBody, provenance: vault }, + ]) + // The source read the body through a clone, so the host's own response + // body is still unread and complete. + expect(await upstream.text()).toBe(errorBody) + }) + + test('inspects deterministic SSE: output, end and stream errors', async () => { + const streamErrors: { errorType?: string; provenance: Provenance }[] = [] + const { source } = makeSource({ + limitFromStreamError: ({ errorType, provenance }) => { + streamErrors.push({ ...(errorType ? { errorType } : {}), provenance }) + return errorType === 'rate_limit_error' + ? { reason: 'account:5h' } + : undefined + }, + }) + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + const { attempt } = await host.run(scopeOf(), hostRequest(sonnetPayload())) + const verdict = (text: string) => { + const [eventLine, dataLine] = text.trim().split('\n') + return native.adapter.inspectEvent?.({ + transport: 'http', + event: eventLine?.slice('event: '.length), + data: dataLine?.slice('data: '.length) ?? '', + attempt, + }) + } + + expect(PREFIXED_STREAM.map(verdict)).toEqual([ + undefined, + undefined, + { outputStarted: true }, + { outputStarted: true }, + undefined, + undefined, + { done: true }, + ]) + const limited = verdict( + sse('error', { + type: 'error', + error: { type: 'rate_limit_error', message: 'Rate limited' }, + }), + ) + expect(limited).toEqual({ + error: 'rate_limit_error: Rate limited', + limit: { reason: 'account:5h' }, + }) + const overloaded = verdict( + sse('error', { + type: 'error', + error: { type: 'overloaded_error', message: 'Overloaded' }, + }), + ) + expect(overloaded).toEqual({ error: 'overloaded_error: Overloaded' }) + expect(streamErrors.map((entry) => entry.errorType)).toEqual([ + 'rate_limit_error', + 'overloaded_error', + ]) + expect(streamErrors[0]?.provenance).toEqual({ + custody: 'local', + receipt: 'local:acct-local', + }) + }) + + test('cancelling the mapped response body cancels the upstream body', async () => { + const { source } = makeSource() + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + const { attempt } = await host.run(scopeOf(), hostRequest(sonnetPayload())) + const cancelled: unknown[] = [] + const upstream = sseResponse(PREFIXED_STREAM.join(''), (reason) => + cancelled.push(reason), + ) + + const response = (await native.adapter.rewriteResponse?.({ + ...attempt, + request: host.sent[0] as Request, + response: upstream, + attempt, + })) as Response + const reader = (response.body as ReadableStream).getReader() + await reader.read() + await reader.cancel('user stopped the turn') + + expect(cancelled).toEqual(['user stopped the turn']) + }) +}) + +describe('OpenCode 2 native Anthropic adapter: attempts and lifecycle', () => { + test('each send keeps its own credential and provenance', async () => { + let issued = 0 + const tokens: string[] = [] + const { source, seen } = makeSource({ + authorize: (_scope, accountId) => { + issued++ + const token = `sk-ant-oat01-${accountId}-${issued}` + tokens.push(token) + return oauth(token, { + custody: 'vault', + receipt: `rcpt-${issued}`, + version: issued, + }) + }, + }) + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + + // Two sends of one session and account, authorized before either is sent. + const first = await host.begin(scopeOf()) + const second = await host.begin(scopeOf()) + const secondFinal = await host.send(second, hostRequest(sonnetPayload())) + const firstFinal = await host.send(first, hostRequest(sonnetPayload())) + + expect(firstFinal.headers.get('authorization')).toBe(`Bearer ${tokens[0]}`) + expect(secondFinal.headers.get('authorization')).toBe(`Bearer ${tokens[1]}`) + expect( + seen.requestOptions.map((entry) => entry.provenance.receipt), + ).toEqual(['rcpt-2', 'rcpt-1']) + await native.adapter.onAttemptEnd?.(first.attempt, { + status: 401, + outputStarted: false, + }) + await native.adapter.onAttemptEnd?.(second.attempt, { + status: 200, + outputStarted: true, + }) + expect( + seen.ends.map((end) => [ + end.context.attemptId, + end.context.provenance.version, + end.outcome.status, + ]), + ).toEqual([ + [first.attempt.attemptId, 1, 401], + [second.attempt.attemptId, 2, 200], + ]) + }) + + test('a credential is released once its send is rewritten, ended or forgotten', async () => { + const { source, seen } = makeSource() + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + + const rewritten = await host.begin(scopeOf()) + const ended = await host.begin(scopeOf({ kind: 'title' })) + const forgotten = await host.begin(scopeOf({ sessionID: 'ses_beta' })) + expect(native.heldCredentialCount).toBe(3) + + await host.send(rewritten, hostRequest(sonnetPayload())) + expect(native.heldCredentialCount).toBe(2) + // One authorization serves one send: sending it again is refused. + const again = await host + .send(rewritten, hostRequest(sonnetPayload())) + .catch((error: unknown) => error) + expect(again).toBeInstanceOf(NativeAnthropicNoAccountError) + expect(host.sent).toHaveLength(1) + + await native.adapter.onAttemptEnd?.(ended.attempt, { + outputStarted: false, + error: { reason: 'abandoned' }, + }) + expect(native.heldCredentialCount).toBe(1) + + native.forgetSession('ses_beta') + expect(native.heldCredentialCount).toBe(0) + expect(native.pendingSendCount).toBe(0) + expect(seen.forgotten).toEqual(['ses_beta']) + const late = await host + .send(forgotten, hostRequest(sonnetPayload())) + .catch((error: unknown) => error) + expect(late).toBeInstanceOf(NativeAnthropicNoAccountError) + expect(host.sent).toHaveLength(1) + }) + + test('forgetting a session revokes an authorization still in progress', async () => { + const gates = new Map< + string, + (auth: NativeAnthropicAuthorization) => void + >() + const { source } = makeSource({ + authorizeAccount: (scope) => + new Promise((resolve) => gates.set(scope.sessionID, resolve)), + }) + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + + const forgotten = host.begin(scopeOf()).catch((error: unknown) => error) + const fresh = host.begin(scopeOf({ sessionID: 'ses_fresh' })) + await until(() => gates.size === 2) + expect(native.operationsInProgress).toBe(2) + native.forgetSession('ses_alpha') + gates.get('ses_alpha')?.( + oauth('sk-ant-oat01-forgotten', { + custody: 'vault', + receipt: 'rcpt-old', + }), + ) + gates.get('ses_fresh')?.( + oauth('sk-ant-oat01-fresh', { custody: 'vault', receipt: 'rcpt-new' }), + ) + + expect(await forgotten).toBeInstanceOf(NativeAnthropicRevokedError) + const healthy = await fresh + expect(healthy.edits.authorization).toBe('Bearer sk-ant-oat01-fresh') + expect(native.heldCredentialCount).toBe(1) + expect(native.pendingSendCount).toBe(1) + expect(native.operationsInProgress).toBe(0) + await host.send(healthy, hostRequest(sonnetPayload())) + expect(host.sent.map((sent) => sent.headers.get('authorization'))).toEqual([ + 'Bearer sk-ant-oat01-fresh', + ]) + expect(native.heldCredentialCount).toBe(0) + + // New work for the cleared session is served normally. + gates.clear() + const again = host.begin(scopeOf()) + await until(() => gates.has('ses_alpha')) + gates.get('ses_alpha')?.( + oauth('sk-ant-oat01-after-clear', { custody: 'local', receipt: 'r' }), + ) + expect((await again).edits.authorization).toBe( + 'Bearer sk-ant-oat01-after-clear', + ) + }) + + test('forgetting a session revokes an account choice still in progress', async () => { + const gates = new Map void>() + const { source } = makeSource({ + chooseAccount: (input) => + new Promise((resolve) => gates.set(input.sessionID, resolve)), + }) + const native = createNativeAnthropicAdapter({ source }) + + const forgotten = Promise.resolve( + native.adapter.chooseAccount(scopeOf()), + ).catch((error: unknown) => error) + const fresh = Promise.resolve( + native.adapter.chooseAccount(scopeOf({ sessionID: 'ses_fresh' })), + ) + await until(() => gates.size === 2) + native.forgetSession('ses_alpha') + gates.get('ses_alpha')?.('acct-stale') + gates.get('ses_fresh')?.('acct-fresh') + + expect(await forgotten).toBeInstanceOf(NativeAnthropicRevokedError) + expect(await fresh).toBe('acct-fresh') + expect(native.operationsInProgress).toBe(0) + }) + + test('forgetting a session revokes a request rewrite still in progress', async () => { + resetPinnedFirstUserTextsForTest() + const gates = new Map void>() + const { source } = makeSource({ + requestOptions: (input) => + new Promise((resolve) => + gates.set(input.sessionID, () => resolve({})), + ), + }) + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + const forgottenSend = await host.begin(scopeOf()) + const freshSend = await host.begin(scopeOf({ sessionID: 'ses_fresh' })) + + const forgotten = host + .send(forgottenSend, hostRequest(sonnetPayload())) + .catch((error: unknown) => error) + const fresh = host.send(freshSend, hostRequest(sonnetPayload())) + await until(() => gates.size === 2) + native.forgetSession('ses_alpha') + gates.get('ses_alpha')?.() + gates.get('ses_fresh')?.() + + expect(await forgotten).toBeInstanceOf(NativeAnthropicRevokedError) + await fresh + expect(host.sent).toHaveLength(1) + expect(host.sent[0]?.headers.get('authorization')).toBe( + 'Bearer sk-ant-oat01-local-token', + ) + expect(native.heldCredentialCount).toBe(0) + expect(native.pendingSendCount).toBe(0) + expect(native.operationsInProgress).toBe(0) + // The forgotten send's body was never rewritten or signed: the rewrite + // would have pinned the session's first user text, as it did for the + // fresh session. + expect(hasPinnedFirstUserTextForTest('ses_alpha')).toBe(false) + expect(hasPinnedFirstUserTextForTest('ses_fresh')).toBe(true) + }) + + test('a session forgotten while the request body is read never reaches the source options or the body rewrite', async () => { + resetPinnedFirstUserTextsForTest() + const { source, seen } = makeSource() + const native = createNativeAnthropicAdapter({ source }) + const host = new Host(native.adapter) + let releaseBody!: () => void + const bodyGate = new Promise((resolve) => { + releaseBody = resolve + }) + const text = JSON.stringify(sonnetPayload()) + const slowBody = new Request(UPSTREAM, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: new ReadableStream({ + async pull(controller) { + await bodyGate + controller.enqueue(new TextEncoder().encode(text)) + controller.close() + }, + }), + duplex: 'half', + } as RequestInit) + const started = await host.begin(scopeOf()) + + const forgotten = host + .send(started, slowBody) + .catch((error: unknown) => error) + await until(() => native.operationsInProgress === 1) + native.forgetSession('ses_alpha') + releaseBody() + + expect(await forgotten).toBeInstanceOf(NativeAnthropicRevokedError) + expect(seen.requestOptions).toHaveLength(0) + expect(hasPinnedFirstUserTextForTest('ses_alpha')).toBe(false) + expect(host.sent).toHaveLength(0) + expect(native.operationsInProgress).toBe(0) + + // Healthy companion: a fresh session's send after the clear is served. + await host.run( + scopeOf({ sessionID: 'ses_fresh' }), + hostRequest(sonnetPayload()), + ) + expect(seen.requestOptions).toHaveLength(1) + expect(host.sent).toHaveLength(1) + }) + + test('work forgotten at its settlement boundary returns nothing', async () => { + // The source answers after an await, and the session is forgotten two + // microtasks after the answer is released: after the adapter's checks + // inside the work have passed, but before the work settles. + const replay = async ( + run: ( + native: NativeAnthropicAdapter, + gate: Promise, + ) => Promise, + source: ( + waitOn: () => Promise, + ) => NativeAnthropicSource, + ) => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let waiting = false + const waitOn = () => { + waiting = true + return gate + } + const native = createNativeAnthropicAdapter({ source: source(waitOn) }) + const work = run(native, gate) + await until(() => waiting) + release() + queueMicrotask(() => + queueMicrotask(() => native.forgetSession('ses_alpha')), + ) + const outcome = await work.catch((error: unknown) => error) + return { native, outcome } + } + const healthy = makeSource().source + + const authorized = await replay( + (native) => + Promise.resolve( + native.adapter.accountHeaders({ ...scopeOf(), accountId: 'acct' }), + ), + (waitOn) => ({ + ...healthy, + authorizeAccount: async () => { + await waitOn() + return oauth('sk-ant-oat01-late', { custody: 'vault', receipt: 'r' }) + }, + }), + ) + expect(authorized.outcome).toBeInstanceOf(NativeAnthropicRevokedError) + expect(authorized.native.heldCredentialCount).toBe(0) + expect(authorized.native.pendingSendCount).toBe(0) + expect(authorized.native.operationsInProgress).toBe(0) + + const chosen = await replay( + (native) => Promise.resolve(native.adapter.chooseAccount(scopeOf())), + (waitOn) => ({ + ...healthy, + chooseAccount: async () => { + await waitOn() + return 'acct-late' + }, + }), + ) + expect(chosen.outcome).toBeInstanceOf(NativeAnthropicRevokedError) + expect(chosen.native.operationsInProgress).toBe(0) + + const sent: Request[] = [] + const rewritten = await replay( + async (native) => { + const host = new Host(native.adapter) + const started = await host.begin(scopeOf()) + return host + .send(started, hostRequest(sonnetPayload())) + .finally(() => sent.push(...host.sent)) + }, + (waitOn) => ({ + ...healthy, + requestOptions: async () => { + await waitOn() + return {} + }, + }), + ) + expect(rewritten.outcome).toBeInstanceOf(NativeAnthropicRevokedError) + expect(sent).toHaveLength(0) + expect(rewritten.native.heldCredentialCount).toBe(0) + expect(rewritten.native.operationsInProgress).toBe(0) + + // Healthy companion: after the clear, new work for the same session is + // served normally. + const native = createNativeAnthropicAdapter({ source: healthy }) + native.forgetSession('ses_alpha') + const host = new Host(native.adapter) + const after = await host.run(scopeOf(), hostRequest(sonnetPayload())) + expect(after.final.headers.get('authorization')).toBe( + 'Bearer sk-ant-oat01-local-token', + ) + expect(host.sent).toHaveLength(1) + }) + + test('pending sends stay bounded and an evicted send is never sent', async () => { + const warnings: string[] = [] + const { source } = makeSource({ + authorize: () => + apiKey( + 'sk-ant-api03-row-key', + { custody: 'api', receipt: 'api:1' }, + 'https://gateway.example.com', + ), + }) + const native = createNativeAnthropicAdapter({ + source, + maxPendingSends: 2, + log: { warn: (message) => warnings.push(message) }, + }) + const host = new Host(native.adapter) + + const oldest = await host.begin(scopeOf()) + await host.begin(scopeOf()) + await host.begin(scopeOf()) + + expect(native.pendingSendCount).toBe(2) + expect(warnings).toHaveLength(1) + const failure = await host + .send(oldest, hostRequest(sonnetPayload())) + .catch((error: unknown) => error) + expect(failure).toBeInstanceOf(NativeAnthropicNoAccountError) + expect(host.sent).toHaveLength(0) + }) + + test('onAttemptEnd stops waiting for a source that never settles', async () => { + const warnings: string[] = [] + const { source } = makeSource({ + onAttemptEnd: () => new Promise(() => {}), + }) + const native = createNativeAnthropicAdapter({ + source, + attemptEndTimeoutMs: 20, + log: { warn: (message) => warnings.push(message) }, + }) + const host = new Host(native.adapter) + const { attempt } = await host.begin(scopeOf()) + + const settled = await Promise.race([ + Promise.resolve( + native.adapter.onAttemptEnd?.(attempt, { + status: 200, + outputStarted: true, + }), + ).then(() => 'settled'), + new Promise((resolve) => setTimeout(() => resolve('stuck'), 1_000)), + ]) + + expect(settled).toBe('settled') + expect(warnings).toEqual([ + 'the account source did not finish recording a send', + ]) + }) +}) + +// Compile-time contract checks. The function is never called; `tsc` checks +// every line, so each `@ts-expect-error` fails the typecheck if the line it +// marks ever starts compiling. +function contractChecks(scope: RequestScope, limit: LimitSignal) { + const healthy: NativeAnthropicSource = { + chooseAccount: (input) => input.previousAccountId ?? 'acct', + authorizeAccount: () => oauth('t', { custody: 'local', receipt: 'r' }), + requestOptions: () => ({ cache1hEnabled: true }), + limitFromStreamError: () => limit, + } + const typed: OpenCode2AuthAdapter = createNativeAnthropicAdapter( + { source: healthy }, + ).adapter + void typed + + const narrowChoose: NativeAnthropicSource = { + ...healthy, + // @ts-expect-error a callback demanding a variant the scope never has + chooseAccount: (input: RequestScope & { variant: string }) => input.variant, + } + const narrowAuthorize: NativeAnthropicSource = { + ...healthy, + // @ts-expect-error a callback that only accepts title requests + authorizeAccount: ( + input: RequestScope & { kind: 'title' }, + accountId: string, + ) => oauth(accountId, { custody: 'local', receipt: input.kind }), + } + const ownedIdentity: NativeAnthropicSource = { + ...healthy, + // @ts-expect-error the identity comes with the credential, not the options + requestOptions: () => ({ identity: IDENTITY }), + } + const ownedSession: NativeAnthropicSource = { + ...healthy, + // @ts-expect-error the session id comes from the host scope + requestOptions: () => ({ sessionId: scope.sessionID }), + } + const resend: NativeAnthropicSource = { + ...healthy, + // @ts-expect-error content filter recovery would send the request again + responseOptions: () => ({ onContentFilter: () => true }), + } + // @ts-expect-error an OAuth authorization must carry its access token + const tokenless: NativeAnthropicAuthorization = { + route: 'oauth', + provenance: { custody: 'local', receipt: 'r' }, + } + void [ + narrowChoose, + narrowAuthorize, + ownedIdentity, + ownedSession, + resend, + tokenless, + ] +} +void contractChecks diff --git a/packages/opencode/src/v2/adapter.ts b/packages/opencode/src/v2/adapter.ts new file mode 100644 index 000000000..6265cc454 --- /dev/null +++ b/packages/opencode/src/v2/adapter.ts @@ -0,0 +1,918 @@ +// The Anthropic half of the OpenCode 2 hooks recipe: what +// `@cortexkit/common-auth/opencode2` leaves to its caller, for requests the +// host sends itself through its own Anthropic HTTP driver. +// +// The installer in common-auth edits those requests at fixed points and asks +// this adapter, in order, for one physical send (an "attempt"): +// +// - `chooseAccount`: which account the request goes to; +// - `accountHeaders`: that account's credential headers, plus the attempt's +// `data`, which the installer hands back on every later call for the same +// send; +// - `rewriteRequest`: the Claude Code wire shape for an OAuth send (URL, +// persona headers, body rewrite and signature), or the plain API shape for +// an API-key send; +// - `quotaFromHeaders`, `limitFromResponse`, `inspectEvent`, `limitFromError`: +// what the response says about the account; +// - `rewriteResponse`: undoing the OAuth tool-name prefix in the response; +// - `onAttemptEnd`: how the send ended. +// +// Everything about accounts and policy belongs to an injected source owned +// by Core (`NativeAnthropicSource`): which account to use, the credential it +// authorizes for one send, the body rewrite options (billing, effort, cache, +// safety, diagnostics), and how a response or error is classified. The +// adapter never reads an account pool, never falls back to the host's own +// credential, and never guesses an account or a plan from the wire. What the +// source says about one send (its route and its opaque provenance, such as a +// vault receipt) stays attached to that send and is handed back unchanged. +// +// The adapter never sends anything itself: no retry, no second send from the +// response side, no relay. Retrying on another account is the installer's +// retry hook, which acts only on the limit signals the source returns here. + +import { + applyCustomHeaders, + assertNotCustodyTombstone, + type ClaudeCodeIdentity, + mergeAnthropicBetas, + remapRequestBodyModel, + stripEmptyTrailingAssistantMessages, + TrailingAssistantHistoryError, +} from '@cortexkit/anthropic-auth-core' +import type { + AccountHeadersResult, + AccountRequest, + Attempt, + AttemptOutcome, + ChooseAccountInput, + EventVerdict, + HeaderEdits, + HostError, + LimitSignal, + OpenCode2AuthAdapter, + RequestScope, + Transport, +} from '@cortexkit/common-auth/opencode2' +import { BILLING_LINEAGE_REQUEST_HEADER } from '../billing-lineage' +import { + EFFORT_PLAN_REQUEST_HEADER, + EffortMarkerCorrelationError, +} from '../effort-history' +import { LANE_START_REQUEST_HEADER } from '../lane-start' +import { + applyServerSideFallbackToBody, + SERVER_SIDE_FALLBACK_BETA, +} from '../server-fallback' +import { + createStrippedStream, + rewriteRequestBody, + rewriteUrl, + setOAuthHeaders, +} from '../transform' + +/** The provider OpenCode 2 serves Claude under. */ +export const ANTHROPIC_PROVIDER_ID = 'anthropic' + +/** + * The request header the installer marks a send with. The installer removes + * it itself; it is listed here so that no copy of it can ever reach the wire + * through this adapter either. Kept as a literal because the adapter imports + * nothing from common-auth at runtime. + */ +const INSTALLER_ATTEMPT_HEADER = 'x-common-auth-attempt' + +/** + * Plugin-internal request headers that correlate a request with this + * plugin's own state. They are meaningful only inside the plugin and are + * removed before the request goes upstream, on both routes. + */ +export const INTERNAL_REQUEST_HEADERS: readonly string[] = [ + 'x-session-affinity', + 'x-opencode-session', + 'x-parent-session-id', + EFFORT_PLAN_REQUEST_HEADER, + BILLING_LINEAGE_REQUEST_HEADER, + LANE_START_REQUEST_HEADER, + INSTALLER_ATTEMPT_HEADER, +] + +/** Headers that carry a credential; on the OAuth route only the adapter sets them. */ +const CREDENTIAL_HEADERS = new Set(['authorization', 'x-api-key']) + +/** + * Fixed text of the refusal raised when the source has no credential for a + * send. It names no account, route or vault, because the host shows and logs + * it. + */ +export const NO_ACCOUNT_REFUSAL = + 'request refused: no account with a usable credential' + +/** + * Most sends whose credential may wait between `accountHeaders` and + * `rewriteRequest` at once. Matches the installer's own default record bound; + * the oldest is dropped first, and a send whose entry was dropped is refused + * rather than sent without it. + */ +export const DEFAULT_MAX_PENDING_SENDS = 512 + +/** + * How long `onAttemptEnd` waits for the source to finish recording how a send + * ended. After that the adapter returns and logs a warning; the source's own + * work is not cancelled. + */ +export const DEFAULT_ATTEMPT_END_TIMEOUT_MS = 5_000 + +/** Longest provider error text kept in an attempt's failure message. */ +const MAX_STREAM_ERROR_MESSAGE = 500 + +/** Which kind of credential one send goes out with. */ +export type NativeAnthropicRoute = 'oauth' | 'api' + +/** + * What the source authorizes for one send. `provenance` is the source's own + * record of the account and credential version that serve it (a local slot, + * a vault receipt with its version, an API key row). The adapter hands it back + * unchanged with every response, error and end of that send, so the source + * can tie a 401 or a quota reading to the exact account and version served. + */ +export type NativeAnthropicAuthorization

= + | { + readonly route: 'oauth' + readonly accessToken: string + /** The Claude Code identity of the account, when the source knows it. */ + readonly identity?: ClaudeCodeIdentity + /** Extra header edits. Credential headers here are ignored. */ + readonly headers?: HeaderEdits + readonly provenance: P + } + | { + readonly route: 'api' + /** + * The API key header edits applied to the send; they must set + * `authorization` or `x-api-key`. + */ + readonly headers: HeaderEdits + /** The URL the send goes to instead of the host's, for this account. */ + readonly baseURL?: string + readonly provenance: P + } + +/** + * The per-send value the installer carries as `Attempt.data`. It holds no + * credential and no request body. + */ +export interface NativeAnthropicAttemptData

{ + readonly route: NativeAnthropicRoute + readonly provenance: P +} + +/** + * One send, as the source's response, error and end callbacks receive it: + * the host's request scope, the attempt, and what the source authorized. + */ +export interface NativeAnthropicAttemptContext

extends AccountRequest { + readonly attemptId: string + readonly transport: Transport | undefined + readonly route: NativeAnthropicRoute + readonly provenance: P +} + +/** The options parameter of `rewriteRequestBody`, inferred and non-nullable. */ +type RewriteBodyOptions = NonNullable[1]> + +/** + * The body rewrite options a source supplies for an OAuth send: billing + * lineage, effort plan, cache, fast mode, safety and diagnostics inputs. Two + * options are left out because the adapter sets them: the Claude Code + * identity, which the source returns with the credential, and the session id, + * which is the host's own session id for the request. + */ +export type NativeAnthropicBodyOptions = Omit< + RewriteBodyOptions, + 'identity' | 'sessionId' +> + +type StrippedStreamOptions = NonNullable< + Parameters[1] +> + +/** + * The response observers a source may attach to an OAuth response. Content + * filter recovery and relay error hooks are left out on purpose: they exist + * to send the request again, and this adapter never sends. + */ +export type NativeAnthropicResponseOptions = Pick< + StrippedStreamOptions, + | 'perf' + | 'onComplete' + | 'serverSideFallbackModel' + | 'onServerSideFallbackOutcome' + | 'onMessageStart' + | 'onMessageDelta' + | 'onMessageResponse' + | 'onStreamEnd' + | 'laneStart' +> + +/** What `requestOptions` receives for an OAuth send, before its body is rewritten. */ +export interface NativeAnthropicRequestContext

+ extends NativeAnthropicAttemptContext

{ + readonly route: 'oauth' + /** + * This plugin's own correlation headers found on the request (lower-case + * names, such as the effort plan or billing lineage id), already removed + * from it. The adapter does not interpret them; the source looks them up + * in its own trackers. + */ + readonly correlation: Readonly> +} + +/** + * What the source's response callbacks receive: the send, plus the HTTP + * status of its response, before the body is read. + */ +export interface NativeAnthropicResponseContext

+ extends NativeAnthropicAttemptContext

{ + readonly status: number +} + +/** An `error` event inside a streamed response. */ +export interface NativeAnthropicStreamErrorContext

+ extends NativeAnthropicAttemptContext

{ + /** The provider's `error.type`, such as `overloaded_error`. */ + readonly errorType: string | undefined + readonly message: string | undefined +} + +/** + * How a send ended. `localRefusal` is set when the adapter refused the + * request itself before anything was sent (a malformed history, say); a + * provider 400 never carries it. + */ +export interface NativeAnthropicAttemptOutcome extends AttemptOutcome { + readonly localRefusal?: { readonly check: string } +} + +/** + * The Core-owned source of accounts and policy. Every member is a function + * property, so under `strictFunctionTypes` a callback that accepts less than + * the adapter passes it is rejected at compile time. + */ +export interface NativeAnthropicSource { + /** Picks the account for one request; `undefined` refuses it. */ + readonly chooseAccount: ( + input: ChooseAccountInput, + ) => Promise | string | undefined + /** + * The credential for one send with the chosen account; `undefined` refuses + * the request before anything is sent. + */ + readonly authorizeAccount: ( + scope: RequestScope, + accountId: string, + ) => + | Promise | undefined> + | NativeAnthropicAuthorization

+ | undefined + /** Body rewrite options for an OAuth send. */ + readonly requestOptions?: ( + input: NativeAnthropicRequestContext

, + ) => Promise | NativeAnthropicBodyOptions + /** Response observers for an OAuth response. */ + readonly responseOptions?: ( + input: NativeAnthropicResponseContext

, + ) => NativeAnthropicResponseOptions | undefined + /** Quota carried by the upstream response headers, read once per response. */ + readonly quotaFromHeaders?: ( + headers: Headers, + input: NativeAnthropicResponseContext

, + ) => Q | undefined + /** + * Classifies an error response before its body streams. `body()` reads a + * copy, so the host still receives the body. + */ + readonly limitFromResponse?: ( + input: NativeAnthropicResponseContext

& { + readonly headers: Headers + readonly body: () => Promise + }, + ) => Promise | LimitSignal | undefined + /** Classifies an `error` event inside a streamed response. */ + readonly limitFromStreamError?: ( + input: NativeAnthropicStreamErrorContext

, + ) => LimitSignal | undefined + /** + * Classifies the error the host hands the retry hook. Never asked about a + * send the adapter refused locally. + */ + readonly limitFromHostError?: ( + error: HostError, + input: NativeAnthropicAttemptContext

, + ) => LimitSignal | undefined + /** + * How a send ended, with the provenance of the credential it used, so a + * 401 can be reported against the exact vault receipt version served. + */ + readonly onAttemptEnd?: ( + input: NativeAnthropicAttemptContext

, + outcome: NativeAnthropicAttemptOutcome, + ) => Promise | void + /** + * Called by the adapter's `forgetSession`; drops whatever the source keeps + * for the session. + */ + readonly forgetSession?: (sessionID: string) => void +} + +export interface NativeAnthropicAdapterLogger { + warn(message: string, data?: Record): void +} + +export interface NativeAnthropicAdapterDeps { + readonly source: NativeAnthropicSource + readonly log?: NativeAnthropicAdapterLogger + /** Most authorized sends that may wait for their request rewrite at once. */ + readonly maxPendingSends?: number + /** + * How long `onAttemptEnd` waits for the source to record how a send + * ended; the source's work is not cancelled. + */ + readonly attemptEndTimeoutMs?: number +} + +export interface NativeAnthropicAdapter { + readonly adapter: OpenCode2AuthAdapter> + /** + * Drops the session's authorized sends that have not been rewritten yet, + * revokes its choices, authorizations and rewrites still in progress so + * none of them can return, and calls the source's `forgetSession`. + */ + forgetSession(sessionID: string): void + /** Number of sends authorized and not yet rewritten or ended. */ + readonly pendingSendCount: number + /** Number of OAuth access tokens held for sends not yet rewritten. */ + readonly heldCredentialCount: number + /** Number of choices, authorizations and rewrites still in progress. */ + readonly operationsInProgress: number +} + +/** + * The request was refused before anything was sent because the source had + * no credential for it. Never retried on the host's own credential. + */ +export class NativeAnthropicNoAccountError extends Error { + readonly kind = 'no-account' + readonly providerID = ANTHROPIC_PROVIDER_ID + + constructor( + readonly sessionID: string, + readonly requestKind: string, + ) { + super(NO_ACCOUNT_REFUSAL) + this.name = 'NativeAnthropicNoAccountError' + } +} + +/** + * The request was refused because its session was forgotten while the + * account choice, authorization or request rewrite for it was still in + * progress. Nothing from that work is returned or kept, and nothing is sent. + */ +export class NativeAnthropicRevokedError extends Error { + readonly kind = 'session-forgotten' + readonly providerID = ANTHROPIC_PROVIDER_ID + + constructor( + readonly sessionID: string, + readonly requestKind: string, + ) { + super('request refused: its session was closed while it was being prepared') + this.name = 'NativeAnthropicRevokedError' + } +} + +/** + * The request was refused with a local 400 because it can never succeed as + * written: its history ends on a meaningful assistant turn, or an effort + * marker in it cannot be matched to the session's effort plan. Nothing was + * sent, and another account cannot help, so the adapter never reports it as + * an account limit. The message and `check` describe only the shape of the + * problem, never conversation content. + */ +export class NativeAnthropicLocalRefusal extends Error { + readonly kind = 'local-invalid-request' + readonly status = 400 + readonly check: string + + constructor( + cause: TrailingAssistantHistoryError | EffortMarkerCorrelationError, + ) { + super(cause.message, { cause }) + this.name = 'NativeAnthropicLocalRefusal' + this.check = cause.check + } +} + +/** + * What the adapter keeps for one authorized send until its request is + * rewritten: the OAuth access token and identity, or the API base URL. + */ +interface PendingSend { + readonly sessionID: string + readonly oauth?: { + readonly accessToken: string + readonly identity?: ClaudeCodeIdentity + } + readonly baseURL?: string +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +/** Lower-cases every header name, keeping the last value for a name. */ +function lowerCaseEdits(edits: HeaderEdits | undefined) { + const out: Record = {} + for (const [name, value] of Object.entries(edits ?? {})) { + out[name.toLowerCase()] = value + } + return out +} + +/** Removes the plugin-internal headers and returns what they held. */ +function takeInternalHeaders(headers: Headers) { + const found: Record = {} + for (const name of INTERNAL_REQUEST_HEADERS) { + const value = headers.get(name) + if (value !== null && name !== INSTALLER_ATTEMPT_HEADER) found[name] = value + headers.delete(name) + } + return found +} + +function isMessagesUrl(url: string) { + try { + return new URL(url).pathname.endsWith('/messages') + } catch { + return false + } +} + +function localRefusalFrom(error: unknown) { + return error instanceof TrailingAssistantHistoryError || + error instanceof EffortMarkerCorrelationError + ? new NativeAnthropicLocalRefusal(error) + : undefined +} + +/** + * Betas that this plugin itself adds to its OAuth sends: the OAuth beta, the + * Claude Code persona beta, and the server-side fallback opt-in that goes + * with the `fallbacks` body field the API route also removes. They are + * stripped from API-key sends so the plugin's OAuth persona and fallback + * handoff never reach an API key or proxy route. Every other beta value the + * host requested is passed on unchanged. + */ +const OAUTH_ONLY_BETAS = new Set([ + 'oauth-2025-04-20', + 'claude-code-20250219', + SERVER_SIDE_FALLBACK_BETA, +]) + +/** The host's betas for an API send, without the OAuth-only ones. */ +function apiBetas(value: string) { + const kept = value + .split(',') + .map((beta) => beta.trim()) + .filter((beta) => beta && !OAUTH_ONLY_BETAS.has(beta)) + return mergeAnthropicBetas(null, kept) +} + +/** + * Applies the API route's body rules. There is no Claude Code persona, tool + * prefix or signature; only the local history check, the plugin's + * server-side fallback opt-in and stored fallback markers removed (the same + * rule the OAuth rewrite applies when fallback is off), and the configured + * model remap. Returns the body text unchanged when nothing changed or it is + * not a JSON object. + */ +function rewriteApiBody(body: string) { + const parsed = parseJson(body) + if (!isRecord(parsed)) return body + const removed = stripEmptyTrailingAssistantMessages(parsed.messages) + const hadFallbacks = Object.hasOwn(parsed, 'fallbacks') + const { droppedMarkers } = applyServerSideFallbackToBody(parsed, false) + const fallbacksRemoved = hadFallbacks && !Object.hasOwn(parsed, 'fallbacks') + const remapped = remapRequestBodyModel(parsed) + return removed > 0 || droppedMarkers > 0 || fallbacksRemoved || remapped + ? JSON.stringify(parsed) + : body +} + +/** Resolves `work` or gives up after `ms`, so a stuck callback cannot hold the host. */ +async function settleWithin( + work: Promise | void, + ms: number, +): Promise<'settled' | 'timed-out'> { + if (!(work instanceof Promise)) return 'settled' + let timer: ReturnType | undefined + try { + return await Promise.race([ + work.then(() => 'settled' as const), + new Promise<'timed-out'>((resolve) => { + timer = setTimeout(() => resolve('timed-out'), ms) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +export function createNativeAnthropicAdapter( + deps: NativeAnthropicAdapterDeps, +): NativeAnthropicAdapter { + const { source, log } = deps + const maxPending = Math.max( + 1, + deps.maxPendingSends ?? DEFAULT_MAX_PENDING_SENDS, + ) + const endTimeoutMs = + deps.attemptEndTimeoutMs ?? DEFAULT_ATTEMPT_END_TIMEOUT_MS + type Data = NativeAnthropicAttemptData

+ + // Keyed by the fresh per-send data object that `accountHeaders` returns + // alongside the credential headers, so two sends never share an entry even + // on the same account and session. An entry is removed when its request is + // rewritten, when the send ends, when its session is forgotten, or when it + // is the oldest beyond the bound. + const pending = new Map() + // Sends refused locally, by the check that refused them. Weakly held: the + // entry lasts while that per-send data object is reachable (the installer + // keeps it on the attempt), because the retry hook may ask about the send + // after it has ended. + const localRefusals = new WeakMap() + + const refuse = (scope: RequestScope) => + new NativeAnthropicNoAccountError(scope.sessionID, scope.kind) + + // Choices, authorizations and rewrites still waiting on the source or on + // the request body, by session. `forgetSession` marks a session's entries + // revoked; each one checks after its waits and again as it settles, so + // work for a forgotten session can never hand back an account, a + // credential or a request. An + // entry is removed as soon as its work finishes, so the map only holds + // sessions with work in progress and keeps nothing for closed sessions. + const inProgress = new Map>() + + const tracked = async ( + scope: RequestScope, + work: (ensureLive: () => void) => Promise, + ): Promise => { + const operation = { revoked: false } + let operations = inProgress.get(scope.sessionID) + if (!operations) { + operations = new Set() + inProgress.set(scope.sessionID, operations) + } + operations.add(operation) + const ensureLive = () => { + if (operation.revoked) { + throw new NativeAnthropicRevokedError(scope.sessionID, scope.kind) + } + } + try { + const result = await work(ensureLive) + // Checked again once the work has settled: a session forgotten after + // the work's last own check but before this point gets nothing back. + ensureLive() + return result + } finally { + const current = inProgress.get(scope.sessionID) + if (current?.delete(operation) && current.size === 0) { + inProgress.delete(scope.sessionID) + } + } + } + + const remember = (data: Data, send: PendingSend) => { + pending.set(data, send) + while (pending.size > maxPending) { + const oldest = pending.keys().next().value + if (oldest === undefined) break + pending.delete(oldest) + log?.warn('dropped the oldest pending Anthropic send to stay bounded', { + bound: maxPending, + }) + } + } + + const contextOf = ( + attempt: Attempt, + data: Data, + ): NativeAnthropicAttemptContext

=> ({ + providerID: attempt.providerID, + modelID: attempt.modelID, + sessionID: attempt.sessionID, + agent: attempt.agent, + kind: attempt.kind, + accountId: attempt.accountId, + attemptId: attempt.attemptId, + transport: attempt.transport, + route: data.route, + provenance: data.provenance, + }) + + const accountHeaders = ( + request: AccountRequest, + ): Promise> => + tracked(request, async (ensureLive) => { + const { accountId, ...scope } = request + const auth = await source.authorizeAccount(scope, accountId) + ensureLive() + return headersFor(scope, auth) + }) + + const headersFor = ( + scope: RequestScope, + auth: NativeAnthropicAuthorization

| undefined, + ): AccountHeadersResult => { + if (!auth) throw refuse(scope) + if (auth.route === 'oauth') { + if (!auth.accessToken) throw refuse(scope) + // A slot whose credential the vault owns holds a non-secret activation + // marker (a tombstone) in place of a token. It stands for the vault's + // ownership, not for credential content, so it is never sent as a + // bearer token. + assertNotCustodyTombstone(auth.accessToken, ANTHROPIC_PROVIDER_ID) + const data: Data = Object.freeze({ + route: 'oauth', + provenance: auth.provenance, + }) + remember(data, { + sessionID: scope.sessionID, + oauth: { + accessToken: auth.accessToken, + ...(auth.identity ? { identity: auth.identity } : {}), + }, + }) + const extra = lowerCaseEdits(auth.headers) + for (const name of CREDENTIAL_HEADERS) delete extra[name] + return { + headers: { + ...extra, + 'x-api-key': null, + authorization: `Bearer ${auth.accessToken}`, + }, + attempt: data, + } + } + const headers = { + authorization: null, + 'x-api-key': null, + ...lowerCaseEdits(auth.headers), + } + if (!headers.authorization && !headers['x-api-key']) throw refuse(scope) + const data: Data = Object.freeze({ + route: 'api', + provenance: auth.provenance, + }) + remember(data, { + sessionID: scope.sessionID, + ...(auth.baseURL ? { baseURL: auth.baseURL } : {}), + }) + return { headers, attempt: data } + } + + const rewriteRequest = ( + input: AccountRequest & { + readonly request: Request + readonly attempt: Attempt + }, + ): Promise => + tracked(input, (ensureLive) => rewriteSend(input, ensureLive)) + + const rewriteSend = async ( + input: AccountRequest & { + readonly request: Request + readonly attempt: Attempt + }, + ensureLive: () => void, + ): Promise => { + const { request, attempt } = input + const data = attempt.data + const send = data ? pending.get(data) : undefined + // One authorization serves one send: the entry is gone after this call, + // so a credential never outlives the request it was issued for. + if (data) pending.delete(data) + if (!data || !send || (data.route === 'oauth' && !send.oauth)) { + throw refuse(input) + } + + const headers = new Headers(request.headers) + const correlation = takeInternalHeaders(headers) + let body = request.body === null ? undefined : await request.text() + // A session forgotten while the body was read never reaches the source's + // options or the body rewrite. + ensureLive() + const messages = body !== undefined && isMessagesUrl(request.url) + + try { + if (data.route === 'oauth' && send.oauth) { + const { accessToken, identity } = send.oauth + let finalBody: Record | null = null + if (body !== undefined && messages) { + const context: NativeAnthropicRequestContext

= { + ...contextOf(attempt, data), + route: 'oauth', + correlation, + } + const options = (await source.requestOptions?.(context)) ?? {} + // Nor is the body rewritten and signed for one forgotten while the + // source chose the options. + ensureLive() + body = await rewriteRequestBody(body, { + ...options, + ...(identity ? { identity } : {}), + sessionId: input.sessionID, + }) + const parsed = parseJson(body) + finalBody = isRecord(parsed) ? parsed : null + } + setOAuthHeaders(headers, accessToken, { + body: finalBody, + ...(identity ? { identity } : {}), + }) + } else { + if (body !== undefined && messages) body = rewriteApiBody(body) + headers.delete('authorization') + headers.delete('x-api-key') + if (body !== undefined && messages && !headers.has('content-type')) { + headers.set('content-type', 'application/json') + } + const betas = headers.get('anthropic-beta') + if (betas !== null) { + const kept = apiBetas(betas) + if (kept) headers.set('anthropic-beta', kept) + else headers.delete('anthropic-beta') + } + applyCustomHeaders(headers) + } + } catch (error) { + const refusal = localRefusalFrom(error) + if (!refusal) throw error + localRefusals.set(data, refusal.check) + log?.warn('refused an Anthropic request locally; nothing was sent', { + check: refusal.check, + }) + throw refusal + } + + // The body may have changed length; let the runtime compute it. + if (body !== undefined) headers.delete('content-length') + // Checked again after the body rewrite and signing. + ensureLive() + const url = + rewriteUrl( + request.url, + send.baseURL !== undefined ? { baseURL: send.baseURL } : {}, + ).url?.toString() ?? request.url + return new Request(url, { + method: request.method, + headers, + redirect: request.redirect, + signal: request.signal, + ...(body !== undefined ? { body } : {}), + }) + } + + const adapter: OpenCode2AuthAdapter = { + providerID: ANTHROPIC_PROVIDER_ID, + chooseAccount: (input) => + tracked(input, async () => source.chooseAccount(input)), + accountHeaders, + rewriteRequest, + rewriteResponse({ response, attempt }) { + const data = attempt.data + // Only the OAuth route prefixes tool names, so only its responses are + // mapped back. An API response reaches the host byte for byte. + if (data?.route !== 'oauth' || !response.body) return undefined + const context = { ...contextOf(attempt, data), status: response.status } + const options = source.responseOptions?.(context) ?? {} + const json = (response.headers.get('content-type') ?? '').includes( + 'application/json', + ) + return createStrippedStream(response, { + ...options, + laneStartOAuthServed: true, + ...(json ? { responseMode: 'json' as const } : {}), + }) + }, + quotaFromHeaders(headers, status, attempt) { + const data = attempt.data + if (!data) return undefined + return source.quotaFromHeaders?.(headers, { + ...contextOf(attempt, data), + status, + }) + }, + async limitFromResponse({ status, headers, body, attempt }) { + const data = attempt.data + if (!data || !source.limitFromResponse) return undefined + return source.limitFromResponse({ + ...contextOf(attempt, data), + status, + headers, + body, + }) + }, + inspectEvent({ data: payload, event, attempt }) { + const parsed = parseJson(payload) + if (!isRecord(parsed)) return undefined + const type = typeof parsed.type === 'string' ? parsed.type : event + // Text, thinking and tool input all arrive in content blocks; nothing + // before the first one has reached the user, so a refusal before it + // can still move to another account. + if (type === 'content_block_start' || type === 'content_block_delta') { + return { outputStarted: true } + } + if (type === 'message_stop') return { done: true } + if (type !== 'error') return undefined + const error = isRecord(parsed.error) ? parsed.error : {} + const errorType = typeof error.type === 'string' ? error.type : undefined + const message = + typeof error.message === 'string' ? error.message : undefined + const data = attempt.data + const limit = data + ? source.limitFromStreamError?.({ + ...contextOf(attempt, data), + errorType, + message, + }) + : undefined + const verdict: EventVerdict = { + error: [errorType ?? 'error', message] + .filter(Boolean) + .join(': ') + .slice(0, MAX_STREAM_ERROR_MESSAGE), + ...(limit ? { limit } : {}), + } + return verdict + }, + limitFromError(error, attempt) { + const data = attempt.data + // A local refusal is about the request, not the account: answering it + // with a limit would move the same broken request to another account. + if (!data || localRefusals.has(data)) return undefined + return source.limitFromHostError?.(error, contextOf(attempt, data)) + }, + async onAttemptEnd(attempt, outcome) { + const data = attempt.data + if (!data) return + pending.delete(data) + const check = localRefusals.get(data) + const ended = source.onAttemptEnd?.(contextOf(attempt, data), { + ...outcome, + ...(check !== undefined ? { localRefusal: { check } } : {}), + }) + if ((await settleWithin(ended, endTimeoutMs)) === 'timed-out') { + log?.warn('the account source did not finish recording a send', { + timeoutMs: endTimeoutMs, + }) + } + }, + } + + return { + adapter, + forgetSession(sessionID) { + for (const operation of inProgress.get(sessionID) ?? []) { + operation.revoked = true + } + inProgress.delete(sessionID) + for (const [data, send] of pending) { + if (send.sessionID === sessionID) pending.delete(data) + } + source.forgetSession?.(sessionID) + }, + get pendingSendCount() { + return pending.size + }, + get heldCredentialCount() { + let count = 0 + for (const send of pending.values()) if (send.oauth) count++ + return count + }, + get operationsInProgress() { + let count = 0 + for (const operations of inProgress.values()) count += operations.size + return count + }, + } +} From 640686ff78ebdc896f96ea506706eb0019029c81 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:17:04 +0200 Subject: [PATCH 039/169] mason: add typed native menu action executor Add a Core executor that checks native menu requests against getNativeMenuModel and the existing command parsers, applies the safety rules, and dispatches only fully checked requests to one host-provided capability. The safety rules are: headless refusal, confirmation for destructive actions, a session ID for the conversation-menu actions that need one, and cancellation before dispatch. Custody mode returns the model's offline setup instructions and never dispatches. Proxies, accessors, foreign prototypes and malformed input are refused without running caller code. Capability failures are reported as a closed generic result. The killswitch account is an opaque, non-blank ID forwarded unchanged. Native route IDs contain colons, so the text command's ':' delimiter rule does not apply to structured entries. The OAuth start/finish pair, routing reset and lane start require a session ID when invoked from the menu. This does not change the provider auth loader or the offline CLI login. No host menu is wired and nothing is added to the package barrel. --- packages/core/src/native-menu-executor.ts | 668 ++++++++ .../src/tests/native-menu-executor.test.ts | 1469 +++++++++++++++++ 2 files changed, 2137 insertions(+) create mode 100644 packages/core/src/native-menu-executor.ts create mode 100644 packages/core/src/tests/native-menu-executor.test.ts diff --git a/packages/core/src/native-menu-executor.ts b/packages/core/src/native-menu-executor.ts new file mode 100644 index 000000000..e0dff880d --- /dev/null +++ b/packages/core/src/native-menu-executor.ts @@ -0,0 +1,668 @@ +import { types as utilTypes } from 'node:util' +import { + getNativeMenuModel, + type NativeMenuAction, + type NativeMenuActionId, + type NativeMenuCacheMode, + type NativeMenuCustodyMode, + type NativeMenuHost, + type NativeMenuLogLevel, + type NativeMenuParameter, + type NativeMenuParameterId, + type NativeMenuRefusalCode, + type NativeMenuRoutingMode, +} from './native-menu-model.ts' + +/** + * Typed input checking and safety gating for native menu actions. + * + * The menu model in native-menu-model.ts only describes actions. This module + * checks the values a menu collected against that description and the + * existing command parsers' rules, applies the safety rules (headless refusal, + * confirmation, session requirement, cancellation before dispatch), and then + * hands one validated request to a host-provided capability. The capability + * performs the real effect; this module performs no I/O, retries, or + * background work, and it does not render a menu or collect input. + */ + +/** + * One killswitch threshold row. The fields match the `/claude-killswitch set` + * entries, but `account` is taken as structured data, not command text. + */ +export type NativeMenuKillswitchEntry = { + /** + * An opaque account ID, passed on exactly as given, or the `main` / `all` + * selector. Native route IDs such as `vault:oauth:...` contain colons, so a + * menu that collects entries as text must keep the IDs whole rather than + * split them with the legacy command's `account:fh,sd` delimiters. + */ + readonly account: string + /** Five-hour remaining-percent threshold. */ + readonly fh: number + /** Seven-day remaining-percent threshold. */ + readonly sd: number + /** Optional model-scoped remaining-percent threshold. */ + readonly scoped?: number +} + +/** Values for an action that takes no parameters: no key is accepted. */ +export type NativeMenuNoValues = { readonly [key: string]: never } + +/** + * The values each action accepts. Optional inputs may be left out or sent as + * `null`, which is how a menu reports an input left empty. + */ +export type NativeMenuActionValues = { + readonly enable: { readonly id: string } + readonly disable: { readonly id: string } + readonly remove: { readonly id: string } + readonly 'move-up': { readonly id: string } + readonly 'move-down': { readonly id: string } + readonly 'reset-backoff': NativeMenuNoValues + readonly 'enrollment-reset': NativeMenuNoValues + readonly 'add-apikey': { + readonly apiKey: string + readonly label?: string | null + readonly baseURL?: string | null + readonly authHeader?: 'authorization-bearer' | 'x-api-key' | null + } + readonly 'add-oauth-start': NativeMenuNoValues + readonly 'add-oauth-finish': { + readonly code: string + readonly label?: string | null + } + readonly 'custody-mode': { readonly mode: NativeMenuCustodyMode } + readonly 'quota-refresh': NativeMenuNoValues + readonly 'routing-mode': { readonly mode: NativeMenuRoutingMode } + readonly 'routing-reset': NativeMenuNoValues + readonly 'killswitch-on': NativeMenuNoValues + readonly 'killswitch-off': NativeMenuNoValues + readonly 'killswitch-set': { + readonly entries: readonly NativeMenuKillswitchEntry[] + } + readonly 'cache-on': NativeMenuNoValues + readonly 'cache-off': NativeMenuNoValues + readonly 'cache-mode': { readonly mode: NativeMenuCacheMode } + readonly 'cachekeep-always': NativeMenuNoValues + readonly 'cachekeep-off': NativeMenuNoValues + readonly 'cachekeep-window': { + readonly startHour: number + readonly endHour: number + } + readonly 'cachekeep-subagents': { readonly enabled: 'on' | 'off' } + readonly 'dump-on': NativeMenuNoValues + readonly 'dump-off': NativeMenuNoValues + readonly 'logging-level': { readonly level: NativeMenuLogLevel } + readonly 'fast-on': NativeMenuNoValues + readonly 'fast-off': NativeMenuNoValues + readonly 'prime-on': NativeMenuNoValues + readonly 'prime-off': NativeMenuNoValues + readonly 'start-fire': NativeMenuNoValues +} + +/** A menu request as the shared UI hands it over, before any checking. */ +export type NativeMenuRequest = { + [Id in NativeMenuActionId]: NativeMenuActionValues[Id] extends NativeMenuNoValues + ? { readonly action: Id; readonly values?: NativeMenuActionValues[Id] } + : { readonly action: Id; readonly values: NativeMenuActionValues[Id] } +}[NativeMenuActionId] + +/** + * Actions whose effect belongs to one conversation: routing reset clears that + * session's sticky assignment, lane start fires inside that session, and the + * OAuth start/finish pair keeps its pending sign-in state per session. + */ +export type NativeMenuSessionActionId = + | 'routing-reset' + | 'start-fire' + | 'add-oauth-start' + | 'add-oauth-finish' + +/** Custody changes are offline setup steps, so they are never dispatched. */ +export type NativeMenuDispatchActionId = Exclude< + NativeMenuActionId, + 'custody-mode' +> + +/** Checked values: empty optional inputs are left out instead of `null`. */ +export type NativeMenuDispatchValues = { + readonly [Key in keyof NativeMenuActionValues[Id]]: Exclude< + NativeMenuActionValues[Id][Key], + null + > +} + +/** A fully checked request, the only thing a host capability receives. */ +export type NativeMenuDispatchRequest = { + [Id in NativeMenuDispatchActionId]: { + readonly action: Id + readonly values: NativeMenuDispatchValues + } & (Id extends NativeMenuSessionActionId + ? { readonly sessionId: string } + : { readonly sessionId?: never }) +}[NativeMenuDispatchActionId] + +export type NativeMenuDispatchOptions = { + /** The caller's signal, passed on so a long capability may stop early. */ + readonly signal?: AbortSignal +} + +/** What the host capability reports after it ran. */ +export type NativeMenuCapabilityOutcome = { + readonly ok: boolean + readonly text: string +} + +/** + * The host capability that performs the real effect for a checked request. + * It must accept every dispatchable action; a callback written for only some + * actions does not satisfy this type. + */ +export type NativeMenuDispatch = ( + request: NativeMenuDispatchRequest, + options: NativeMenuDispatchOptions, +) => Promise + +export type NativeMenuExecutorOptions = { + readonly host: NativeMenuHost + readonly dispatch: NativeMenuDispatch +} + +/** Facts about the invocation that the safety rules depend on. */ +export type NativeMenuExecutionContext = { + /** False when no person can answer prompts (a headless or scripted run). */ + readonly interactive: boolean + /** True once the person confirmed an action that requires confirmation. */ + readonly confirmed?: boolean + /** The conversation the request came from, when the host has one. */ + readonly sessionId?: string + readonly signal?: AbortSignal +} + +export type NativeMenuExecutorRefusalCode = + | NativeMenuRefusalCode + | 'invalid-request' + | 'invalid-context' + | 'unsupported-action' + | 'unknown-parameter' + | 'missing-parameter' + | 'invalid-parameter' + | 'confirmation-required' + | 'session-required' + | 'aborted' + +export type NativeMenuExecutionResult = + | { + /** The capability ran; `ok` and `text` are what it reported. */ + readonly status: 'executed' + readonly action: NativeMenuDispatchActionId + readonly ok: boolean + readonly text: string + } + | { + /** + * The capability was called but threw, rejected, or returned something + * unreadable. Whether its effect happened is unknown. + */ + readonly status: 'failed' + readonly action: NativeMenuDispatchActionId + readonly code: 'execution-failed' | 'invalid-outcome' + } + | { + /** Offline custody setup steps; nothing was changed. */ + readonly status: 'guidance' + readonly action: 'custody-mode' + readonly mode: NativeMenuCustodyMode + readonly instructions: readonly string[] + } + | { + /** Nothing was dispatched. */ + readonly status: 'refused' + readonly code: NativeMenuExecutorRefusalCode + readonly action?: NativeMenuActionId + readonly parameter?: NativeMenuParameterId + } + +export type NativeMenuExecutor = { + readonly execute: ( + request: NativeMenuRequest, + context: NativeMenuExecutionContext, + ) => Promise +} + +const SESSION_ACTIONS: ReadonlySet = + new Set([ + 'routing-reset', + 'start-fire', + 'add-oauth-start', + 'add-oauth-finish', + ]) + +const HOSTS: ReadonlySet = new Set(['opencode', 'pi']) + +// Captured once so a signal's state is read through the platform getter, not +// through any property a caller-made object could define. +const readAborted = Object.getOwnPropertyDescriptor( + AbortSignal.prototype, + 'aborted', +)?.get + +type Refusal = Extract + +function refused( + code: NativeMenuExecutorRefusalCode, + action?: NativeMenuActionId, + parameter?: NativeMenuParameterId, +): Refusal { + return Object.freeze({ + status: 'refused', + code, + ...(action !== undefined ? { action } : {}), + ...(parameter !== undefined ? { parameter } : {}), + }) +} + +/** + * Reads own data fields of a plain object without running caller code. + * Proxies are rejected before any reflection, because even asking a proxy for + * its prototype or keys runs its traps. Accessors, symbol keys, and objects + * with another prototype are rejected rather than read. Returns `unknown-key` + * when the object is well formed but carries a field outside `allowed`. + */ +function readPlain( + value: unknown, + allowed: readonly string[], +): Map | 'malformed' | 'unknown-key' { + if (value === null || typeof value !== 'object') return 'malformed' + if (utilTypes.isProxy(value)) return 'malformed' + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return 'malformed' + const fields = new Map() + let unknownKey = false + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return 'malformed' + const descriptor = Reflect.getOwnPropertyDescriptor(value, key) + if (!descriptor || !Object.hasOwn(descriptor, 'value')) return 'malformed' + if (!allowed.includes(key)) unknownKey = true + else fields.set(key, descriptor.value) + } + return unknownKey ? 'unknown-key' : fields +} + +/** Reads a dense plain array's elements without running caller code. */ +function readArray(value: unknown): unknown[] | undefined { + if (value === null || typeof value !== 'object') return undefined + if (utilTypes.isProxy(value)) return undefined + if (!Array.isArray(value)) return undefined + if (Object.getPrototypeOf(value) !== Array.prototype) return undefined + const lengthDescriptor = Reflect.getOwnPropertyDescriptor(value, 'length') + const length = lengthDescriptor?.value + if (typeof length !== 'number') return undefined + const keys = Reflect.ownKeys(value) + // Exactly the indices plus `length`: no holes, no extra or symbol keys. + if (keys.length !== length + 1) return undefined + const items: unknown[] = [] + for (let index = 0; index < length; index++) { + const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index)) + if (!descriptor || !Object.hasOwn(descriptor, 'value')) return undefined + items.push(descriptor.value) + } + return items +} + +function hasContent(value: string): boolean { + return /\S/.test(value) +} + +/** + * Non-negative safe integers, excluding -0: the values the command parsers + * produce from digit-only text. No upper bound is applied, because the + * killswitch parser applies none. + */ +function isDigitInteger(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + value >= 0 && + !Object.is(value, -0) + ) +} + +/** + * Whole hours 0..23, the bounds the `/claude-cachekeep HH-HH` parser in + * cachekeep.ts accepts. + */ +function isHour(value: unknown): value is number { + return isDigitInteger(value) && value <= 23 +} + +/** + * Mirrors isValidApiBaseURL in accounts.ts (http or https, no embedded + * credentials). That module is not imported because it pulls in storage code + * this pure module must not depend on. + */ +function isApiBaseURL(value: string): boolean { + try { + const url = new URL(value.trim()) + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + !url.username && + !url.password + ) + } catch { + return false + } +} + +/** + * A non-blank account ID or the `main` / `all` selector. IDs are opaque: the + * value is never split, trimmed, or mapped, and the host capability decides + * whether it names a known account. The text command's rule against colons + * only exists because it uses a colon as a delimiter; native route IDs contain + * colons by design, so that rule does not apply to structured entries. + */ +function isKillswitchAccount(value: unknown): value is string { + return typeof value === 'string' && hasContent(value) +} + +const ENTRY_KEYS = ['account', 'fh', 'sd', 'scoped'] as const + +function readKillswitchEntries( + value: unknown, +): readonly NativeMenuKillswitchEntry[] | undefined { + const items = readArray(value) + // The parser treats `set` without any entry as a usage error. + if (!items || items.length === 0) return undefined + const entries: NativeMenuKillswitchEntry[] = [] + for (const item of items) { + const fields = readPlain(item, ENTRY_KEYS) + if (!(fields instanceof Map)) return undefined + const account = fields.get('account') + const fh = fields.get('fh') + const sd = fields.get('sd') + const scoped = fields.get('scoped') + if (!isKillswitchAccount(account)) return undefined + if (!isDigitInteger(fh) || !isDigitInteger(sd)) return undefined + if (scoped !== undefined && !isDigitInteger(scoped)) return undefined + entries.push( + Object.freeze( + scoped === undefined + ? { account, fh, sd } + : { account, fh, sd, scoped }, + ), + ) + } + return Object.freeze(entries) +} + +type ParameterCheck = + | { readonly ok: true; readonly present: boolean; readonly value?: unknown } + | { + readonly ok: false + readonly code: 'missing-parameter' | 'invalid-parameter' + } + +const missing = { ok: false, code: 'missing-parameter' } as const +const invalid = { ok: false, code: 'invalid-parameter' } as const +const absent = { ok: true, present: false } as const + +function accept(value: unknown): ParameterCheck { + return { ok: true, present: true, value } +} + +// Error codes name the parameter only; no submitted value is ever echoed, so +// a protected entry such as an API key cannot leak through a refusal. +function checkParameter( + parameter: NativeMenuParameter, + provided: boolean, + value: unknown, +): ParameterCheck { + const empty = !provided || value === undefined || value === null + switch (parameter.kind) { + case 'account-id': + if (empty) return missing + if (typeof value !== 'string') return invalid + return hasContent(value) ? accept(value) : missing + case 'text': { + if (empty) return parameter.required ? missing : absent + if (typeof value !== 'string') return invalid + // The commands treat a blank optional input as not given. + if (!hasContent(value)) return parameter.required ? missing : absent + // The command parser takes the API key as one token, so it never + // contains whitespace; a pasted key with a line break is refused. + if (parameter.id === 'apiKey' && /\s/.test(value)) return invalid + if (parameter.id === 'baseURL' && !isApiBaseURL(value)) return invalid + return accept(value) + } + case 'choice': + if (empty) return parameter.required ? missing : absent + if (typeof value !== 'string') return invalid + return (parameter.choices as readonly string[]).includes(value) + ? accept(value) + : invalid + case 'number': + if (empty) return missing + return isHour(value) ? accept(value) : invalid + case 'limit-entries': { + if (empty) return missing + const entries = readKillswitchEntries(value) + return entries ? accept(entries) : invalid + } + default: { + const unhandled: never = parameter + return unhandled + } + } +} + +function readSignal(value: unknown): AbortSignal | 'malformed' { + if (value === null || typeof value !== 'object') return 'malformed' + if (utilTypes.isProxy(value) || !readAborted) return 'malformed' + try { + // The platform getter throws for anything that is not a real signal. + Reflect.apply(readAborted, value, []) + } catch { + return 'malformed' + } + return value as AbortSignal +} + +function isAborted(signal: AbortSignal | undefined): boolean { + return signal !== undefined && readAborted !== undefined + ? Reflect.apply(readAborted, signal, []) === true + : false +} + +type CheckedContext = { + readonly interactive: boolean + readonly confirmed: boolean + readonly sessionId?: string + readonly signal?: AbortSignal +} + +const CONTEXT_KEYS = [ + 'interactive', + 'confirmed', + 'sessionId', + 'signal', +] as const + +function readContext(value: unknown): CheckedContext | undefined { + const fields = readPlain(value, CONTEXT_KEYS) + if (!(fields instanceof Map)) return undefined + const interactive = fields.get('interactive') + const confirmed = fields.get('confirmed') + const sessionId = fields.get('sessionId') + const rawSignal = fields.get('signal') + if (typeof interactive !== 'boolean') return undefined + if (confirmed !== undefined && typeof confirmed !== 'boolean') + return undefined + if (sessionId !== undefined && typeof sessionId !== 'string') return undefined + let signal: AbortSignal | undefined + if (rawSignal !== undefined) { + const read = readSignal(rawSignal) + if (read === 'malformed') return undefined + signal = read + } + return { + interactive, + confirmed: confirmed === true, + ...(sessionId !== undefined && hasContent(sessionId) ? { sessionId } : {}), + ...(signal !== undefined ? { signal } : {}), + } +} + +type Plan = + | { readonly kind: 'done'; readonly result: NativeMenuExecutionResult } + | { + readonly kind: 'dispatch' + readonly action: NativeMenuDispatchActionId + readonly request: NativeMenuDispatchRequest + readonly options: NativeMenuDispatchOptions + } + +function done(result: NativeMenuExecutionResult): Plan { + return { kind: 'done', result } +} + +function plan( + actions: ReadonlyMap, + rawRequest: unknown, + rawContext: unknown, +): Plan { + const request = readPlain(rawRequest, ['action', 'values']) + if (!(request instanceof Map)) return done(refused('invalid-request')) + const actionId = request.get('action') + if (typeof actionId !== 'string') return done(refused('invalid-request')) + // Unknown names and actions this host does not offer are refused alike. + const action = actions.get(actionId) + if (!action) return done(refused('unsupported-action')) + + const context = readContext(rawContext) + if (!context) return done(refused('invalid-context', action.id)) + if (action.interactive && !context.interactive) + return done(refused(action.headlessRefusal, action.id)) + + const rawValues = request.get('values') + const allowed = action.parameters.map((parameter) => parameter.id) + const values = + rawValues === undefined + ? new Map() + : readPlain(rawValues, allowed) + if (values === 'malformed') return done(refused('invalid-request', action.id)) + if (values === 'unknown-key') + return done(refused('unknown-parameter', action.id)) + + const checked: Record = {} + for (const parameter of action.parameters) { + const result = checkParameter( + parameter, + values.has(parameter.id), + values.get(parameter.id), + ) + if (!result.ok) return done(refused(result.code, action.id, parameter.id)) + if (result.present) checked[parameter.id] = result.value + } + // The cachekeep parser rejects a window that starts and ends on one hour. + if (action.id === 'cachekeep-window' && checked.startHour === checked.endHour) + return done(refused('invalid-parameter', action.id, 'endHour')) + + if (action.destructive && !context.confirmed) + return done(refused('confirmation-required', action.id)) + const needsSession = SESSION_ACTIONS.has(action.id) + if (needsSession && context.sessionId === undefined) + return done(refused('session-required', action.id)) + if (isAborted(context.signal)) return done(refused('aborted', action.id)) + + if (action.kind === 'setup-guidance') { + return done( + Object.freeze({ + status: 'guidance', + action: action.id, + mode: checked.mode as NativeMenuCustodyMode, + instructions: action.setupInstructions, + }), + ) + } + + const dispatchRequest = Object.freeze({ + action: action.id, + values: Object.freeze(checked), + ...(needsSession ? { sessionId: context.sessionId } : {}), + }) as NativeMenuDispatchRequest + return { + kind: 'dispatch', + action: action.id, + request: dispatchRequest, + options: Object.freeze( + context.signal !== undefined ? { signal: context.signal } : {}, + ), + } +} + +function readOutcome( + action: NativeMenuDispatchActionId, + outcome: unknown, +): NativeMenuExecutionResult { + const fields = readPlain(outcome, ['ok', 'text']) + const ok = fields instanceof Map ? fields.get('ok') : undefined + const text = fields instanceof Map ? fields.get('text') : undefined + if (typeof ok !== 'boolean' || typeof text !== 'string') + return Object.freeze({ status: 'failed', action, code: 'invalid-outcome' }) + return Object.freeze({ status: 'executed', action, ok, text }) +} + +function invalidOption(key: string): TypeError { + return new TypeError(`Native menu executor option ${key} is invalid`) +} + +/** + * Creates an executor for one host. The host's available actions and their + * safety rules come from getNativeMenuModel; `dispatch` is captured once and + * called only with fully checked requests. + */ +export function createNativeMenuExecutor( + options: NativeMenuExecutorOptions, +): NativeMenuExecutor { + const fields = readPlain(options, ['host', 'dispatch']) + if (!(fields instanceof Map)) + throw new TypeError('Native menu executor options are invalid') + const host = fields.get('host') + const dispatch = fields.get('dispatch') + if (!HOSTS.has(host)) throw invalidOption('host') + if (typeof dispatch !== 'function' || utilTypes.isProxy(dispatch)) + throw invalidOption('dispatch') + + const actions = new Map() + for (const group of getNativeMenuModel(host as NativeMenuHost).groups) + for (const action of group.actions) actions.set(action.id, action) + + const execute = async ( + request: NativeMenuRequest, + context: NativeMenuExecutionContext, + ): Promise => { + let next: Plan + try { + next = plan(actions, request, context) + } catch { + return refused('invalid-request') + } + if (next.kind === 'done') return next.result + let outcome: unknown + try { + outcome = await Reflect.apply(dispatch, undefined, [ + next.request, + next.options, + ]) + } catch { + // The thrown value is never read: it may carry secrets or run code. + return Object.freeze({ + status: 'failed', + action: next.action, + code: 'execution-failed', + }) + } + return readOutcome(next.action, outcome) + } + return Object.freeze({ execute: Object.freeze(execute) }) +} diff --git a/packages/core/src/tests/native-menu-executor.test.ts b/packages/core/src/tests/native-menu-executor.test.ts new file mode 100644 index 000000000..da8975d85 --- /dev/null +++ b/packages/core/src/tests/native-menu-executor.test.ts @@ -0,0 +1,1469 @@ +import { expect, test } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { projectVaultRoster } from '@cortexkit/common-auth/claustrum' +import type { KnobValue } from '@cortexkit/common-auth/commands' +import { moduleReferences } from '../../scripts/check-native-type-closure.ts' +import { isValidApiBaseURL } from '../accounts.ts' +import { parseCacheKeepCommandAction } from '../cachekeep.ts' +import { parseKillswitchCommandAction } from '../killswitch.ts' +import { + createNativeMenuExecutor, + type NativeMenuActionValues, + type NativeMenuCapabilityOutcome, + type NativeMenuDispatch, + type NativeMenuDispatchActionId, + type NativeMenuDispatchOptions, + type NativeMenuDispatchRequest, + type NativeMenuDispatchValues, + type NativeMenuExecutionContext, + type NativeMenuExecutionResult, + type NativeMenuExecutorOptions, + type NativeMenuRequest, +} from '../native-menu-executor.ts' +import { + getNativeMenuModel, + type NativeMenuActionId, + type NativeMenuHost, +} from '../native-menu-model.ts' + +type Assert = T +type Equal = [A] extends [B] ? ([B] extends [A] ? true : false) : false +type Assignable = [A] extends [B] ? true : false +type RequestFor = Extract< + NativeMenuRequest, + { action: Id } +> + +// Compile-time contracts. Each refused shape sits next to an accepted +// companion, so a refusal cannot pass merely because the whole type collapsed. +type NarrowDispatch = ( + request: Extract, + options: NativeMenuDispatchOptions, +) => Promise +type FullDispatch = ( + request: NativeMenuDispatchRequest, + options: NativeMenuDispatchOptions, +) => Promise +const typeContracts: [ + Assert>, + Assert< + Equal< + NativeMenuDispatchRequest['action'], + Exclude + > + >, + Assert>, + // A callback written for one action cannot stand in for the full capability. + Assert, false>>, + Assert, true>>, + // The capability is a required function property, not an optional one. + Assert< + Equal< + Record extends Pick + ? true + : false, + false + > + >, + Assert< + Equal< + Assignable< + { action: 'enable'; values: { mode: 'local' } }, + NativeMenuRequest + >, + false + > + >, + Assert< + Equal< + Assignable< + { action: 'enable'; values: { id: string } }, + NativeMenuRequest + >, + true + > + >, + Assert, false>>, + Assert< + Equal< + Assignable< + { action: 'cache-on'; values: { id: string } }, + NativeMenuRequest + >, + false + > + >, + Assert, true>>, + Assert< + Equal< + Assignable< + { action: 'routing-mode'; values: { mode: 'hybrid' } }, + NativeMenuRequest + >, + false + > + >, + Assert< + Equal< + Assignable< + { action: 'routing-mode'; values: { mode: 'sticky-balanced' } }, + NativeMenuRequest + >, + true + > + >, + Assert, false>>, + Assert< + Equal< + Assignable< + { + action: 'cachekeep-window' + values: { startHour: string; endHour: number } + }, + NativeMenuRequest + >, + false + > + >, + // The optional model-scoped remaining-percent threshold stays part of the + // typed killswitch entry. + Assert< + Equal< + Assignable< + { + action: 'killswitch-set' + values: { + entries: { + account: string + fh: number + sd: number + scoped: number + }[] + } + }, + NativeMenuRequest + >, + true + > + >, + Assert< + Equal< + Assignable< + { + action: 'killswitch-set' + values: { entries: { account: string; fh: number }[] } + }, + NativeMenuRequest + >, + false + > + >, + // The optional API-key account label may arrive as null (an input left + // empty) but is omitted from the checked request, which never carries null. + Assert< + Equal< + Assignable< + { action: 'add-apikey'; values: { apiKey: string; label: null } }, + NativeMenuRequest + >, + true + > + >, + Assert< + Equal< + Assignable< + { action: 'add-apikey'; values: { apiKey: string; label: null } }, + NativeMenuDispatchRequest + >, + false + > + >, + Assert< + Equal< + Assignable< + { action: 'custody-mode'; values: { mode: 'local' } }, + NativeMenuDispatchRequest + >, + false + > + >, + Assert< + Equal< + Assignable< + { action: 'routing-reset'; values: Record }, + NativeMenuDispatchRequest + >, + false + > + >, + Assert< + Equal< + Assignable< + { + action: 'routing-reset' + values: Record + sessionId: string + }, + NativeMenuDispatchRequest + >, + true + > + >, + Assert< + Equal< + Assignable< + { action: 'enable'; values: { id: string }; sessionId: string }, + NativeMenuDispatchRequest + >, + false + > + >, + // The compile-time checked input types stay within the value types the + // shared UI sends for an input (its KnobValue projection). + Assert< + Assignable< + Exclude< + NativeMenuActionValues['add-apikey'][keyof NativeMenuActionValues['add-apikey']], + undefined + >, + KnobValue + > + >, +] = [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, +] + +const samples: { [Id in NativeMenuActionId]: RequestFor } = { + enable: { action: 'enable', values: { id: 'work-alt' } }, + disable: { action: 'disable', values: { id: 'work alt' } }, + remove: { action: 'remove', values: { id: 'acct-3' } }, + 'move-up': { action: 'move-up', values: { id: 'acct-4' } }, + 'move-down': { action: 'move-down', values: { id: 'acct-5' } }, + 'reset-backoff': { action: 'reset-backoff' }, + 'enrollment-reset': { action: 'enrollment-reset', values: {} }, + 'add-apikey': { + action: 'add-apikey', + values: { + apiKey: 'sk-sample-key', + label: ' Spare ', + baseURL: 'https://api.example.test/claude', + authHeader: 'x-api-key', + }, + }, + 'add-oauth-start': { action: 'add-oauth-start' }, + 'add-oauth-finish': { + action: 'add-oauth-finish', + values: { code: 'abc#state', label: 'Second' }, + }, + 'custody-mode': { action: 'custody-mode', values: { mode: 'claustrum' } }, + 'quota-refresh': { action: 'quota-refresh' }, + 'routing-mode': { + action: 'routing-mode', + values: { mode: 'fallback-first' }, + }, + 'routing-reset': { action: 'routing-reset' }, + 'killswitch-on': { action: 'killswitch-on' }, + 'killswitch-off': { action: 'killswitch-off' }, + 'killswitch-set': { + action: 'killswitch-set', + values: { + entries: [ + { account: 'main', fh: 3, sd: 8, scoped: 0 }, + { account: 'work-alt', fh: 5, sd: 10 }, + ], + }, + }, + 'cache-on': { action: 'cache-on' }, + 'cache-off': { action: 'cache-off' }, + 'cache-mode': { action: 'cache-mode', values: { mode: 'hybrid' } }, + 'cachekeep-always': { action: 'cachekeep-always' }, + 'cachekeep-off': { action: 'cachekeep-off' }, + 'cachekeep-window': { + action: 'cachekeep-window', + values: { startHour: 22, endHour: 6 }, + }, + 'cachekeep-subagents': { + action: 'cachekeep-subagents', + values: { enabled: 'off' }, + }, + 'dump-on': { action: 'dump-on' }, + 'dump-off': { action: 'dump-off' }, + 'logging-level': { action: 'logging-level', values: { level: 'trace' } }, + 'fast-on': { action: 'fast-on' }, + 'fast-off': { action: 'fast-off' }, + 'prime-on': { action: 'prime-on' }, + 'prime-off': { action: 'prime-off' }, + 'start-fire': { action: 'start-fire' }, +} + +const expectedValues: { + [Id in NativeMenuDispatchActionId]: NativeMenuDispatchValues +} = { + enable: { id: 'work-alt' }, + disable: { id: 'work alt' }, + remove: { id: 'acct-3' }, + 'move-up': { id: 'acct-4' }, + 'move-down': { id: 'acct-5' }, + 'reset-backoff': {}, + 'enrollment-reset': {}, + 'add-apikey': { + apiKey: 'sk-sample-key', + label: ' Spare ', + baseURL: 'https://api.example.test/claude', + authHeader: 'x-api-key', + }, + 'add-oauth-start': {}, + 'add-oauth-finish': { code: 'abc#state', label: 'Second' }, + 'quota-refresh': {}, + 'routing-mode': { mode: 'fallback-first' }, + 'routing-reset': {}, + 'killswitch-on': {}, + 'killswitch-off': {}, + 'killswitch-set': { + entries: [ + { account: 'main', fh: 3, sd: 8, scoped: 0 }, + { account: 'work-alt', fh: 5, sd: 10 }, + ], + }, + 'cache-on': {}, + 'cache-off': {}, + 'cache-mode': { mode: 'hybrid' }, + 'cachekeep-always': {}, + 'cachekeep-off': {}, + 'cachekeep-window': { startHour: 22, endHour: 6 }, + 'cachekeep-subagents': { enabled: 'off' }, + 'dump-on': {}, + 'dump-off': {}, + 'logging-level': { level: 'trace' }, + 'fast-on': {}, + 'fast-off': {}, + 'prime-on': {}, + 'prime-off': {}, + 'start-fire': {}, +} + +const SESSION_ACTIONS: readonly NativeMenuActionId[] = [ + 'routing-reset', + 'start-fire', + 'add-oauth-start', + 'add-oauth-finish', +] +const full: NativeMenuExecutionContext = { + interactive: true, + confirmed: true, + sessionId: 'session-1', +} + +function harness( + host: NativeMenuHost = 'opencode', + outcome: NativeMenuCapabilityOutcome = { ok: true, text: 'done' }, +) { + const calls: [NativeMenuDispatchRequest, NativeMenuDispatchOptions][] = [] + const dispatch: NativeMenuDispatch = async (request, options) => { + calls.push([request, options]) + return outcome + } + return { calls, executor: createNativeMenuExecutor({ host, dispatch }) } +} + +function hostActionIds(host: NativeMenuHost): NativeMenuActionId[] { + return getNativeMenuModel(host).groups.flatMap((group) => + group.actions.map((action) => action.id), + ) +} + +// Untyped entry point for malformed runtime input a menu could still send. +function executeRaw( + executor: ReturnType, + request: unknown, + context: unknown = full, +): Promise { + return Reflect.apply(executor.execute, undefined, [request, context]) +} + +function trappedProxy(target: T, log: string[]): T { + const handler: ProxyHandler = {} + for (const trap of [ + 'get', + 'has', + 'ownKeys', + 'getOwnPropertyDescriptor', + 'getPrototypeOf', + 'defineProperty', + 'set', + 'apply', + ] as const) { + ;(handler as Record)[trap] = (...args: unknown[]) => { + log.push(trap) + return Reflect.apply( + Reflect[trap] as (...input: unknown[]) => unknown, + undefined, + args, + ) + } + } + return new Proxy(target, handler) +} + +test('type contracts hold at compile time', () => { + expect(typeContracts.every(Boolean)).toBe(true) +}) + +test('every available action reaches the capability with exactly the checked values', async () => { + expect(Object.keys(samples).sort()).toEqual(hostActionIds('opencode').sort()) + for (const host of ['opencode', 'pi'] as const) { + for (const id of hostActionIds(host)) { + if (id === 'custody-mode') continue + const { calls, executor } = harness(host) + const result = await executor.execute(samples[id], full) + expect(result).toEqual({ + status: 'executed', + action: id, + ok: true, + text: 'done', + }) + expect(calls).toHaveLength(1) + const [request, options] = calls[0] ?? [] + const expected = SESSION_ACTIONS.includes(id) + ? { action: id, values: expectedValues[id], sessionId: 'session-1' } + : { action: id, values: expectedValues[id] } + expect(request).toEqual(expected as NativeMenuDispatchRequest) + expect(Object.keys(request ?? {}).sort()).toEqual( + Object.keys(expected).sort(), + ) + expect(Object.isFrozen(request)).toBe(true) + expect(Object.isFrozen(request?.values)).toBe(true) + expect(options).toEqual({}) + } + } +}) + +test('dispatched requests are fresh copies, not the caller objects', async () => { + const { calls, executor } = harness() + const entries = [{ account: 'main', fh: 1, sd: 2, scoped: 3 }] + await executor.execute( + { action: 'killswitch-set', values: { entries } }, + full, + ) + const sent = calls[0]?.[0] + expect(sent?.action).toBe('killswitch-set') + if (sent?.action !== 'killswitch-set') return + expect(sent.values.entries).not.toBe(entries) + expect(sent.values.entries[0]).not.toBe(entries[0]) + expect(Object.isFrozen(sent.values.entries)).toBe(true) + expect(Object.isFrozen(sent.values.entries[0])).toBe(true) + entries[0] = { account: 'other', fh: 9, sd: 9, scoped: 9 } + expect(sent.values.entries).toEqual([ + { account: 'main', fh: 1, sd: 2, scoped: 3 }, + ]) +}) + +test('actions the host does not offer and unknown actions are refused', async () => { + const unavailable = hostActionIds('opencode').filter( + (id) => !hostActionIds('pi').includes(id), + ) + expect(unavailable.sort()).toEqual( + ( + [ + 'quota-refresh', + 'killswitch-on', + 'killswitch-off', + 'killswitch-set', + 'prime-on', + 'prime-off', + 'start-fire', + ] satisfies NativeMenuActionId[] + ).sort(), + ) + const { calls, executor } = harness('pi') + for (const id of unavailable) { + expect(await executor.execute(samples[id], full)).toEqual({ + status: 'refused', + code: 'unsupported-action', + }) + } + for (const action of [ + 'nope', + 'status', + 'constructor', + '__proto__', + 'toString', + ]) { + expect(await executeRaw(executor, { action })).toEqual({ + status: 'refused', + code: 'unsupported-action', + }) + } + expect(await executeRaw(executor, { action: 7 })).toEqual({ + status: 'refused', + code: 'invalid-request', + }) + expect(calls).toHaveLength(0) + // The healthy companion: the same executor still serves an available action. + expect((await executor.execute(samples['dump-on'], full)).status).toBe( + 'executed', + ) +}) + +test('custody setup is offline guidance only and never dispatches', async () => { + const { calls, executor } = harness() + const model = getNativeMenuModel('opencode') + .groups.flatMap((group) => group.actions) + .find((action) => action.id === 'custody-mode') + if (model?.kind !== 'setup-guidance') + throw new Error('custody metadata missing') + for (const mode of ['local', 'claustrum'] as const) { + const result = await executor.execute( + { action: 'custody-mode', values: { mode } }, + { interactive: true }, + ) + expect(result).toEqual({ + status: 'guidance', + action: 'custody-mode', + mode, + instructions: model.setupInstructions, + }) + if (result.status === 'guidance') + expect(result.instructions).toBe(model.setupInstructions) + } + expect( + await executor.execute(samples['custody-mode'], { interactive: false }), + ).toEqual({ + status: 'refused', + code: 'offline-required', + action: 'custody-mode', + }) + expect( + await executeRaw(executor, { + action: 'custody-mode', + values: { mode: 'remote' }, + }), + ).toEqual({ + status: 'refused', + code: 'invalid-parameter', + action: 'custody-mode', + parameter: 'mode', + }) + expect(calls).toHaveLength(0) +}) + +test('interactive-only actions are refused in headless contexts', async () => { + const { calls, executor } = harness() + for (const id of [ + 'add-apikey', + 'add-oauth-start', + 'add-oauth-finish', + ] as const) { + expect( + await executor.execute(samples[id], { ...full, interactive: false }), + ).toEqual({ status: 'refused', code: 'interactive-required', action: id }) + } + expect(calls).toHaveLength(0) + expect( + (await executor.execute(samples.enable, { interactive: false })).status, + ).toBe('executed') +}) + +test('destructive actions require explicit confirmation', async () => { + const { calls, executor } = harness() + for (const id of ['remove', 'enrollment-reset'] as const) { + for (const context of [ + { interactive: true }, + { interactive: true, confirmed: false }, + ]) { + expect(await executor.execute(samples[id], context)).toEqual({ + status: 'refused', + code: 'confirmation-required', + action: id, + }) + } + } + expect(calls).toHaveLength(0) + expect((await executor.execute(samples.remove, full)).status).toBe('executed') + expect( + (await executor.execute(samples.enable, { interactive: true })).status, + ).toBe('executed') +}) + +test('session-dependent actions require a session identifier', async () => { + const { calls, executor } = harness() + for (const id of SESSION_ACTIONS) { + for (const sessionId of [undefined, '', ' ']) { + const context = + sessionId === undefined + ? { interactive: true } + : { interactive: true, sessionId } + expect(await executor.execute(samples[id], context)).toEqual({ + status: 'refused', + code: 'session-required', + action: id, + }) + } + } + expect(calls).toHaveLength(0) + expect( + (await executor.execute(samples['quota-refresh'], { interactive: true })) + .status, + ).toBe('executed') +}) + +test('a signal aborted before dispatch does no work', async () => { + const { calls, executor } = harness() + const controller = new AbortController() + controller.abort() + expect( + await executor.execute(samples['prime-on'], { + ...full, + signal: controller.signal, + }), + ).toEqual({ status: 'refused', code: 'aborted', action: 'prime-on' }) + expect( + await executor.execute(samples['custody-mode'], { + interactive: true, + signal: controller.signal, + }), + ).toEqual({ status: 'refused', code: 'aborted', action: 'custody-mode' }) + expect(calls).toHaveLength(0) +}) + +test('an abort after dispatch begins reports what the capability did', async () => { + const controller = new AbortController() + let seen: AbortSignal | undefined + const executor = createNativeMenuExecutor({ + host: 'opencode', + dispatch: async (_request, options) => { + seen = options.signal + controller.abort() + return { ok: true, text: 'Prime enabled' } + }, + }) + expect( + await executor.execute(samples['prime-on'], { + ...full, + signal: controller.signal, + }), + ).toEqual({ + status: 'executed', + action: 'prime-on', + ok: true, + text: 'Prime enabled', + }) + expect(seen).toBe(controller.signal) +}) + +// A hole at index 0: the array reports length 2 but owns only index 1. +const sparseEntries: unknown[] = [] +sparseEntries[1] = { account: 'main', fh: 1, sd: 2 } + +type Rejection = readonly [ + label: string, + request: unknown, + code: string, + parameter?: string, +] +const rejections: readonly Rejection[] = [ + [ + 'missing account id', + { action: 'enable', values: {} }, + 'missing-parameter', + 'id', + ], + ['missing values', { action: 'enable' }, 'missing-parameter', 'id'], + [ + 'null account id', + { action: 'enable', values: { id: null } }, + 'missing-parameter', + 'id', + ], + [ + 'blank account id', + { action: 'enable', values: { id: ' ' } }, + 'missing-parameter', + 'id', + ], + [ + 'numeric account id', + { action: 'enable', values: { id: 4 } }, + 'invalid-parameter', + 'id', + ], + [ + 'unknown parameter', + { action: 'enable', values: { id: 'a', extra: 1 } }, + 'unknown-parameter', + ], + [ + 'parameter on a no-input action', + { action: 'cache-on', values: { id: 'a' } }, + 'unknown-parameter', + ], + [ + 'api-key primary flag', + { action: 'add-apikey', values: { apiKey: 'k', primary: true } }, + 'unknown-parameter', + ], + [ + 'missing api key', + { action: 'add-apikey', values: { label: 'x' } }, + 'missing-parameter', + 'apiKey', + ], + [ + 'api key with whitespace', + { action: 'add-apikey', values: { apiKey: 'sk-a b' } }, + 'invalid-parameter', + 'apiKey', + ], + [ + 'api key with line break', + { action: 'add-apikey', values: { apiKey: 'sk-ab\n' } }, + 'invalid-parameter', + 'apiKey', + ], + [ + 'ftp base URL', + { action: 'add-apikey', values: { apiKey: 'k', baseURL: 'ftp://x.test' } }, + 'invalid-parameter', + 'baseURL', + ], + [ + 'base URL with credentials', + { + action: 'add-apikey', + values: { apiKey: 'k', baseURL: 'https://u:p@x.test' }, + }, + 'invalid-parameter', + 'baseURL', + ], + [ + 'unparseable base URL', + { action: 'add-apikey', values: { apiKey: 'k', baseURL: 'not a url' } }, + 'invalid-parameter', + 'baseURL', + ], + [ + 'unknown auth header', + { action: 'add-apikey', values: { apiKey: 'k', authHeader: 'cookie' } }, + 'invalid-parameter', + 'authHeader', + ], + [ + 'numeric label', + { action: 'add-apikey', values: { apiKey: 'k', label: 3 } }, + 'invalid-parameter', + 'label', + ], + [ + 'missing oauth code', + { action: 'add-oauth-finish', values: { code: ' ' } }, + 'missing-parameter', + 'code', + ], + [ + 'routing mode case', + { action: 'routing-mode', values: { mode: 'Main-First' } }, + 'invalid-parameter', + 'mode', + ], + [ + 'cache mode from routing', + { action: 'cache-mode', values: { mode: 'main-first' } }, + 'invalid-parameter', + 'mode', + ], + [ + 'logging level', + { action: 'logging-level', values: { level: 'verbose' } }, + 'invalid-parameter', + 'level', + ], + [ + 'subagents boolean', + { action: 'cachekeep-subagents', values: { enabled: true } }, + 'invalid-parameter', + 'enabled', + ], + [ + 'missing hour', + { action: 'cachekeep-window', values: { startHour: 1 } }, + 'missing-parameter', + 'endHour', + ], + [ + 'hour above 23', + { action: 'cachekeep-window', values: { startHour: 1, endHour: 24 } }, + 'invalid-parameter', + 'endHour', + ], + [ + 'negative hour', + { action: 'cachekeep-window', values: { startHour: -1, endHour: 4 } }, + 'invalid-parameter', + 'startHour', + ], + [ + 'fractional hour', + { action: 'cachekeep-window', values: { startHour: 1.5, endHour: 4 } }, + 'invalid-parameter', + 'startHour', + ], + [ + 'negative zero hour', + { action: 'cachekeep-window', values: { startHour: -0, endHour: 4 } }, + 'invalid-parameter', + 'startHour', + ], + [ + 'string hour', + { action: 'cachekeep-window', values: { startHour: '3', endHour: 4 } }, + 'invalid-parameter', + 'startHour', + ], + [ + 'NaN hour', + { + action: 'cachekeep-window', + values: { startHour: Number.NaN, endHour: 4 }, + }, + 'invalid-parameter', + 'startHour', + ], + [ + 'empty window', + { action: 'cachekeep-window', values: { startHour: 5, endHour: 5 } }, + 'invalid-parameter', + 'endHour', + ], + [ + 'entries missing', + { action: 'killswitch-set', values: {} }, + 'missing-parameter', + 'entries', + ], + [ + 'entries empty', + { action: 'killswitch-set', values: { entries: [] } }, + 'invalid-parameter', + 'entries', + ], + [ + 'entries as text', + { action: 'killswitch-set', values: { entries: 'main:3,8' } }, + 'invalid-parameter', + 'entries', + ], + [ + 'entry account blank', + { + action: 'killswitch-set', + values: { entries: [{ account: ' \t', fh: 1, sd: 2 }] }, + }, + 'invalid-parameter', + 'entries', + ], + [ + 'entry account empty', + { + action: 'killswitch-set', + values: { entries: [{ account: '', fh: 1, sd: 2 }] }, + }, + 'invalid-parameter', + 'entries', + ], + [ + 'entry negative threshold', + { + action: 'killswitch-set', + values: { entries: [{ account: 'main', fh: -1, sd: 2 }] }, + }, + 'invalid-parameter', + 'entries', + ], + [ + 'entry fractional threshold', + { + action: 'killswitch-set', + values: { entries: [{ account: 'main', fh: 1, sd: 2.5 }] }, + }, + 'invalid-parameter', + 'entries', + ], + [ + 'entry unsafe threshold', + { + action: 'killswitch-set', + values: { entries: [{ account: 'main', fh: 2 ** 53, sd: 2 }] }, + }, + 'invalid-parameter', + 'entries', + ], + [ + 'entry null scoped', + { + action: 'killswitch-set', + values: { entries: [{ account: 'main', fh: 1, sd: 2, scoped: null }] }, + }, + 'invalid-parameter', + 'entries', + ], + [ + 'entry missing sd', + { + action: 'killswitch-set', + values: { entries: [{ account: 'main', fh: 1 }] }, + }, + 'invalid-parameter', + 'entries', + ], + [ + 'entry extra field', + { + action: 'killswitch-set', + values: { entries: [{ account: 'main', fh: 1, sd: 2, model: 'x' }] }, + }, + 'invalid-parameter', + 'entries', + ], + [ + 'sparse entries', + { + action: 'killswitch-set', + values: { entries: sparseEntries }, + }, + 'invalid-parameter', + 'entries', + ], +] + +test('parameter values are checked against the domain rules', async () => { + const { calls, executor } = harness() + for (const [label, request, code, parameter] of rejections) { + const result = await executeRaw(executor, request) + const action = (request as { action: NativeMenuActionId }).action + expect({ label, result }).toEqual({ + label, + result: { + status: 'refused', + code, + action, + ...(parameter !== undefined ? { parameter } : {}), + } as NativeMenuExecutionResult, + }) + } + expect(calls).toHaveLength(0) +}) + +test('empty optional inputs are left out and filled ones forwarded unchanged', async () => { + const { calls, executor } = harness() + await executor.execute( + { + action: 'add-apikey', + values: { + apiKey: 'sk-only', + label: null, + baseURL: ' ', + authHeader: null, + }, + }, + full, + ) + await executor.execute( + { action: 'add-oauth-finish', values: { code: 'c#s', label: '' } }, + full, + ) + expect(calls.map(([request]) => request)).toEqual([ + { action: 'add-apikey', values: { apiKey: 'sk-only' } }, + { + action: 'add-oauth-finish', + values: { code: 'c#s' }, + sessionId: 'session-1', + }, + ]) +}) + +test('cache keep hours accept exactly what the cachekeep parser accepts', async () => { + const { executor } = harness() + for (let startHour = -1; startHour <= 24; startHour++) { + for (let endHour = -1; endHour <= 24; endHour++) { + const parsed = parseCacheKeepCommandAction(`${startHour}-${endHour}`) + const result = await executor.execute( + { action: 'cachekeep-window', values: { startHour, endHour } }, + full, + ) + expect({ + startHour, + endHour, + accepted: result.status === 'executed', + }).toEqual({ + startHour, + endHour, + accepted: parsed.type === 'window', + }) + } + } +}) + +test('killswitch entries keep published native route IDs intact', async () => { + // A real route ID from the published roster projection. Native IDs contain + // colons by design, so the executor must not apply the text command's + // `account:fh,sd` delimiter rule to structured entries. + const roster = projectVaultRoster( + undefined, + { + view: 'synthetic-view', + credentials: [ + { + credentialId: 'oauth:anthropic:synthetic-work', + credentialType: 'oauth', + accountIdentity: 'synthetic-work-account', + state: 'active', + }, + ], + skipped: [], + }, + { now: 1 }, + ) + const routeId = roster.rows[0]?.routeId ?? '' + expect(routeId.startsWith('vault:oauth:anthropic:synthetic-work~')).toBe(true) + const entries = [ + { account: routeId, fh: 4, sd: 9, scoped: 1 }, + { account: ' padded id ', fh: 0, sd: 0 }, + ] + const { calls, executor } = harness() + expect( + await executor.execute( + { action: 'killswitch-set', values: { entries } }, + full, + ), + ).toEqual({ + status: 'executed', + action: 'killswitch-set', + ok: true, + text: 'done', + }) + expect(calls.map(([request]) => request)).toEqual([ + { action: 'killswitch-set', values: { entries } }, + ]) +}) + +test('killswitch main and all selectors reach the capability unchanged', async () => { + const { calls, executor } = harness() + const entries = [ + { account: 'main', fh: 3, sd: 8, scoped: 0 }, + { account: 'all', fh: 5, sd: 10 }, + ] + expect( + ( + await executor.execute( + { action: 'killswitch-set', values: { entries } }, + full, + ) + ).status, + ).toBe('executed') + expect(calls.map(([request]) => request)).toEqual([ + { action: 'killswitch-set', values: { entries } }, + ]) +}) + +test('killswitch entries forward exactly what the killswitch parser yields', async () => { + const { calls, executor } = harness() + const tokens = [ + 'main:3,8', + 'main:3,8,0', + 'all:5,10', + 'work-alt:5,10,2', + 'x:0,0,100', + ] + for (const token of tokens) { + const parsed = parseKillswitchCommandAction(`set ${token}`) + if (parsed.type !== 'set') throw new Error(`parser refused ${token}`) + calls.length = 0 + const result = await executor.execute( + { action: 'killswitch-set', values: { entries: parsed.entries } }, + full, + ) + expect(result.status).toBe('executed') + const sent = calls[0]?.[0] + expect( + sent?.action === 'killswitch-set' ? sent.values.entries : undefined, + ).toEqual(parsed.entries) + } +}) + +test('base URL check matches the stored-account rule', async () => { + const { executor } = harness() + for (const baseURL of [ + 'https://api.kie.ai/claude', + 'http://localhost:8080', + ' https://padded.test ', + 'ftp://x.test', + 'https://user@x.test', + 'https://user:pw@x.test', + 'javascript:alert(1)', + 'not a url', + ]) { + const result = await executor.execute( + { action: 'add-apikey', values: { apiKey: 'k', baseURL } }, + full, + ) + expect({ baseURL, accepted: result.status === 'executed' }).toEqual({ + baseURL, + accepted: isValidApiBaseURL(baseURL), + }) + } +}) + +test('malformed contexts are refused before any dispatch', async () => { + const { calls, executor } = harness() + const fakeSignal = Object.create(AbortSignal.prototype) as AbortSignal + for (const context of [ + null, + undefined, + {}, + { interactive: 'yes' }, + { interactive: true, confirmed: 1 }, + { interactive: true, sessionId: 5 }, + { interactive: true, extra: true }, + { interactive: true, signal: fakeSignal }, + { interactive: true, signal: {} }, + ]) { + // Called directly so an undefined context is not replaced by a default. + const result = await Reflect.apply(executor.execute, undefined, [ + samples.enable, + context, + ]) + expect(result).toEqual({ + status: 'refused', + code: 'invalid-context', + action: 'enable', + }) + } + expect(calls).toHaveLength(0) +}) + +test('proxies are refused without running any trap', async () => { + const { calls, executor } = harness() + const log: string[] = [] + const cases: [unknown, unknown][] = [ + [trappedProxy({ action: 'enable', values: { id: 'a' } }, log), full], + [{ action: 'enable', values: trappedProxy({ id: 'a' }, log) }, full], + [samples.enable, trappedProxy({ interactive: true }, log)], + [ + samples['prime-on'], + { + interactive: true, + signal: trappedProxy(new AbortController().signal, log), + }, + ], + [ + { + action: 'killswitch-set', + values: { + entries: trappedProxy([{ account: 'main', fh: 1, sd: 2 }], log), + }, + }, + full, + ], + [ + { + action: 'killswitch-set', + values: { + entries: [trappedProxy({ account: 'main', fh: 1, sd: 2 }, log)], + }, + }, + full, + ], + ] + for (const [request, context] of cases) { + expect((await executeRaw(executor, request, context)).status).toBe( + 'refused', + ) + } + expect(log).toEqual([]) + expect(calls).toHaveLength(0) +}) + +test('accessors, iterators and foreign prototypes are refused without being called', async () => { + const { calls, executor } = harness() + const touched: string[] = [] + const getter = (name: string, value: unknown) => ({ + get() { + touched.push(name) + return value + }, + enumerable: true, + }) + const accessorRequest = Object.defineProperty( + { values: { id: 'a' } }, + 'action', + getter('action', 'enable'), + ) + const accessorValues = Object.defineProperty({}, 'id', getter('id', 'a')) + const accessorContext = Object.defineProperty( + {}, + 'interactive', + getter('interactive', true), + ) + const accessorEntry = Object.defineProperty( + { fh: 1, sd: 2 }, + 'account', + getter('account', 'main'), + ) + const accessorEntries = Object.defineProperty( + [], + '0', + getter('entry', { account: 'main', fh: 1, sd: 2 }), + ) + const iteratingEntries = [{ account: 'main', fh: 1, sd: 2 }] + Object.defineProperty(iteratingEntries, Symbol.iterator, { + value: () => { + touched.push('iterator') + return [][Symbol.iterator]() + }, + }) + class Entries extends Array {} + const subclassEntries = Entries.from([{ account: 'main', fh: 1, sd: 2 }]) + class Values { + id = 'a' + } + const cases: [unknown, unknown][] = [ + [accessorRequest, full], + [{ action: 'enable', values: accessorValues }, full], + [samples.enable, accessorContext], + [{ action: 'killswitch-set', values: { entries: [accessorEntry] } }, full], + [{ action: 'killswitch-set', values: { entries: accessorEntries } }, full], + [{ action: 'killswitch-set', values: { entries: iteratingEntries } }, full], + [{ action: 'killswitch-set', values: { entries: subclassEntries } }, full], + [{ action: 'enable', values: new Values() }, full], + [{ action: 'enable', values: { id: 'a', [Symbol('x')]: 1 } }, full], + [new Map([['action', 'enable']]), full], + ] + for (const [request, context] of cases) { + expect((await executeRaw(executor, request, context)).status).toBe( + 'refused', + ) + } + expect(touched).toEqual([]) + expect(calls).toHaveLength(0) +}) + +test('capability failures are reported as a closed generic failure', async () => { + const secret = 'sk-ant-secret-value' + const touched: string[] = [] + const hostile = { + get message() { + touched.push('message') + return secret + }, + get cause() { + touched.push('cause') + return secret + }, + } + const throwers: NativeMenuDispatch[] = [ + async () => { + throw new Error(`upstream rejected ${secret}`, { cause: secret }) + }, + async () => { + throw hostile + }, + () => { + throw new Error(secret) + }, + async () => Promise.reject(trappedProxy({ secret }, touched)), + ] + for (const dispatch of throwers) { + const executor = createNativeMenuExecutor({ host: 'opencode', dispatch }) + const result = await executor.execute( + { action: 'add-apikey', values: { apiKey: secret } }, + full, + ) + expect(result).toEqual({ + status: 'failed', + action: 'add-apikey', + code: 'execution-failed', + }) + expect(Reflect.ownKeys(result).sort()).toEqual(['action', 'code', 'status']) + expect(JSON.stringify(result)).not.toContain(secret) + } + expect(touched).toEqual([]) +}) + +test('refusals never echo submitted values', async () => { + const { executor } = harness() + const secret = 'sk-ant-pasted secret' + const result = await executor.execute( + { action: 'add-apikey', values: { apiKey: secret } }, + full, + ) + expect(result).toEqual({ + status: 'refused', + code: 'invalid-parameter', + action: 'add-apikey', + parameter: 'apiKey', + }) + expect(JSON.stringify(result)).not.toContain('sk-ant') +}) + +test('an unreadable capability outcome is reported as unknown, not success', async () => { + const touched: string[] = [] + // Resolving any promise with an object makes the platform look up `then` on + // it; that lookup belongs to the capability's own promise. Every other read + // would come from the executor and must not happen. + const thenLookups: string[] = [] + const outcomeProxy = new Proxy( + { ok: true, text: 'x' }, + { + get(target, key, receiver) { + if (key === 'then') thenLookups.push('then') + else touched.push(`get:${String(key)}`) + return Reflect.get(target, key, receiver) + }, + getOwnPropertyDescriptor(target, key) { + touched.push(`descriptor:${String(key)}`) + return Reflect.getOwnPropertyDescriptor(target, key) + }, + ownKeys(target) { + touched.push('ownKeys') + return Reflect.ownKeys(target) + }, + getPrototypeOf(target) { + touched.push('getPrototypeOf') + return Reflect.getPrototypeOf(target) + }, + }, + ) + const outcomes: unknown[] = [ + undefined, + { ok: 'yes', text: 'x' }, + { ok: true }, + { ok: true, text: 'x', extra: 1 }, + Object.defineProperty({ ok: true }, 'text', { + get() { + touched.push('text') + return 'x' + }, + enumerable: true, + }), + outcomeProxy, + ] + for (const outcome of outcomes) { + const executor = createNativeMenuExecutor({ + host: 'opencode', + dispatch: async () => outcome as NativeMenuCapabilityOutcome, + }) + expect(await executor.execute(samples['dump-on'], full)).toEqual({ + status: 'failed', + action: 'dump-on', + code: 'invalid-outcome', + }) + } + expect(touched).toEqual([]) + expect(thenLookups).toEqual(['then']) + const reported = createNativeMenuExecutor({ + host: 'opencode', + dispatch: async () => ({ ok: false, text: 'Quota service unavailable' }), + }) + expect(await reported.execute(samples['dump-on'], full)).toEqual({ + status: 'executed', + action: 'dump-on', + ok: false, + text: 'Quota service unavailable', + }) +}) + +test('construction captures only a known host and a plain function capability', async () => { + const dispatch: NativeMenuDispatch = async () => ({ ok: true, text: 'first' }) + const touched: string[] = [] + const bad: unknown[] = [ + null, + { host: 'claude', dispatch }, + { host: 'opencode' }, + { host: 'opencode', dispatch: 'run' }, + { host: 'opencode', dispatch, extra: 1 }, + { host: 'opencode', dispatch: trappedProxy(dispatch, touched) }, + trappedProxy({ host: 'opencode', dispatch }, touched), + Object.defineProperty({ host: 'opencode' }, 'dispatch', { + get() { + touched.push('dispatch') + return dispatch + }, + enumerable: true, + }), + ] + for (const options of bad) { + expect(() => + Reflect.apply(createNativeMenuExecutor, undefined, [options]), + ).toThrow(TypeError) + } + expect(touched).toEqual([]) + + const options = { host: 'opencode' as NativeMenuHost, dispatch } + const executor = createNativeMenuExecutor(options) + options.dispatch = async () => ({ ok: true, text: 'second' }) + options.host = 'pi' + expect(Object.isFrozen(executor)).toBe(true) + expect(Reflect.ownKeys(executor)).toEqual(['execute']) + expect(await executor.execute(samples['quota-refresh'], full)).toEqual({ + status: 'executed', + action: 'quota-refresh', + ok: true, + text: 'first', + }) +}) + +test('the module imports only the menu model and node:util at runtime', async () => { + const source = await readFile( + new URL('../native-menu-executor.ts', import.meta.url), + 'utf8', + ) + const runtime = new Bun.Transpiler({ loader: 'ts' }) + .scanImports(source) + .map(({ path }) => path) + .sort() + expect(runtime).toEqual(['./native-menu-model.ts', 'node:util']) + // Neither file relies on suppressing type errors. + const testSource = await readFile(new URL(import.meta.url), 'utf8') + const suppression = new RegExp( + ['@ts-', '(ignore|expect-error|nocheck)|as ', 'any\\b'].join(''), + ) + expect(suppression.test(source)).toBe(false) + expect(suppression.test(testSource)).toBe(false) +}) + +test('the emitted declaration carries no common-auth reference', async () => { + const declaration = await readFile( + new URL('../../dist/native-menu-executor.d.ts', import.meta.url), + 'utf8', + ) + expect(declaration).toContain( + 'export declare function createNativeMenuExecutor', + ) + const specifiers = moduleReferences(declaration).map( + ({ specifier }) => specifier, + ) + expect(specifiers.sort()).toEqual(['./native-menu-model.ts']) +}) From 95e2079fe8cda0b58dd5e6427ad661fb335d28b2 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:15:29 +0200 Subject: [PATCH 040/169] Clarify OAuth supplemental header ownership --- packages/opencode/src/v2/adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/v2/adapter.ts b/packages/opencode/src/v2/adapter.ts index bee9c49fd..477c841f8 100644 --- a/packages/opencode/src/v2/adapter.ts +++ b/packages/opencode/src/v2/adapter.ts @@ -141,7 +141,7 @@ export type NativeAnthropicAuthorization

= readonly accessToken: string /** The Claude Code identity of the account, when the source knows it. */ readonly identity?: ClaudeCodeIdentity - /** Extra header edits. Credential headers here are ignored. */ + /** Additional OAuth request headers; authorization and x-api-key edits are ignored so they cannot replace the authorized bearer. */ readonly headers?: HeaderEdits readonly provenance: P } From 0a3204c4cccb9be759720a29c9faf677ac768ffd Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:07:44 +0200 Subject: [PATCH 041/169] mason: adopt verified common-auth 0.11.2 --- bun.lock | 4 ++-- packages/core/package.json | 2 +- packages/core/scripts/check-native-type-closure.test.ts | 4 ++-- packages/core/scripts/check-native-type-closure.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 35d30003d..add6f1d4a 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "^0.10.0", + "@cortexkit/common-auth": "0.11.2", }, }, "packages/e2e-tests": { @@ -237,7 +237,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.10.0", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-K369fRBlbilRrqtB0n+lkVjYWB1dkIE1MVoec3XurXnzGQq36sDQ/dRuRDbGDIIyWOX+VEkrZoFa/6/9momSlA=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.2", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-MgC/FunFOlqkFqNZzqwRDgI7QWNBkcmihUUQ+JsCmPkUGkyIdRvADSHOUhFca6ZxGleDirG4cdPrTE0dKrMTQw=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], diff --git a/packages/core/package.json b/packages/core/package.json index f33ccb44d..f97621281 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "^0.10.0" + "@cortexkit/common-auth": "0.11.2" }, "dependencies": { "@cortexkit/claustrum-client": "0.6.2", diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index e1aafdf9f..a85211a84 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -98,7 +98,7 @@ test('rejects unsupported published producer versions before copying declaration ) await expect(closeNativeTypeDeclarations(dist, producer)).rejects.toThrow( - 'Expected published common-auth 0.10.0, got 0.9.0', + 'Expected published common-auth 0.11.2, got 0.9.0', ) expect(await readdir(dist)).not.toContain('internal-types') }) @@ -111,7 +111,7 @@ test('copies the complete published relative declaration graph including cycles producer, 'package.json', JSON.stringify({ - version: '0.10.0', + version: '0.11.2', exports: { './store': { types: './dist/store/index.d.ts' } }, }), ) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts index 50f4779b3..319f9c5b3 100644 --- a/packages/core/scripts/check-native-type-closure.ts +++ b/packages/core/scripts/check-native-type-closure.ts @@ -185,9 +185,9 @@ export async function closeNativeTypeDeclarations( version: string exports: Record } - if (manifest.version !== '0.10.0') + if (manifest.version !== '0.11.2') throw new Error( - `Expected published common-auth 0.10.0, got ${manifest.version}`, + `Expected published common-auth 0.11.2, got ${manifest.version}`, ) const producerDist = join(producerRoot, 'dist') const assets = join(distRoot, TYPE_ASSETS) From 9d49ea2c1a09be04f09b4f6937d23d32fb365eb6 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:49:03 +0200 Subject: [PATCH 042/169] feat: support Claude Haiku 5.5 across OpenCode and Pi --- ARCHITECTURE.md | 10 +- CHANGELOG.md | 2 + README.md | 1 + STRUCTURE.md | 6 +- packages/core/README.md | 2 + packages/core/src/models.ts | 35 ++ packages/core/src/tests/haiku55.test.ts | 68 ++++ .../core/src/tests/thinking-binding.test.ts | 53 +++ packages/core/src/thinking-binding.ts | 4 +- packages/e2e-tests/tests/haiku55.test.ts | 100 ++++++ packages/opencode/README.md | 1 + packages/opencode/src/index.ts | 63 +++- packages/opencode/src/tests/haiku55.test.ts | 185 ++++++++++ packages/opencode/src/tests/index.test.ts | 36 ++ packages/opencode/src/transform.ts | 31 ++ packages/pi/README.md | 6 +- packages/pi/src/convert.ts | 31 +- packages/pi/src/index.ts | 35 ++ packages/pi/src/tests/haiku55.test.ts | 317 ++++++++++++++++++ packages/pi/src/tests/index.test.ts | 37 ++ packages/pi/src/tests/setup.ts | 16 +- 21 files changed, 1023 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/tests/haiku55.test.ts create mode 100644 packages/e2e-tests/tests/haiku55.test.ts create mode 100644 packages/opencode/src/tests/haiku55.test.ts create mode 100644 packages/pi/src/tests/haiku55.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 25a3781bb..6ac006437 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,12 +43,12 @@ **OpenCode Request Lifecycle:** -1. **Plugin load** — OpenCode loads `@cortexkit/opencode-anthropic-auth` plugin, which starts background refresh timers, creates `QuotaManager` and `FallbackAccountManager`, creates or loads the rotation-invariant main-account slot ID, initializes RPC server, and registers `auth.loader` + `provider.models` hooks. The model hook publishes Fable 5.1, limited-access Mythos 5.1, Opus 5.5, and Sonnet 5.5 with their native adaptive effort variants, and publishes native adaptive `low` through `max` effort variants for Opus 5 and Opus 5.5 instead of legacy manual-thinking budgets. On boot, the plugin resolves the initial sidebar routing only after the asynchronous account-storage load completes, ensuring a peer's concurrent routing-authoritative publish during the load is not overwritten by a stale pre-load snapshot. — `packages/opencode/src/index.ts` +1. **Plugin load** — OpenCode loads `@cortexkit/opencode-anthropic-auth` plugin, which starts background refresh timers, creates `QuotaManager` and `FallbackAccountManager`, creates or loads the main-account slot ID that stays stable when OAuth tokens rotate, initializes RPC server, and registers `auth.loader` + `provider.models` hooks. The model hook publishes Fable 5.1, limited-access Mythos 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 with their native adaptive effort variants, and publishes native adaptive `low` through `max` effort variants for Opus 5 and Opus 5.5 instead of legacy manual-thinking budgets. On boot, the plugin waits for account storage to load before resolving initial sidebar routing. This prevents startup data from overwriting the active account and route published by another plugin instance during the load. — `packages/opencode/src/index.ts` 2. **Auth loader** — When OpenCode creates an Anthropic session, the plugin's `auth.loader` runs: captures the OAuth `getAuth` function and starts the main token-refresh background loop. Main refreshes are coalesced by account-storage path across every project plugin instance in the process, so a slow host credential write cannot make peer requests time out behind the refresh file lock. Before a main OAuth request uses quota state, the plugin resolves Anthropic's account UUID through Claude Code bootstrap; concurrent lookups for the same access token share one promise, while a request whose credentials genuinely change during resolution aborts. A sticky request whose old access token receives 401 re-reads host auth and directly retries with a concurrently rotated, still-valid access token rather than refreshing the new credential again. The OAuth exchange callback and the post-login acknowledgement read the account store best-effort: a corrupt or partially written file degrades to the missing-entry path and never discards just-exchanged credentials. — `packages/opencode/src/index.ts` (AnthropicAuthPlugin → auth.loader) 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()` removes only provably empty trailing assistant messages, refuses meaningful or unknown assistant tails before cache/billing/signing, and strips trailing whitespace after tool_use, normalizes Fable/Mythos 5/5.1, Sonnet 5/5.5, and Opus 5/5.5 adaptive thinking (mapping Sonnet 5.5 opt-outs to `between_tools` and removing its unsupported forced tool choice, 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` +6. **Request body rewrite** — `rewriteRequestBody()` removes only provably empty trailing assistant messages, refuses meaningful or unknown assistant tails before cache/billing/signing, and strips trailing whitespace after tool_use, normalizes Fable/Mythos 5/5.1, Sonnet 5/5.5, Haiku 5.5, and Opus 5/5.5 adaptive thinking (mapping Sonnet 5.5 opt-outs to `between_tools` and removing its unsupported forced tool choice, 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` @@ -57,7 +57,7 @@ **Pi Request Lifecycle:** 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` +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/5.5, Sonnet 5.5, and Haiku 5.5 (with native request-wide pricing tiers above 100K input), and registers a `streamSimple` function — `packages/pi/src/index.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 @@ -165,9 +165,9 @@ - Pattern: Request-time restoration converts CortexKit-signed hidden markers back to Anthropic `fallback` blocks before `cch` signing. The streaming rewriter converts newly returned fallback blocks into hidden thinking markers that OpenCode can persist, observes top-level served models and `usage.iterations[].type === "fallback_message"`, and emits one outcome at the terminal message delta. It also tracks completed `tool_use` blocks: when a terminal refusal follows one, it activates client recovery and exposes a normal `tool_use` finish so OpenCode preserves the call and continues with the existing result. Marker restoration is enabled only for eligible server-fallback requests; custom API-key routes drop internal markers and server-fallback opt-ins. **ThinkingBindingControls:** -- Purpose: Controls how Fable 5.1, Opus 5.5, and Sonnet 5.5 handle replayed thinking whose original conversation prefix changed after host compaction or prompt transformation +- Purpose: Controls how Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 handle replayed thinking whose original conversation prefix changed after host compaction or prompt transformation - Location: `packages/core/src/thinking-binding.ts` -- Pattern: Shared outgoing-body inspection recognizes non-empty signed `thinking` and `redacted_thinking` blocks. OAuth Fable 5.1, Opus 5.5, and Sonnet 5.5 continuations with adaptive thinking use `thinkingBinding.prefixMismatchBehavior`: `account-default` sends no override and leaves the behavior to Anthropic, while `error` or `drop_block` adds `thinking.block_binding.prefix_mismatch_behavior` and beta `thinking-binding-controls-2026-08-01`. Sonnet 5.5's `between_tools` mode rejects this field and does not receive it. First turns, Mythos 5.1, older models, and API-key routes remain unchanged. +- Pattern: Shared outgoing-body inspection recognizes non-empty signed `thinking` and `redacted_thinking` blocks. OAuth Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 continuations with adaptive thinking use `thinkingBinding.prefixMismatchBehavior`: `account-default` sends no local override; Anthropic uses that account's settings to handle replayed thinking when its preceding messages, instructions or tools have changed, while `error` or `drop_block` adds `thinking.block_binding.prefix_mismatch_behavior` and beta `thinking-binding-controls-2026-08-01`. Sonnet 5.5's `between_tools` mode rejects this field and does not receive it. First turns, Mythos 5.1, older models, and API-key routes remain unchanged. **MidConversationOutputConfig:** - Purpose: Preserves Fable 5.1 prompt-cache prefixes while users change reasoning effort between turns diff --git a/CHANGELOG.md b/CHANGELOG.md index 002cac0d0..047636acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Features +- Add Claude Haiku 5.5 (`claude-haiku-5-5`) to Core, OpenCode, and Pi with a 1M context, 128K output and native `low` through `max` adaptive effort. Preserve supported forced tool choice, remove rejected manual thinking budgets and sampling parameters, and honor OpenCode thinking opt-outs at high effort or below. Standard input/output pricing is $0.10/$0.50 per million tokens; all rates increase fivefold above 100K total input tokens, including cached input. OpenCode and Pi apply the selected pricing tier to all input, output and cached tokens in the request, not just the tokens above the threshold. Configured `error` or `drop_block` behavior also covers replayed Haiku 5.5 thinking after earlier conversation content changes. OAuth cost zeroing, fast-mode eligibility, safety fallback and Haiku 4.5 quota priming remain unchanged. + - Add Claude Sonnet 5.5 (`claude-sonnet-5-5`) to Core, OpenCode, and Pi with its 1M context, 128K output, and official pricing. OpenCode and Pi request readable adaptive thinking. OpenCode maps explicit thinking opt-outs to Sonnet 5.5's `between_tools` mode, removes unsupported forced tool choice, and offers native `low` through `max` effort variants. Apply configured `error` or `drop_block` behavior to replayed Sonnet 5.5 and Opus 5.5 thinking when earlier messages, instructions, or tools have changed; the default account behavior stays unchanged. Pi exposes the model's supported `low`–`max` effort levels while refusing unsupported `minimal` and unavailable `off`. ### Fixes diff --git a/README.md b/README.md index 8d94e4d5a..1ff5b51df 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **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-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/5.5, and Sonnet 5.5. OpenCode exposes native `low`, `medium`, `high`, `xhigh`, and `max` effort variants for Fable 5.1, Opus 5/5.5, and Sonnet 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. +- **Claude Haiku 5.5**: `claude-haiku-5-5` supports a 1M context, 128K output and native `low` through `max` adaptive effort. Forced tool choice remains supported. Standard input/output rates are $0.10/$0.50 per million tokens; all rates increase fivefold above 100K total input tokens, including cached input. Quota priming still uses Haiku 4.5. - **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. - **Host-local quota feed (OpenCode)**: optionally publish sanitized response-header quota observations so another local CortexKit process can reuse fresh account state without polling Anthropic's rate-limited usage endpoint. diff --git a/STRUCTURE.md b/STRUCTURE.md index ff21e3333..242948ccd 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, Opus 5.5, and Sonnet 5.5 adaptive-thinking 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) +- 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, a bounded in-memory map that holds the Claude Code billing-version suffix stable for each session, origin-aware thinking-signature filtering, configurable Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 adaptive-thinking 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 @@ -118,8 +118,8 @@ anthropic-auth/ - `packages/core/src/cache1h.ts`: 1h prompt cache configuration and commands - `packages/core/src/fast.ts`: Fast mode configuration and commands - `packages/core/src/dump.ts`: Request/response dump capture logic, response metadata artifacts, same-session on-disk byte-diff baseline recovery after restart, CacheKeep/Prime prewarm tagging, and commands -- `packages/core/src/models.ts`: Supported Claude models and specs, including Fable/Mythos 5.1 release metadata and pricing, context-window qualifier normalization (`[1m]`) for model-family checks, and the Haiku 4.5 prime model -- `packages/core/src/thinking-binding.ts`: Shared replay detection and configurable `account-default`/`error`/`drop_block` controls for Fable 5.1, Opus 5.5, and Sonnet 5.5 signed/redacted thinking +- `packages/core/src/models.ts`: Supported Claude models and specs, including Fable/Mythos 5.1 release metadata and pricing, Haiku 5.5 metadata and its 100K request-wide pricing tier, context-window qualifier normalization (`[1m]`) for model-family checks, and the Haiku 4.5 prime model +- `packages/core/src/thinking-binding.ts`: Shared replay detection and configurable `account-default`/`error`/`drop_block` controls for Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 signed/redacted thinking - `packages/core/src/mid-conversation-output-config.ts`: Shared Fable 5.1 effort normalization and empty-system-marker insertion while keeping the cached top-level effort stable - `packages/core/src/logger.ts`: Shared structured logger - `packages/core/src/json.ts`: Shared JSON parsing helpers that redact parser source context while preserving path, line, and column diagnostics diff --git a/packages/core/README.md b/packages/core/README.md index 4661ac219..3de9f9ef1 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,3 +6,5 @@ User-facing packages: - `@cortexkit/opencode-anthropic-auth` for OpenCode - `@cortexkit/pi-anthropic-auth` for Pi + +Claude Haiku 5.5 (`claude-haiku-5-5`) has a 1M-token context window and 128K max output. Standard input/output rates are $0.10/$0.50 per million tokens; all rates increase fivefold above 100K total input, including cached tokens. Optional quota priming continues to send a minimal Haiku 4.5 request to start a new quota window. diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 287bc0d86..a03d6930e 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -177,6 +177,41 @@ export function isClaudeSonnet55Model(model: unknown) { ) } +export const CLAUDE_HAIKU_5_5_MODEL_ID = 'claude-haiku-5-5' +export const CLAUDE_HAIKU_5_5_RELEASE_DATE = '2026-10-07' +export const CLAUDE_HAIKU_5_5_CONTEXT_WINDOW = 1_000_000 +export const CLAUDE_HAIKU_5_5_MAX_OUTPUT_TOKENS = 128_000 +export const CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD = 100_000 + +export const CLAUDE_HAIKU_5_5_PRICING = { + input: 0.1, + output: 0.5, + cacheRead: 0.01, + cacheWrite5m: 0.125, + cacheWrite1h: 0.2, +} as const + +/** Haiku 5.5's higher rates apply to every token in requests with more than 100K input tokens, including cached input. */ +export const CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING = { + input: 0.5, + output: 2.5, + cacheRead: 0.05, + cacheWrite5m: 0.625, + cacheWrite1h: 1, +} as const + +export const CLAUDE_HAIKU_5_5_ADAPTIVE_THINKING = + CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING + +export function isClaudeHaiku55Model(model: unknown) { + if (typeof model !== 'string') return false + const normalized = normalizeAnthropicModelId(model) + return ( + normalized === CLAUDE_HAIKU_5_5_MODEL_ID || + /^claude-haiku-5-5-\d{8}$/.test(normalized) + ) +} + export const CLAUDE_OPUS_5_MODEL_ID = 'claude-opus-5' /** diff --git a/packages/core/src/tests/haiku55.test.ts b/packages/core/src/tests/haiku55.test.ts new file mode 100644 index 000000000..e01c3f47f --- /dev/null +++ b/packages/core/src/tests/haiku55.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from 'bun:test' +import { isFastModeSupportedModel } from '../constants.ts' +import { + CLAUDE_HAIKU_4_5_MODEL_ID, + CLAUDE_HAIKU_4_5_PRICING, + CLAUDE_HAIKU_5_5_ADAPTIVE_THINKING, + CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, + CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING, + CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD, + CLAUDE_HAIKU_5_5_MAX_OUTPUT_TOKENS, + CLAUDE_HAIKU_5_5_MODEL_ID, + CLAUDE_HAIKU_5_5_PRICING, + CLAUDE_HAIKU_5_5_RELEASE_DATE, + isClaudeHaiku55Model, +} from '../models.ts' + +test('Haiku 5.5 publishes its own limits and does not replace the Prime model', () => { + expect(CLAUDE_HAIKU_5_5_MODEL_ID).toBe('claude-haiku-5-5') + expect(CLAUDE_HAIKU_5_5_RELEASE_DATE).toBe('2026-10-07') + expect(CLAUDE_HAIKU_5_5_CONTEXT_WINDOW).toBe(1_000_000) + expect(CLAUDE_HAIKU_5_5_MAX_OUTPUT_TOKENS).toBe(128_000) + expect(CLAUDE_HAIKU_5_5_ADAPTIVE_THINKING).toEqual({ + type: 'adaptive', + display: 'summarized', + }) + expect(CLAUDE_HAIKU_4_5_MODEL_ID).toBe('claude-haiku-4-5') + expect(CLAUDE_HAIKU_4_5_PRICING).toEqual({ input: 1, output: 5 }) +}) + +test.each([ + 'claude-haiku-5-5', + 'claude-haiku-5-5[1m]', + 'claude-haiku-5-5-20261007', + 'claude-haiku-5-5-20261007[1m]', +])('Haiku 5.5 identifies %s without enabling fast mode', (model) => { + expect(isClaudeHaiku55Model(model)).toBe(true) + expect(isFastModeSupportedModel(model)).toBe(false) +}) + +test.each([ + 'claude-haiku-4-5', + 'claude-haiku-5', + 'claude-haiku-5-50', + 'claude-haiku-5-5-other', + 'claude-sonnet-5-5', + undefined, + 5, +])('Haiku 5.5 rejects the neighboring model %s', (model) => + expect(isClaudeHaiku55Model(model)).toBe(false), +) + +test('Haiku 5.5 publishes both rate sets and the exact 100K threshold', () => { + expect(CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD).toBe(100_000) + expect(CLAUDE_HAIKU_5_5_PRICING).toEqual({ + input: 0.1, + output: 0.5, + cacheRead: 0.01, + cacheWrite5m: 0.125, + cacheWrite1h: 0.2, + }) + expect(CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING).toEqual({ + input: 0.5, + output: 2.5, + cacheRead: 0.05, + cacheWrite5m: 0.625, + cacheWrite1h: 1, + }) +}) diff --git a/packages/core/src/tests/thinking-binding.test.ts b/packages/core/src/tests/thinking-binding.test.ts index 00aa7f632..bf0a400cc 100644 --- a/packages/core/src/tests/thinking-binding.test.ts +++ b/packages/core/src/tests/thinking-binding.test.ts @@ -165,3 +165,56 @@ test('Opus 5.5 uses the same explicit adaptive-thinking prefix control', () => { prefix_mismatch_behavior: 'error', }) }) + +describe('Haiku 5.5 thinking binding controls', () => { + test.each(['error', 'drop_block'] as const)( + 'adds %s only for replayed signed or redacted thinking', + (behavior) => { + for (const block of [ + { type: 'thinking', thinking: 'reason', signature: 'signed' }, + { type: 'redacted_thinking', data: 'redacted' }, + ]) { + const body = bodyWith('claude-haiku-5-5[1m]', block) + expect(applyThinkingBindingControls(body, behavior)).toBe(true) + expect(body.thinking.block_binding).toEqual({ + prefix_mismatch_behavior: behavior, + }) + expect(hasThinkingBindingControls(body)).toBe(true) + } + }, + ) + test('account-default leaves Haiku replay bytes unchanged', () => { + const body = bodyWith('claude-haiku-5-5', { + type: 'thinking', + signature: 'signed', + thinking: 'reason', + }) + const before = JSON.stringify(body) + expect(applyThinkingBindingControls(body)).toBe(false) + expect(JSON.stringify(body)).toBe(before) + }) + test('first Haiku turns receive no prefix control', () => { + const body = bodyWith('claude-haiku-5-5') + expect(applyThinkingBindingControls(body, 'drop_block')).toBe(false) + expect(hasThinkingBindingControls(body)).toBe(false) + }) + test('disabled Haiku thinking receives no unsupported block_binding field', () => { + const body = bodyWith('claude-haiku-5-5', { + type: 'thinking', + signature: 'signed', + thinking: 'reason', + }) + body.thinking.type = 'disabled' + expect(applyThinkingBindingControls(body, 'drop_block')).toBe(false) + expect(hasThinkingBindingControls(body)).toBe(false) + }) + test('Haiku 4.5 remains outside the prefix-control family', () => { + const body = bodyWith('claude-haiku-4-5', { + type: 'thinking', + signature: 'signed', + thinking: 'reason', + }) + expect(applyThinkingBindingControls(body, 'drop_block')).toBe(false) + expect(hasThinkingBindingControls(body)).toBe(false) + }) +}) diff --git a/packages/core/src/thinking-binding.ts b/packages/core/src/thinking-binding.ts index c07a453fc..2c777bf3b 100644 --- a/packages/core/src/thinking-binding.ts +++ b/packages/core/src/thinking-binding.ts @@ -1,6 +1,7 @@ import type { AccountStorage } from './accounts.ts' import { isClaudeFable51Model, + isClaudeHaiku55Model, isClaudeOpus55Model, isClaudeSonnet55Model, } from './models.ts' @@ -56,7 +57,8 @@ export function applyThinkingBindingControls( if ( !isClaudeFable51Model(body.model) && !isClaudeOpus55Model(body.model) && - !isClaudeSonnet55Model(body.model) + !isClaudeSonnet55Model(body.model) && + !isClaudeHaiku55Model(body.model) ) return false if (!hasReplayableThinkingBlocks(body)) return false diff --git a/packages/e2e-tests/tests/haiku55.test.ts b/packages/e2e-tests/tests/haiku55.test.ts new file mode 100644 index 000000000..ba9c831ec --- /dev/null +++ b/packages/e2e-tests/tests/haiku55.test.ts @@ -0,0 +1,100 @@ +import { afterEach, expect, test } from 'bun:test' +import { E2EHarness } from '../src/harness.ts' + +let harness: E2EHarness | null = null + +// Background title calls can use Haiku too; their arrival order does not +// identify the user request whose effort and usage this suite checks. +function isHaikuModelRequest(body: Record) { + return body.model === 'claude-haiku-5-5' && + body.max_tokens !== 0 && + !JSON.stringify(body).includes('Generate a title for this conversation') +} + +afterEach(async () => { + const finished = harness + harness = null + await finished?.dispose() +}) + +test.each(['low', 'max'])( + 'Haiku 5.5 %s keeps native adaptive effort through the OpenCode host', + async (effort) => { + harness = await E2EHarness.create() + harness.script([{ type: 'text', text: 'yes' }]) + const session = await harness.createSession() + await harness.sendPrompt(session, 'Reply yes', 45_000, 'claude-haiku-5-5', effort) + const request = harness.anthropic.requests().find( + (entry) => isHaikuModelRequest(entry.body), + ) + expect(request).toBeDefined() + expect(request?.body.thinking).toMatchObject({ + type: 'adaptive', + display: 'summarized', + }) + expect(request?.body.thinking).not.toHaveProperty('budget_tokens') + expect(request?.body.output_config).toMatchObject({ effort }) + expect(request?.body.temperature).toBeUndefined() + expect(request?.body.top_p).toBeUndefined() + expect(request?.body.top_k).toBeUndefined() + expect(request?.body.speed).toBeUndefined() + expect(request?.body.fallbacks).toBeUndefined() + }, + 90_000, +) + +test('Haiku 5.5 structured output preserves forced choice and reaches its tool', async () => { + harness = await E2EHarness.create() + harness.script([ + { type: 'tool_use', name: 'mcp_StructuredOutput', input: { answer: 'ok' } }, + ]) + const session = await harness.createSession() + const format = { + type: 'json_schema' as const, + schema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + additionalProperties: false, + }, + } + const result = await harness.sendPrompt( + session, + 'Return a JSON object with answer ok', + 45_000, + 'claude-haiku-5-5', + undefined, + format, + ) + const info = (result.data as { info?: { structured?: unknown; error?: unknown } } | undefined)?.info + expect(info?.structured).toEqual({ answer: 'ok' }) + expect(info?.error).toBeUndefined() + const request = harness.anthropic.requests().find( + (entry) => isHaikuModelRequest(entry.body) && + Array.isArray(entry.body.tools) && + entry.body.tools.some((tool: { name?: string }) => tool.name === 'mcp_StructuredOutput'), + ) + expect(request).toBeDefined() + expect(request?.body.tool_choice).toEqual({ type: 'any' }) + expect(request?.body.thinking).toMatchObject({ type: 'adaptive' }) +}, 90_000) + +test('OpenCode real accounting changes the Haiku 5.5 rate only above 100K cache-inclusive input', async () => { + harness = await E2EHarness.create({ + childEnv: { OPENCODE_AUTH_CONTENT: JSON.stringify({ anthropic: { type: 'api', key: 'synthetic-api-key' } }) }, + }) + for (const total of [100_000, 100_001]) { + harness.script([{ type: 'text', text: 'ok', usage: { input_tokens: total - 10_000, output_tokens: 20, cache_creation_input_tokens: 5000, cache_read_input_tokens: 5000 } }]) + const session = await harness.createSession() + const result = await harness.sendPrompt(session, 'Reply ok', 45_000, 'claude-haiku-5-5', 'low') + const info = (result.data as { info?: { cost?: number; tokens?: { input?: number; cache?: { read?: number; write?: number } } } } | undefined)?.info + const request = harness.anthropic.requests().filter((entry) => isHaikuModelRequest(entry.body)).at(-1) + expect(request).toBeDefined() + expect(request?.body.thinking).toMatchObject({ type: 'adaptive' }) + expect(request?.body).not.toHaveProperty('temperature') + expect(request?.body).not.toHaveProperty('top_p') + expect(request?.body).not.toHaveProperty('top_k') + expect(info?.tokens).toMatchObject({ input: total - 10_000, cache: { read: 5000, write: 5000 } }) + expect(info?.cost).toBeCloseTo(total === 100_000 ? 0.009685 : 0.0484255, 10) + } +}, 90_000) diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 2e33fe398..5a28c58f3 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -34,6 +34,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **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-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/5.5, and Sonnet 5.5. OpenCode exposes native `low`, `medium`, `high`, `xhigh`, and `max` effort variants for Fable 5.1, Opus 5/5.5, and Sonnet 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. +- **Claude Haiku 5.5**: `claude-haiku-5-5` supports a 1M context, 128K output and native `low` through `max` adaptive effort. Forced tool choice remains supported. Configured `error` or `drop_block` behavior handles replayed thinking after earlier conversation content changes. Explicit disabled thinking is preserved, with incompatible `xhigh`/`max` effort capped to `high`; manual budgets and sampling parameters are removed. Standard input/output rates are $0.10/$0.50 per million tokens, with fivefold request-wide rates above 100K total input including cache. OAuth model costs are zeroed by default. The opt-in `/claude-prime` command uses Haiku 4.5 to start a new quota window. - **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. - **Host-local quota feed**: optionally publish sanitized response-header quota observations so another local CortexKit process can reuse fresh account state without polling Anthropic's rate-limited usage endpoint. diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 711569965..17ba97a05 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -27,6 +27,13 @@ import { CLAUDE_FABLE_MYTHOS_5_PRICING, CLAUDE_FAST_COMMAND_NAME, CLAUDE_HAIKU_4_5_MODEL_ID, + CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, + CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING, + CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD, + CLAUDE_HAIKU_5_5_MAX_OUTPUT_TOKENS, + CLAUDE_HAIKU_5_5_MODEL_ID, + CLAUDE_HAIKU_5_5_PRICING, + CLAUDE_HAIKU_5_5_RELEASE_DATE, CLAUDE_LOGGING_COMMAND_NAME, CLAUDE_OPUS_5_5_CONTEXT_WINDOW, CLAUDE_OPUS_5_5_MAX_OUTPUT_TOKENS, @@ -110,6 +117,7 @@ import { isCacheKeepSubagentsEnabled, isClaudeFable51Model, isClaudeFableOrMythos51Model, + isClaudeHaiku55Model, isClaudeOpus5FamilyModel, isClaudeOpus5Model, isClaudeOpus55Model, @@ -208,6 +216,7 @@ import { tokenFingerprint, } from '@cortexkit/anthropic-auth-core' import type { Hooks, Plugin } from '@opencode-ai/plugin' +import type { Model as ProviderModelV2 } from '@opencode-ai/sdk/v2' import { BILLING_LINEAGE_REQUEST_HEADER, BillingLineageTracker, @@ -888,6 +897,31 @@ type AnthropicProviderModel = { [key: string]: unknown } +function haiku55ModelCost(): ProviderModelV2['cost'] { + return { + input: CLAUDE_HAIKU_5_5_PRICING.input, + output: CLAUDE_HAIKU_5_5_PRICING.output, + cache: { + read: CLAUDE_HAIKU_5_5_PRICING.cacheRead, + write: CLAUDE_HAIKU_5_5_PRICING.cacheWrite5m, + }, + tiers: [ + { + tier: { + type: 'context', + size: CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD, + }, + input: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.input, + output: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.output, + cache: { + read: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.cacheRead, + write: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.cacheWrite5m, + }, + }, + ], + } +} + function addNativeClaudeModels< T extends Record, >(models: T) { @@ -935,6 +969,32 @@ function addNativeClaudeModels< ] }), ), + ...(models[CLAUDE_HAIKU_5_5_MODEL_ID] + ? {} + : { + [CLAUDE_HAIKU_5_5_MODEL_ID]: { + ...base, + id: CLAUDE_HAIKU_5_5_MODEL_ID, + name: 'Claude Haiku 5.5', + api: base.api + ? { ...base.api, id: CLAUDE_HAIKU_5_5_MODEL_ID } + : undefined, + cost: haiku55ModelCost(), + limit: { + ...(base.limit ?? {}), + context: CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, + output: CLAUDE_HAIKU_5_5_MAX_OUTPUT_TOKENS, + }, + capabilities: { + ...(base.capabilities ?? {}), + reasoning: true, + attachment: true, + toolcall: true, + }, + release_date: CLAUDE_HAIKU_5_5_RELEASE_DATE, + variants: createNativeAdaptiveEffortVariants(), + }, + }), ...(models[CLAUDE_SONNET_5_5_MODEL_ID] ? {} : { @@ -1034,7 +1094,8 @@ function applyNativeAdaptiveEffortVariants< id, isClaudeOpus5FamilyModel(modelId) || isClaudeFable51Model(modelId) || - isClaudeSonnet55Model(modelId) + isClaudeSonnet55Model(modelId) || + isClaudeHaiku55Model(modelId) ? { ...model, variants: createNativeAdaptiveEffortVariants() } : model, ] diff --git a/packages/opencode/src/tests/haiku55.test.ts b/packages/opencode/src/tests/haiku55.test.ts new file mode 100644 index 000000000..530f62722 --- /dev/null +++ b/packages/opencode/src/tests/haiku55.test.ts @@ -0,0 +1,185 @@ +import { expect, test } from 'bun:test' +import { + THINKING_BINDING_CONTROLS_BETA, + TrailingAssistantHistoryError, +} from '@cortexkit/anthropic-auth-core' +import { rewriteRequestBody, setOAuthHeaders } from '../transform.ts' + +async function rewrite( + fields: Record = {}, + controls: NonNullable[1]> = {}, +) { + return JSON.parse( + await rewriteRequestBody( + JSON.stringify({ + model: 'claude-haiku-5-5', + max_tokens: 2048, + messages: [{ role: 'user', content: 'hello' }], + ...fields, + }), + { fastModeEnabled: true, serverSideFallbackEnabled: true, ...controls }, + ), + ) +} + +test.each([ + undefined, + { type: 'enabled', budget_tokens: 4096 }, + { type: 'adaptive' }, +])( + 'Haiku 5.5 normalizes thinking %j to summarized adaptive without sampling', + async (thinking) => { + const body = await rewrite({ + thinking, + temperature: 0.7, + top_p: 1, + top_k: 32, + speed: 'fast', + fallbacks: 'default', + }) + expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(body.temperature).toBeUndefined() + expect(body.top_p).toBeUndefined() + expect(body.top_k).toBeUndefined() + expect(body.speed).toBeUndefined() + expect(body.fallbacks).toBeUndefined() + }, +) + +test.each(['low', 'medium', 'high'])( + 'Haiku 5.5 preserves valid disabled thinking at %s', + async (effort) => { + const body = await rewrite({ + thinking: { type: 'disabled', display: 'summarized' }, + output_config: { effort }, + }) + expect(body.thinking).toEqual({ type: 'disabled' }) + expect(body.output_config).toEqual({ effort }) + }, +) + +test.each(['xhigh', 'max'])( + 'Haiku 5.5 disabled thinking takes priority over unsupported %s effort', + async (effort) => { + const body = await rewrite({ + thinking: { type: 'disabled' }, + output_config: { effort }, + }) + expect(body.thinking).toEqual({ type: 'disabled' }) + expect(body.output_config).toEqual({ effort: 'high' }) + const enabled = await rewrite({ output_config: { effort } }) + expect(enabled.thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + }) + expect(enabled.output_config).toEqual({ effort }) + }, +) + +test.each([{ type: 'any' }, { type: 'tool', name: 'Read' }])( + 'Haiku 5.5 preserves forced tool choice %j and normal tool prefixing', + async (choice) => { + const body = await rewrite({ + tool_choice: choice, + tools: [{ name: 'Read', input_schema: { type: 'object' } }], + }) + expect(body.tool_choice).toEqual( + choice.type === 'tool' ? { type: 'tool', name: 'mcp_Read' } : choice, + ) + expect(body.tools[0].name).toBe('mcp_Read') + }, +) + +test.each(['claude-haiku-5-5[1m]', 'claude-haiku-5-5-20261007'])( + 'Haiku 5.5 recognizes %s during outgoing-body normalization', + async (model) => { + const body = await rewrite({ + model, + thinking: { type: 'enabled', budget_tokens: 1024 }, + top_k: 1, + }) + expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(body.top_k).toBeUndefined() + }, +) + +test('Haiku 4.5 retains its manual thinking and sampling contract', async () => { + const body = await rewrite({ + model: 'claude-haiku-4-5', + thinking: { type: 'enabled', budget_tokens: 1024 }, + temperature: 0.7, + top_p: 0.9, + top_k: 16, + }) + expect(body.thinking).toEqual({ type: 'enabled', budget_tokens: 1024 }) + expect(body.temperature).toBe(0.7) + expect(body.top_p).toBe(0.9) + expect(body.top_k).toBe(16) +}) + +test('Haiku 5.5 refuses meaningful assistant prefill before rewriting history', async () => { + await expect( + rewrite({ + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'prior answer' }, + ], + }), + ).rejects.toBeInstanceOf(TrailingAssistantHistoryError) +}) + +const replayedMessages = [ + { role: 'user', content: 'synthetic earlier turn' }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'reason', signature: 'signed' }, + { type: 'text', text: 'answer' }, + ], + }, + { role: 'user', content: 'continue' }, +] + +test.each(['error', 'drop_block'] as const)( + 'OpenCode Haiku replay receives configured %s control and matching beta', + async (behavior) => { + const body = await rewrite( + { messages: replayedMessages }, + { thinkingPrefixMismatchBehavior: behavior }, + ) + expect(body.thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + block_binding: { prefix_mismatch_behavior: behavior }, + }) + const headers = setOAuthHeaders(new Headers(), 'synthetic-access-token', { + body, + }) + expect(headers.get('anthropic-beta')?.split(',')).toContain( + THINKING_BINDING_CONTROLS_BETA, + ) + }, +) + +test('OpenCode Haiku first turns, account-default replay and disabled thinking do not opt in', async () => { + const bodies = [ + await rewrite({}, { thinkingPrefixMismatchBehavior: 'drop_block' }), + await rewrite( + { messages: replayedMessages }, + { thinkingPrefixMismatchBehavior: 'account-default' }, + ), + await rewrite( + { messages: replayedMessages, thinking: { type: 'disabled' } }, + { thinkingPrefixMismatchBehavior: 'drop_block' }, + ), + ] + for (const body of bodies) { + expect(body.thinking).not.toHaveProperty('block_binding') + const headers = setOAuthHeaders(new Headers(), 'synthetic-access-token', { + body, + }) + expect(headers.get('anthropic-beta')?.split(',')).not.toContain( + THINKING_BINDING_CONTROLS_BETA, + ) + } +}) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index b46b3639c..e1d7b4a5f 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -4801,6 +4801,29 @@ describe('provider.models', () => { }) expect(result?.['claude-mythos-5-1']?.variants).toBeUndefined() expect(result?.['claude-mythos-5-1']?.name).toBe('Claude Mythos 5.1') + expect(result?.['claude-haiku-5-5']?.api?.id).toBe('claude-haiku-5-5') + expect(result?.['claude-haiku-5-5']?.name).toBe('Claude Haiku 5.5') + expect(result?.['claude-haiku-5-5']?.release_date).toBe('2026-10-07') + expect(result?.['claude-haiku-5-5']?.limit).toMatchObject({ + context: 1_000_000, + output: 128_000, + }) + expect(result?.['claude-haiku-5-5']?.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }) + expect(Object.keys(result?.['claude-haiku-5-5']?.variants ?? {})).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + ]) + expect(result?.['claude-haiku-5-5']?.variants?.max).toEqual({ + thinking: { type: 'adaptive', display: 'summarized' }, + effort: 'max', + }) expect(result?.['claude-sonnet-5-5']?.api?.id).toBe('claude-sonnet-5-5') expect(result?.['claude-sonnet-5-5']?.release_date).toBe('2026-09-28') expect(result?.['claude-sonnet-5-5']?.cost).toEqual({ @@ -4871,6 +4894,19 @@ describe('provider.models', () => { output: 10, cache: { read: 0.2, write: 2.5 }, }) + expect(result?.['claude-haiku-5-5']?.cost).toEqual({ + input: 0.1, + output: 0.5, + cache: { read: 0.01, write: 0.125 }, + tiers: [ + { + tier: { type: 'context', size: 100_000 }, + input: 0.5, + output: 2.5, + cache: { read: 0.05, write: 0.625 }, + }, + ], + }) }) test('replaces stale Opus 5 manual-thinking variants with adaptive efforts', async () => { diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index 52d38ad0b..ad4d16bfa 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -11,6 +11,7 @@ import { CLAUDE_CODE_IDENTITY, CLAUDE_FABLE_5_MODEL_ID, CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING, + CLAUDE_HAIKU_5_5_ADAPTIVE_THINKING, CLAUDE_OPUS_5_5_ADAPTIVE_THINKING, CLAUDE_OPUS_5_ADAPTIVE_THINKING, CLAUDE_SONNET_5_5_ADAPTIVE_THINKING, @@ -21,6 +22,7 @@ import { hasThinkingBindingControls, isClaudeFableOrMythos5Model, isClaudeFableOrMythos51Model, + isClaudeHaiku55Model, isClaudeOpus5Model, isClaudeOpus55Model, isClaudeSonnet5Model, @@ -1124,6 +1126,32 @@ function normalizeOpus5Request( return { replacedExisting: hadThinking, display: 'summarized' } } +/** Haiku 5.5 accepts forced tool choice, but not manual thinking or sampling overrides. */ +function normalizeHaiku55Request( + parsed: Record, +): { replacedExisting: boolean; display: 'summarized' | 'disabled' } | null { + if (!isClaudeHaiku55Model(parsed.model)) return null + delete parsed.temperature + delete parsed.top_p + delete parsed.top_k + const hadThinking = Object.hasOwn(parsed, 'thinking') + if (isRecord(parsed.thinking) && parsed.thinking.type === 'disabled') { + parsed.thinking = { type: 'disabled' } + const outputConfig = parsed.output_config + // Keep thinking disabled, but lower xhigh/max effort to high because + // Haiku rejects those effort levels when thinking is disabled. + if ( + isRecord(outputConfig) && + (outputConfig.effort === 'xhigh' || outputConfig.effort === 'max') + ) { + outputConfig.effort = 'high' + } + return { replacedExisting: hadThinking, display: 'disabled' } + } + parsed.thinking = { ...CLAUDE_HAIKU_5_5_ADAPTIVE_THINKING } + return { replacedExisting: hadThinking, display: 'summarized' } +} + export function prepareFableCacheWarmSource( bodyText: string, fableModel = CLAUDE_FABLE_5_MODEL_ID, @@ -1342,6 +1370,7 @@ export async function rewriteRequestBody( const fableMythosThinking = normalizeFableMythosRequest(parsed) const sonnet5Thinking = normalizeSonnet5FamilyRequest(parsed) const opus5Thinking = normalizeOpus5Request(parsed) + const haiku55Thinking = normalizeHaiku55Request(parsed) options.perf?.('model_normalize', { ms: rewriteRoundMs(rewriteNowMs() - modelNormalizeStart), model: typeof parsed.model === 'string' ? parsed.model : undefined, @@ -1354,6 +1383,8 @@ export async function rewriteRequestBody( replacedSonnet5Thinking: sonnet5Thinking?.replacedExisting ?? false, opus5ThinkingDisplay: opus5Thinking?.display, replacedOpus5Thinking: opus5Thinking?.replacedExisting ?? false, + haiku55ThinkingDisplay: haiku55Thinking?.display, + replacedHaiku55Thinking: haiku55Thinking?.replacedExisting ?? false, removedNonAnthropicThinking, serverSideFallbackEnabled: serverSideFallback.enabled, serverFallbackMarkersRestored: serverSideFallback.restoredMarkers, diff --git a/packages/pi/README.md b/packages/pi/README.md index b9eaa1fc5..5ce702be5 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -2,7 +2,11 @@ Pi package for CortexKit Anthropic OAuth support. It overrides Pi's built-in `anthropic` provider with a CortexKit provider extension backed by the shared `@cortexkit/anthropic-auth-core` package. -The Pi provider catalog includes Claude Fable 5 (`claude-fable-5`) and 5.1 (`claude-fable-5-1`), limited-access Claude Mythos 5 (`claude-mythos-5`) and 5.1 (`claude-mythos-5-1`), Claude Opus 5 (`claude-opus-5`), Claude Opus 5.5 (`claude-opus-5-5`), Claude Opus 4.8, Claude Opus 4.5, Claude Sonnet 4.5, Claude Sonnet 5 (`claude-sonnet-5`), and Claude Sonnet 5.5 (`claude-sonnet-5-5`). Fable/Mythos reasoning uses Anthropic adaptive thinking with `thinking.display: "summarized"` and `output_config.effort`; the package does not send rejected manual `thinking.budget_tokens` for those models. Opus 5.5 requires adaptive thinking, while Sonnet 5.5 also supports a `between_tools` mode for applications that explicitly turn off up-front thinking. This Pi provider sends adaptive thinking with summarized display for both models. For Sonnet 5.5 it exposes `low` through `max` effort, but not `minimal` (unsupported by the model) or `off` (Pi's simple-provider options cannot request `between_tools`). OAuth Fable 5.1 sessions add effort changes as system messages instead of rewriting earlier history, preserving cached prompt content. When a later OAuth request replays Fable 5.1, Opus 5.5, or Sonnet 5.5 thinking after earlier messages, instructions, or tools have changed, `thinkingBinding.prefixMismatchBehavior` selects account behavior (`account-default`), rejection (`error`), or removal of mismatched thinking (`drop_block`). These controls require adaptive thinking. +The Pi provider catalog includes Claude Fable 5 (`claude-fable-5`) and 5.1 (`claude-fable-5-1`), limited-access Claude Mythos 5 (`claude-mythos-5`) and 5.1 (`claude-mythos-5-1`), Claude Opus 5 (`claude-opus-5`), Claude Opus 5.5 (`claude-opus-5-5`), Claude Haiku 5.5 (`claude-haiku-5-5`), Claude Opus 4.8, Claude Opus 4.5, Claude Sonnet 4.5, Claude Sonnet 5 (`claude-sonnet-5`), and Claude Sonnet 5.5 (`claude-sonnet-5-5`). Fable/Mythos reasoning uses Anthropic adaptive thinking with `thinking.display: "summarized"` and `output_config.effort`; the package does not send rejected manual `thinking.budget_tokens` for those models. Opus 5.5 requires adaptive thinking, while Sonnet 5.5 also supports a `between_tools` mode for applications that explicitly turn off up-front thinking. This Pi provider sends adaptive thinking with summarized display for both models. For Sonnet 5.5 it exposes `low` through `max` effort, but not `minimal` (unsupported by the model) or `off` (Pi's simple-provider options cannot request `between_tools`). OAuth Fable 5.1 sessions add effort changes as system messages instead of rewriting earlier history, preserving cached prompt content. + +Replayed thinking signatures refer to the messages, instructions and tools that preceded the thinking block. If those earlier inputs change, `thinkingBinding.prefixMismatchBehavior` controls how OAuth Fable 5.1, Opus 5.5, Sonnet 5.5 and Haiku 5.5 requests handle that mismatch: `account-default` sends no local override and uses Anthropic's account settings; `error` rejects the request; `drop_block` removes the affected thinking blocks. These controls require adaptive thinking. + +Haiku 5.5 has a 1M context, 128K output and summarized adaptive thinking with `low` through `max` effort. Haiku 5.5 does not support `minimal` effort. The model accepts disabled thinking, but Pi's simple-provider options cannot request it, so Pi does not expose an `off` level. Standard input/output rates are $0.10/$0.50 per million tokens; all rates increase fivefold above 100K total input including cache. Pi's native pricing tiers apply the selected rates to the whole request, including both cache lifetimes. This package is part of the CortexKit Anthropic Auth monorepo, which supports both OpenCode (`@cortexkit/opencode-anthropic-auth`) and Pi (`@cortexkit/pi-anthropic-auth`) through the same shared core logic. diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index d64dcd443..4b241981a 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -8,6 +8,7 @@ import { CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING, + CLAUDE_HAIKU_5_5_ADAPTIVE_THINKING, CLAUDE_OPUS_5_5_ADAPTIVE_THINKING, CLAUDE_OPUS_5_ADAPTIVE_THINKING, CLAUDE_SONNET_5_5_ADAPTIVE_THINKING, @@ -15,6 +16,7 @@ import { ClaudeCodeFirstUserTextTracker, type ClaudeCodeIdentity, isClaudeFableOrMythos5Model, + isClaudeHaiku55Model, isClaudeOpus5Model, isClaudeOpus55Model, isClaudeSonnet5Model, @@ -78,7 +80,16 @@ export type AnthropicRequestBody = { tools?: Array> thinking?: | { type: 'enabled'; budget_tokens: number } - | { type: 'adaptive'; display: 'summarized' } + | { + type: 'adaptive' + display: 'summarized' + block_binding?: { + prefix_mismatch_behavior: Exclude< + ThinkingPrefixMismatchBehavior, + 'account-default' + > + } + } output_config?: { effort: string } cache_control?: { type: 'ephemeral'; ttl?: '1h' } speed?: 'fast' @@ -657,6 +668,7 @@ export async function buildAnthropicRequest( const isFableOrMythos5 = isClaudeFableOrMythos5Model(modelId) const isSonnet5 = isClaudeSonnet5Model(modelId) const isSonnet55 = isClaudeSonnet55Model(modelId) + const isHaiku55 = isClaudeHaiku55Model(modelId) const isOpus5 = isClaudeOpus5Model(modelId) const isOpus55 = isClaudeOpus55Model(modelId) // Request summaries so the host can display adaptive reasoning. @@ -665,6 +677,8 @@ export async function buildAnthropicRequest( // constants let each model's thinking configuration change independently. if (isFableOrMythos5) { body.thinking = { ...CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING } + } else if (isHaiku55) { + body.thinking = { ...CLAUDE_HAIKU_5_5_ADAPTIVE_THINKING } } else if (isSonnet55) { body.thinking = { ...CLAUDE_SONNET_5_5_ADAPTIVE_THINKING } } else if (isSonnet5) { @@ -676,10 +690,19 @@ export async function buildAnthropicRequest( } if (options?.reasoning) { - if (isSonnet55 && options.reasoning === 'minimal') { - throw new Error('Claude Sonnet 5.5 does not support minimal effort') + if ((isSonnet55 || isHaiku55) && options.reasoning === 'minimal') { + throw new Error( + `${isHaiku55 ? 'Claude Haiku 5.5' : 'Claude Sonnet 5.5'} does not support minimal effort`, + ) } - if (isFableOrMythos5 || isSonnet5 || isSonnet55 || isOpus5 || isOpus55) { + if ( + isFableOrMythos5 || + isSonnet5 || + isSonnet55 || + isHaiku55 || + isOpus5 || + isOpus55 + ) { body.output_config = { effort: options.reasoning } } else { const budgets: Record = { diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 7e8645dbf..68401ce0e 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -5,6 +5,12 @@ import { CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS, CLAUDE_FABLE_MYTHOS_5_MODEL_SPECS, CLAUDE_FABLE_MYTHOS_5_PRICING, + CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, + CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING, + CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD, + CLAUDE_HAIKU_5_5_MAX_OUTPUT_TOKENS, + CLAUDE_HAIKU_5_5_MODEL_ID, + CLAUDE_HAIKU_5_5_PRICING, CLAUDE_SONNET_5_5_CONTEXT_WINDOW, CLAUDE_SONNET_5_5_MAX_OUTPUT_TOKENS, CLAUDE_SONNET_5_5_MODEL_ID, @@ -229,6 +235,35 @@ export default async function cortexKitPiAnthropicAuth( contextWindow: 1_000_000, maxTokens: 128_000, }, + { + id: CLAUDE_HAIKU_5_5_MODEL_ID, + name: 'Claude Haiku 5.5', + reasoning: true, + thinkingLevelMap: { + off: null, + minimal: null, + xhigh: 'xhigh', + max: 'max', + }, + input: textImageInput(), + cost: { + input: CLAUDE_HAIKU_5_5_PRICING.input, + output: CLAUDE_HAIKU_5_5_PRICING.output, + cacheRead: CLAUDE_HAIKU_5_5_PRICING.cacheRead, + cacheWrite: CLAUDE_HAIKU_5_5_PRICING.cacheWrite5m, + tiers: [ + { + inputTokensAbove: CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD, + input: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.input, + output: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.output, + cacheRead: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.cacheRead, + cacheWrite: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.cacheWrite5m, + }, + ], + }, + contextWindow: CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, + maxTokens: CLAUDE_HAIKU_5_5_MAX_OUTPUT_TOKENS, + }, { id: CLAUDE_SONNET_5_5_MODEL_ID, name: 'Claude Sonnet 5.5', diff --git a/packages/pi/src/tests/haiku55.test.ts b/packages/pi/src/tests/haiku55.test.ts new file mode 100644 index 000000000..208b27d3c --- /dev/null +++ b/packages/pi/src/tests/haiku55.test.ts @@ -0,0 +1,317 @@ +import { expect, test } from 'bun:test' +import { + CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, + CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING, + CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD, + CLAUDE_HAIKU_5_5_MAX_OUTPUT_TOKENS, + CLAUDE_HAIKU_5_5_MODEL_ID, + CLAUDE_HAIKU_5_5_PRICING, + hasThinkingBindingControls, + saveAccounts, + THINKING_BINDING_CONTROLS_BETA, +} from '@cortexkit/anthropic-auth-core' +import { + type AssistantMessage, + type Context, + calculateCost, + type Model, + type Usage, +} from '@earendil-works/pi-ai' +import { buildAnthropicRequest } from '../convert.ts' +import { getPiAccountStoragePath } from '../paths.ts' +import { streamCortexKitAnthropic } from '../stream.ts' +import { trackPiTestBody } from './setup.ts' + +const context: Context = { + messages: [{ role: 'user', content: 'hello', timestamp: 0 }], + tools: [], +} +const cache = { enabled: false, mode: 'hybrid' as const } + +test.each(['low', 'medium', 'high', 'xhigh', 'max'] as const)( + 'Pi Haiku 5.5 uses native %s effort without manual budgets or fast mode', + async (reasoning) => { + const { body } = await buildAnthropicRequest( + 'claude-haiku-5-5', + context, + { reasoning }, + cache, + true, + ) + expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(body.output_config).toEqual({ effort: reasoning }) + expect(body.thinking).not.toHaveProperty('budget_tokens') + expect(body.speed).toBeUndefined() + expect(body).not.toHaveProperty('temperature') + expect(body).not.toHaveProperty('top_p') + expect(body).not.toHaveProperty('top_k') + }, +) + +test('Pi Haiku 5.5 defaults to adaptive thinking without inventing an effort override', async () => { + const { body } = await buildAnthropicRequest( + 'claude-haiku-5-5', + context, + undefined, + cache, + ) + expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(body.output_config).toBeUndefined() +}) + +test('Pi Haiku 5.5 refuses unsupported minimal effort', async () => { + await expect( + buildAnthropicRequest( + 'claude-haiku-5-5', + context, + { reasoning: 'minimal' }, + cache, + ), + ).rejects.toThrow('Claude Haiku 5.5 does not support minimal effort') +}) + +test('Pi Haiku 4.5 keeps the older manual-thinking contract', async () => { + const { body } = await buildAnthropicRequest( + 'claude-haiku-4-5', + context, + { reasoning: 'low' }, + cache, + ) + expect(body.thinking).toEqual({ type: 'enabled', budget_tokens: 4096 }) + expect(body.output_config).toBeUndefined() +}) + +const pricedModel: Model<'cortexkit-anthropic-messages'> = { + id: CLAUDE_HAIKU_5_5_MODEL_ID, + name: 'Claude Haiku 5.5', + api: 'cortexkit-anthropic-messages', + provider: 'anthropic', + baseUrl: 'https://example.invalid', + reasoning: true, + input: ['text', 'image'], + contextWindow: CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, + maxTokens: CLAUDE_HAIKU_5_5_MAX_OUTPUT_TOKENS, + cost: { + input: CLAUDE_HAIKU_5_5_PRICING.input, + output: CLAUDE_HAIKU_5_5_PRICING.output, + cacheRead: CLAUDE_HAIKU_5_5_PRICING.cacheRead, + cacheWrite: CLAUDE_HAIKU_5_5_PRICING.cacheWrite5m, + tiers: [ + { + inputTokensAbove: CLAUDE_HAIKU_5_5_LONG_CONTEXT_THRESHOLD, + input: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.input, + output: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.output, + cacheRead: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.cacheRead, + cacheWrite: CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING.cacheWrite5m, + }, + ], + }, +} + +test.each([100_000, 100_001])( + 'Pi real accounting selects the whole-request rate at %s cache-inclusive input tokens', + (inputTotal) => { + const usage: Usage = { + input: inputTotal - 1000, + output: 20, + cacheRead: 500, + cacheWrite: 500, + cacheWrite1h: 250, + totalTokens: inputTotal + 20, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + } + const cost = calculateCost(pricedModel, usage) + const rates = + inputTotal === 100_000 + ? CLAUDE_HAIKU_5_5_PRICING + : CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING + expect(cost.input).toBeCloseTo( + ((inputTotal - 1000) * rates.input) / 1_000_000, + 12, + ) + expect(cost.output).toBeCloseTo((20 * rates.output) / 1_000_000, 12) + expect(cost.cacheRead).toBeCloseTo((500 * rates.cacheRead) / 1_000_000, 12) + expect(cost.cacheWrite).toBeCloseTo( + (250 * rates.cacheWrite5m + 250 * rates.cacheWrite1h) / 1_000_000, + 12, + ) + expect(cost.total).toBeCloseTo( + cost.input + cost.output + cost.cacheRead + cost.cacheWrite, + 12, + ) + }, +) + +function history(redacted: boolean): Context { + const assistant: AssistantMessage = { + role: 'assistant', + api: 'cortexkit-anthropic-messages', + provider: 'anthropic', + model: CLAUDE_HAIKU_5_5_MODEL_ID, + content: [ + { + type: 'thinking', + thinking: 'reason', + thinkingSignature: 'signed-or-redacted', + redacted, + }, + { type: 'text', text: 'answer' }, + ], + stopReason: 'stop', + timestamp: 0, + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + } + return { + messages: [ + { role: 'user', content: 'earlier synthetic turn', timestamp: 0 }, + assistant, + { role: 'user', content: 'continue', timestamp: 1 }, + ], + tools: [], + } +} +const syntheticIdentity = { + deviceId: 'd'.repeat(64), + sessionId: 'synthetic-haiku-binding', +} + +test.each(['error', 'drop_block'] as const)( + 'Pi Haiku replay receives %s for signed and redacted thinking', + async (behavior) => { + for (const redacted of [false, true]) { + const { body } = await buildAnthropicRequest( + CLAUDE_HAIKU_5_5_MODEL_ID, + history(redacted), + undefined, + cache, + false, + syntheticIdentity, + { thinkingPrefixMismatchBehavior: behavior }, + ) + expect(body.thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + block_binding: { prefix_mismatch_behavior: behavior }, + }) + expect(hasThinkingBindingControls(body)).toBe(true) + } + }, +) + +test('Pi Haiku first turns, account-default replay and API-key replay receive no binding opt-in', async () => { + const requests = [ + await buildAnthropicRequest( + CLAUDE_HAIKU_5_5_MODEL_ID, + context, + undefined, + cache, + false, + syntheticIdentity, + { thinkingPrefixMismatchBehavior: 'drop_block' }, + ), + await buildAnthropicRequest( + CLAUDE_HAIKU_5_5_MODEL_ID, + history(false), + undefined, + cache, + false, + syntheticIdentity, + ), + await buildAnthropicRequest( + CLAUDE_HAIKU_5_5_MODEL_ID, + history(false), + undefined, + cache, + false, + undefined, + { thinkingPrefixMismatchBehavior: 'drop_block' }, + ), + ] + for (const { body } of requests) { + expect(body.thinking).not.toHaveProperty('block_binding') + expect(hasThinkingBindingControls(body)).toBe(false) + } +}) + +test('Pi Haiku physical OAuth dispatch carries its prefix control and beta together', () => + trackPiTestBody( + (async () => { + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [], + thinkingBinding: { prefixMismatchBehavior: 'drop_block' }, + }, + getPiAccountStoragePath(), + ) + const originalFetch = globalThis.fetch + let capturedBody: Record | undefined + let capturedHeaders: Headers | undefined + const fakeFetch = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + const url = input instanceof Request ? input.url : String(input) + if (url.includes('/api/claude_cli/bootstrap')) + return Response.json({ + oauth_account: { account_uuid: 'synthetic-haiku-account' }, + }) + if (url.endsWith('/api/oauth/usage')) + return Response.json({ + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() + 3_600_000).toISOString(), + }, + seven_day: { + utilization: 0, + resets_at: new Date(Date.now() + 7 * 86_400_000).toISOString(), + }, + }) + if (!url.includes('/v1/messages')) + throw new Error('Unexpected transport in isolated Haiku stream test') + capturedBody = JSON.parse(String(init?.body)) + capturedHeaders = new Headers(init?.headers) + return new Response( + [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"synthetic","model":"claude-haiku-5-5","usage":{"input_tokens":1,"output_tokens":0}}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ].join(''), + { headers: { 'content-type': 'text/event-stream' } }, + ) + } + globalThis.fetch = Object.assign(fakeFetch, { + preconnect: (..._args: Parameters) => { + throw new Error('Preconnect is forbidden in isolated stream tests') + }, + }) + try { + const stream = streamCortexKitAnthropic(pricedModel, history(false), { + apiKey: 'sk-ant-oat-synthetic-haiku', + sessionId: 'synthetic-haiku-dispatch', + }) + for await (const event of stream) { + expect(event.type).not.toBe('error') + } + expect(capturedBody).toMatchObject({ + thinking: { + type: 'adaptive', + block_binding: { prefix_mismatch_behavior: 'drop_block' }, + }, + }) + expect(capturedHeaders?.get('anthropic-beta')?.split(',')).toContain( + THINKING_BINDING_CONTROLS_BETA, + ) + } finally { + globalThis.fetch = originalFetch + } + })(), + )) diff --git a/packages/pi/src/tests/index.test.ts b/packages/pi/src/tests/index.test.ts index b80a57802..2491d243c 100644 --- a/packages/pi/src/tests/index.test.ts +++ b/packages/pi/src/tests/index.test.ts @@ -85,6 +85,43 @@ describe('cortexKitPiAnthropicAuth provider registration', () => { }) }) + test('exposes Claude Haiku 5.5 with adaptive efforts and the exact 100K price tier', async () => { + const { pi, providers } = mockPi() + await cortexKitPiAnthropicAuth(pi) + const haiku55 = providers + .get('anthropic') + ?.models?.find((model) => model.id === 'claude-haiku-5-5') + expect(haiku55).toMatchObject({ + id: 'claude-haiku-5-5', + name: 'Claude Haiku 5.5', + reasoning: true, + thinkingLevelMap: { + off: null, + minimal: null, + xhigh: 'xhigh', + max: 'max', + }, + input: ['text', 'image'], + contextWindow: 1_000_000, + maxTokens: 128_000, + cost: { + input: 0.1, + output: 0.5, + cacheRead: 0.01, + cacheWrite: 0.125, + tiers: [ + { + inputTokensAbove: 100_000, + input: 0.5, + output: 2.5, + cacheRead: 0.05, + cacheWrite: 0.625, + }, + ], + }, + }) + }) + test('exposes Claude Sonnet 5.5 with native limits and prices', async () => { const { pi, providers } = mockPi() await cortexKitPiAnthropicAuth(pi) diff --git a/packages/pi/src/tests/setup.ts b/packages/pi/src/tests/setup.ts index 34605010a..f1454f594 100644 --- a/packages/pi/src/tests/setup.ts +++ b/packages/pi/src/tests/setup.ts @@ -4,6 +4,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' let testDir: string | undefined +const activeBodies = new Set>() + +/** Bun deadlines do not cancel async bodies; keep their fixture alive until they settle. */ +export function trackPiTestBody(body: Promise): Promise { + activeBodies.add(body) + void body.then( + () => activeBodies.delete(body), + () => activeBodies.delete(body), + ) + return body +} const HOST_PATH_ENV_VARS = [ 'OPENCODE_CONFIG_DIR', 'OPENCODE_ANTHROPIC_AUTH_FILE', @@ -32,6 +43,9 @@ beforeEach(async () => { }) afterEach(async () => { - if (testDir) await rm(testDir, { recursive: true, force: true }) + const retiredDir = testDir + const retiredBodies = [...activeBodies] testDir = undefined + await Promise.allSettled(retiredBodies) + if (retiredDir) await rm(retiredDir, { recursive: true, force: true }) }) From 370f42133d786c9ff5eb23d7f3b422287eaf73be Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:10:35 +0200 Subject: [PATCH 043/169] docs: clarify Haiku prefix policy and configuration --- ARCHITECTURE.md | 4 ++-- CHANGELOG.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6ac006437..48e3bd02f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,7 +43,7 @@ **OpenCode Request Lifecycle:** -1. **Plugin load** — OpenCode loads `@cortexkit/opencode-anthropic-auth` plugin, which starts background refresh timers, creates `QuotaManager` and `FallbackAccountManager`, creates or loads the main-account slot ID that stays stable when OAuth tokens rotate, initializes RPC server, and registers `auth.loader` + `provider.models` hooks. The model hook publishes Fable 5.1, limited-access Mythos 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 with their native adaptive effort variants, and publishes native adaptive `low` through `max` effort variants for Opus 5 and Opus 5.5 instead of legacy manual-thinking budgets. On boot, the plugin waits for account storage to load before resolving initial sidebar routing. This prevents startup data from overwriting the active account and route published by another plugin instance during the load. — `packages/opencode/src/index.ts` +1. **Plugin load** — OpenCode loads `@cortexkit/opencode-anthropic-auth` plugin, which starts background refresh timers, creates `QuotaManager` and `FallbackAccountManager`, creates or loads the main-account slot ID that stays stable when OAuth tokens rotate, initializes RPC server, and registers `auth.loader` + `provider.models` hooks. The model hook publishes Fable 5.1, limited-access Mythos 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 with their native adaptive effort variants, and publishes native adaptive `low` through `max` effort variants for Opus 5 and Opus 5.5 instead of legacy manual-thinking budgets. On boot, the plugin waits for account storage to load before resolving initial sidebar routing from the current shared state. A snapshot taken before the load could otherwise overwrite an active account and route published by another plugin instance during the load. — `packages/opencode/src/index.ts` 2. **Auth loader** — When OpenCode creates an Anthropic session, the plugin's `auth.loader` runs: captures the OAuth `getAuth` function and starts the main token-refresh background loop. Main refreshes are coalesced by account-storage path across every project plugin instance in the process, so a slow host credential write cannot make peer requests time out behind the refresh file lock. Before a main OAuth request uses quota state, the plugin resolves Anthropic's account UUID through Claude Code bootstrap; concurrent lookups for the same access token share one promise, while a request whose credentials genuinely change during resolution aborts. A sticky request whose old access token receives 401 re-reads host auth and directly retries with a concurrently rotated, still-valid access token rather than refreshing the new credential again. The OAuth exchange callback and the post-login acknowledgement read the account store best-effort: a corrupt or partially written file degrades to the missing-entry path and never discards just-exchanged credentials. — `packages/opencode/src/index.ts` (AnthropicAuthPlugin → auth.loader) 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) @@ -167,7 +167,7 @@ **ThinkingBindingControls:** - Purpose: Controls how Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 handle replayed thinking whose original conversation prefix changed after host compaction or prompt transformation - Location: `packages/core/src/thinking-binding.ts` -- Pattern: Shared outgoing-body inspection recognizes non-empty signed `thinking` and `redacted_thinking` blocks. OAuth Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 continuations with adaptive thinking use `thinkingBinding.prefixMismatchBehavior`: `account-default` sends no local override; Anthropic uses that account's settings to handle replayed thinking when its preceding messages, instructions or tools have changed, while `error` or `drop_block` adds `thinking.block_binding.prefix_mismatch_behavior` and beta `thinking-binding-controls-2026-08-01`. Sonnet 5.5's `between_tools` mode rejects this field and does not receive it. First turns, Mythos 5.1, older models, and API-key routes remain unchanged. +- Pattern: Shared outgoing-body inspection recognizes non-empty signed `thinking` and `redacted_thinking` blocks. OAuth Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 continuations with adaptive thinking use `thinkingBinding.prefixMismatchBehavior`: `account-default` omits the local override and lets Anthropic handle changed-prefix thinking for the OAuth account serving the request; the plugin does not implement or infer that server policy, while `error` or `drop_block` adds `thinking.block_binding.prefix_mismatch_behavior` and beta `thinking-binding-controls-2026-08-01`. Sonnet 5.5's `between_tools` mode rejects this field and does not receive it. First turns, Mythos 5.1, older models, and API-key routes remain unchanged. **MidConversationOutputConfig:** - Purpose: Preserves Fable 5.1 prompt-cache prefixes while users change reasoning effort between turns diff --git a/CHANGELOG.md b/CHANGELOG.md index 047636acb..cce2f55d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Features -- Add Claude Haiku 5.5 (`claude-haiku-5-5`) to Core, OpenCode, and Pi with a 1M context, 128K output and native `low` through `max` adaptive effort. Preserve supported forced tool choice, remove rejected manual thinking budgets and sampling parameters, and honor OpenCode thinking opt-outs at high effort or below. Standard input/output pricing is $0.10/$0.50 per million tokens; all rates increase fivefold above 100K total input tokens, including cached input. OpenCode and Pi apply the selected pricing tier to all input, output and cached tokens in the request, not just the tokens above the threshold. Configured `error` or `drop_block` behavior also covers replayed Haiku 5.5 thinking after earlier conversation content changes. OAuth cost zeroing, fast-mode eligibility, safety fallback and Haiku 4.5 quota priming remain unchanged. +- Add Claude Haiku 5.5 (`claude-haiku-5-5`) to Core, OpenCode, and Pi with a 1M context, 128K output and native `low` through `max` adaptive effort. Preserve supported forced tool choice, remove rejected manual thinking budgets and sampling parameters, and honor OpenCode thinking opt-outs at high effort or below. Standard input/output pricing is $0.10/$0.50 per million tokens; all rates increase fivefold above 100K total input tokens, including cached input. OpenCode and Pi apply the selected pricing tier to all input, output and cached tokens in the request, not just the tokens above the threshold. `thinkingBinding.prefixMismatchBehavior` set to `error` or `drop_block` also covers replayed Haiku 5.5 thinking after earlier conversation content changes. OAuth cost zeroing, fast-mode eligibility, safety fallback and Haiku 4.5 quota priming remain unchanged. - Add Claude Sonnet 5.5 (`claude-sonnet-5-5`) to Core, OpenCode, and Pi with its 1M context, 128K output, and official pricing. OpenCode and Pi request readable adaptive thinking. OpenCode maps explicit thinking opt-outs to Sonnet 5.5's `between_tools` mode, removes unsupported forced tool choice, and offers native `low` through `max` effort variants. Apply configured `error` or `drop_block` behavior to replayed Sonnet 5.5 and Opus 5.5 thinking when earlier messages, instructions, or tools have changed; the default account behavior stays unchanged. Pi exposes the model's supported `low`–`max` effort levels while refusing unsupported `minimal` and unavailable `off`. From bbd9bdfff0c8f5568721190e2866a202d8b8568d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:25:34 +0200 Subject: [PATCH 044/169] chore: adopt verified common-auth 0.11.3 orphan recovery --- bun.lock | 4 ++-- packages/core/package.json | 2 +- packages/core/scripts/check-native-type-closure.test.ts | 4 ++-- packages/core/scripts/check-native-type-closure.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index add6f1d4a..165629d16 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "0.11.2", + "@cortexkit/common-auth": "0.11.3", }, }, "packages/e2e-tests": { @@ -237,7 +237,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.2", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-MgC/FunFOlqkFqNZzqwRDgI7QWNBkcmihUUQ+JsCmPkUGkyIdRvADSHOUhFca6ZxGleDirG4cdPrTE0dKrMTQw=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.3", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-WuKDdMJDx9y9b7P/pL22wFGDuCz5JntYrb13neSLkKttyUcgUN6fa9oY3Kdj87uQ1QUN4OoKo8aA9SYUc/+7Uw=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], diff --git a/packages/core/package.json b/packages/core/package.json index f97621281..472e1c915 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "0.11.2" + "@cortexkit/common-auth": "0.11.3" }, "dependencies": { "@cortexkit/claustrum-client": "0.6.2", diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index a85211a84..eec42478a 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -98,7 +98,7 @@ test('rejects unsupported published producer versions before copying declaration ) await expect(closeNativeTypeDeclarations(dist, producer)).rejects.toThrow( - 'Expected published common-auth 0.11.2, got 0.9.0', + 'Expected published common-auth 0.11.3, got 0.9.0', ) expect(await readdir(dist)).not.toContain('internal-types') }) @@ -111,7 +111,7 @@ test('copies the complete published relative declaration graph including cycles producer, 'package.json', JSON.stringify({ - version: '0.11.2', + version: '0.11.3', exports: { './store': { types: './dist/store/index.d.ts' } }, }), ) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts index 319f9c5b3..06bdbd1fb 100644 --- a/packages/core/scripts/check-native-type-closure.ts +++ b/packages/core/scripts/check-native-type-closure.ts @@ -185,9 +185,9 @@ export async function closeNativeTypeDeclarations( version: string exports: Record } - if (manifest.version !== '0.11.2') + if (manifest.version !== '0.11.3') throw new Error( - `Expected published common-auth 0.11.2, got ${manifest.version}`, + `Expected published common-auth 0.11.3, got ${manifest.version}`, ) const producerDist = join(producerRoot, 'dist') const assets = join(distRoot, TYPE_ASSETS) From c4a5a5697b53093b43c379dccf8ebfddbd4ca516 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:45:17 +0200 Subject: [PATCH 045/169] feat(core): expose native host services through a curated public API --- .../scripts/check-native-type-closure.test.ts | 26 +++++- packages/core/src/index.ts | 81 +++++++++++++++++++ .../core/src/tests/native-public-api.test.ts | 44 ++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/tests/native-public-api.test.ts diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index eec42478a..792adb49a 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -291,6 +291,22 @@ test('packed Core types and Node runtime work in a fresh consumer without common ` import { createNativePoolStore, resolveNativePoolPaths, type NativePoolStoreOptions, type NativeLockEvent } from '@cortexkit/anthropic-auth-core'; import * as core from '@cortexkit/anthropic-auth-core'; + import type { + NativeLocalCredentialService, NativeLocalCredentialServiceOptions, NativeRefreshRequest, NativeRefreshResult, + NativeCustody, NativeCustodyOptions, NativeCustodyIdentity, NativeCustodyReceipt, + NativeMenuExecutor, NativeMenuExecutorOptions, NativeMenuHost, NativeMenuModel, + } from '@cortexkit/anthropic-auth-core'; + const localFactory: (options: NativeLocalCredentialServiceOptions) => NativeLocalCredentialService = core.createNativeLocalCredentialService; + const custodyFactory: (options: NativeCustodyOptions) => NativeCustody = core.createNativeCustody; + const menuFactory: (options: NativeMenuExecutorOptions) => NativeMenuExecutor = core.createNativeMenuExecutor; + const menuModel: (host: NativeMenuHost) => NativeMenuModel = core.getNativeMenuModel; + type LocalAuthorize = (request: NativeRefreshRequest) => Promise; + type CustodyAuthorize = (identity: NativeCustodyIdentity, signal?: AbortSignal) => Promise; + type NativeAuthorize = NativeLocalCredentialService['authorize'] extends LocalAuthorize ? true : false; + type ScopedAuthorize = NativeCustody['authorize'] extends CustodyAuthorize ? true : false; + const localContract: NativeAuthorize = true; + const scopedContract: ScopedAuthorize = true; + void [localFactory, custodyFactory, menuFactory, menuModel, localContract, scopedContract]; const paths = await resolveNativePoolPaths('./account.json', './state.json'); const options: NativePoolStoreOptions = { paths, quota: { validate: () => true, merge: (_, value) => value }, @@ -368,6 +384,7 @@ test('packed Core types and Node runtime work in a fresh consumer without common 'runtime.mjs', ` import { createNativePoolStore, resolveNativePoolPaths } from '@cortexkit/anthropic-auth-core'; + import * as core from '@cortexkit/anthropic-auth-core'; import assert from 'node:assert/strict'; const paths = await resolveNativePoolPaths('./account.json', './state.json'); assert.equal(paths.roster.endsWith('state.json.roster.json'), true); @@ -377,7 +394,14 @@ test('packed Core types and Node runtime work in a fresh consumer without common assert.equal(loaded.status, 'ready'); assert.equal(loaded.rows[0].stamp, 'bound'); assert.equal(loaded.rows[0].credential.access, 'synthetic-access'); - console.log('Node ' + process.version + ': 4 packed runtime assertions passed'); + for (const factory of ['createNativeLocalCredentialService', 'createNativeCustody', 'getNativeMenuModel', 'createNativeMenuExecutor', 'captureNativeLocalPoolBinding']) assert.equal(typeof core[factory], 'function', factory); + for (const host of ['opencode', 'pi']) { + const model = core.getNativeMenuModel(host); + assert.equal(model.host, host); + assert.ok(model.groups.length > 0); + } + for (const internal of ['updateNativeRuntime', 'publishNativeLocalSuccess', 'publishNativeLocalFailure', 'assertNativePoolAuthority', 'createNativeRefreshCoordinator']) assert.equal(Object.hasOwn(core, internal), false, internal); + console.log('Node ' + process.version + ': 18 packed runtime assertions passed'); `, ) console.log(await run(['node', 'runtime.mjs'], consumer)) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d18f28971..c6d5042a0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,8 +23,89 @@ export * from './mid-conversation-output-config.ts' export * from './model-remap.ts' export * from './models.ts' export * from './native-credential-validation.ts' +export type { + NativeCustody, + NativeCustodyClient, + NativeCustodyError, + NativeCustodyErrorCode, + NativeCustodyIdentity, + NativeCustodyInventory, + NativeCustodyLogger, + NativeCustodyOptions, + NativeCustodyReceipt, + NativeCustodyReporterSource, + NativeCustodyRetry, +} from './native-custody.ts' +export { createNativeCustody } from './native-custody.ts' +export type { + NativeLocalAttributedFailure, + NativeLocalCredentialService, + NativeLocalCredentialServiceOptions, + NativeLocalFailurePolicyInput, +} from './native-local-credential-service.ts' +export { createNativeLocalCredentialService } from './native-local-credential-service.ts' +export type { NativeLocalExternalPolicy } from './native-local-runtime-readers.ts' +export type { + NativeMenuActionValues, + NativeMenuCapabilityOutcome, + NativeMenuDispatch, + NativeMenuDispatchActionId, + NativeMenuDispatchOptions, + NativeMenuDispatchRequest, + NativeMenuDispatchValues, + NativeMenuExecutionContext, + NativeMenuExecutionResult, + NativeMenuExecutor, + NativeMenuExecutorOptions, + NativeMenuExecutorRefusalCode, + NativeMenuKillswitchEntry, + NativeMenuNoValues, + NativeMenuRequest, + NativeMenuSessionActionId, +} from './native-menu-executor.ts' +export { createNativeMenuExecutor } from './native-menu-executor.ts' +export type { + NativeMenuAction, + NativeMenuActionId, + NativeMenuCacheMode, + NativeMenuChoiceParameter, + NativeMenuCommandId, + NativeMenuCustodyMode, + NativeMenuGroup, + NativeMenuGroupId, + NativeMenuHost, + NativeMenuLogLevel, + NativeMenuModel, + NativeMenuParameter, + NativeMenuParameterId, + NativeMenuRefusalCode, + NativeMenuRoutingMode, + NativeMenuStatus, +} from './native-menu-model.ts' +export { getNativeMenuModel } from './native-menu-model.ts' +export type { + NativeCommittedMaterial, + NativeIdentityBootstrap, + NativeRefreshContext, + NativeRefreshDispatchVersion, + NativeRefreshFailure, + NativeRefreshHandoff, + NativeRefreshObservation, + NativeRefreshRequest, + NativeRefreshRestriction, + NativeRefreshResult, + NativeRefreshSubject, + NativeServingAdmission, +} from './native-refresh-coordinator.ts' +export type { + NativeLocalFailureAttribution, + NativeLocalFailurePolicy, + NativeLocalFailureSkipReason, +} from './native-runtime.ts' export * from './oauth-profile.ts' export * from './pkce.ts' +export type { NativeLocalPoolBinding } from './pool-binding.ts' +export { captureNativeLocalPoolBinding } from './pool-binding.ts' export * from './pool-paths.ts' export * from './pool-store.ts' export * from './prime.ts' diff --git a/packages/core/src/tests/native-public-api.test.ts b/packages/core/src/tests/native-public-api.test.ts new file mode 100644 index 000000000..c031a6e30 --- /dev/null +++ b/packages/core/src/tests/native-public-api.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from 'bun:test' +import * as publicApi from '../index.ts' +import { createNativeCustody } from '../native-custody.ts' +import { createNativeLocalCredentialService } from '../native-local-credential-service.ts' +import { createNativeMenuExecutor } from '../native-menu-executor.ts' +import { getNativeMenuModel } from '../native-menu-model.ts' +import { captureNativeLocalPoolBinding } from '../pool-binding.ts' + +test('Core exposes the existing native host factories without alternate implementations', () => { + expect(publicApi.createNativeLocalCredentialService).toBe( + createNativeLocalCredentialService, + ) + expect(publicApi.createNativeCustody).toBe(createNativeCustody) + expect(publicApi.getNativeMenuModel).toBe(getNativeMenuModel) + expect(publicApi.createNativeMenuExecutor).toBe(createNativeMenuExecutor) + expect(publicApi.captureNativeLocalPoolBinding).toBe( + captureNativeLocalPoolBinding, + ) +}) + +test('native public factories do not expose internal runtime or authority publishers', () => { + for (const name of [ + 'updateNativeRuntime', + 'publishNativeLocalSuccess', + 'publishNativeLocalFailure', + 'publishNativeFirstIdentity', + 'assertNativePoolAuthority', + 'createNativeRefreshCoordinator', + 'buildNativeRosterSeed', + ]) + expect(Object.hasOwn(publicApi, name)).toBe(false) +}) + +test('the public menu getter preserves both host profiles', () => { + for (const host of ['opencode', 'pi'] as const) { + const exposed = publicApi.getNativeMenuModel(host) + expect(exposed).toEqual(getNativeMenuModel(host)) + expect(exposed.host).toBe(host) + expect(exposed.groups.length).toBeGreaterThan(0) + expect(new Set(exposed.groups.map((group) => group.id)).size).toBe( + exposed.groups.length, + ) + } +}) From 835e12e50de9d47ba59b5b7f1f1808905c1cbbe8 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:10:19 +0200 Subject: [PATCH 046/169] mason: add replayable Bun guard mutation catalogue Record three current-source guard breaks with ckdev-mutate 0.9.0 named commands and full Core/Pi unit-package JUnit audits. Rebuild Core before clean/mutated dispatch and after exact restoration; retain raw proof artifacts outside tracked source. Require exact console/JUnit file, descriptive identity, occurrence and outcome agreement before publishing a report or executed count. Normalizer fixtures now supply observed events rather than only counts; application assertions and production guards are unchanged. Document package-only coverage, native report refusals and the history row's 13 same-class collateral failures. --- docs/testing/mutations.md | 201 +++++ mutations.toml | 56 ++ scripts/run-mutation-bun.ts | 726 ++++++++++++++++ scripts/tests/run-mutation-bun.test.ts | 1062 ++++++++++++++++++++++++ 4 files changed, 2045 insertions(+) create mode 100644 docs/testing/mutations.md create mode 100644 mutations.toml create mode 100644 scripts/run-mutation-bun.ts create mode 100644 scripts/tests/run-mutation-bun.test.ts diff --git a/docs/testing/mutations.md b/docs/testing/mutations.md new file mode 100644 index 000000000..1e06c6c44 --- /dev/null +++ b/docs/testing/mutations.md @@ -0,0 +1,201 @@ +# Mutation proofs for request guards + +`mutations.toml` records three deliberately bounded runtime breaks against existing +assertions. Run it only in an isolated checkout: `ckdev-mutate` edits its working +checkout; it does **not** create a scratch worktree. Never reset/check out a target +while replay is active, and never point builds at a live host installation. + +## Toolchain and scope + +Use Bun **1.3.14** and **ckdev-mutate 0.9.0**, pinned to commons +`dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73` (the reviewed release). +`ckdev-mutate 0.9.0` passes descriptive test names as single arguments, including +interior spaces, and reads JUnit results from the full owning-package test run. +The rows need no alternate test names or compatibility layer. + +Install the runner under the checkout-local ignored directory +`node_modules/.cache/ckdev-mutate`. Keep `CARGO_HOME`, `CARGO_TARGET_DIR` and the +install `--root` there, and do not overwrite a global executable. The command +below installs ckdev-mutate 0.9.0 from the exact commons revision above: + +```sh +CARGO_HOME="$PWD/node_modules/.cache/ckdev-mutate/cargo-home" \ +CARGO_TARGET_DIR="$PWD/node_modules/.cache/ckdev-mutate/target" \ +cargo install --locked --git https://github.com/cortexkit/commons \ + --rev dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73 \ + --root "$PWD/node_modules/.cache/ckdev-mutate/install" cortexkit-mutate +``` + +Verify that `ckdev-mutate --version` reports 0.9.0 and its Cargo install metadata +records revision `dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73`. Put its install `bin` +directory and Bun 1.3.14 on `PATH`, including for nested build scripts. + +These proofs send no HTTP requests to model providers and use no real credentials. +They do not change existing application tests, CI workflows, dependencies or +sibling repositories. + +| Control | Runtime break | Existing assertion | +| --- | --- | --- | +| `history-refuses-meaningful-assistant` | Treat meaningful assistant trailers as removable empty trailers. | `refuses unknown or meaningful content 0 without mutating history` in Core request-history tests. | +| `fast-excludes-opus-46-47` | Widen `claude-opus-4-8` to `claude-opus-4-`, incorrectly enabling fast mode for Opus 4.6 and 4.7. | Core asserts that fast mode is not supported for Opus 4.6, 4.7 and `4.7[1m]`. | +| `thinking-binding-excludes-api-key` | Call `applyThinkingBindingControls(body, 'drop_block')` even when Pi has no OAuth identity, bypassing the check that excludes API-key routes. | `buildAnthropicRequest — Fable/Mythos thinking > does not add Fable 5.1 binding controls to an API-key request`. | + +The `thinking-binding-excludes-api-key` guard is owned by Pi's converter at its +`applyThinkingBindingControls` call boundary. Pi imports the built Core helper, +which has no credential-route parameter. Changing Core's `account-default` +behavior alone would not reach Pi's API-key assertion because Pi never calls +`applyThinkingBindingControls` without an OAuth identity. The mutation adds that +call outside the identity gate while leaving OAuth metadata untouched. + +## Current-source builds and exact dispatch + +The root `prebuild` in `mutations.toml` runs `bun run --cwd packages/core build` from the +Git root. Replay rebuilds Core from the current clean source before baselines, +from the current mutated source before each mutation's test dispatch, and from +the exactly restored source at session end. Pi converter tests import Core's +dist. Reusing clean-source dist for a mutant is not evidence that the intended +assertion caught the mutation. +Run Core and host tests sequentially, never concurrently with another Core build. + +Named commands receive the fixed repository-relative test-file path and the full +Bun descriptive name as separate argv elements, with no shell interpolation: + +```sh +bun scripts/run-mutation-bun.ts packages/core/src/tests/fast.test.ts \ + 'fast mode eligibility for claude-opus-4-7[1m] is false' +``` + +The wrapper accepts only the three reviewed owning test files. It rejects empty, +control-character and boundary-whitespace names and unsafe/unsupported paths. +Bun prints nested scopes separated by ` > `, but its name filter joins those +scopes with spaces. The wrapper applies that observed convention, escapes regex +metacharacters, and anchors the entire filter. The printed result must still +match the original descriptive name exactly. + +Each child uses the real Bun executable, explicit package cwd and package +`bunfig.toml`. Existing package preloads remain active. Import-time defaults are +sandboxed before spawn, inherited host overrides are cleared, and the sandbox is +removed only after child exit and both pipes settle. Cancellation kills owned +child work before waiting for those joins. No global timers are replaced. + +Raw stdout/stderr stay on their original pipes. A single `CKDEV executed 1 tests` +record is emitted only for a verified pinned-Bun result with exactly one executed +test, matching pass/fail totals, a positive assertion count, the intended exact +name and a normal 0/1 status. A failing result must contain the selected normal +`expect` assertion diagnostic. Compilation/import/build errors, hook errors, timeouts, +crashes, signals, spawn failures, extra tests and ambiguous/zero summaries refuse +with status **126**. These failures are not evidence that the intended assertion +caught the mutation. Filtered, discovered and skipped tests are not counted. +At the end of a run with multiple failures, Bun prints each failed result again. +The wrapper compares these repeated lines with the original failure lines and +does not count them as additional test executions. + +## Proof procedure and retained evidence + +Test `scripts/run-mutation-bun.ts` and typecheck the repository scripts with: + +```sh +bun test scripts/tests/run-mutation-bun.test.ts +./node_modules/.bin/tsc -p tsconfig.scripts.json +``` + +Keep the first raw stdout, stderr and exit status for every baseline, mutant and +restored run in an ignored checkout directory such as `tmp/mutation-proof`. +Record each target's original SHA-256, and compare the restored source bytes with +the original Git bytes as well as the hash. Each mutation anchor must occur +exactly once. `NON-VACUITY BREAK` comments appear in the catalogue's replacement +strings to mark temporary mutants; they must not remain in restored source. + +For direct proofs, stage the exact live targets first, verify an empty unstaged +`git diff --stat`, apply one catalogue mutation and retain its non-empty diff. +Rebuild `packages/core` from the current mutated source, run every named expected assertion, +and independently run healthy companion assertions. Restore only after dispatch is finished with +`git checkout -- && touch `, verify empty unstaged diff and the +original source hash, then rebuild Core from the restored source. Never use stash. + +The history companion checks that only provably empty trailing assistant messages +are removed from the owned history array, preserving the preceding user message. +Other healthy companions verify that fast mode remains supported for Opus +4.8/5/5.5 and that Pi still adds binding controls to signed-thinking OAuth +requests: +`buildAnthropicRequest — Fable/Mythos thinking > adds Fable 5.1 binding controls when compacted history replays signed thinking`. + +A named-only proof does **not** observe package breadth. Under `--broad`, the two +Core rows additionally run the full Core unit directory (`packages/core/src/tests`) +and the Pi row runs the full Pi unit directory (`packages/pi/src/tests`). Each +uses its own package cwd/config, in a separate sequential Bun process. Measured +clean baselines on the pinned toolchain contain **1,114 Core tests** and **155 Pi +tests**, all green. These are unit-package domains, not workspace or E2E audits. +Core history/fast guards also affect OpenCode and Pi; auditing Core alone does +**not** prove absence of host/workspace collateral. + +The supported fields are `broad_command`, `broad_report` and `broad_id = "{name}"`. +Bun creates fresh JUnit XML at `tmp/mutations/core.xml` or `tmp/mutations/pi.xml`. +The runner deletes the selected report before every invocation and validates the +complete XML, expected identities and baseline failures. Missing, empty, garbage, +ambiguous or mismatched reports are refused. Such reports are not evidence that +the intended assertion caught the mutation. + +### JUnit identity normalization + +Real Bun 1.3.14 XML uses a leaf `name` and a describe `classname`; top-level tests +have an empty classname. Core has three duplicated leaf names in different +classes, and Pi has two repeated table-test names where Bun leaves `%s` unexpanded. +One unconditional `{classname} > {name}` template would also prepend ` > ` to +Core's top-level row IDs. The wrapper therefore changes **only** each testcase's +`name` attribute to the full descriptive name (a nonempty classname followed by +` > ` and the leaf, otherwise the leaf alone). Classnames, suite totals, case +order, statuses, failure/error bodies and all other bytes remain unchanged. + +Before changing name attributes or emitting a verified count, the wrapper compares +every testcase with the corresponding observed console event in execution order: +the source file, full descriptive name, pass/fail outcome and occurrence number +must agree. Matching aggregate counts alone are insufficient. XML that erases a +failure, moves it to another test or changes case identities returns 126; its raw +console output and original XML are retained without publishing a normalized +report. The source line used in a duplicate ID comes from Bun's original JUnit +attribute, and the occurrence is checked against the observed console order. + +Repeated full names receive a deterministic document-order suffix: +` [case N at file:line]`. This distinguishes Bun's repeated table-case occurrences, +not their parameter values. No alias table or production test rename is involved. +The three rows' expected names are unique and remain exactly unchanged. A repeated +expected name would lose that exact identity and the runner must refuse it as +missing, rather than guess which occurrence guards the row. Final normalized-ID +collisions also refuse. Self-tests cover these collisions, native row identity, +entities, dollar punctuation and byte-preservation of failure bodies. Complete +XML structural validation remains the official runner's responsibility. + +The wrapper retains raw and normalized XML, matched IDs, argv/cwd/status and raw +child stdout/stderr in separate invocation folders under `tmp/mutations/audits`. +A successful JUnit audit sets the runner JSON field `breadth_observed` to `true`. +Command-row targets are +exactly JUnit classnames (including the empty Core top-level classname), not +files. Same-class collateral remains CAUGHT; other classes can yield +CAUGHT_BROADLY. Those observed failures are evidence, not a claim of unchanged +behavior outside the declared package. + +The verified `history-refuses-meaningful-assistant` replay also fails 13 other +Core assertions in the same empty JUnit classname, while the empty-assistant +removal companion stays green. The `fast-excludes-opus-46-47` and +`thinking-binding-excludes-api-key` replays have no newly red collateral in their +declared unit-package audits. All three rows are CAUGHT; these outcomes do not +extend the coverage domains above. + +Run the final supported gates sequentially: + +```sh +ckdev-mutate --version +ckdev-mutate check +ckdev-mutate run --all --broad --report tmp/mutations/rows.json +``` + +Retain the first raw output when a check fails, diagnose the failure before +rerunning an affected check, and do not treat compile errors, timeouts or crashes +as evidence that the intended assertion caught the mutation. + +Keep a durable archive of the raw console transcripts and original report bytes, +the source hashes and exact-byte restoration checks, the staged source diff, and +the runner's row reports. Store the archive outside ephemeral build caches so +reviewers can inspect the evidence and source comments before a commit. CI +integration is separate from this catalogue. diff --git a/mutations.toml b/mutations.toml new file mode 100644 index 000000000..79c1f8a6e --- /dev/null +++ b/mutations.toml @@ -0,0 +1,56 @@ +# Named proofs stay exact; --broad additionally audits the owning unit package. +prebuild = [ + { name = "core-current-source", command = ["bun", "run", "--cwd", "packages/core", "build"] }, +] + +[[control]] +id = "history-refuses-meaningful-assistant" +guards = "Meaningful assistant trailers must be refused without deleting history or replaying the preceding question." +file = "packages/core/src/request-history.ts" +old = "if (!isProvablyEmptyContent(last.content)) {" +new = "if (!isProvablyEmptyContent(last.content) && end < 0) { // NON-VACUITY BREAK: accept and remove meaningful trailers" +test_file = "packages/core/src/tests/request-history.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/request-history.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["refuses unknown or meaningful content 0 without mutating history"] +expect_message = "error: expect(received).toThrow(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "fast-excludes-opus-46-47" +guards = "Fast mode excludes Opus 4.6 and 4.7 while retaining Opus 4.8, 5 and 5.5." +file = "packages/core/src/constants.ts" +old = "model.startsWith('claude-opus-4-8') || model.startsWith('claude-opus-5')" +new = "model.startsWith('claude-opus-4-') || model.startsWith('claude-opus-5') /* NON-VACUITY BREAK: widen unsupported models */" +test_file = "packages/core/src/tests/fast.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/fast.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["fast mode eligibility for claude-opus-4-6 is false", "fast mode eligibility for claude-opus-4-7 is false", "fast mode eligibility for claude-opus-4-7[1m] is false"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "thinking-binding-excludes-api-key" +guards = "Pi must not apply Core thinking-binding controls without an OAuth identity, including signed history on API-key routes." +file = "packages/pi/src/convert.ts" +old = ''' if (identity) { + applyThinkingBindingControls(''' +new = ''' // NON-VACUITY BREAK: decorate API-key routes with an explicit override + if (!identity) applyThinkingBindingControls(body, 'drop_block') + if (identity) { + applyThinkingBindingControls(''' +test_file = "packages/pi/src/tests/convert.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/pi/src/tests/convert.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["buildAnthropicRequest — Fable/Mythos thinking > does not add Fable 5.1 binding controls to an API-key request"] +expect_message = "error: expect(received).toBeUndefined()" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/pi", "tmp/mutations/pi.xml"] +broad_report = "tmp/mutations/pi.xml" +broad_id = "{name}" diff --git a/scripts/run-mutation-bun.ts b/scripts/run-mutation-bun.ts new file mode 100644 index 000000000..562d4572d --- /dev/null +++ b/scripts/run-mutation-bun.ts @@ -0,0 +1,726 @@ +import { + lstat, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { resolve } from 'node:path' + +export function testEnvironment( + sandbox: string, + inherited: Record, +) { + const env: Record = { + ...inherited, + NO_COLOR: '1', + FORCE_COLOR: '0', + } + // Package setup runs before each test, but Core captures several defaults at + // import time. Remove inherited overrides and sandbox defaults before spawn. + for (const key of Object.keys(env)) { + if ( + key.startsWith('OPENCODE_ANTHROPIC_AUTH_') || + key.startsWith('PI_ANTHROPIC_AUTH_') + ) + delete env[key] + } + for (const key of [ + 'HOME', + 'TMPDIR', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'XDG_STATE_HOME', + 'XDG_RUNTIME_DIR', + 'OPENCODE_CONFIG_DIR', + 'PI_AGENT_DIR', + 'PI_CODING_AGENT_DIR', + ]) + env[key] = sandbox + for (const key of [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_LOG_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'PI_ANTHROPIC_AUTH_FILE', + 'PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + ]) + env[key] = resolve(sandbox, key) + for (const key of [ + 'OPENCODE_ANTHROPIC_AUTH_DUMP_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'PI_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + ]) + env[key] = resolve(sandbox, key) + return env +} + +export function selectionFor(file: string, name: string) { + if ( + !name || + name.trim() !== name || + [...name].some( + (char) => + char.charCodeAt(0) < 32 || + (char.charCodeAt(0) >= 127 && char.charCodeAt(0) <= 159), + ) + ) + throw new Error('Invalid full test name') + // Limit execution to the owning files and their reviewed package preloads. + // Accepting arbitrary paths could load a test with different safety setup. + if ( + ![ + 'packages/core/src/tests/request-history.test.ts', + 'packages/core/src/tests/fast.test.ts', + 'packages/pi/src/tests/convert.test.ts', + ].includes(file) + ) + throw new Error(`Unsafe or unsupported test file: ${file}`) + const cwd = file.startsWith('packages/core/') + ? 'packages/core' + : 'packages/pi' + return { + cwd, + file: file.slice(cwd.length + 1), + name, + // Bun joins describe scopes with spaces for filtering, but prints " > ". + filterName: name.replaceAll(' > ', ' '), + } +} + +export function exactPattern(name: string) { + return `^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$` +} + +export type Transcript = { + stdout: string + stderr: string + exitCode: number + signal: string | null +} + +export type ExecutionEvent = { + name: string + file: string + status: 'pass' | 'fail' + occurrence: number +} + +// Validate executed console events, not discovery or filtered-out counts. A +// package run may contain many results; a named run must contain exactly one. +export function parseExecution( + name: string | undefined, + transcript: Transcript, +) { + const { stdout, stderr, exitCode, signal } = transcript + if (signal || (exitCode !== 0 && exitCode !== 1)) + throw new Error('Child did not exit normally with a test status') + const text = `${stdout}\n${stderr}` + if (text.includes('CKDEV executed')) + throw new Error('Reserved count record in child output') + const lines = text.split(/\r?\n/) + const matches = (pattern: RegExp) => + lines.filter((line) => pattern.test(line)) + if ( + matches(/^bun test /).length !== 1 || + matches(/^bun test v1\.3\.14 \([a-f0-9]+\)$/).length !== 1 + ) + throw new Error('Missing or ambiguous pinned Bun header') + // Multi-test failures repeat their result lines in a final recap. Verify that + // recap byte-for-byte instead of treating it as a second execution of a test. + const recapIndexes = lines.flatMap((line, index) => + /^\d+ tests failed:$/.test(line) ? [index] : [], + ) + if (recapIndexes.length > 1) throw new Error('Ambiguous failure recap') + const recapIndex = recapIndexes[0] + const eventLines = + recapIndex === undefined ? lines : lines.slice(0, recapIndex) + const results = eventLines.filter((line) => /^\((?:pass|fail)\) /.test(line)) + if (recapIndex !== undefined) { + const actualFailures = results.filter((line) => line.startsWith('(fail) ')) + const recap = lines + .slice(recapIndex + 1) + .filter((line) => /^\((?:pass|fail)\) /.test(line)) + if ( + Number(lines[recapIndex]?.split(' ')[0]) !== actualFailures.length || + recap.length !== actualFailures.length || + recap.some((line, index) => line !== actualFailures[index]) + ) + throw new Error('Mismatched failure recap') + } + let testFile: string | undefined + const ordinals = new Map() + const events: ExecutionEvent[] = [] + for (const line of eventLines) { + const fileHeader = /^(src\/tests\/[^:\r\n]+\.test\.ts):$/.exec(line) + if (fileHeader) { + testFile = fileHeader[1] + continue + } + if (!/^\((?:pass|fail)\) /.test(line)) continue + const match = /^\((pass|fail)\) (.+) \[\d+(?:\.\d+)?(?:ms|s)\]$/.exec(line) + const status = match?.[1] + const testName = match?.[2] + if (!testFile || !testName || (status !== 'pass' && status !== 'fail')) + throw new Error('Malformed test result or missing test file header') + const occurrence = (ordinals.get(testName) ?? 0) + 1 + ordinals.set(testName, occurrence) + events.push({ status, name: testName, file: testFile, occurrence }) + } + if ( + name !== undefined && + (events.length !== 1 || + events[0]?.name !== name || + events[0]?.status !== (exitCode === 0 ? 'pass' : 'fail')) + ) + throw new Error('Mismatched test name or status') + const count = (word: string) => { + const entries = matches(new RegExp(`^ \\d+ ${word}$`)) + if (entries.length !== 1) + throw new Error(`Missing or ambiguous ${word} count`) + const value = Number(entries[0]?.trim().split(' ')[0]) + if (!Number.isSafeInteger(value)) + throw new Error('Overflowed summary count') + return value + } + const passed = count('pass') + const failed = count('fail') + const total = passed + failed + if ( + !total || + total !== events.length || + passed !== events.filter((event) => event.status === 'pass').length || + failed !== events.filter((event) => event.status === 'fail').length || + (exitCode === 0) !== (failed === 0) + ) + throw new Error('Mismatched executed summary') + const totals = matches(/^Ran \d+ tests? across \d+ files?\. /) + const summary = + /^Ran (\d+) tests? across ([1-9]\d*) files?\. \[\d+(?:\.\d+)?(?:ms|s)\]$/.exec( + totals[0] ?? '', + ) + if ( + totals.length !== 1 || + !summary || + Number(summary[1]) !== total || + (name !== undefined && Number(summary[2]) !== 1) + ) + throw new Error('Missing, zero or ambiguous executed total') + const expects = matches(/^ \d+ expect\(\) calls$/) + if ( + expects.length !== 1 || + !/^ [1-9]\d* expect\(\) calls$/.test(expects[0] ?? '') + ) + throw new Error('No verified assertion executed') + const errors = matches(/^error:/) + if ( + errors.length !== failed || + errors.some( + (line) => + !/^error: expect\(received\)\.[A-Za-z]+\([^\r\n]*\)$/.test(line), + ) + ) + throw new Error('Not a genuine selected expect assertion failure') + if ( + matches( + /^(?:Test .*timed? out|(?:beforeEach|afterEach|beforeAll|afterAll).*|Unhandled.*)/i, + ).length + ) + throw new Error('Timeout, hook or unhandled error') + // A hook assertion can look like a test assertion. Inspect its diagnostic + // block, not passing test names such as "handles timeout" in a package run. + for (const [index, line] of lines.entries()) { + if (!line.startsWith('(fail) ')) continue + let start = index - 1 + while ( + start >= 0 && + !/^\((?:pass|fail)\) |\.test\.ts:$/.test(lines[start] ?? '') + ) + start-- + const block = lines.slice(start + 1, index).join('\n') + if ( + /timed? out|timeout|unhandled|beforeEach|afterEach|beforeAll|afterAll/i.test( + block, + ) + ) + throw new Error('Timeout, hook or unhandled error') + } + if ( + matches(/^ \d+ (?:error|errors|skip|todo)/).length || + matches(/^\((?:skip|todo)\) /).length || + /^(?:SyntaxError|ReferenceError|TypeError|ResolveMessage|BuildMessage):|^.*error between tests/m.test( + text, + ) + ) + throw new Error('Compilation, import, skipped test or runtime error') + return { count: total, events } +} + +export function executedCount( + name: string | undefined, + transcript: Transcript, +): number { + return parseExecution(name, transcript).count +} + +function decodeAttribute(value: string) { + const entity = /&(?:amp|lt|gt|quot|apos|#\d+|#x[\da-fA-F]+);/g + if (value.replace(entity, '').includes('&')) + throw new Error('Unsupported XML entity') + return value.replace(entity, (match) => { + switch (match) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case ''': + return "'" + default: + return String.fromCodePoint( + match.startsWith('&#x') + ? Number.parseInt(match.slice(3, -1), 16) + : Number(match.slice(2, -1)), + ) + } + }) +} + +function encodeAttribute(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +// Bun writes one testcase opening tag per line. Change only its name attribute: +// results, failure messages, classnames, suite totals and order remain byte-exact. +// The official runner still validates the complete XML document afterward. +export function normalizeReport( + raw: string, + events: readonly ExecutionEvent[], +) { + if ( + !raw.startsWith('\n') || + !raw.trimEnd().endsWith('') + ) + throw new Error('Missing or unsupported Bun JUnit document') + const lines = raw.split('\n') + const cases = lines.flatMap((line, index) => { + if (!/^\s*]+?)(\s*\/?>)$/.exec(line) + if (!opening) throw new Error('Malformed testcase opening tag') + const attributes = opening[2] ?? '' + const pairs = [...attributes.matchAll(/([a-z]+)="([^"]*)"/g)] + if ( + attributes.replace(/([a-z]+)="([^"]*)"/g, '').trim() || + new Set(pairs.map((pair) => pair[1])).size !== pairs.length + ) + throw new Error('Malformed or duplicate testcase attributes') + const attrs: Record = Object.fromEntries( + pairs.map((pair) => [pair[1], decodeAttribute(pair[2] ?? '')]), + ) + if ( + !attrs.name || + attrs.classname === undefined || + !attrs.file || + !/^\d+$/.test(attrs.line ?? '') + ) + throw new Error('Missing testcase identity attributes') + const name = attrs.classname + ? `${attrs.classname} > ${attrs.name}` + : attrs.name + let status: 'pass' | 'fail' = 'pass' + if (!(opening[3] ?? '').trim().startsWith('/')) { + const closing = lines.findIndex( + (line, position) => + position > index && /^\s*<\/testcase>\s*$/.test(line), + ) + if (closing < 0) throw new Error('Unclosed testcase') + const children = lines.slice(index + 1, closing) + if ( + children.some((line) => /^\s*<(?:testcase|error|skipped)\b/.test(line)) + ) + throw new Error('Unsupported or erroneous testcase contents') + const failures = children.filter((line) => /^\s* 1) throw new Error('Ambiguous testcase failure') + if (failures.length === 1) status = 'fail' + else if (children.some((line) => line.trim())) + throw new Error('Unsupported testcase contents') + } + return [ + { + index, + name, + file: attrs.file, + status, + location: `${attrs.file}:${attrs.line}`, + }, + ] + }) + if (!cases.length || cases.length !== events.length) + throw new Error( + 'JUnit testcase count does not match executed console events', + ) + const counts = new Map() + for (const entry of cases) + counts.set(entry.name, (counts.get(entry.name) ?? 0) + 1) + const ordinals = new Map() + const ids: string[] = [] + for (const [index, entry] of cases.entries()) { + const ordinal = (ordinals.get(entry.name) ?? 0) + 1 + ordinals.set(entry.name, ordinal) + const event = events[index] + if ( + !event || + event.name !== entry.name || + event.file !== entry.file || + event.status !== entry.status || + event.occurrence !== ordinal + ) + throw new Error( + 'JUnit identity or outcome does not match console execution', + ) + // Bun leaves %s unexpanded in "Pi refuses meaningful assistant history locally + // (sk-ant-oat-fixture-history, empty user=%s)" and "Pi refuses meaningful + // assistant history locally (fixture-api-key, empty user=%s)". Each observed + // occurrence needs a distinct report ID. Catalogue assertions have unique + // names, so their exact named selections keep those names unchanged. + const id = + counts.get(entry.name) === 1 + ? entry.name + : `${entry.name} [case ${ordinal} at ${entry.location}]` + ids.push(id) + lines[entry.index] = (lines[entry.index] ?? '').replace( + /\bname="[^"]*"/, + () => `name="${encodeAttribute(id)}"`, + ) + } + if (new Set(ids).size !== ids.length) + throw new Error('Colliding normalized JUnit IDs') + return { xml: lines.join('\n'), ids } +} + +export function packageSelection(pkg: string, report: string) { + if ( + !['packages/core', 'packages/pi'].includes(pkg) || + report !== `tmp/mutations/${pkg.slice('packages/'.length)}.xml` + ) + throw new Error('Unsafe package or broad report path') + return { cwd: pkg, file: 'src/tests', report } +} + +export type OwnedChild = { + stdout: ReadableStream + stderr: ReadableStream + exited: Promise + readonly signalCode: string | null + kill: () => void +} +type Dependencies = { + spawn: (argv: string[], cwd: string) => OwnedChild + stdout: (bytes: Uint8Array) => void + stderr: (bytes: Uint8Array) => void + completed?: ( + transcript: Transcript, + argv: string[], + cwd: string, + ) => Promise + report?: { + read: (path: string) => Promise + write: ( + path: string, + normalized: { xml: string; ids: string[] }, + transcript: Transcript, + argv: string[], + ) => Promise + } +} + +export async function runTest( + file: string, + name: string, + dependencies: Dependencies, + signal: AbortSignal, +): Promise { + return runInvocation({ file, name }, dependencies, signal) +} + +export async function runPackage( + pkg: string, + report: string, + dependencies: Dependencies, + signal: AbortSignal, +): Promise { + return runInvocation({ pkg, report }, dependencies, signal) +} + +async function runInvocation( + request: { file: string; name: string } | { pkg: string; report: string }, + dependencies: Dependencies, + signal: AbortSignal, +): Promise { + const reportError = (error: unknown) => { + dependencies.stderr(Buffer.from(`CKDEV ERROR: ${String(error)}\n`)) + return 126 + } + try { + const selected = + 'pkg' in request + ? packageSelection(request.pkg, request.report) + : selectionFor(request.file, request.name) + if (signal.aborted) throw new Error('Invocation cancelled before spawn') + const argv = [ + process.execPath, + 'test', + '--no-orphans', + '--config', + './bunfig.toml', + `./${selected.file}`, + ...('report' in selected + ? [ + '--reporter=junit', + `--reporter-outfile=${resolve(import.meta.dir, '..', selected.report)}`, + ] + : ['--test-name-pattern', exactPattern(selected.filterName)]), + ] + const child = dependencies.spawn( + argv, + resolve(import.meta.dir, '..', selected.cwd), + ) + let terminated = false + const cancel = () => { + if (terminated) return + terminated = true + child.kill() + } + signal.addEventListener('abort', cancel, { once: true }) + if (signal.aborted) cancel() + const consume = async ( + stream: ReadableStream, + emit: (bytes: Uint8Array) => void, + ) => { + const chunks: Uint8Array[] = [] + try { + for await (const chunk of stream) { + emit(chunk) + chunks.push(chunk) + } + return new TextDecoder('utf-8', { fatal: true }).decode( + Buffer.concat(chunks), + ) + } catch (error) { + // Terminate the Bun test process before waiting for its stdout reader, + // stderr reader and exit promise to finish after a reader failure. + cancel() + throw error + } + } + try { + const results = await Promise.allSettled([ + consume(child.stdout, dependencies.stdout), + consume(child.stderr, dependencies.stderr), + child.exited.catch((error: unknown) => { + cancel() + throw error + }), + ]) + const [out, err, exit] = results + if (out.status === 'rejected') throw out.reason + if (err.status === 'rejected') throw err.reason + if (exit.status === 'rejected') throw exit.reason + const transcript = { + stdout: out.value, + stderr: err.value, + exitCode: exit.value, + signal: child.signalCode, + } + await dependencies.completed?.(transcript, argv, selected.cwd) + if (signal.aborted) throw new Error('Invocation cancelled') + // Retain Bun's raw XML even if exact console event identity, executed-count + // or child-status validation refuses the run; the rejected bytes explain + // the failure without publishing a verified normalized report. + const raw = + 'report' in selected && dependencies.report + ? await dependencies.report.read(selected.report) + : undefined + const execution = parseExecution( + 'name' in selected ? selected.name : undefined, + transcript, + ) + if ('report' in selected) { + if (!dependencies.report) throw new Error('No broad report writer') + if (raw === undefined) throw new Error('Missing broad report') + const normalized = normalizeReport(raw, execution.events) + await dependencies.report.write( + selected.report, + normalized, + transcript, + argv, + ) + } + dependencies.stdout( + Buffer.from(`CKDEV executed ${execution.count} tests\n`), + ) + return exit.value + } finally { + signal.removeEventListener('abort', cancel) + } + } catch (error) { + return reportError(error) + } +} + +if (import.meta.main) { + const controller = new AbortController() + let sandbox: string | undefined + let artifactDir: string | undefined + const cancel = () => controller.abort() + process.on('SIGINT', cancel) + process.on('SIGTERM', cancel) + try { + const broad = process.argv[2] === '--broad' + if (Bun.version !== '1.3.14' || process.argv.length !== (broad ? 5 : 4)) { + process.stderr.write( + 'CKDEV ERROR: requires Bun 1.3.14 and either file/name or --broad package/report\n', + ) + process.exitCode = 126 + } else { + const tmp = resolve(import.meta.dir, '..', 'tmp') + await mkdir(tmp, { recursive: true }) + if ((await lstat(tmp)).isSymbolicLink()) + throw new Error('Unsafe temporary directory') + if (broad) packageSelection(process.argv[3] ?? '', process.argv[4] ?? '') + else selectionFor(process.argv[2] ?? '', process.argv[3] ?? '') + const reports = resolve(tmp, 'mutations') + await mkdir(reports, { recursive: true }) + if ((await lstat(reports)).isSymbolicLink()) + throw new Error('Unsafe report directory') + const audits = resolve(reports, 'audits') + await mkdir(audits, { recursive: true }) + if ((await lstat(audits)).isSymbolicLink()) + throw new Error('Unsafe audit directory') + artifactDir = await mkdtemp( + resolve(audits, broad ? 'package-' : 'named-'), + ) + const retained = artifactDir + sandbox = await mkdtemp(resolve(tmp, 'mutation-bun-')) + const env = testEnvironment(sandbox, process.env) + const dependencies: Dependencies = { + spawn: (argv, cwd) => { + const child = Bun.spawn(argv, { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env, + }) + return { + stdout: child.stdout, + stderr: child.stderr, + exited: child.exited, + get signalCode() { + return child.signalCode + }, + kill: () => { + child.kill('SIGKILL') + }, + } + }, + stdout: (bytes) => { + process.stdout.write(bytes) + }, + stderr: (bytes) => { + process.stderr.write(bytes) + }, + completed: async (transcript, argv, cwd) => { + await Promise.allSettled([ + writeFile(resolve(retained, 'stdout.raw'), transcript.stdout), + writeFile(resolve(retained, 'stderr.raw'), transcript.stderr), + writeFile( + resolve(retained, 'invocation.json'), + JSON.stringify( + { + argv, + cwd, + exitCode: transcript.exitCode, + signal: transcript.signal, + }, + null, + 2, + ), + ), + ]).then((results) => { + for (const result of results) + if (result.status === 'rejected') throw result.reason + }) + }, + report: { + read: async (path) => { + const absolute = resolve(import.meta.dir, '..', path) + const stat = await lstat(absolute) + if (!stat.isFile() || stat.isSymbolicLink()) + throw new Error('Unsafe broad report file') + const raw = await readFile(absolute) + await writeFile(resolve(retained, 'report.raw.xml'), raw) + return new TextDecoder('utf-8', { fatal: true }).decode(raw) + }, + write: async (path, normalized) => { + await writeFile( + resolve(import.meta.dir, '..', path), + normalized.xml, + ) + await writeFile(resolve(retained, 'report.xml'), normalized.xml) + await writeFile( + resolve(retained, 'matched-ids.json'), + JSON.stringify(normalized.ids, null, 2), + ) + }, + }, + } + process.exitCode = broad + ? await runPackage( + process.argv[3] ?? '', + process.argv[4] ?? '', + dependencies, + controller.signal, + ) + : await runTest( + process.argv[2] ?? '', + process.argv[3] ?? '', + dependencies, + controller.signal, + ) + } + } catch (error) { + process.stderr.write(`CKDEV ERROR: ${String(error)}\n`) + process.exitCode = 126 + } finally { + process.off('SIGINT', cancel) + process.off('SIGTERM', cancel) + try { + if (sandbox) await rm(sandbox, { recursive: true, force: true }) + if (artifactDir) + await writeFile( + resolve(artifactDir, 'wrapper-status.json'), + JSON.stringify({ exitCode: process.exitCode ?? 0 }), + ) + } catch (error) { + // Raw-output/status-file writes or temporary test-directory cleanup must + // refuse with 126 on failure, even when the Bun test assertion ran and a + // completed child already emitted its genuine assertion and count record. + process.stderr.write(`CKDEV ERROR: cleanup failed: ${String(error)}\n`) + process.exitCode = 126 + } + } +} diff --git a/scripts/tests/run-mutation-bun.test.ts b/scripts/tests/run-mutation-bun.test.ts new file mode 100644 index 000000000..2116a1f8f --- /dev/null +++ b/scripts/tests/run-mutation-bun.test.ts @@ -0,0 +1,1062 @@ +import { expect, test } from 'bun:test' +import { + type ExecutionEvent, + exactPattern, + executedCount, + normalizeReport, + type OwnedChild, + packageSelection, + parseExecution, + runPackage, + runTest, + selectionFor, + type Transcript, + testEnvironment, +} from '../run-mutation-bun' + +const file = 'packages/core/src/tests/fast.test.ts' +const name = 'fast mode eligibility for claude-opus-4-7[1m] is false' +const apiFile = 'packages/pi/src/tests/convert.test.ts' +const apiName = + 'buildAnthropicRequest — Fable/Mythos thinking > does not add Fable 5.1 binding controls to an API-key request' +const header = 'bun test v1.3.14 (0d9b296a)\n' + +test('sandboxes import-time defaults and refuses inherited host path overrides', () => { + const inherited = { + PATH: '/toolchain', + HOME: '/real-home', + TMPDIR: '/real-tmp', + OPENCODE_ANTHROPIC_AUTH_FILE: '/real-auth', + OPENCODE_ANTHROPIC_AUTH_DUMP: '1', + PI_ANTHROPIC_AUTH_FILE: '/real-pi-auth', + PI_ANTHROPIC_AUTH_UNKNOWN_FILE: '/real-extra', + } + const env = testEnvironment('/sandbox', inherited) + expect(env.HOME).toBe('/sandbox') + expect(env.OPENCODE_ANTHROPIC_AUTH_FILE).toBe( + '/sandbox/OPENCODE_ANTHROPIC_AUTH_FILE', + ) + expect(env.PI_ANTHROPIC_AUTH_FILE).toBe('/sandbox/PI_ANTHROPIC_AUTH_FILE') + expect(env.OPENCODE_ANTHROPIC_AUTH_DUMP).toBeUndefined() + expect(env.PI_ANTHROPIC_AUTH_UNKNOWN_FILE).toBeUndefined() + expect(env.PATH).toBe('/toolchain') + expect(inherited.HOME).toBe('/real-home') +}) +function green(selectedName = name): Transcript { + return { + stdout: header, + stderr: `\nsrc/tests/fast.test.ts:\n(pass) ${selectedName} [4.73ms]\n\n 1 pass\n 8 filtered out\n 0 fail\n 1 expect() calls\nRan 1 test across 1 file. [87.00ms]\n`, + exitCode: 0, + signal: null, + } +} +function red(): Transcript { + return { + stdout: header, + stderr: `\nsrc/tests/fast.test.ts:\n16 | expect(isFastModeSupportedModel(model)).toBe(expected)\n ^\nerror: expect(received).toBe(expected)\n\nExpected: false\nReceived: true\n\n at (/checkout/packages/core/src/tests/fast.test.ts:16:43)\n(fail) ${name} [2.11ms]\n\n 0 pass\n 1 fail\n 1 expect() calls\nRan 1 test across 1 file. [110.00ms]\n`, + exitCode: 1, + signal: null, + } +} + +test('accepts the real pinned baseline and assertion-failure summary shapes', () => { + expect(executedCount(name, green())).toBe(1) + expect(executedCount(name, red())).toBe(1) +}) + +test('anchors literal punctuation and keeps Bun filter/display names distinct', () => { + const regex = new RegExp(exactPattern(name)) + expect(regex.test(name)).toBe(true) + expect(regex.test(name.replace('[1m]', '1m'))).toBe(false) + expect(regex.test(`prefix ${name}`)).toBe(false) + expect(regex.test(`${name} suffix`)).toBe(false) + const api = selectionFor(apiFile, apiName) + expect(api.name).toBe( + 'buildAnthropicRequest — Fable/Mythos thinking > does not add Fable 5.1 binding controls to an API-key request', + ) + expect(api.filterName).toBe( + 'buildAnthropicRequest — Fable/Mythos thinking does not add Fable 5.1 binding controls to an API-key request', + ) + const punctuation = 'scope (a+b)? [x] {2} ^$ . | \\ /' + expect(new RegExp(exactPattern(punctuation)).test(punctuation)).toBe(true) +}) + +test('accepts the header and summary on either pipe, including CRLF', () => { + const fixture = red() + expect( + executedCount(name, { + ...fixture, + stdout: fixture.stderr.replaceAll('\n', '\r\n'), + stderr: header, + }), + ).toBe(1) +}) + +const invalid: { label: string; transcript: Transcript }[] = [ + { + label: 'zero tests', + transcript: { + ...green(), + stderr: ' 0 pass\n 0 fail\nRan 0 tests across 1 file. [2.00ms]\n', + }, + }, + { + label: 'ambiguous total', + transcript: { + ...green(), + stderr: `${green().stderr}Ran 1 test across 1 file. [2.00ms]\n`, + }, + }, + { + label: 'mismatched total', + transcript: { + ...green(), + stderr: green().stderr.replace('Ran 1 test', 'Ran 2 tests'), + }, + }, + { + label: 'mismatched pass count', + transcript: { + ...green(), + stderr: green().stderr.replace(' 1 pass', ' 2 pass'), + }, + }, + { + label: 'ambiguous pass count', + transcript: { ...green(), stderr: `${green().stderr} 1 pass\n` }, + }, + { + label: 'multiple files', + transcript: { + ...green(), + stderr: green().stderr.replace('1 file.', '2 files.'), + }, + }, + { + label: 'unrelated failing test', + transcript: { + ...red(), + stderr: red().stderr.replace(`(fail) ${name}`, '(fail) unrelated guard'), + }, + }, + { + label: 'extra failing test', + transcript: { + ...red(), + stderr: `${red().stderr}(fail) unrelated guard [1.00ms]\n`, + }, + }, + { label: 'wrong child status', transcript: { ...red(), exitCode: 0 } }, + { + label: 'compilation failure with assertion-looking source', + transcript: { + ...red(), + stderr: `${red().stderr}error: Unexpected token\n`, + }, + }, + { + label: 'import failure with assertion-looking source', + transcript: { + ...red(), + stderr: `${red().stderr}error: Cannot find module missing.ts\n`, + }, + }, + { + label: 'runtime failure', + transcript: { + ...red(), + stderr: red().stderr.replace( + 'error: expect(received).toBe(expected)', + 'TypeError: not a function', + ), + }, + }, + { + label: 'hook error after assertion', + transcript: { + ...red(), + stderr: `${red().stderr}error: afterEach failed\n`, + }, + }, + { + label: 'hook error summary', + transcript: { ...red(), stderr: `${red().stderr} 1 error\n` }, + }, + { + label: 'test timeout', + transcript: { + ...red(), + stderr: `${red().stderr}Test timed out after 5000ms\n`, + }, + }, + { + label: 'hook timeout', + transcript: { + ...red(), + stderr: `${red().stderr}beforeEach hook timed out\n`, + }, + }, + { + label: 'unhandled error', + transcript: { ...red(), stderr: `${red().stderr}Unhandled rejection\n` }, + }, + { label: 'signal death', transcript: { ...red(), signal: 'SIGSEGV' } }, + { label: 'crash status', transcript: { ...red(), exitCode: 139 } }, + { + label: 'refused child invocation', + transcript: { ...red(), exitCode: 126 }, + }, + { + label: 'missing executable status', + transcript: { ...red(), exitCode: 127 }, + }, + { + label: 'absent assertion', + transcript: { + ...red(), + stderr: red().stderr.replace( + 'error: expect(received).toBe(expected)', + '', + ), + }, + }, + { + label: 'zero assertions', + transcript: { + ...green(), + stderr: green().stderr.replace('1 expect()', '0 expect()'), + }, + }, + { + label: 'skipped result', + transcript: { + ...green(), + stderr: green().stderr.replace('(pass)', '(skip)'), + }, + }, + { + label: 'skipped count', + transcript: { ...green(), stderr: `${green().stderr} 1 skip\n` }, + }, + { label: 'missing version', transcript: { ...green(), stdout: '' } }, + { + label: 'unpinned version', + transcript: { ...green(), stdout: header.replace('1.3.14', '1.3.13') }, + }, + { + label: 'ambiguous version', + transcript: { ...green(), stdout: `${header}${header}` }, + }, + { + label: 'spoofed wrapper count', + transcript: { ...green(), stdout: `${header}CKDEV executed 1 tests\n` }, + }, +] +for (const { label, transcript } of invalid) { + test(`refuses ${label} instead of counting an assertion-looking transcript`, () => { + expect(() => executedCount(name, transcript)).toThrow() + }) +} + +function bytes(text: string) { + return new ReadableStream({ + start(controller) { + const buffer = Buffer.from(text) + // Split within UTF-8 as well as line boundaries, retaining original bytes. + for (const byte of buffer) controller.enqueue(Uint8Array.of(byte)) + controller.close() + }, + }) +} +function childFor(transcript: Transcript): OwnedChild { + return { + stdout: bytes(transcript.stdout), + stderr: bytes(transcript.stderr), + exited: Promise.resolve(transcript.exitCode), + signalCode: transcript.signal, + kill: () => { + throw new Error('Completed fixture must not be killed') + }, + } +} +function sinks() { + const stdout: Uint8Array[] = [] + const stderr: Uint8Array[] = [] + return { + out: () => Buffer.concat(stdout).toString('utf8'), + err: () => Buffer.concat(stderr).toString('utf8'), + stdout: (chunk: Uint8Array) => { + stdout.push(chunk) + }, + stderr: (chunk: Uint8Array) => { + stderr.push(chunk) + }, + } +} + +test('preserves raw bytes on their original pipes and the genuine child status', async () => { + for (const transcript of [green(), red()]) { + const output = sinks() + let argv: string[] = [] + let cwd = '' + const status = await runTest( + file, + name, + { + ...output, + spawn: (args, directory) => { + argv = args + cwd = directory + return childFor(transcript) + }, + }, + new AbortController().signal, + ) + expect(status).toBe(transcript.exitCode) + expect(output.out()).toBe(`${transcript.stdout}CKDEV executed 1 tests\n`) + expect(output.err()).toBe(transcript.stderr) + expect(argv).toEqual([ + process.execPath, + 'test', + '--no-orphans', + '--config', + './bunfig.toml', + './src/tests/fast.test.ts', + '--test-name-pattern', + exactPattern(name), + ]) + expect(cwd).toBe( + `${import.meta.dir.replace('/scripts/tests', '')}/packages/core`, + ) + } + const api = selectionFor(apiFile, apiName) + const output = sinks() + const transcript = green(api.name) + expect( + await runTest( + apiFile, + apiName, + { ...output, spawn: () => childFor(transcript) }, + new AbortController().signal, + ), + ).toBe(0) + expect(output.err()).toBe(transcript.stderr) +}) + +test('invalid names and unsafe paths refuse before spawning', async () => { + const inputs = [ + { file, name: '' }, + { file, name: ' name' }, + { file, name: 'name ' }, + { file, name: 'name\n' }, + { file, name: 'name\u0000' }, + { file, name: 'name\u0085' }, + { file: '../fast.test.ts', name }, + { file: '/tmp/fast.test.ts', name }, + { file: 'packages/core/../pi/src/tests/convert.test.ts', name }, + { file: 'packages/core/src/tests/setup.ts', name }, + { file: 'constructor', name }, + ] + for (const input of inputs) { + const output = sinks() + expect( + await runTest( + input.file, + input.name, + { + ...output, + spawn: () => { + throw new Error('Must not spawn') + }, + }, + new AbortController().signal, + ), + ).toBe(126) + expect(output.err()).not.toContain('Must not spawn') + expect(output.out()).toBe('') + } +}) + +test('spawn failure is refused without a count record', async () => { + const output = sinks() + expect( + await runTest( + file, + name, + { + ...output, + spawn: () => { + throw new Error('ENOENT') + }, + }, + new AbortController().signal, + ), + ).toBe(126) + expect(output.err()).toContain('ENOENT') + expect(output.out()).toBe('') +}) + +test('all malformed transcript fixtures return 126 and preserve raw output', async () => { + for (const { transcript } of invalid) { + const output = sinks() + expect( + await runTest( + file, + name, + { ...output, spawn: () => childFor(transcript) }, + new AbortController().signal, + ), + ).toBe(126) + expect(output.out()).toBe(transcript.stdout) + expect(output.err().startsWith(transcript.stderr)).toBe(true) + expect(output.err()).toContain('CKDEV ERROR:') + } +}) + +test('cancellation kills owned work before joining pending pipes and body', async () => { + const output = sinks() + const abort = new AbortController() + const events: string[] = [] + let stopOut = () => {} + let stopErr = () => {} + let stopBody = () => {} + const child: OwnedChild = { + stdout: new ReadableStream({ + start(controller) { + stopOut = () => { + events.push('stdout done') + controller.close() + } + }, + }), + stderr: new ReadableStream({ + start(controller) { + stopErr = () => { + events.push('stderr done') + controller.close() + } + }, + }), + exited: new Promise((resolve) => { + stopBody = () => { + events.push('body done') + resolve(137) + } + }), + signalCode: 'SIGKILL', + kill: () => { + events.push('kill') + stopOut() + stopErr() + stopBody() + }, + } + const running = runTest( + file, + name, + { ...output, spawn: () => child }, + abort.signal, + ) + abort.abort() + expect(await running).toBe(126) + expect(events).toEqual(['kill', 'stdout done', 'stderr done', 'body done']) + expect(output.err()).toContain('Invocation cancelled') + expect(output.out()).toBe('') +}) + +test('pipe failure kills owned work and all-settled joins the other pipe and exit', async () => { + const output = sinks() + let killed = false + let closeOther = () => {} + let finish = () => {} + const child: OwnedChild = { + stdout: new ReadableStream({ + start(controller) { + controller.error(new Error('pipe broken')) + }, + }), + stderr: new ReadableStream({ + start(controller) { + closeOther = () => { + controller.enqueue(Buffer.from('raw tail\n')) + controller.close() + } + }, + }), + exited: new Promise((resolve) => { + finish = () => resolve(137) + }), + signalCode: 'SIGKILL', + kill: () => { + killed = true + closeOther() + finish() + }, + } + expect( + await runTest( + file, + name, + { ...output, spawn: () => child }, + new AbortController().signal, + ), + ).toBe(126) + expect(killed).toBe(true) + expect(output.err()).toBe('raw tail\nCKDEV ERROR: Error: pipe broken\n') +}) + +test('exit-promise rejection terminates pending pipes before joining them', async () => { + const output = sinks() + let closed = false + let closeOut = () => {} + let closeErr = () => {} + const child: OwnedChild = { + stdout: new ReadableStream({ + start(controller) { + closeOut = () => controller.close() + }, + }), + stderr: new ReadableStream({ + start(controller) { + closeErr = () => controller.close() + }, + }), + exited: Promise.reject(new Error('wait failed')), + signalCode: null, + kill: () => { + closed = true + closeOut() + closeErr() + }, + } + expect( + await runTest( + file, + name, + { ...output, spawn: () => child }, + new AbortController().signal, + ), + ).toBe(126) + expect(closed).toBe(true) + expect(output.err()).toContain('wait failed') +}) + +const junitStart = + '\n\n \n' +function junit(caseLines: string[]) { + return `${junitStart}${caseLines.join('\n')}\n \n\n` +} +function testcase(leaf: string, classname = '', suffix = ' />') { + return ` second [2.00ms]\n\n 2 pass\n 0 fail\n 2 expect() calls\nRan 2 tests across 1 file. [10.00ms]\n', + exitCode: 0, + signal: null, + } +} + +test('package events count only executed results, not a passing timeout title', () => { + expect(executedCount(undefined, packageGreen())).toBe(2) + expect(() => executedCount('handles timeout', packageGreen())).toThrow() + expect(() => + executedCount(undefined, { + ...packageGreen(), + stderr: packageGreen().stderr.replace(' 2 pass', ' 3 pass'), + }), + ).toThrow() + expect(() => + executedCount(undefined, { + ...packageGreen(), + stderr: packageGreen().stderr.replace('Ran 2 tests', 'Ran 3 tests'), + }), + ).toThrow() +}) + +test('normalizes only JUnit name attributes and preserves native named row identities', () => { + const fullName = + 'buildAnthropicRequest — Fable/Mythos thinking > does not add Fable 5.1 binding controls to an API-key request' + const caseLines = [ + testcase( + 'refuses unknown or meaningful content 0 without mutating history', + ), + testcase( + 'does not add Fable 5.1 binding controls to an API-key request', + 'buildAnthropicRequest — Fable/Mythos thinking', + ), + ] + const raw = junit(caseLines) + const events = [ + event('refuses unknown or meaningful content 0 without mutating history'), + event(fullName), + ] + const normalized = normalizeReport(raw, events) + expect(normalized.ids).toEqual([ + 'refuses unknown or meaningful content 0 without mutating history', + fullName, + ]) + expect(normalized.xml).toBe( + raw.replace( + 'name="does not add Fable 5.1 binding controls to an API-key request"', + 'name="buildAnthropicRequest — Fable/Mythos thinking > does not add Fable 5.1 binding controls to an API-key request"', + ), + ) + expect(normalizeReport(raw, events)).toEqual(normalized) + expect( + normalizeReport( + junit([ + testcase('fast mode eligibility for claude-opus-4-7[1m] is false'), + ]), + [event('fast mode eligibility for claude-opus-4-7[1m] is false')], + ).ids, + ).toEqual(['fast mode eligibility for claude-opus-4-7[1m] is false']) +}) + +test('XML entity and dollar punctuation normalization cannot rewrite result data', () => { + const raw = junit([ + testcase( + 'a & "b" 'c' $& A', + 'scope <x>', + '>', + ), + ' ', + ' ', + ]) + const normalized = normalizeReport(raw, [ + event('scope > a & "b" \'c\' $& A', 'fail'), + ]) + expect(normalized.ids).toEqual(['scope > a & "b" \'c\' $& A']) + expect(normalized.xml).toContain('classname="scope <x>"') + expect(normalized.xml.slice(normalized.xml.indexOf(' { + const raw = junit([ + testcase('Pi history empty user=%s'), + testcase('Pi history empty user=%s', '', '>'), + ' original body', + ' ', + ]) + const events = [ + event('Pi history empty user=%s'), + event('Pi history empty user=%s', 'fail', 2), + ] + const normalized = normalizeReport(raw, events) + expect(normalized.ids).toEqual([ + 'Pi history empty user=%s [case 1 at src/tests/fixture.test.ts:12]', + 'Pi history empty user=%s [case 2 at src/tests/fixture.test.ts:12]', + ]) + expect(normalized.xml).toContain( + 'original body', + ) + expect(normalized.xml.match(/ { + const raw = junit([ + testcase('dup'), + testcase('dup'), + testcase('dup [case 1 at src/tests/fixture.test.ts:12]'), + ]) + expect(() => + normalizeReport(raw, [ + event('dup'), + event('dup', 'pass', 2), + event('dup [case 1 at src/tests/fixture.test.ts:12]'), + ]), + ).toThrow('Colliding normalized JUnit IDs') +}) + +test('missing, empty, garbage and mismatched report identities refuse', () => { + for (const raw of [ + '', + 'garbage', + junit([]), + junit([testcase('one')]).replace('', ''), + junit([testcase('one')]).replace('classname=""', ''), + junit([testcase('one')]).replace('name="one"', 'name="one" name="two"'), + junit([testcase('bad &unknown;')]), + ]) { + expect(() => normalizeReport(raw, [event('one')])).toThrow() + } + expect(() => + normalizeReport(junit([testcase('one')]), [event('one'), event('two')]), + ).toThrow() +}) + +test('package/report selection cannot escape reviewed unit configs or report paths', () => { + expect(packageSelection('packages/core', 'tmp/mutations/core.xml')).toEqual({ + cwd: 'packages/core', + file: 'src/tests', + report: 'tmp/mutations/core.xml', + }) + for (const [pkg, report] of [ + ['packages/opencode', 'tmp/mutations/core.xml'], + ['packages/core', '../core.xml'], + ['packages/pi', '/tmp/pi.xml'], + ['packages/pi', 'tmp/mutations/core.xml'], + ]) { + expect(() => packageSelection(pkg ?? '', report ?? '')).toThrow() + } +}) + +test('broad dispatch uses full package argv and writes only verified nonempty reports', async () => { + const output = sinks() + let actualArgv: string[] = [] + let actualIds: string[] = [] + const raw = junit([testcase('handles timeout'), testcase('second', 'scope')]) + expect( + await runPackage( + 'packages/core', + 'tmp/mutations/core.xml', + { + ...output, + spawn: (argv) => { + actualArgv = argv + return childFor(packageGreen()) + }, + report: { + read: async () => raw, + write: async (_path, normalized) => { + actualIds = normalized.ids + }, + }, + }, + new AbortController().signal, + ), + ).toBe(0) + expect(actualArgv.slice(0, 6)).toEqual([ + process.execPath, + 'test', + '--no-orphans', + '--config', + './bunfig.toml', + './src/tests', + ]) + expect(actualArgv).toContain('--reporter=junit') + expect(actualArgv.some((arg) => arg.includes('test-name-pattern'))).toBe( + false, + ) + expect(actualIds).toEqual(['handles timeout', 'scope > second']) + expect(output.out()).toBe(`${header}CKDEV executed 2 tests\n`) +}) + +test('missing and empty broad reports return 126 without a count or report write', async () => { + for (const raw of ['', junit([]), 'not XML']) { + const output = sinks() + let written = false + expect( + await runPackage( + 'packages/core', + 'tmp/mutations/core.xml', + { + ...output, + spawn: () => childFor(packageGreen()), + report: { + read: async () => raw, + write: async () => { + written = true + }, + }, + }, + new AbortController().signal, + ), + ).toBe(126) + expect(written).toBe(false) + expect(output.out()).toBe(header) + expect(output.err()).toContain('CKDEV ERROR:') + } + const output = sinks() + expect( + await runPackage( + 'packages/core', + 'tmp/mutations/core.xml', + { + ...output, + spawn: () => childFor(packageGreen()), + report: { + read: async () => { + throw new Error('ENOENT report') + }, + write: async () => { + throw new Error('Must not write') + }, + }, + }, + new AbortController().signal, + ), + ).toBe(126) + expect(output.err()).toContain('ENOENT report') +}) + +function multiRed(): Transcript { + return { + ...red(), + stderr: red() + .stderr.replace( + 'src/tests/fast.test.ts:\n', + 'src/tests/fast.test.ts:\n(pass) healthy companion [1.00ms]\n', + ) + .replace( + ' 0 pass\n', + `1 tests failed:\n(fail) ${name} [2.11ms]\n\n 1 pass\n`, + ) + .replace('Ran 1 test across', 'Ran 2 tests across'), + } +} + +test('real multi-test failure recap is verified but never counted twice', () => { + expect(executedCount(undefined, multiRed())).toBe(2) + expect(() => executedCount(name, multiRed())).toThrow() + expect(() => + executedCount(undefined, { + ...multiRed(), + stderr: multiRed().stderr.replace('1 tests failed:', '2 tests failed:'), + }), + ).toThrow() + expect(() => + executedCount(undefined, { + ...multiRed(), + stderr: `${multiRed().stderr}(fail) unrelated recap [2.11ms]\n`, + }), + ).toThrow() + expect(() => + executedCount(undefined, { + ...multiRed(), + stderr: multiRed().stderr.replace( + '1 tests failed:', + '1 tests failed:\n1 tests failed:', + ), + }), + ).toThrow() +}) + +test('package assertion failures retain a red case and a healthy companion in JUnit', async () => { + const output = sinks() + const raw = junit([ + testcase('healthy companion'), + testcase(name, '', '>'), + ' Expected: false; Received: true', + ' ', + ]).replaceAll( + 'file="src/tests/fixture.test.ts"', + 'file="src/tests/fast.test.ts"', + ) + let written = '' + expect( + await runPackage( + 'packages/core', + 'tmp/mutations/core.xml', + { + ...output, + spawn: () => childFor(multiRed()), + report: { + read: async () => raw, + write: async (_path, normalized) => { + written = normalized.xml + }, + }, + }, + new AbortController().signal, + ), + ).toBe(1) + expect(written).toContain( + 'Expected: false; Received: true', + ) + expect(written).toContain('name="healthy companion"') + expect(output.out()).toBe(`${header}CKDEV executed 2 tests\n`) +}) + +function consistencyTranscript(): Transcript { + return { + stdout: header, + stderr: + '\nsrc/tests/synthetic.test.ts:\nerror: expect(received).toBe(expected)\n(fail) selected mutation assertion [1.00ms]\n(pass) healthy companion [1.00ms]\n\n 1 pass\n 1 fail\n 2 expect() calls\nRan 2 tests across 1 file. [2.00ms]\n', + exitCode: 1, + signal: null, + } +} +function consistencyXml( + selectedFails: boolean, + companionFails: boolean, + selectedName = 'selected mutation assertion', +) { + const selected = `\n\n' : ' />'}` + const companion = `\n\n' : ' />'}` + return `\n\n\n${selected}\n${companion}\n\n\n` +} +async function runConsistencyFixture(raw: string) { + const output = sinks() + let written = false + let retained = '' + const transcript = consistencyTranscript() + const status = await runPackage( + 'packages/core', + 'tmp/mutations/core.xml', + { + ...output, + spawn: () => childFor(transcript), + report: { + read: async () => { + retained = raw + return raw + }, + write: async () => { + written = true + }, + }, + }, + new AbortController().signal, + ) + return { + status, + written, + retained, + stdout: output.out(), + stderr: output.err(), + transcript, + } +} + +test('accepts matching console and JUnit failure attribution with a healthy companion', async () => { + const result = await runConsistencyFixture(consistencyXml(true, false)) + expect(result.status).toBe(1) + expect(result.written).toBe(true) + expect(result.stdout).toBe(`${header}CKDEV executed 2 tests\n`) +}) + +test('refuses JUnit erased failure despite matching aggregate test count', async () => { + const raw = consistencyXml(false, false) + const result = await runConsistencyFixture(raw) + expect(result.status).toBe(126) + expect(result.written).toBe(false) + expect(result.stdout).toBe(header) + expect(result.stderr.startsWith(result.transcript.stderr)).toBe(true) + expect(result.retained).toBe(raw) +}) + +test('refuses JUnit failure moved to the healthy companion despite matching aggregate counts', async () => { + const raw = consistencyXml(false, true) + const result = await runConsistencyFixture(raw) + expect(result.status).toBe(126) + expect(result.written).toBe(false) + expect(result.stdout).toBe(header) + expect(result.stderr.startsWith(result.transcript.stderr)).toBe(true) + expect(result.retained).toBe(raw) +}) + +test('refuses changed JUnit identities despite matching aggregate counts and outcomes', async () => { + const raw = consistencyXml(true, false, 'unrelated assertion') + const result = await runConsistencyFixture(raw) + expect(result.status).toBe(126) + expect(result.written).toBe(false) + expect(result.stdout).toBe(header) + expect(result.stderr.startsWith(result.transcript.stderr)).toBe(true) + expect(result.retained).toBe(raw) +}) + +test('console events retain source file, exact name, outcome and occurrence before the recap', () => { + expect(parseExecution(undefined, multiRed()).events).toEqual([ + { + file: 'src/tests/fast.test.ts', + name: 'healthy companion', + status: 'pass', + occurrence: 1, + }, + { file: 'src/tests/fast.test.ts', name, status: 'fail', occurrence: 1 }, + ]) +}) + +test('refuses changed JUnit source files with unchanged names counts and outcomes', async () => { + const raw = consistencyXml(true, false).replaceAll( + 'file="src/tests/synthetic.test.ts"', + 'file="src/tests/other.test.ts"', + ) + const result = await runConsistencyFixture(raw) + expect(result.status).toBe(126) + expect(result.written).toBe(false) + expect(result.stdout).toBe(header) + expect(result.retained).toBe(raw) +}) + +test('refuses a failure shifted between repeated Bun cases at the same source location', async () => { + const duplicateName = + 'Pi refuses meaningful assistant history locally (fixture-api-key, empty user=%s)' + const sourceFile = 'src/tests/request-history-stream.test.ts' + const transcript: Transcript = { + stdout: header, + stderr: `\n${sourceFile}:\n(pass) ${duplicateName} [1.00ms]\nerror: expect(received).toBe(expected)\n(fail) ${duplicateName} [1.00ms]\n\n1 tests failed:\n(fail) ${duplicateName} [1.00ms]\n\n 1 pass\n 1 fail\n 2 expect() calls\nRan 2 tests across 1 file. [2.00ms]\n`, + exitCode: 1, + signal: null, + } + const cases = [ + testcase(duplicateName), + testcase(duplicateName, '', '>'), + ' ', + ' ', + ] + const matching = junit(cases) + .replaceAll('src/tests/fixture.test.ts', sourceFile) + .replaceAll('line="12"', 'line="62"') + const observed = parseExecution(undefined, transcript) + expect(observed.events).toEqual([ + { name: duplicateName, file: sourceFile, status: 'pass', occurrence: 1 }, + { name: duplicateName, file: sourceFile, status: 'fail', occurrence: 2 }, + ]) + expect(normalizeReport(matching, observed.events).ids).toEqual([ + `${duplicateName} [case 1 at ${sourceFile}:62]`, + `${duplicateName} [case 2 at ${sourceFile}:62]`, + ]) + const moved = junit([ + testcase(duplicateName, '', '>'), + ' ', + ' ', + testcase(duplicateName), + ]) + .replaceAll('src/tests/fixture.test.ts', sourceFile) + .replaceAll('line="12"', 'line="62"') + const output = sinks() + let written = false + let retained = '' + expect( + await runPackage( + 'packages/pi', + 'tmp/mutations/pi.xml', + { + ...output, + spawn: () => childFor(transcript), + report: { + read: async () => { + retained = moved + return moved + }, + write: async () => { + written = true + }, + }, + }, + new AbortController().signal, + ), + ).toBe(126) + expect(written).toBe(false) + expect(retained).toBe(moved) + expect(output.out()).toBe(header) + expect(output.err().startsWith(transcript.stderr)).toBe(true) +}) From 0e6315148406c088f38ff18ca994a6286c62d4cd Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:50:06 +0200 Subject: [PATCH 047/169] ci: add pinned mutation guard replay and nightly breadth --- .github/workflows/mutations.yml | 52 ++++ docs/testing/mutations.md | 21 ++ scripts/run-mutation-ci.ts | 200 ++++++++++++++ scripts/tests/run-mutation-ci.test.ts | 359 ++++++++++++++++++++++++++ 4 files changed, 632 insertions(+) create mode 100644 .github/workflows/mutations.yml create mode 100644 scripts/run-mutation-ci.ts create mode 100644 scripts/tests/run-mutation-ci.test.ts diff --git a/.github/workflows/mutations.yml b/.github/workflows/mutations.yml new file mode 100644 index 000000000..786e3d348 --- /dev/null +++ b/.github/workflows/mutations.yml @@ -0,0 +1,52 @@ +name: Mutation guards + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mutation-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + guards: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + MUTATION_EVENT: ${{ github.event_name }} + MUTATION_BASE: ${{ github.event.pull_request.base.sha }} + MUTATION_HOME: ${{ github.workspace }}/node_modules/.cache/ckdev-mutate-0.9.0 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4 + - run: bun install --frozen-lockfile + - name: Install pinned mutation runner + shell: bash + run: | + CARGO_HOME="$MUTATION_HOME/cargo-home" \ + CARGO_TARGET_DIR="$MUTATION_HOME/target" \ + cargo install --locked --git https://github.com/cortexkit/commons \ + --rev dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73 \ + --root "$MUTATION_HOME/install" cortexkit-mutate + echo "$MUTATION_HOME/install/bin" >> "$GITHUB_PATH" + - name: Verify CI selection and report guards + run: bun test scripts/tests/run-mutation-ci.test.ts scripts/tests/run-mutation-bun.test.ts + - name: Replay mutation guards + run: bun scripts/run-mutation-ci.ts + - name: Retain reports and raw test evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mutation-${{ github.run_id }}-${{ github.run_attempt }} + path: tmp/mutations/ + if-no-files-found: warn diff --git a/docs/testing/mutations.md b/docs/testing/mutations.md index 9a3df9af7..e47262f86 100644 --- a/docs/testing/mutations.md +++ b/docs/testing/mutations.md @@ -199,3 +199,24 @@ the source hashes and exact-byte restoration checks, the staged source diff, and the runner's row reports. Store the archive outside ephemeral build caches so reviewers can inspect the evidence and source comments before a commit. CI integration is separate from this catalogue. + +## Automated replay + +The read-only `Mutation guards` workflow uses a separate checkout from the regular +CI job. Pull requests run `ckdev-mutate run --diff `, comparing the committed +base to `HEAD`. The runner selects changed row targets, guarding test files and +parsed catalogue entries. Root prebuild changes also select all rows. + +Changes to the Bun wrapper, its self-tests, the CI driver/workflow or shared test +configuration force `--all`, because the runner does not follow helper dependencies. +Main pushes run all named checks. Scheduled and manually requested jobs also use +`--broad` for full Core/Pi package audits. These domains still exclude OpenCode, +workspace-wide and process E2E collateral. + +The CI driver verifies the runner version and requires tests to detect each +mutation (`CAUGHT`, `CAUGHT_BROADLY` or `HUB`), rather than accepting skipped or +equivalent rows. Full replays must report every configured row. A PR with no affected +rows may report an empty selection; it is not recorded as a caught mutation. +Reports, invocation/status records and raw test evidence are uploaded even when +a replay fails. Runner installation is pinned to the public commons revision and +kept in checkout-local ignored storage; no private repository token is required. diff --git a/scripts/run-mutation-ci.ts b/scripts/run-mutation-ci.ts new file mode 100644 index 000000000..5dd82c33e --- /dev/null +++ b/scripts/run-mutation-ci.ts @@ -0,0 +1,200 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +export const MUTATION_RUNNER_VERSION = 'ckdev-mutate 0.9.0' +export const MUTATION_RUNNER_REVISION = + 'dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73' + +const forceAllPaths = new Set([ + 'scripts/run-mutation-bun.ts', + 'scripts/tests/run-mutation-bun.test.ts', + 'scripts/run-mutation-ci.ts', + 'scripts/tests/run-mutation-ci.test.ts', + '.github/workflows/mutations.yml', + 'package.json', + 'bun.lock', + 'mise.toml', + 'tsconfig.scripts.json', + 'packages/core/package.json', + 'packages/core/bunfig.toml', + 'packages/core/src/tests/setup.ts', + 'packages/core/src/tests/test-lifetime.ts', + 'bunfig.toml', + 'packages/pi/package.json', + 'packages/pi/bunfig.toml', + 'packages/pi/src/tests/setup.ts', +]) + +export function mutationSelection( + event: string, + base: string | undefined, + changedPaths: readonly string[], +): string[] { + switch (event) { + case 'pull_request': + if (!base || !/^[a-f0-9]{40}$/.test(base)) + throw new Error('Pull-request base must be a full commit SHA') + // The runner follows row targets, not helper dependencies. Changes to + // the wrapper, its tests or shared test configuration need all rows. + return changedPaths.some((path) => forceAllPaths.has(path)) + ? ['--all'] + : ['--diff', base] + case 'push': + return ['--all'] + case 'schedule': + case 'workflow_dispatch': + return ['--all', '--broad'] + default: + throw new Error('Unsupported mutation workflow event') + } +} + +export function verifyMutationReport( + value: unknown, + selection: readonly string[], + expectedIds: readonly string[], +) { + if (!Array.isArray(value)) throw new Error('Mutation report must be an array') + if (!value.length && !selection.includes('--diff')) + throw new Error('Full mutation replay produced no rows') + const expected = new Set(expectedIds) + if (!expected.size || expected.size !== expectedIds.length) + throw new Error('Invalid catalogue row IDs') + const ids = new Set() + for (const entry of value) { + const row: unknown = entry + if ( + !row || + typeof row !== 'object' || + !('id' in row) || + !('outcome' in row) || + !('breadth_observed' in row) || + typeof row.id !== 'string' || + !row.id || + ids.has(row.id) || + typeof row.outcome !== 'string' || + !row.outcome || + typeof row.breadth_observed !== 'boolean' + ) + throw new Error('Malformed or duplicate mutation report row') + if ( + !expected.has(row.id) || + !['CAUGHT', 'CAUGHT_BROADLY', 'HUB'].includes(row.outcome) + ) + throw new Error('Unexpected or uncaught mutation row') + if (row.outcome === 'CAUGHT_BROADLY' && !row.breadth_observed) + throw new Error('Broad catch is missing breadth evidence') + if (selection.includes('--broad') && !row.breadth_observed) + throw new Error('Requested mutation breadth was not observed') + ids.add(row.id) + } + if (selection.includes('--all') && ids.size !== expected.size) + throw new Error('Full replay omitted catalogue rows') + return value.length +} + +function git(args: string[]): string { + const child = Bun.spawnSync(['git', ...args], { + stdout: 'pipe', + stderr: 'pipe', + }) + if (child.exitCode !== 0 || child.signalCode) + throw new Error('Cannot verify committed mutation input') + return child.stdout.toString() +} + +if (import.meta.main) { + try { + if (Bun.version !== '1.3.14') + throw new Error('Mutation CI requires Bun 1.3.14') + if (process.env.GITHUB_ACTIONS !== 'true') + throw new Error( + 'Mutation CI must run in an isolated GitHub Actions checkout', + ) + const event = process.env.MUTATION_EVENT ?? '' + const base = process.env.MUTATION_BASE + git(['diff', '--exit-code', 'HEAD', '--']) + const changed = + event === 'pull_request' + ? git([ + 'diff', + '--name-only', + '-z', + base && /^[a-f0-9]{40}$/.test(base) ? base : 'INVALID_BASE', + 'HEAD', + '--', + ]) + .split('\0') + .filter(Boolean) + : [] + const selection = mutationSelection(event, base, changed) + const version = Bun.spawnSync(['ckdev-mutate', '--version'], { + stdout: 'pipe', + stderr: 'pipe', + }) + if ( + version.exitCode !== 0 || + version.signalCode || + version.stdout.toString().trim() !== MUTATION_RUNNER_VERSION + ) + throw new Error( + 'Installed mutation runner does not match the pinned version', + ) + const directory = resolve('tmp/mutations') + await mkdir(directory, { recursive: true }) + const artifact = await mkdtemp(resolve(directory, 'ci-')) + const report = resolve(artifact, 'rows.json') + const argv = ['ckdev-mutate', 'run', ...selection, '--report', report] + await writeFile( + resolve(artifact, 'invocation.json'), + JSON.stringify( + { + event, + base, + changed, + argv, + runnerRevision: MUTATION_RUNNER_REVISION, + }, + null, + 2, + ), + ) + const child = Bun.spawn(argv, { stdout: 'inherit', stderr: 'inherit' }) + const status = await child.exited + await writeFile( + resolve(artifact, 'status.json'), + JSON.stringify({ exitCode: status, signal: child.signalCode }), + ) + if (status !== 0 || child.signalCode) + throw new Error( + 'Mutation runner failed; inspect retained logs and report', + ) + let value: unknown + try { + value = JSON.parse(await readFile(report, 'utf8')) + } catch { + throw new Error('Missing or invalid mutation JSON report') + } + const catalogue = Bun.TOML.parse(await readFile('mutations.toml', 'utf8')) + if (!('control' in catalogue) || !Array.isArray(catalogue.control)) + throw new Error('Missing catalogue controls') + const expectedIds = catalogue.control.map((entry: unknown) => { + const control = entry + if ( + !control || + typeof control !== 'object' || + !('id' in control) || + typeof control.id !== 'string' + ) + throw new Error('Malformed catalogue control') + return control.id + }) + const rows = verifyMutationReport(value, selection, expectedIds) + console.log( + `Mutation CI passed: ${rows} selected rows; breadth ${selection.includes('--broad') ? 'observed' : 'not requested'}`, + ) + } catch (error) { + console.error(error instanceof Error ? error.message : 'Mutation CI failed') + process.exitCode = 1 + } +} diff --git a/scripts/tests/run-mutation-ci.test.ts b/scripts/tests/run-mutation-ci.test.ts new file mode 100644 index 000000000..46f656fa5 --- /dev/null +++ b/scripts/tests/run-mutation-ci.test.ts @@ -0,0 +1,359 @@ +import { expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { resolve } from 'node:path' +import { createTestLifetimeSuite } from '../../packages/core/src/tests/test-lifetime.ts' +import { + MUTATION_RUNNER_REVISION, + MUTATION_RUNNER_VERSION, + mutationSelection, + verifyMutationReport, +} from '../run-mutation-ci.ts' + +const base = 'a'.repeat(40) +const ids = ['history', 'fast', 'binding'] +const caught = (id: string, broad = false) => ({ + id, + outcome: 'CAUGHT', + breadth_observed: broad, +}) + +test('PR target edits use the official committed diff selector', () => { + expect( + mutationSelection('pull_request', base, [ + 'packages/core/src/request-history.ts', + ]), + ).toEqual(['--diff', base]) + expect(mutationSelection('pull_request', base, ['mutations.toml'])).toEqual([ + '--diff', + base, + ]) + expect(mutationSelection('pull_request', base, ['README.md'])).toEqual([ + '--diff', + base, + ]) +}) + +test('wrapper and shared configuration changes force every row', () => { + for (const path of [ + 'scripts/run-mutation-bun.ts', + 'scripts/tests/run-mutation-bun.test.ts', + 'scripts/run-mutation-ci.ts', + 'scripts/tests/run-mutation-ci.test.ts', + '.github/workflows/mutations.yml', + 'package.json', + 'bun.lock', + 'mise.toml', + 'packages/core/src/tests/setup.ts', + 'packages/core/src/tests/test-lifetime.ts', + 'bunfig.toml', + 'packages/pi/src/tests/setup.ts', + ]) + expect(mutationSelection('pull_request', base, [path])).toEqual(['--all']) +}) + +test('main runs all named checks and scheduled or manual runs require breadth', () => { + expect(mutationSelection('push', undefined, [])).toEqual(['--all']) + expect(mutationSelection('schedule', undefined, [])).toEqual([ + '--all', + '--broad', + ]) + expect(mutationSelection('workflow_dispatch', undefined, [])).toEqual([ + '--all', + '--broad', + ]) +}) + +test('unknown events and missing or injectable PR bases refuse', () => { + expect(() => mutationSelection('pull_request_target', base, [])).toThrow() + for (const invalid of [ + undefined, + '', + 'main', + '-HEAD', + base + '; echo unsafe', + 'A'.repeat(40), + ]) + expect(() => + mutationSelection('pull_request', invalid, [ + 'scripts/run-mutation-bun.ts', + ]), + ).toThrow() +}) + +test('empty diff selections succeed, but full replays cannot omit rows', () => { + expect(verifyMutationReport([], ['--diff', base], ids)).toBe(0) + expect(verifyMutationReport([caught('fast')], ['--diff', base], ids)).toBe(1) + expect(() => verifyMutationReport([], ['--all'], ids)).toThrow() + expect(() => verifyMutationReport([caught('fast')], ['--all'], ids)).toThrow() + expect( + verifyMutationReport( + ids.map((id) => caught(id)), + ['--all'], + ids, + ), + ).toBe(3) +}) + +test('missing reports, unknown IDs, duplicates and non-catches refuse', () => { + for (const value of [ + null, + {}, + [caught('other')], + [caught('fast'), caught('fast')], + [{ id: 'fast', outcome: 'SURVIVED', breadth_observed: false }], + [{ id: 'fast', outcome: 'SKIPPED_PLATFORM', breadth_observed: false }], + [{ id: 'fast', outcome: 'EQUIVALENT', breadth_observed: false }], + [{ id: 'fast', outcome: 'ERROR', breadth_observed: false }], + [{ id: 'fast', outcome: 'CAUGHT' }], + ]) + expect(() => verifyMutationReport(value, ['--diff', base], ids)).toThrow() + expect(() => + verifyMutationReport([], ['--diff', base], ['fast', 'fast']), + ).toThrow() +}) + +test('nightly breadth is required on every reported row, not just one', () => { + expect(() => + verifyMutationReport( + [caught('history', true), caught('fast'), caught('binding', true)], + ['--all', '--broad'], + ids, + ), + ).toThrow() + expect( + verifyMutationReport( + ids.map((id) => caught(id, true)), + ['--all', '--broad'], + ids, + ), + ).toBe(3) + expect(() => + verifyMutationReport( + [{ id: 'fast', outcome: 'CAUGHT_BROADLY', breadth_observed: false }], + ['--diff', base], + ids, + ), + ).toThrow() +}) + +test('workflow installs the public pinned runner without write permissions or secrets', () => { + const text = readFileSync( + new URL('../../.github/workflows/mutations.yml', import.meta.url), + 'utf8', + ) + expect(text).toContain('contents: read') + expect(text).toContain('persist-credentials: false') + expect(text).toContain(`--rev ${MUTATION_RUNNER_REVISION}`) + expect(text).toContain(MUTATION_RUNNER_VERSION.replace(' ', '-')) + expect(text).toContain('bun install --frozen-lockfile') + expect(text).toContain('if: always()') + expect(text).not.toContain('pull_request_target') + expect(text).not.toContain('secrets.') +}) + +const lifetimes = createTestLifetimeSuite() + +async function ciFixture() { + await mkdir(resolve(import.meta.dir, '../../tmp'), { recursive: true }) + const root = await mkdtemp( + resolve(import.meta.dir, '../../tmp/mutation-ci-fixture-'), + ) + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + await mkdir(resolve(root, 'scripts'), { recursive: true }) + await mkdir(resolve(root, 'bin'), { recursive: true }) + await writeFile( + resolve(root, 'scripts/run-mutation-ci.ts'), + await readFile(new URL('../run-mutation-ci.ts', import.meta.url)), + ) + await writeFile( + resolve(root, 'mutations.toml'), + ids.map((id) => `[[control]]\nid = "${id}"\n`).join('\n'), + ) + await writeFile(resolve(root, 'README.md'), 'baseline\n') + const binary = resolve(root, 'bin/ckdev-mutate') + await writeFile( + binary, + `#!/usr/bin/env bun +import { writeFileSync } from 'node:fs' +if(process.argv[2] === '--version') { console.log(process.env.FAKE_VERSION ?? 'ckdev-mutate 0.9.0'); process.exit(0) } +writeFileSync('runner-argv.json', JSON.stringify(process.argv.slice(2))) +const report = process.argv[process.argv.indexOf('--report') + 1] +const rows = process.env.FAKE_EMPTY === '1' ? [] : ['history','fast','binding'].map(id => ({ id, outcome:'CAUGHT', breadth_observed:process.argv.includes('--broad') })) +writeFileSync(report, process.env.FAKE_BAD_JSON === '1' ? 'invalid' : JSON.stringify(rows)) +process.exit(process.env.FAKE_FAILURE === '1' ? 1 : 0) +`, + ) + await chmod(binary, 0o700) + const env = { ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined } + for (const argv of [ + ['init', '-q'], + ['add', '.'], + [ + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '-qm', + 'synthetic CI baseline', + ], + ]) { + const child = Bun.spawnSync(['git', ...argv], { + cwd: root, + env, + stdout: 'pipe', + stderr: 'pipe', + }) + expect(child.exitCode).toBe(0) + } + const base = Bun.spawnSync(['git', 'rev-parse', 'HEAD'], { + cwd: root, + env, + stdout: 'pipe', + }) + .stdout.toString() + .trim() + return { root, base, env } +} + +function invokeCI( + fixture: Awaited>, + event: string, + extra: Record = {}, +) { + return Bun.spawnSync([process.execPath, 'scripts/run-mutation-ci.ts'], { + cwd: fixture.root, + env: { + ...fixture.env, + PATH: `${fixture.root}/bin:${process.env.PATH}`, + GITHUB_ACTIONS: 'true', + MUTATION_EVENT: event, + MUTATION_BASE: fixture.base, + ...extra, + }, + stdout: 'pipe', + stderr: 'pipe', + }) +} + +lifetimes.test( + 'real CLI driver preserves official diff argv in a clean synthetic repository', + async () => { + const fixture = await ciFixture() + const result = invokeCI(fixture, 'pull_request') + expect(result.exitCode).toBe(0) + const argv = JSON.parse( + await readFile(resolve(fixture.root, 'runner-argv.json'), 'utf8'), + ) + expect(argv.slice(0, 3)).toEqual(['run', '--diff', fixture.base]) + expect(result.stdout.toString()).toContain( + '3 selected rows; breadth not requested', + ) + }, +) + +lifetimes.test( + 'real CLI driver records empty diff selection without claiming a catch', + async () => { + const fixture = await ciFixture() + const result = invokeCI(fixture, 'pull_request', { FAKE_EMPTY: '1' }) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toContain('0 selected rows') + }, +) + +lifetimes.test( + 'real CLI driver rejects empty full replay, invalid report and runner failure', + async () => { + const fixture = await ciFixture() + for (const extra of [ + { FAKE_EMPTY: '1' }, + { FAKE_BAD_JSON: '1' }, + { FAKE_FAILURE: '1' }, + ]) { + const result = invokeCI(fixture, 'push', extra) + expect(result.exitCode).toBe(1) + expect(result.stdout.toString()).not.toContain('Mutation CI passed') + } + }, +) + +lifetimes.test( + 'real CLI driver validates broad mode and rejects dirty input or wrong binary', + async () => { + const fixture = await ciFixture() + expect(invokeCI(fixture, 'schedule').exitCode).toBe(0) + const argv = JSON.parse( + await readFile(resolve(fixture.root, 'runner-argv.json'), 'utf8'), + ) + expect(argv.slice(0, 3)).toEqual(['run', '--all', '--broad']) + expect( + invokeCI(fixture, 'push', { FAKE_VERSION: 'ckdev-mutate 0.8.0' }) + .exitCode, + ).toBe(1) + await writeFile(resolve(fixture.root, 'README.md'), 'uncommitted change\n') + expect(invokeCI(fixture, 'push').exitCode).toBe(1) + }, +) + +test('parsed workflow has isolated read-only PR/main/nightly jobs and retained artifacts', () => { + const workflow: unknown = Bun.YAML.parse( + readFileSync( + new URL('../../.github/workflows/mutations.yml', import.meta.url), + 'utf8', + ), + ) + const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + if ( + !record(workflow) || + !record(workflow.on) || + !record(workflow.permissions) || + !record(workflow.jobs) + ) + throw new Error('Invalid mutation workflow') + expect(workflow.permissions).toEqual({ contents: 'read' }) + expect(Object.keys(workflow.on).sort()).toEqual([ + 'pull_request', + 'push', + 'schedule', + 'workflow_dispatch', + ]) + if (!record(workflow.on.push)) throw new Error('Missing main trigger') + expect(workflow.on.push.branches).toEqual(['main']) + expect(Array.isArray(workflow.on.schedule)).toBe(true) + const job = workflow.jobs.guards + if (!record(job) || !Array.isArray(job.steps) || !record(job.env)) + throw new Error('Invalid mutation job') + expect(job.env.MUTATION_EVENT).toBe('${{ github.event_name }}') + expect(job.env.MUTATION_BASE).toBe( + '${{ github.event.pull_request.base.sha }}', + ) + const steps: unknown[] = job.steps + const checkout = steps.find( + (step) => + record(step) && + typeof step.uses === 'string' && + step.uses.startsWith('actions/checkout@'), + ) + if (!record(checkout) || !record(checkout.with)) + throw new Error('Missing isolated checkout') + expect(checkout.with['fetch-depth']).toBe(0) + expect(checkout.with['persist-credentials']).toBe(false) + const upload = steps.find( + (step) => + record(step) && + typeof step.uses === 'string' && + step.uses.startsWith('actions/upload-artifact@'), + ) + if (!record(upload) || !record(upload.with)) + throw new Error('Missing artifact retention') + expect(upload.if).toBe('always()') + expect(upload.with.path).toBe('tmp/mutations/') +}) From cb5e1efa4fa1867768de5ae9fff9f0818720a7f9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:51:00 +0200 Subject: [PATCH 048/169] test(ci): clarify literal workflow expressions --- scripts/tests/run-mutation-ci.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/tests/run-mutation-ci.test.ts b/scripts/tests/run-mutation-ci.test.ts index 46f656fa5..b416de11e 100644 --- a/scripts/tests/run-mutation-ci.test.ts +++ b/scripts/tests/run-mutation-ci.test.ts @@ -78,7 +78,7 @@ test('unknown events and missing or injectable PR bases refuse', () => { '', 'main', '-HEAD', - base + '; echo unsafe', + `${base}; echo unsafe`, 'A'.repeat(40), ]) expect(() => @@ -331,9 +331,9 @@ test('parsed workflow has isolated read-only PR/main/nightly jobs and retained a const job = workflow.jobs.guards if (!record(job) || !Array.isArray(job.steps) || !record(job.env)) throw new Error('Invalid mutation job') - expect(job.env.MUTATION_EVENT).toBe('${{ github.event_name }}') + expect(job.env.MUTATION_EVENT).toBe(`\${{ github.event_name }}`) expect(job.env.MUTATION_BASE).toBe( - '${{ github.event.pull_request.base.sha }}', + `\${{ github.event.pull_request.base.sha }}`, ) const steps: unknown[] = job.steps const checkout = steps.find( From afc0834bd63c14098c5f988bac2cb8f407041bfe Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:09:58 +0200 Subject: [PATCH 049/169] fix(ci): isolate mutation fixtures and parse native GitHub output --- scripts/run-mutation-bun.ts | 16 +++++++-- scripts/tests/run-mutation-bun.test.ts | 46 ++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/scripts/run-mutation-bun.ts b/scripts/run-mutation-bun.ts index b395fe3b6..709a9dd51 100644 --- a/scripts/run-mutation-bun.ts +++ b/scripts/run-mutation-bun.ts @@ -6,8 +6,15 @@ import { rm, writeFile, } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { resolve } from 'node:path' +// Credential fixtures check every ancestor. Use system temporary storage, +// not a checkout that may have a legitimate group-writable parent. +export async function createMutationSandbox() { + return mkdtemp(resolve(tmpdir(), 'anthropic-auth-mutation-')) +} + export function testEnvironment( sandbox: string, inherited: Record, @@ -158,11 +165,16 @@ export function parseExecution( const ordinals = new Map() const events: ExecutionEvent[] = [] for (const line of eventLines) { - const fileHeader = /^(src\/tests\/[^:\r\n]+\.test\.ts):$/.exec(line) + const fileHeader = + /^(?:::group::)?(src\/tests\/[^:\r\n]+\.test\.ts):$/.exec(line) if (fileHeader) { testFile = fileHeader[1] continue } + if (line === '::endgroup::') { + testFile = undefined + continue + } if (!/^\((?:pass|fail)\) /.test(line)) continue const match = /^\((pass|fail)\) (.+) \[\d+(?:\.\d+)?(?:ms|s)\]$/.exec(line) const status = match?.[1] @@ -616,7 +628,7 @@ if (import.meta.main) { resolve(audits, broad ? 'package-' : 'named-'), ) const retained = artifactDir - sandbox = await mkdtemp(resolve(tmp, 'mutation-bun-')) + sandbox = await createMutationSandbox() const env = testEnvironment(sandbox, process.env) const dependencies: Dependencies = { spawn: (argv, cwd) => { diff --git a/scripts/tests/run-mutation-bun.test.ts b/scripts/tests/run-mutation-bun.test.ts index 2116a1f8f..6ec6a0eeb 100644 --- a/scripts/tests/run-mutation-bun.test.ts +++ b/scripts/tests/run-mutation-bun.test.ts @@ -1,5 +1,10 @@ import { expect, test } from 'bun:test' +import { realpath, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname } from 'node:path' +import { createTestLifetimeSuite } from '../../packages/core/src/tests/test-lifetime.ts' import { + createMutationSandbox, type ExecutionEvent, exactPattern, executedCount, @@ -1060,3 +1065,44 @@ test('refuses a failure shifted between repeated Bun cases at the same source lo expect(output.out()).toBe(header) expect(output.err().startsWith(transcript.stderr)).toBe(true) }) + +const filesystemTests = createTestLifetimeSuite() +filesystemTests.test( + 'credential sandbox has a private directory under system temporary storage', + async () => { + const sandbox = await createMutationSandbox() + filesystemTests.deferCleanup(() => + rm(sandbox, { recursive: true, force: true }), + ) + expect(await realpath(dirname(sandbox))).toBe(await realpath(tmpdir())) + expect((await stat(sandbox)).mode & 0o777).toBe(0o700) + }, +) + +test('native GitHub group headers preserve exact file identity and outcome checks', () => { + const transcript = green() + const grouped = { + ...transcript, + stderr: transcript.stderr + .replace('src/tests/fast.test.ts:', '::group::src/tests/fast.test.ts:') + .replace(' 1 pass', '::endgroup::\n\n 1 pass'), + } + expect(parseExecution(name, grouped)).toEqual( + parseExecution(name, transcript), + ) + expect(executedCount(name, grouped)).toBe(1) +}) + +test('closed GitHub groups cannot attribute unrelated result lines to an earlier file', () => { + const transcript = green() + const grouped = { + ...transcript, + stderr: transcript.stderr.replace( + 'src/tests/fast.test.ts:', + '::group::src/tests/fast.test.ts:\n::endgroup::', + ), + } + expect(() => parseExecution(name, grouped)).toThrow( + 'missing test file header', + ) +}) From b00317ef82fcd1d86ac52f96fc3337b97ae0b3b3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:18:58 +0200 Subject: [PATCH 050/169] fix(ci): retain genuine negated assertion failures in breadth audits --- scripts/run-mutation-bun.ts | 4 +++- scripts/tests/run-mutation-bun.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/run-mutation-bun.ts b/scripts/run-mutation-bun.ts index 709a9dd51..a7b05fbe6 100644 --- a/scripts/run-mutation-bun.ts +++ b/scripts/run-mutation-bun.ts @@ -235,7 +235,9 @@ export function parseExecution( errors.length !== failed || errors.some( (line) => - !/^error: expect\(received\)\.[A-Za-z]+\([^\r\n]*\)$/.test(line), + !/^error: expect\(received\)\.(?:not\.)?[A-Za-z]+\([^\r\n]*\)$/.test( + line, + ), ) ) throw new Error('Not a genuine selected expect assertion failure') diff --git a/scripts/tests/run-mutation-bun.test.ts b/scripts/tests/run-mutation-bun.test.ts index 6ec6a0eeb..bea5f5a41 100644 --- a/scripts/tests/run-mutation-bun.test.ts +++ b/scripts/tests/run-mutation-bun.test.ts @@ -1106,3 +1106,25 @@ test('closed GitHub groups cannot attribute unrelated result lines to an earlier 'missing test file header', ) }) + +test('genuine negated assertion failures retain the normal selected-test contract', () => { + const transcript = red() + const negated = { + ...transcript, + stderr: transcript.stderr.replace( + 'error: expect(received).toBe(expected)', + 'error: expect(received).not.toHaveProperty(path)', + ), + } + expect(executedCount(name, negated)).toBe(1) + expect(parseExecution(name, negated).events[0]?.status).toBe('fail') + expect(() => + executedCount(name, { + ...negated, + stderr: negated.stderr.replace( + 'not.toHaveProperty', + 'not.not.toHaveProperty', + ), + }), + ).toThrow('genuine selected expect assertion') +}) From b8d20285ef64f69ebccff33b404810c88dc2cc9e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:49:05 +0200 Subject: [PATCH 051/169] docs: explain Haiku replay controls and preserved behavior --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 +- STRUCTURE.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 48e3bd02f..d37b24c67 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,7 +167,7 @@ **ThinkingBindingControls:** - Purpose: Controls how Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 handle replayed thinking whose original conversation prefix changed after host compaction or prompt transformation - Location: `packages/core/src/thinking-binding.ts` -- Pattern: Shared outgoing-body inspection recognizes non-empty signed `thinking` and `redacted_thinking` blocks. OAuth Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 continuations with adaptive thinking use `thinkingBinding.prefixMismatchBehavior`: `account-default` omits the local override and lets Anthropic handle changed-prefix thinking for the OAuth account serving the request; the plugin does not implement or infer that server policy, while `error` or `drop_block` adds `thinking.block_binding.prefix_mismatch_behavior` and beta `thinking-binding-controls-2026-08-01`. Sonnet 5.5's `between_tools` mode rejects this field and does not receive it. First turns, Mythos 5.1, older models, and API-key routes remain unchanged. +- Pattern: Shared outgoing-body inspection recognizes non-empty signed `thinking` and `redacted_thinking` blocks. When an OAuth request replays thinking from an earlier assistant response, changes to the messages, instructions or tools before that response can cause Anthropic to reject its signature. For Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 requests using adaptive thinking, `thinkingBinding.prefixMismatchBehavior` controls that case: `account-default` sends no override and leaves the decision to Anthropic, `error` requests rejection of mismatched thinking, and `drop_block` requests removal of the affected thinking blocks. Both explicit settings add `thinking.block_binding.prefix_mismatch_behavior` and beta `thinking-binding-controls-2026-08-01`. Sonnet 5.5's `between_tools` mode rejects this field and does not receive it. First turns, Mythos 5.1, older models, and API-key routes remain unchanged. **MidConversationOutputConfig:** - Purpose: Preserves Fable 5.1 prompt-cache prefixes while users change reasoning effort between turns diff --git a/CHANGELOG.md b/CHANGELOG.md index cce2f55d9..f039de010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Features -- Add Claude Haiku 5.5 (`claude-haiku-5-5`) to Core, OpenCode, and Pi with a 1M context, 128K output and native `low` through `max` adaptive effort. Preserve supported forced tool choice, remove rejected manual thinking budgets and sampling parameters, and honor OpenCode thinking opt-outs at high effort or below. Standard input/output pricing is $0.10/$0.50 per million tokens; all rates increase fivefold above 100K total input tokens, including cached input. OpenCode and Pi apply the selected pricing tier to all input, output and cached tokens in the request, not just the tokens above the threshold. `thinkingBinding.prefixMismatchBehavior` set to `error` or `drop_block` also covers replayed Haiku 5.5 thinking after earlier conversation content changes. OAuth cost zeroing, fast-mode eligibility, safety fallback and Haiku 4.5 quota priming remain unchanged. +- Add Claude Haiku 5.5 (`claude-haiku-5-5`) to Core, OpenCode, and Pi with a 1M context, 128K output and native `low` through `max` adaptive effort. Preserve supported forced tool choice, remove rejected manual thinking budgets and sampling parameters, and honor OpenCode thinking opt-outs at high effort or below. Standard input/output pricing is $0.10/$0.50 per million tokens; all rates increase fivefold above 100K total input tokens, including cached input. OpenCode and Pi apply the selected pricing tier to all input, output and cached tokens in the request, not just the tokens above the threshold. `thinkingBinding.prefixMismatchBehavior` set to `error` or `drop_block` also covers replayed Haiku 5.5 thinking after earlier conversation content changes. Subscription OAuth requests continue to show zero API cost in the host. Haiku 5.5 does not enable fast mode or automatic safety fallback, and quota priming continues to use Haiku 4.5. - Add Claude Sonnet 5.5 (`claude-sonnet-5-5`) to Core, OpenCode, and Pi with its 1M context, 128K output, and official pricing. OpenCode and Pi request readable adaptive thinking. OpenCode maps explicit thinking opt-outs to Sonnet 5.5's `between_tools` mode, removes unsupported forced tool choice, and offers native `low` through `max` effort variants. Apply configured `error` or `drop_block` behavior to replayed Sonnet 5.5 and Opus 5.5 thinking when earlier messages, instructions, or tools have changed; the default account behavior stays unchanged. Pi exposes the model's supported `low`–`max` effort levels while refusing unsupported `minimal` and unavailable `off`. diff --git a/STRUCTURE.md b/STRUCTURE.md index 242948ccd..3416d6fd2 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, a bounded in-memory map that holds the Claude Code billing-version suffix stable for each session, origin-aware thinking-signature filtering, configurable Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 adaptive-thinking 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) +- 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, Claude Code billing header construction, origin-aware thinking-signature filtering, configurable Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 adaptive-thinking 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 From 5a4d02d6098e6645649587c183f559a2643cf04e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:13:03 +0200 Subject: [PATCH 052/169] ci: adopt verified mutation runner 0.9.2 --- .github/workflows/mutations.yml | 4 ++-- scripts/run-mutation-ci.ts | 4 ++-- scripts/tests/run-mutation-ci.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/mutations.yml b/.github/workflows/mutations.yml index 786e3d348..af7a561da 100644 --- a/.github/workflows/mutations.yml +++ b/.github/workflows/mutations.yml @@ -22,7 +22,7 @@ jobs: env: MUTATION_EVENT: ${{ github.event_name }} MUTATION_BASE: ${{ github.event.pull_request.base.sha }} - MUTATION_HOME: ${{ github.workspace }}/node_modules/.cache/ckdev-mutate-0.9.0 + MUTATION_HOME: ${{ github.workspace }}/node_modules/.cache/ckdev-mutate-0.9.2 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 with: @@ -36,7 +36,7 @@ jobs: CARGO_HOME="$MUTATION_HOME/cargo-home" \ CARGO_TARGET_DIR="$MUTATION_HOME/target" \ cargo install --locked --git https://github.com/cortexkit/commons \ - --rev dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73 \ + --rev b491012199d98b09fd167e00783df297d35a62b8 \ --root "$MUTATION_HOME/install" cortexkit-mutate echo "$MUTATION_HOME/install/bin" >> "$GITHUB_PATH" - name: Verify CI selection and report guards diff --git a/scripts/run-mutation-ci.ts b/scripts/run-mutation-ci.ts index 5dd82c33e..ff7c7568e 100644 --- a/scripts/run-mutation-ci.ts +++ b/scripts/run-mutation-ci.ts @@ -1,9 +1,9 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' -export const MUTATION_RUNNER_VERSION = 'ckdev-mutate 0.9.0' +export const MUTATION_RUNNER_VERSION = 'ckdev-mutate 0.9.2' export const MUTATION_RUNNER_REVISION = - 'dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73' + 'b491012199d98b09fd167e00783df297d35a62b8' const forceAllPaths = new Set([ 'scripts/run-mutation-bun.ts', diff --git a/scripts/tests/run-mutation-ci.test.ts b/scripts/tests/run-mutation-ci.test.ts index b416de11e..b7c952ae9 100644 --- a/scripts/tests/run-mutation-ci.test.ts +++ b/scripts/tests/run-mutation-ci.test.ts @@ -183,7 +183,7 @@ async function ciFixture() { binary, `#!/usr/bin/env bun import { writeFileSync } from 'node:fs' -if(process.argv[2] === '--version') { console.log(process.env.FAKE_VERSION ?? 'ckdev-mutate 0.9.0'); process.exit(0) } +if(process.argv[2] === '--version') { console.log(process.env.FAKE_VERSION ?? ${JSON.stringify(MUTATION_RUNNER_VERSION)}); process.exit(0) } writeFileSync('runner-argv.json', JSON.stringify(process.argv.slice(2))) const report = process.argv[process.argv.indexOf('--report') + 1] const rows = process.env.FAKE_EMPTY === '1' ? [] : ['history','fast','binding'].map(id => ({ id, outcome:'CAUGHT', breadth_observed:process.argv.includes('--broad') })) From ba2c296bd25cb4be33c2edb4d7bc33317f54ad84 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:41:27 +0200 Subject: [PATCH 053/169] docs: align mutation runner installation with verified 0.9.2 --- docs/testing/mutations.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/testing/mutations.md b/docs/testing/mutations.md index e47262f86..24e8a27bc 100644 --- a/docs/testing/mutations.md +++ b/docs/testing/mutations.md @@ -7,27 +7,27 @@ while replay is active, and never point builds at a live host installation. ## Toolchain and scope -Use Bun **1.3.14** and **ckdev-mutate 0.9.0**, pinned to commons -`dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73` (the reviewed release). -`ckdev-mutate 0.9.0` passes descriptive test names as single arguments, including +Use Bun **1.3.14** and **ckdev-mutate 0.9.2**, pinned to the [CortexKit Commons repository](https://github.com/cortexkit/commons) +at `b491012199d98b09fd167e00783df297d35a62b8` (the reviewed release). +`ckdev-mutate 0.9.2` passes descriptive test names as single arguments, including interior spaces, and reads JUnit results from the full owning-package test run. The rows need no alternate test names or compatibility layer. Install the runner under the checkout-local ignored directory -`node_modules/.cache/ckdev-mutate`. Keep `CARGO_HOME`, `CARGO_TARGET_DIR` and the +`node_modules/.cache/ckdev-mutate-0.9.2`. Keep `CARGO_HOME`, `CARGO_TARGET_DIR` and the install `--root` there, and do not overwrite a global executable. The command -below installs ckdev-mutate 0.9.0 from the exact commons revision above: +below installs ckdev-mutate 0.9.2 from that exact source revision: ```sh -CARGO_HOME="$PWD/node_modules/.cache/ckdev-mutate/cargo-home" \ -CARGO_TARGET_DIR="$PWD/node_modules/.cache/ckdev-mutate/target" \ +CARGO_HOME="$PWD/node_modules/.cache/ckdev-mutate-0.9.2/cargo-home" \ +CARGO_TARGET_DIR="$PWD/node_modules/.cache/ckdev-mutate-0.9.2/target" \ cargo install --locked --git https://github.com/cortexkit/commons \ - --rev dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73 \ - --root "$PWD/node_modules/.cache/ckdev-mutate/install" cortexkit-mutate + --rev b491012199d98b09fd167e00783df297d35a62b8 \ + --root "$PWD/node_modules/.cache/ckdev-mutate-0.9.2/install" cortexkit-mutate ``` -Verify that `ckdev-mutate --version` reports 0.9.0 and its Cargo install metadata -records revision `dfdb11293eba0e5f03bd5d8e73c7c4b64dae1f73`. Put its install `bin` +Verify that `ckdev-mutate --version` reports 0.9.2 and its Cargo install metadata +records revision `b491012199d98b09fd167e00783df297d35a62b8`. Put its install `bin` directory and Bun 1.3.14 on `PATH`, including for nested build scripts. These proofs send no HTTP requests to model providers and use no real credentials. From f2701de2d20113eedaf4463b4ac7d3088e113bf6 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:03:57 +0200 Subject: [PATCH 054/169] chore: adopt verified common-auth 0.11.4 --- bun.lock | 4 ++-- packages/core/package.json | 2 +- packages/core/scripts/check-native-type-closure.test.ts | 4 ++-- packages/core/scripts/check-native-type-closure.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 165629d16..821ce81a4 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "0.11.3", + "@cortexkit/common-auth": "0.11.4", }, }, "packages/e2e-tests": { @@ -237,7 +237,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.3", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-WuKDdMJDx9y9b7P/pL22wFGDuCz5JntYrb13neSLkKttyUcgUN6fa9oY3Kdj87uQ1QUN4OoKo8aA9SYUc/+7Uw=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.4", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-KTF75QolTeCZZfQjJXvzGXadHbi8H+D/E6TktilS1U0Q1RqIRfoaJN5qMcTTM4IyMKvDdHRIeYaKTT7jod1I9w=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], diff --git a/packages/core/package.json b/packages/core/package.json index 472e1c915..db32979b6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "0.11.3" + "@cortexkit/common-auth": "0.11.4" }, "dependencies": { "@cortexkit/claustrum-client": "0.6.2", diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index 792adb49a..d64956a46 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -98,7 +98,7 @@ test('rejects unsupported published producer versions before copying declaration ) await expect(closeNativeTypeDeclarations(dist, producer)).rejects.toThrow( - 'Expected published common-auth 0.11.3, got 0.9.0', + 'Expected published common-auth 0.11.4, got 0.9.0', ) expect(await readdir(dist)).not.toContain('internal-types') }) @@ -111,7 +111,7 @@ test('copies the complete published relative declaration graph including cycles producer, 'package.json', JSON.stringify({ - version: '0.11.3', + version: '0.11.4', exports: { './store': { types: './dist/store/index.d.ts' } }, }), ) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts index 06bdbd1fb..9245f2c5d 100644 --- a/packages/core/scripts/check-native-type-closure.ts +++ b/packages/core/scripts/check-native-type-closure.ts @@ -185,9 +185,9 @@ export async function closeNativeTypeDeclarations( version: string exports: Record } - if (manifest.version !== '0.11.3') + if (manifest.version !== '0.11.4') throw new Error( - `Expected published common-auth 0.11.3, got ${manifest.version}`, + `Expected published common-auth 0.11.4, got ${manifest.version}`, ) const producerDist = join(producerRoot, 'dist') const assets = join(distRoot, TYPE_ASSETS) From 1bc2f02cbe1d4c0e03fdc39eea421cab1df8e7ba Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:16:29 +0200 Subject: [PATCH 055/169] ci: gate native packaging and mutation wrappers --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f40fde78..853c0954b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: - run: bun run build - run: bun test scripts/check-pi-dist-imports.test.ts - run: bun run check:pi-dist-imports + - run: bun run --cwd packages/core test:scripts + - run: bun test scripts/tests/run-mutation-ci.test.ts scripts/tests/run-mutation-bun.test.ts - run: bun run check:claustrum-golden env: GITHUB_TOKEN: ${{ github.token }} From a5c6e47027b7b60107269fe5f059ea82320cc474 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:04:16 +0200 Subject: [PATCH 056/169] feat(core): expose captured migration journal reader --- packages/core/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c6d5042a0..f2234b2e4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -104,6 +104,8 @@ export type { } from './native-runtime.ts' export * from './oauth-profile.ts' export * from './pkce.ts' +export type { NativeMigrationJournal } from './pool-authority.ts' +export { readNativeMigrationJournal } from './pool-authority.ts' export type { NativeLocalPoolBinding } from './pool-binding.ts' export { captureNativeLocalPoolBinding } from './pool-binding.ts' export * from './pool-paths.ts' From 389fb533e0ba9dfb788f897eac9b4f1dd98c5170 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:04:16 +0200 Subject: [PATCH 057/169] feat(core): expose captured migration journal reader --- packages/core/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c6d5042a0..f2234b2e4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -104,6 +104,8 @@ export type { } from './native-runtime.ts' export * from './oauth-profile.ts' export * from './pkce.ts' +export type { NativeMigrationJournal } from './pool-authority.ts' +export { readNativeMigrationJournal } from './pool-authority.ts' export type { NativeLocalPoolBinding } from './pool-binding.ts' export { captureNativeLocalPoolBinding } from './pool-binding.ts' export * from './pool-paths.ts' From 1084066e47b2f06a1dad571820366cfbc2f331a2 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:43:25 +0200 Subject: [PATCH 058/169] feat(core): expose checked Claude menu renderer --- packages/core/src/index.ts | 2 + packages/core/src/native-ui.ts | 349 ++++++++++++++ packages/core/src/tests/native-ui.test.ts | 544 ++++++++++++++++++++++ 3 files changed, 895 insertions(+) create mode 100644 packages/core/src/native-ui.ts create mode 100644 packages/core/src/tests/native-ui.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f2234b2e4..ed58a165e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,6 +102,8 @@ export type { NativeLocalFailurePolicy, NativeLocalFailureSkipReason, } from './native-runtime.ts' +export type { NativeUiOptions } from './native-ui.ts' +export { createNativeUi, runPiCommandMenu } from './native-ui.ts' export * from './oauth-profile.ts' export * from './pkce.ts' export type { NativeMigrationJournal } from './pool-authority.ts' diff --git a/packages/core/src/native-ui.ts b/packages/core/src/native-ui.ts new file mode 100644 index 000000000..209ec98df --- /dev/null +++ b/packages/core/src/native-ui.ts @@ -0,0 +1,349 @@ +import { types as utilTypes } from 'node:util' +import { + type CommandApplyResult, + type CommandInvocation, + type CommandMenu, + type CommandMenuModel, + createTextRedactor, + type MenuKnob, + parseApplyRequest, + type SectionSlot, +} from '@cortexkit/common-auth/commands' + +// Export the menu renderer from @cortexkit/anthropic-auth-core so the Pi +// extension does not need a separate common-auth installation. +export { runPiCommandMenu } from '@cortexkit/common-auth/commands' + +import { + createNativeMenuExecutor, + type NativeMenuDispatch, + type NativeMenuExecutionResult, + type NativeMenuRequest, +} from './native-menu-executor.ts' +import { + getNativeMenuModel, + type NativeMenuAction, + type NativeMenuCommandId, + type NativeMenuGroupId, + type NativeMenuHost, + type NativeMenuParameter, +} from './native-menu-model.ts' + +export interface NativeUiOptions { + readonly host: NativeMenuHost + /** Execute account/settings changes and host actions; this adapter never writes their storage. */ + readonly dispatch: NativeMenuDispatch + /** Read-only account, quota and settings status. Exclude OAuth tokens, API keys and other secrets. */ + readonly readStatus: ( + command: Exclude, + invocation: CommandInvocation, + ) => Promise + /** The host sets this flag; RPC callers cannot declare themselves interactive. */ + readonly interactive: boolean +} + +const slots: Record = { + Accounts: 'accounts', + Quota: 'quota', + Routing: 'routing', + Limits: 'limits', + Cache: 'cache', + Diagnostics: 'diagnostics', + Extras: 'extra', +} + +/** Inspect data fields only; rejected input must not run credential getters. */ +function ownData(value: unknown, key: string): unknown { + if (value === null || typeof value !== 'object' || utilTypes.isProxy(value)) + return undefined + try { + const field = Object.getOwnPropertyDescriptor(value, key) + return field && 'value' in field ? field.value : undefined + } catch { + return undefined + } +} + +/** Parsing reads properties, so reject getters, proxies and non-plain objects before it runs. */ +function isDataRecord(value: unknown): boolean { + if (value === null || typeof value !== 'object' || utilTypes.isProxy(value)) + return false + try { + if (Array.isArray(value)) return false + const prototype: unknown = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return false + return Reflect.ownKeys(value).every((key) => { + const field = Object.getOwnPropertyDescriptor(value, key) + return field !== undefined && 'value' in field + }) + } catch { + return false + } +} + +function knob(parameter: NativeMenuParameter): MenuKnob { + switch (parameter.kind) { + case 'choice': + return { + kind: 'choice', + id: parameter.id, + label: parameter.id, + choices: [ + ...(!parameter.required ? [{ value: '', label: 'Default' }] : []), + ...parameter.choices.map((value) => ({ value, label: value })), + ], + } + case 'number': + return { + kind: 'number', + id: parameter.id, + label: parameter.id, + required: true, + min: 0, + max: 23, + } + case 'account-id': + return { + kind: 'text', + id: parameter.id, + label: 'Account ID (copy the complete ID from status)', + required: true, + } + case 'text': + return { + kind: 'text', + id: parameter.id, + label: parameter.id, + required: parameter.required, + masked: parameter.protectedEntry, + } + case 'limit-entries': + // Each menu input holds a scalar value. JSON carries threshold rows + // without splitting account IDs that contain colons; fh and sd are + // five-hour and seven-day percentages, and scoped is model-specific. + return { + kind: 'text', + id: parameter.id, + label: 'Threshold rows as JSON: account, fh, sd, optional scoped', + required: true, + placeholder: '[{"account":"main","fh":5,"sd":10,"scoped":0}]', + } + } +} + +function outcome(result: NativeMenuExecutionResult): { + ok: boolean + text: string + code?: string + needsConfirmation?: boolean +} { + switch (result.status) { + case 'executed': + return { + ok: result.ok, + text: result.text, + ...(!result.ok ? { code: 'refused' } : {}), + } + case 'guidance': + return { + ok: true, + text: `Offline ${result.mode} setup guidance (nothing changed):\n${result.instructions.join('\n')}`, + } + case 'failed': + return { ok: false, code: result.code, text: 'Action failed.' } + case 'refused': + return { + ok: false, + code: result.code, + text: `Action refused: ${result.code}${result.parameter ? ` (${result.parameter})` : ''}.`, + ...(result.code === 'confirmation-required' + ? { needsConfirmation: true } + : {}), + } + } +} + +/** + * Builds the Claude menu from the supported commands for OpenCode or Pi. + * Shared renderers display it, but each submitted action still goes through + * input validation, required confirmation and host capability checks. + * Credential fields never contain saved values. Current host input controls + * display typed text even when the field requests masking; do not promise + * hidden entry until those controls support it. + */ +export function createNativeUi(options: NativeUiOptions): CommandMenu { + const { host, dispatch, readStatus, interactive } = options + const native = getNativeMenuModel(host) + const executor = createNativeMenuExecutor({ host, dispatch }) + const protectedIds = new Set( + native.groups.flatMap((group) => + group.actions.flatMap((action) => + action.parameters.flatMap((parameter) => + parameter.kind === 'text' && parameter.protectedEntry + ? [parameter.id] + : [], + ), + ), + ), + ) + const redact = createTextRedactor({ + extraValuePatterns: [/sk-ant-[A-Za-z0-9_-]+/g], + }) + + function capture( + invocation: CommandInvocation, + scrub: (text: string) => string = redact, + ): CommandInvocation { + const { sessionId, notify } = invocation + return { + ...(sessionId !== undefined ? { sessionId } : {}), + notify: (text, kind) => notify(scrub(text), kind), + } + } + + async function model( + invocation: CommandInvocation, + scrub: (text: string) => string = redact, + readable = true, + ): Promise { + const safeInvocation = capture(invocation, scrub) + const sections = [] + for (const group of native.groups) { + const lines: string[] = [] + for (const status of group.statuses) { + if (!readable) { + lines.push(`${status.label}: status unavailable.`) + continue + } + try { + lines.push(scrub(await readStatus(status.command, safeInvocation))) + } catch { + // Arbitrary thrown values can quote credentials; never inspect them. + lines.push(`${status.label}: status unavailable.`) + } + } + sections.push({ + id: group.id, + slot: slots[group.id], + title: group.id, + lines, + items: [], + actions: group.actions.map((action) => ({ + id: action.id, + label: action.label, + knobs: action.parameters.map(knob), + ...(action.destructive + ? { confirm: { message: action.confirm, irreversible: true } } + : {}), + ...(action.kind === 'setup-guidance' + ? { description: 'Offline guidance only; no settings are changed.' } + : {}), + })), + }) + } + return { command: 'claude', title: 'Claude', sections } + } + + return { + command: 'claude', + async open(invocation) { + return { command: 'claude', menu: await model(capture(invocation)) } + }, + async apply(rawRequest, rawInvocation): Promise { + const invocation = capture(rawInvocation) + let request: ReturnType + const rawValues = ownData(rawRequest, 'values') + const readable = + isDataRecord(rawRequest) && + (rawValues === undefined || isDataRecord(rawValues)) + try { + request = readable ? parseApplyRequest(rawRequest) : undefined + } catch { + // An in-process caller can supply throwing accessors, unlike JSON RPC. + // Refuse without inspecting the thrown value. + request = undefined + } + const group = native.groups.find( + (entry) => entry.id === request?.sectionId, + ) + const action: NativeMenuAction | undefined = group?.actions.find( + (entry) => entry.id === request?.actionId, + ) + const values: Record = { ...request?.values } + // Status callbacks can reflect submitted input even after a request was + // refused. Collect protected data fields independently of action lookup + // and parsing, then scrub both returned text and invocation notifications. + const protectedValues: string[] = [] + for (const id of protectedIds) { + for (const submitted of [values, rawValues]) { + const value = ownData(submitted, id) + if (typeof value === 'string' && value.length > 0) + protectedValues.push(value) + } + } + const scrub = (text: string) => { + let safe = text + for (const value of protectedValues) + safe = safe.split(value).join('[REDACTED]') + return redact(safe) + } + const finish = async (result: ReturnType) => ({ + command: 'claude', + ...result, + text: scrub(result.text), + // Without readable data fields there is no safe way to identify a + // submitted secret. Do not invoke status callbacks that could echo it. + menu: await model(invocation, scrub, readable), + }) + if ( + !request || + request.command !== 'claude' || + !action || + request.itemId !== undefined || + (request.sessionId !== undefined && + request.sessionId !== invocation.sessionId) + ) { + return finish({ + ok: false, + code: 'invalid-request', + text: 'Invalid menu request.', + }) + } + for (const parameter of action.parameters) { + if ( + parameter.kind === 'choice' && + !parameter.required && + values[parameter.id] === '' + ) + values[parameter.id] = null + if ( + parameter.kind === 'limit-entries' && + typeof values[parameter.id] === 'string' + ) { + try { + const decoded: unknown = JSON.parse(String(values[parameter.id])) + values[parameter.id] = decoded + } catch { + return finish({ + ok: false, + code: 'invalid-parameter', + text: 'Threshold rows must be valid JSON.', + }) + } + } + } + // The scalar transport cannot express the correlated native request type. + // This is the untrusted boundary: the executor checks every field before + // the injected capability receives a typed NativeMenuDispatchRequest. + const result = await executor.execute( + { action: action.id, values } as NativeMenuRequest, + { + interactive, + confirmed: request.confirmed, + sessionId: invocation.sessionId, + }, + ) + return finish(outcome(result)) + }, + } +} diff --git a/packages/core/src/tests/native-ui.test.ts b/packages/core/src/tests/native-ui.test.ts new file mode 100644 index 000000000..d737d05a3 --- /dev/null +++ b/packages/core/src/tests/native-ui.test.ts @@ -0,0 +1,544 @@ +import { describe, expect, test } from 'bun:test' +import type { + CommandApplyRequest, + KnobValues, +} from '@cortexkit/common-auth/commands' +import type { NativeMenuDispatchRequest } from '../native-menu-executor.ts' +import type { + NativeMenuActionId, + NativeMenuGroupId, +} from '../native-menu-model.ts' +import { createNativeUi, runPiCommandMenu } from '../native-ui.ts' + +const opaqueId = 'vault:oauth:team:person' +const entries = [{ account: opaqueId, fh: 5, sd: 10, scoped: 23 }] +const fixtures = { + enable: ['Accounts', { id: opaqueId }], + disable: ['Accounts', { id: opaqueId }], + remove: ['Accounts', { id: opaqueId }], + 'move-up': ['Accounts', { id: opaqueId }], + 'move-down': ['Accounts', { id: opaqueId }], + 'reset-backoff': ['Accounts', {}], + 'enrollment-reset': ['Accounts', {}], + 'add-apikey': [ + 'Accounts', + { + apiKey: 'synthetic-key', + label: 'two words', + baseURL: 'https://example.invalid/v1', + authHeader: 'x-api-key', + }, + ], + 'add-oauth-start': ['Accounts', {}], + 'add-oauth-finish': [ + 'Accounts', + { code: 'synthetic-code', label: 'two words' }, + ], + 'custody-mode': ['Accounts', { mode: 'claustrum' }], + 'quota-refresh': ['Quota', {}], + 'routing-mode': ['Routing', { mode: 'sticky-balanced' }], + 'routing-reset': ['Routing', {}], + 'killswitch-on': ['Limits', {}], + 'killswitch-off': ['Limits', {}], + 'killswitch-set': ['Limits', { entries: JSON.stringify(entries) }], + 'cache-on': ['Cache', {}], + 'cache-off': ['Cache', {}], + 'cache-mode': ['Cache', { mode: 'hybrid' }], + 'cachekeep-always': ['Cache', {}], + 'cachekeep-off': ['Cache', {}], + 'cachekeep-window': ['Cache', { startHour: 23, endHour: 7 }], + 'cachekeep-subagents': ['Cache', { enabled: 'off' }], + 'dump-on': ['Diagnostics', {}], + 'dump-off': ['Diagnostics', {}], + 'logging-level': ['Diagnostics', { level: 'trace' }], + 'fast-on': ['Extras', {}], + 'fast-off': ['Extras', {}], + 'prime-on': ['Extras', {}], + 'prime-off': ['Extras', {}], + 'start-fire': ['Extras', {}], +} satisfies Record + +const invocation = { sessionId: 'conversation-1', notify() {} } +const piUnavailable = [ + 'quota-refresh', + 'killswitch-on', + 'killswitch-off', + 'killswitch-set', + 'prime-on', + 'prime-off', + 'start-fire', +] + +function setup(host: 'opencode' | 'pi' = 'opencode', interactive = true) { + const calls: NativeMenuDispatchRequest[] = [] + const reads: string[] = [] + const menu = createNativeUi({ + host, + interactive, + dispatch: async (request) => { + calls.push(request) + return { ok: true, text: 'Applied' } + }, + readStatus: async (command, context) => { + reads.push(`${command}:${context.sessionId}`) + return `${command}: public status` + }, + }) + const apply = ( + actionId: NativeMenuActionId, + patch: Partial = {}, + ) => + menu.apply( + { + command: 'claude', + sectionId: fixtures[actionId][0], + actionId, + values: fixtures[actionId][1], + sessionId: invocation.sessionId, + confirmed: true, + ...patch, + }, + invocation, + ) + return { menu, calls, reads, apply } +} + +describe('native UI contract', () => { + test('bundled Pi renderer executes the same checked menu without a second backend', async () => { + const { menu, calls } = setup('pi') + const inputs = [ + 'synthetic-private-key', + 'two words', + 'https://example.invalid/api', + ] + const notices: string[] = [] + let roots = 0 + let sections = 0 + await runPiCommandMenu( + menu, + { + async select(title, choices) { + if (title === 'Claude') + return roots++ === 0 + ? choices.find((choice) => choice.startsWith('Accounts:')) + : undefined + if (title.startsWith('Accounts')) + return sections++ === 0 + ? choices.find((choice) => choice === 'Add API-key fallback') + : undefined + if (title === 'authHeader') + return choices.find((choice) => choice === 'x-api-key') + return undefined + }, + async input() { + return inputs.shift() + }, + async confirm() { + throw new Error('API key creation is not destructive') + }, + notify(text) { + notices.push(text) + }, + }, + { sessionId: invocation.sessionId }, + ) + expect(calls).toEqual([ + { + action: 'add-apikey', + values: { + apiKey: 'synthetic-private-key', + label: 'two words', + baseURL: 'https://example.invalid/api', + authHeader: 'x-api-key', + }, + }, + ]) + expect(inputs).toEqual([]) + expect(notices).toEqual(['Applied']) + }) + for (const host of ['opencode', 'pi'] as const) { + test(`${host} presents the exact native inventory and typed knobs`, async () => { + const { menu, reads } = setup(host) + const payload = await menu.open(invocation) + expect(payload.command).toBe('claude') + expect(payload.menu.sections.map((section) => section.id)).toEqual([ + 'Accounts', + 'Quota', + 'Routing', + 'Limits', + 'Cache', + 'Diagnostics', + 'Extras', + ]) + expect( + payload.menu.sections.flatMap((section) => + section.actions.map((action) => action.id), + ), + ).toEqual( + Object.keys(fixtures).filter( + (id) => host === 'opencode' || !piUnavailable.includes(id), + ), + ) + const actions = payload.menu.sections.flatMap( + (section) => section.actions, + ) + const api = actions.find((action) => action.id === 'add-apikey') + expect(api?.knobs.map((knob) => knob.id)).toEqual([ + 'apiKey', + 'label', + 'baseURL', + 'authHeader', + ]) + expect(api?.knobs[0]).toEqual({ + kind: 'text', + id: 'apiKey', + label: 'apiKey', + required: true, + masked: true, + }) + expect( + actions.find((action) => action.id === 'remove')?.confirm?.irreversible, + ).toBe(true) + expect( + actions.find((action) => action.id === 'enrollment-reset')?.confirm, + ).toBeDefined() + expect(reads).toEqual([ + 'account:conversation-1', + 'quota:conversation-1', + 'routing:conversation-1', + ...(host === 'opencode' ? ['killswitch:conversation-1'] : []), + 'cache:conversation-1', + 'cachekeep:conversation-1', + 'dump:conversation-1', + 'logging:conversation-1', + 'fast:conversation-1', + 'prime:conversation-1', + ]) + }) + for (const actionId of Object.keys(fixtures) as NativeMenuActionId[]) { + if (host === 'pi' && piUnavailable.includes(actionId)) continue + test(`${host} executes ${actionId} with all parameters intact`, async () => { + const { calls, apply } = setup(host) + const result = await apply(actionId) + expect(result.ok).toBe(true) + if (actionId === 'custody-mode') { + expect(calls).toEqual([]) + expect(result.text).toContain('nothing changed') + expect(result.text).toContain('Stop all selected hosts') + return + } + expect(calls).toEqual([ + { + action: actionId, + values: + actionId === 'killswitch-set' + ? { entries } + : fixtures[actionId][1], + ...([ + 'routing-reset', + 'start-fire', + 'add-oauth-start', + 'add-oauth-finish', + ].includes(actionId) + ? { sessionId: invocation.sessionId } + : {}), + }, + ]) + }) + } + } + + test('confirmation cannot be bypassed by transport or the renderer', async () => { + const { calls, apply } = setup() + for (const action of ['remove', 'enrollment-reset'] as const) { + const result = await apply(action, { confirmed: false }) + expect(result.code).toBe('confirmation-required') + expect(result.needsConfirmation).toBe(true) + } + expect(calls).toEqual([]) + }) + + test('headless credential entry and custody guidance are refused', async () => { + const { calls, apply } = setup('opencode', false) + expect((await apply('add-apikey')).code).toBe('interactive-required') + expect((await apply('add-oauth-start')).code).toBe('interactive-required') + expect((await apply('custody-mode')).code).toBe('offline-required') + expect(calls).toEqual([]) + }) + + test('Pi cannot dispatch OpenCode-only capabilities', async () => { + const { calls, apply } = setup('pi') + for (const action of piUnavailable as NativeMenuActionId[]) { + expect((await apply(action)).ok).toBe(false) + } + expect(calls).toEqual([]) + }) + + test('forged sections, items, aliases and cross-session requests never dispatch', async () => { + const { calls, apply } = setup() + for (const patch of [ + { command: 'claude-account' }, + { sectionId: 'Quota' }, + { itemId: opaqueId }, + { sessionId: 'other-session' }, + ]) { + expect((await apply('enable', patch)).code).toBe('invalid-request') + } + expect(calls).toEqual([]) + }) + + test('structured limits reject bad JSON, scoped thresholds and unknown fields', async () => { + const { calls, apply } = setup() + for (const value of [ + 'main:5,10', + '[{"account":"main","fh":5,"sd":10,"scoped":-1}]', + '[{"account":"main","fh":5,"sd":10,"extra":1}]', + ]) { + expect( + (await apply('killswitch-set', { values: { entries: value } })).ok, + ).toBe(false) + } + expect( + ( + await apply('cachekeep-window', { + values: { startHour: 3, endHour: 3 }, + }) + ).code, + ).toBe('invalid-parameter') + expect( + (await apply('enable', { values: { id: opaqueId, token: 'synthetic' } })) + .code, + ).toBe('unknown-parameter') + expect(calls).toEqual([]) + }) + + test('optional empty values reach the backend as absent, not invented defaults', async () => { + const { calls, apply } = setup() + expect( + ( + await apply('add-apikey', { + values: { + apiKey: 'synthetic-key', + label: null, + baseURL: null, + authHeader: '', + }, + }) + ).ok, + ).toBe(true) + expect(calls).toEqual([ + { action: 'add-apikey', values: { apiKey: 'synthetic-key' } }, + ]) + }) + + test('outcome and refreshed status never echo a protected value', async () => { + let after = false + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + dispatch: async () => { + after = true + return { ok: true, text: 'Accepted synthetic-private-key' } + }, + readStatus: async () => + after ? 'status synthetic-private-key sk-ant-testsecret' : 'public', + }) + const result = await menu.apply( + { + command: 'claude', + sectionId: 'Accounts', + actionId: 'add-apikey', + values: { apiKey: 'synthetic-private-key' }, + }, + invocation, + ) + expect(result.ok).toBe(true) + expect(JSON.stringify(result)).not.toContain('synthetic-private-key') + expect(JSON.stringify(result)).not.toContain('sk-ant-testsecret') + expect(result.text).toContain('[REDACTED]') + }) + + test('protected API key and OAuth code notifications are scrubbed on success, refusal and invalid refreshes', async () => { + for (const [actionId, parameter, secret] of [ + ['add-apikey', 'apiKey', 'arbitrary-custom-provider-private-key'], + ['add-oauth-finish', 'code', 'private approval proof#verifier'], + ] as const) { + for (const mode of [ + 'success', + 'headless', + 'wrong-section', + 'invalid-action', + 'session-mismatch', + 'invalid-parser', + ] as const) { + const notifications: { text: string; kind: string | undefined }[] = [] + let dispatches = 0 + const menu = createNativeUi({ + host: 'opencode', + interactive: mode !== 'headless', + dispatch: async () => { + dispatches++ + return { ok: true, text: `Accepted ${secret}` } + }, + readStatus: async (_command, context) => { + context.notify('Public refresh completed', 'info') + context.notify(`Provider notification: ${secret}`, 'warning') + return `Provider status: ${secret}` + }, + }) + const result = await menu.apply( + { + command: 'claude', + sectionId: mode === 'wrong-section' ? 'Quota' : 'Accounts', + actionId: + mode === 'invalid-action' ? 'unknown-native-action' : actionId, + values: { + [parameter]: secret, + ...(mode === 'invalid-parser' ? { malformed: Number.NaN } : {}), + }, + sessionId: + mode === 'session-mismatch' + ? 'different-session' + : 'notification-session', + }, + { + sessionId: 'notification-session', + notify(text, kind) { + notifications.push({ text, kind }) + }, + }, + ) + expect(dispatches).toBe(mode === 'success' ? 1 : 0) + expect(result.ok).toBe(mode === 'success') + expect(notifications).toHaveLength(20) + expect( + notifications.filter( + (entry) => entry.text === 'Public refresh completed', + ), + ).toEqual( + Array.from({ length: 10 }, () => ({ + text: 'Public refresh completed', + kind: 'info', + })), + ) + expect( + notifications.filter((entry) => entry.kind === 'warning'), + ).toEqual( + Array.from({ length: 10 }, () => ({ + text: 'Provider notification: [REDACTED]', + kind: 'warning', + })), + ) + expect(JSON.stringify({ result, notifications })).not.toContain(secret) + } + } + }) + + test('unreadable requests never invoke credential getters, proxy traps or echoing status callbacks', async () => { + let getters = 0 + let traps = 0 + let statuses = 0 + let dispatches = 0 + const notifications: string[] = [] + const secret = 'unreadable-request-private-value' + const hostile = new Proxy( + {}, + { + get() { + traps++ + throw new Error('unexpected proxy get') + }, + getOwnPropertyDescriptor() { + traps++ + throw new Error('unexpected proxy descriptor') + }, + getPrototypeOf() { + traps++ + throw new Error('unexpected proxy prototype') + }, + ownKeys() { + traps++ + throw new Error('unexpected proxy enumeration') + }, + }, + ) + const request = { + command: 'claude', + sectionId: 'Accounts', + actionId: 'add-apikey', + values: { apiKey: secret }, + } + const accessorRequest = { ...request } + Object.defineProperty(accessorRequest, 'values', { + enumerable: true, + get() { + getters++ + throw new Error('unexpected values getter') + }, + }) + const accessorValues = { apiKey: secret } + Object.defineProperty(accessorValues, 'unknown', { + enumerable: true, + get() { + getters++ + throw new Error('unexpected unknown getter') + }, + }) + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + dispatch: async () => { + dispatches++ + return { ok: true, text: 'not expected' } + }, + readStatus: async (_command, context) => { + statuses++ + context.notify(secret) + return secret + }, + }) + for (const raw of [ + hostile, + { ...request, values: hostile }, + accessorRequest, + { ...request, values: accessorValues }, + ]) { + const result = await menu.apply(raw as CommandApplyRequest, { + sessionId: 'a', + notify(text) { + notifications.push(text) + }, + }) + expect(result.code).toBe('invalid-request') + expect(result.ok).toBe(false) + expect(result.menu.sections[0]?.lines[0]).toContain('status unavailable') + expect(JSON.stringify(result)).not.toContain(secret) + } + expect(getters).toBe(0) + expect(traps).toBe(0) + expect(statuses).toBe(0) + expect(dispatches).toBe(0) + expect(notifications).toEqual([]) + }) + + test('exceptions do not leak and status failures are visibly unavailable', async () => { + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + dispatch: async () => { + throw new Error('secret-operation-material') + }, + readStatus: async () => { + throw new Error('secret-status-material') + }, + }) + const result = await menu.apply( + { command: 'claude', sectionId: 'Cache', actionId: 'cache-on' }, + invocation, + ) + expect(result.ok).toBe(false) + expect(result.code).toBe('execution-failed') + expect(JSON.stringify(result)).not.toContain('secret-') + expect(result.menu.sections[0]?.lines[0]).toContain('status unavailable') + }) +}) From 4a124abfb0eb5641f8c4322264ecd11b3837c47c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:44:49 +0200 Subject: [PATCH 059/169] style(core): simplify checked menu request guard --- packages/core/src/native-ui.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/native-ui.ts b/packages/core/src/native-ui.ts index 209ec98df..098234fc3 100644 --- a/packages/core/src/native-ui.ts +++ b/packages/core/src/native-ui.ts @@ -296,8 +296,7 @@ export function createNativeUi(options: NativeUiOptions): CommandMenu { menu: await model(invocation, scrub, readable), }) if ( - !request || - request.command !== 'claude' || + request?.command !== 'claude' || !action || request.itemId !== undefined || (request.sessionId !== undefined && From b625bb38dfa4699c5a7ecdeb57356d63448bb7b8 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:05:27 +0200 Subject: [PATCH 060/169] feat(core): expose bundled command protocol types --- packages/core/src/index.ts | 24 ++++++++++++++++++++++-- packages/core/src/native-ui.ts | 21 ++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ed58a165e..fb46e270d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,8 +102,28 @@ export type { NativeLocalFailurePolicy, NativeLocalFailureSkipReason, } from './native-runtime.ts' -export type { NativeUiOptions } from './native-ui.ts' -export { createNativeUi, runPiCommandMenu } from './native-ui.ts' +export type { + CommandApplyRequest, + CommandApplyResult, + CommandDialogPayload, + CommandInvocation, + CommandMenu, + CommandMenuModel, + KnobValue, + KnobValues, + MenuAction, + MenuItem, + MenuKnob, + MenuSection, + NativeUiOptions, + NotifyKind, + SectionSlot, +} from './native-ui.ts' +export { + createNativeUi, + parseApplyRequest, + runPiCommandMenu, +} from './native-ui.ts' export * from './oauth-profile.ts' export * from './pkce.ts' export type { NativeMigrationJournal } from './pool-authority.ts' diff --git a/packages/core/src/native-ui.ts b/packages/core/src/native-ui.ts index 098234fc3..8f663e0e8 100644 --- a/packages/core/src/native-ui.ts +++ b/packages/core/src/native-ui.ts @@ -10,9 +10,28 @@ import { type SectionSlot, } from '@cortexkit/common-auth/commands' +export type { + CommandApplyRequest, + CommandApplyResult, + CommandDialogPayload, + CommandInvocation, + CommandMenu, + CommandMenuModel, + KnobValue, + KnobValues, + MenuAction, + MenuItem, + MenuKnob, + MenuSection, + NotifyKind, + SectionSlot, +} from '@cortexkit/common-auth/commands' // Export the menu renderer from @cortexkit/anthropic-auth-core so the Pi // extension does not need a separate common-auth installation. -export { runPiCommandMenu } from '@cortexkit/common-auth/commands' +export { + parseApplyRequest, + runPiCommandMenu, +} from '@cortexkit/common-auth/commands' import { createNativeMenuExecutor, From 44d496135eee2c0cc4319c629a174e783645a05c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:37:08 +0200 Subject: [PATCH 061/169] test(opencode): exercise packed TUI loader invocation --- .../scripts/smoke-tui-pack-install.ts | 54 ++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/opencode/scripts/smoke-tui-pack-install.ts b/packages/opencode/scripts/smoke-tui-pack-install.ts index 33858a043..ffdb46233 100644 --- a/packages/opencode/scripts/smoke-tui-pack-install.ts +++ b/packages/opencode/scripts/smoke-tui-pack-install.ts @@ -169,6 +169,7 @@ try { const stubbedRuntimeProbe = ` import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { readFile } from "node:fs/promises"; const packageRoot = process.argv[1]; const runtimeSpecifiers = ${JSON.stringify(runtimeSpecifiers)}; @@ -190,11 +191,33 @@ Bun.plugin({ }, }); +const loadedPaths = []; +Bun.plugin({ + name: "observe-compiled-tui-load", + setup(build) { + build.onLoad({ filter: /tui[.]tsx$/ }, async (args) => { + loadedPaths.push(args.path.split(String.fromCharCode(92)).join("/")); + return { contents: await readFile(args.path, "utf8"), loader: "tsx" }; + }); + }, +}); +process.env.OPENCODE_TUI_PREFERENCES_FILE = join(packageRoot, "absent-smoke-preferences.jsonc"); +process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = join(packageRoot, "absent-smoke-rpc"); const entry = await import(pathToFileURL(join(packageRoot, "src/tui/entry.mjs")).href); if (entry.default?.id !== "cortexkit.anthropic-auth" || typeof entry.default?.tui !== "function") { throw new Error("compiled TUI entry export shape is invalid"); } -console.log("stubbed runtime probe loaded the compiled TUI path"); +let registrations = 0; +await entry.default.tui({ + slots: { register(value) { if (typeof value.slots.sidebar_content !== "function") throw new Error("missing sidebar"); registrations++; } }, + state: { path: { directory: packageRoot } }, + route: { current: () => ({ type: "home" }) }, +}); +if (registrations !== 1 || !loadedPaths.some((path) => path.includes("tui-compiled")) || loadedPaths.some((path) => path.endsWith("/src/tui.tsx"))) { + throw new Error("compiled TUI invocation used the wrong loader path"); +} +console.log("stubbed runtime probe invoked the actual compiled TUI path"); +process.exit(0); ` run('bun', ['-e', stubbedRuntimeProbe, installedPackageRoot], installRoot) check('stubbed host runtime imports the compiled TUI path', true) @@ -202,17 +225,36 @@ console.log("stubbed runtime probe loaded the compiled TUI path"); const rawFallbackProbe = ` import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { readFile } from "node:fs/promises"; const packageRoot = process.argv[1]; -const raw = await import(pathToFileURL(join(packageRoot, "src/tui.tsx")).href); +const loadedPaths = []; +Bun.plugin({ + name: "observe-raw-tui-load", + setup(build) { + build.onLoad({ filter: /tui[.]tsx$/ }, async (args) => { + loadedPaths.push(args.path.split(String.fromCharCode(92)).join("/")); + return { contents: await readFile(args.path, "utf8"), loader: "tsx" }; + }); + }, +}); +process.env.OPENCODE_TUI_PREFERENCES_FILE = join(packageRoot, "absent-smoke-preferences.jsonc"); +process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = join(packageRoot, "absent-smoke-rpc"); const entry = await import(pathToFileURL(join(packageRoot, "src/tui/entry.mjs")).href); -if (entry.default !== raw.default) { - throw new Error("loader did not fall back to raw TSX under bare Bun"); -} if (entry.default?.id !== "cortexkit.anthropic-auth" || typeof entry.default?.tui !== "function") { throw new Error("raw TUI fallback export shape is invalid"); } -console.log("raw TSX fallback loaded the TUI entry"); +let registrations = 0; +await entry.default.tui({ + slots: { register(value) { if (typeof value.slots.sidebar_content !== "function") throw new Error("missing sidebar"); registrations++; } }, + state: { path: { directory: packageRoot } }, + route: { current: () => ({ type: "home" }) }, +}); +if (registrations !== 1 || !loadedPaths.some((path) => path.endsWith("/src/tui.tsx")) || loadedPaths.some((path) => path.includes("tui-compiled"))) { + throw new Error("bare Bun TUI invocation did not use the raw TSX fallback"); +} +console.log("bare Bun invoked the actual raw TSX fallback"); +process.exit(0); ` run('bun', ['-e', rawFallbackProbe, installedPackageRoot], installRoot) check('bare Bun imports the raw-TSX fallback', true) From 63e8f56b6f559ecfbbab8303ebfde9b901e52fb1 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:51:34 +0200 Subject: [PATCH 062/169] chore(core): adopt verified common-auth 0.11.5 --- bun.lock | 4 ++-- packages/core/package.json | 2 +- packages/core/scripts/check-native-type-closure.test.ts | 4 ++-- packages/core/scripts/check-native-type-closure.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 821ce81a4..a61e96b94 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "0.11.4", + "@cortexkit/common-auth": "0.11.5", }, }, "packages/e2e-tests": { @@ -237,7 +237,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.4", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-KTF75QolTeCZZfQjJXvzGXadHbi8H+D/E6TktilS1U0Q1RqIRfoaJN5qMcTTM4IyMKvDdHRIeYaKTT7jod1I9w=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.5", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-9ez7nmPsMxhiUQOBFwU8+91UHQ2ka7nwrn5OGLaj/61V89Wce/ZtICnIR47yXWT/ojA7zYSvGIr6tzGAf/UpFA=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], diff --git a/packages/core/package.json b/packages/core/package.json index db32979b6..1510eb17c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "0.11.4" + "@cortexkit/common-auth": "0.11.5" }, "dependencies": { "@cortexkit/claustrum-client": "0.6.2", diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index d64956a46..2482487fa 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -98,7 +98,7 @@ test('rejects unsupported published producer versions before copying declaration ) await expect(closeNativeTypeDeclarations(dist, producer)).rejects.toThrow( - 'Expected published common-auth 0.11.4, got 0.9.0', + 'Expected published common-auth 0.11.5, got 0.9.0', ) expect(await readdir(dist)).not.toContain('internal-types') }) @@ -111,7 +111,7 @@ test('copies the complete published relative declaration graph including cycles producer, 'package.json', JSON.stringify({ - version: '0.11.4', + version: '0.11.5', exports: { './store': { types: './dist/store/index.d.ts' } }, }), ) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts index 9245f2c5d..fe51f2fc8 100644 --- a/packages/core/scripts/check-native-type-closure.ts +++ b/packages/core/scripts/check-native-type-closure.ts @@ -185,9 +185,9 @@ export async function closeNativeTypeDeclarations( version: string exports: Record } - if (manifest.version !== '0.11.4') + if (manifest.version !== '0.11.5') throw new Error( - `Expected published common-auth 0.11.4, got ${manifest.version}`, + `Expected published common-auth 0.11.5, got ${manifest.version}`, ) const producerDist = join(producerRoot, 'dist') const assets = join(distRoot, TYPE_ASSETS) From 3713bdce3837b71b55d14cf2ff4d43f9d8283beb Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:08:06 +0200 Subject: [PATCH 063/169] mason: add native account and vault runtime --- packages/core/src/native-account-runtime.ts | 1233 +++++++++++++++++ packages/core/src/native-account-view.ts | 636 +++++++++ packages/core/src/native-vault-runtime.ts | 735 ++++++++++ .../src/tests/native-account-runtime.test.ts | 732 ++++++++++ .../src/tests/native-account-view.test.ts | 187 +++ .../src/tests/native-vault-runtime.test.ts | 659 +++++++++ 6 files changed, 4182 insertions(+) create mode 100644 packages/core/src/native-account-runtime.ts create mode 100644 packages/core/src/native-account-view.ts create mode 100644 packages/core/src/native-vault-runtime.ts create mode 100644 packages/core/src/tests/native-account-runtime.test.ts create mode 100644 packages/core/src/tests/native-account-view.test.ts create mode 100644 packages/core/src/tests/native-vault-runtime.test.ts diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts new file mode 100644 index 000000000..87c087688 --- /dev/null +++ b/packages/core/src/native-account-runtime.ts @@ -0,0 +1,1233 @@ +import { randomUUID } from 'node:crypto' +import { mutateVaultRoster } from '@cortexkit/common-auth/claustrum' +import { withLock } from '@cortexkit/common-auth/fs' +import { + fingerprintOf, + POOL_LOCK_DEFAULTS, + type PoolCredential, +} from '@cortexkit/common-auth/store' + +import { + buildRefreshOperationError, + fetchOAuthQuotaSnapshot, + killswitchPassesPolicy, + type OAuthAccountProfile, + type OAuthQuotaSnapshot, + type PrimeUsageDelta, + quotaSnapshotPassesModelScope, + quotaSnapshotPassesPolicy, +} from './accounts.ts' +import { + applyNativeMetadataPatch, + captureNativeMetadataPatch, + captureNativePlainData, + type NativeAccountMetadataPatch, + type NativeAccountSnapshot, + type NativeAccountView, + nativeAccountReadError, + nativeQuotaFailure, + nativeVaultPrimaryPin, + nativeVaultPrimaryRow, + projectNativeAccountViews, +} from './native-account-view.ts' +import { nativeLocalCredentialValidationMatches } from './native-credential-validation.ts' +import { + NativeCustodyError, + type NativeCustodyReceipt, +} from './native-custody.ts' +import { + createNativeLocalCredentialService, + type NativeLocalCredentialServiceOptions, +} from './native-local-credential-service.ts' +import type { NativeLocalExternalPolicy } from './native-local-runtime-readers.ts' +import { nativeQuotaCodec, toNativeQuotaMap } from './native-quota-codec.ts' +import type { + NativeKnownCredentialSubject, + NativeRefreshResult, + NativeRefreshSubject, +} from './native-refresh-coordinator.ts' +import { + type NativeRuntimeEntry, + NativeRuntimeError, + readNativeRuntime, + updateNativeRuntime, +} from './native-runtime.ts' +import { + acquireNativeVaultRuntime, + type NativeVaultRuntime, + type NativeVaultRuntimeOptions, +} from './native-vault-runtime.ts' +import { fetchOAuthAccountProfile } from './oauth-profile.ts' +import { requireNativePoolAuthority } from './pool-authority.ts' +import { + captureNativeLocalPoolBinding, + type NativeLocalPoolBinding, + nativeLocalPoolBindingMatches, +} from './pool-binding.ts' +import type { NativePoolPaths } from './pool-paths.ts' +import { createNativePoolStore, nativePoolStoreLocks } from './pool-store.ts' +import type { RelayConfig } from './relay.ts' +import { tokenFingerprint } from './token-fingerprint.ts' + +export type { + NativeAccountMetadataPatch, + NativeAccountSnapshot, + NativeAccountView, +} from './native-account-view.ts' + +export interface NativeAccountRuntimeOptions { + paths: NativePoolPaths + host: 'opencode' | 'pi' + local?: Partial< + Pick< + NativeLocalCredentialServiceOptions, + 'refreshToken' | 'resolveIdentity' | 'failurePolicy' | 'now' + > + > + vault?: Omit +} +export interface NativeLocalAuthorizeOptions { + intent?: 'serve' | 'refresh' + rejectedAccessToken?: string + signal?: AbortSignal + policy?: ( + account: NativeAccountView, + snapshot: NativeAccountSnapshot, + ) => NativeLocalExternalPolicy +} +export interface NativeAccountWriteInput { + routeId: string + credential: PoolCredential + accountIdentity?: string + label?: string + replace?: boolean +} +export interface NativeApiSubject { + kind: 'api' + binding: NativeLocalPoolBinding + credentialFingerprint: string + baseURL: string + authHeader?: 'authorization-bearer' | 'x-api-key' +} + +export type NativeRelayUpdate = Partial> & { + token?: string | null +} + +export interface NativeAccountRuntime { + readonly vault: NativeVaultRuntime + /** Fresh private relay authorization for transport only; never include it in a status snapshot. */ + getRelayConfig(): Promise + updateRelay(patch: NativeRelayUpdate): Promise + read(): Promise + authorizeLocal( + routeId: string, + options?: NativeLocalAuthorizeOptions, + ): Promise + authorizeApi( + routeId: string, + signal?: AbortSignal, + ): Promise<{ + type: 'api' + apiKey: string + baseURL: string + authHeader?: 'authorization-bearer' | 'x-api-key' + subject: NativeApiSubject + }> + authorizeVault( + routeId: string, + signal?: AbortSignal, + ): Promise + loginOAuth(input: { + routeId: string + credential: { access: string; refresh: string; expires: number } + accountIdentity?: string + label?: string + replace?: boolean + }): Promise + putAccount(input: NativeAccountWriteInput): Promise + addApi(input: { + routeId?: string + apiKey: string + baseURL?: string + authHeader?: 'authorization-bearer' | 'x-api-key' + label?: string + replace?: boolean + }): Promise + updateSettings( + mutator: ( + settings: Record, + ) => + | Record + | undefined + | Promise | undefined>, + ): Promise<{ + settings: Record + outcome: 'updated' | 'unchanged' + }> + setEnabled(routeId: string, enabled: boolean): Promise + remove(routeId: string): Promise + reorder(routeIds: readonly string[]): Promise + captureLocalSubject(routeId: string): Promise + publishLocal( + subject: NativeKnownCredentialSubject, + patch: NativeAccountMetadataPatch, + ): Promise + publishApi( + subject: NativeApiSubject, + patch: { lastUsed: number }, + ): Promise + resetBackoff( + routeId: string, + fence: NativeRefreshSubject | NativeCustodyReceipt, + kind?: 'refresh' | 'quota' | 'all', + ): Promise + getOrCreateAuthLineage( + routeId: string, + fence: NativeKnownCredentialSubject | NativeCustodyReceipt, + ): Promise + incrementPrimeUsage( + routeId: string, + fence: NativeKnownCredentialSubject | NativeCustodyReceipt, + delta?: PrimeUsageDelta, + ): Promise + fetchQuota( + routeId: string, + fetchImpl?: typeof fetch, + signal?: AbortSignal, + ): Promise + fetchProfile( + routeId: string, + fetchImpl?: typeof fetch, + signal?: AbortSignal, + ): Promise + close(): void +} + +/** Evaluate quota and killswitch policy for this caller's model, outside shared credential refresh work. */ +export function nativeAccountPolicy( + account: NativeAccountView, + snapshot: NativeAccountSnapshot, + modelId?: string, +): NativeLocalExternalPolicy { + if (!account.enabled) return { status: 'blocked', reason: 'account-disabled' } + if ( + !quotaSnapshotPassesPolicy(account.quota, snapshot.policyStorage) || + !quotaSnapshotPassesModelScope(account.quota, modelId) || + !killswitchPassesPolicy( + account.quota, + snapshot.policyStorage, + account.id === 'main' ? undefined : account.id, + modelId, + ) + ) + return { status: 'blocked', reason: 'quota-ineligible' } + return { status: 'allowed' } +} + +/** The migration journal records the offline move from host auth and sidecars to the credential pool. Only its committed state permits this service to use the pool and separate runtime metadata. */ +export function createNativeAccountRuntime( + options: NativeAccountRuntimeOptions, +): NativeAccountRuntime { + const paths = Object.freeze({ ...options.paths }) + const now = options.local?.now ?? options.vault?.now ?? Date.now + const shutdown = new AbortController() + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec, now }) + const vault = acquireNativeVaultRuntime({ + ...options.vault, + paths, + host: options.host, + }) + function check(signal?: AbortSignal) { + if (shutdown.signal.aborted) throw new NativeCustodyError('closed') + signal?.throwIfAborted() + } + async function authority(signal?: AbortSignal) { + check(signal) + await requireNativePoolAuthority(paths) + check(signal) + } + async function poolRow(id: string) { + const read = await store.read() + if (read.status !== 'ready') + throw new NativeRuntimeError('publication-refused') + return read.rows.find((row) => row.id === id) + } + async function physicalId(id: string): Promise { + if (id !== 'main') return id + const settings = await store.readSettings() + if ( + settings.status !== 'ready' || + typeof settings.settings.mainAccountId !== 'string' + ) + throw new NativeCustodyError('route-unavailable') + return settings.settings.mainAccountId + } + async function localWrite( + subject: NativeRefreshSubject | NativeApiSubject, + change: ( + entry: NativeRuntimeEntry, + previous?: NativeRuntimeEntry, + ) => NativeRuntimeEntry, + exactAccess: boolean | 'binding' = true, + ): Promise { + await authority() + const binding = subject.binding + const matches = async () => { + const row = await poolRow(binding.rowId) + if ( + !row || + !nativeLocalPoolBindingMatches(binding, paths, row) || + row.stamp !== 'bound' || + !row.credential + ) + return false + if (exactAccess === 'binding') + return row.credential.type === ('kind' in subject ? 'api' : 'oauth') + if ('kind' in subject) + return ( + row.credential.type === 'api' && + fingerprintOf(row.credential) === subject.credentialFingerprint && + row.credential.baseURL === subject.baseURL && + row.credential.authHeader === subject.authHeader + ) + if ( + row.credential.type !== 'oauth' || + fingerprintOf(row.credential) !== subject.credentialFingerprint + ) + return false + return ( + !exactAccess || + nativeLocalCredentialValidationMatches(subject, binding, row.credential) + ) + } + const [config, state] = nativePoolStoreLocks(paths) + try { + return await withLock( + config.path, + { ...POOL_LOCK_DEFAULTS, name: config.name }, + async (configLease) => + withLock( + state.path, + { ...POOL_LOCK_DEFAULTS, name: state.name }, + async (stateLease) => { + if (!(await matches())) return false + await updateNativeRuntime( + paths.runtime, + paths.storageId, + (current) => { + const old = current.accounts[binding.rowId] + const previous = old?.binding + const same = + previous?.kind === 'local' && + previous.rowId === binding.rowId && + previous.identity === binding.identity && + previous.credentialEpoch === binding.credentialEpoch + current.accounts[binding.rowId] = change( + same && old ? old : { binding }, + old, + ) + return current + }, + { + beforeRename: async () => { + await authority() + if (!(await matches())) + throw new NativeRuntimeError('publication-refused') + await configLease.assertOwned() + await stateLease.assertOwned() + }, + }, + ) + return true + }, + ), + ) + } catch (error) { + if ( + error instanceof NativeRuntimeError && + error.code === 'runtime-conflict' + ) + return false + throw error + } + } + async function initializeCredentialEpoch(id: string) { + const row = await poolRow(id) + if (!row?.credential || row.credentialEpoch === undefined) + throw new NativeRuntimeError('publication-refused') + const binding: NativeLocalPoolBinding = { + kind: 'local', + storageId: paths.storageId, + rowId: row.id, + credentialEpoch: row.credentialEpoch, + ...(row.identity !== undefined ? { identity: row.identity } : {}), + } + const subject: NativeRefreshSubject | NativeApiSubject = + row.credential.type === 'api' + ? { + kind: 'api', + binding, + credentialFingerprint: fingerprintOf(row.credential), + baseURL: row.credential.baseURL, + authHeader: row.credential.authHeader, + } + : { + binding, + credentialFingerprint: fingerprintOf(row.credential), + version: {}, + } + const kept = await localWrite( + subject, + (entry, previous) => { + const old = previous?.binding + if ( + old?.kind === 'local' && + old.credentialEpoch === binding.credentialEpoch && + old.identity === binding.identity + ) + return entry + // A new credentialEpoch identifies replacement credentials, not an ordinary + // token refresh. Drop old validation, token backoff and the key that deduplicates + // Prime requests, so replacement credentials cannot inherit old permission + // or claims. Retain counters and profile only for the same account UUID. + const sameAccount = + old?.kind === 'local' && + old.rowId === binding.rowId && + binding.identity !== undefined && + old.identity === binding.identity + const next: NativeRuntimeEntry = { binding } + if (sameAccount && previous) { + for (const key of [ + 'prime', + 'profile', + 'lastUsed', + 'lastQuotaRefreshError', + 'quotaErrorClearedAt', + 'quotaErrorGeneration', + ] as const) { + if (previous[key] !== undefined) + Object.assign(next, { [key]: previous[key] }) + } + } + return next + }, + 'binding', + ) + if (!kept) throw new NativeRuntimeError('publication-refused') + } + const runtime: NativeAccountRuntime = { + vault, + async getRelayConfig() { + await authority() + return withLock( + paths.config, + { ...POOL_LOCK_DEFAULTS, name: 'native-relay' }, + async (lease) => { + const settings = await store.readSettings() + if (settings.status !== 'ready') + throw new NativeRuntimeError('publication-refused') + const flags = relayFlags(settings.settings.relay) + const read = await readNativeRuntime(paths.runtime, paths.storageId) + const token = + read.status === 'ready' ? read.state.relay?.token : undefined + await authority() + await lease.assertOwned() + if (!flags.enabled || !flags.url || !token) return null + return { ...flags, token } + }, + ) + }, + async updateRelay(input) { + const patch = captureNativePlainData(input) as NativeRelayUpdate + if ( + !patch || + typeof patch !== 'object' || + Array.isArray(patch) || + Object.keys(patch).some( + (key) => + ![ + 'enabled', + 'url', + 'token', + 'fallbackToDirect', + 'transport', + ].includes(key), + ) + ) + throw new NativeRuntimeError('publication-refused') + for (const key of ['enabled', 'fallbackToDirect'] as const) + if (patch[key] !== undefined && typeof patch[key] !== 'boolean') + throw new NativeRuntimeError('publication-refused') + if (patch.url !== undefined && typeof patch.url !== 'string') + throw new NativeRuntimeError('publication-refused') + if ( + patch.transport !== undefined && + !['http', 'websocket'].includes(patch.transport) + ) + throw new NativeRuntimeError('publication-refused') + if ( + patch.token !== undefined && + patch.token !== null && + (typeof patch.token !== 'string' || + !patch.token.trim() || + /\p{Cc}/u.test(patch.token)) + ) + throw new NativeRuntimeError('publication-refused') + await authority() + await withLock( + paths.config, + { ...POOL_LOCK_DEFAULTS, name: 'native-relay' }, + async (lease) => { + const read = await store.readSettings() + if (read.status !== 'ready') + throw new NativeRuntimeError('publication-refused') + const previous = relayFlags(read.settings.relay) + const metadata = await readNativeRuntime( + paths.runtime, + paths.storageId, + ) + const token = + patch.token === undefined + ? metadata.status === 'ready' + ? metadata.state.relay?.token + : undefined + : patch.token?.trim() + const next = relayFlags({ + ...previous, + ...patch, + enabled: + patch.token === null + ? false + : (patch.enabled ?? previous.enabled), + }) + if (next.enabled && (!next.url || !token)) + throw new NativeRuntimeError('publication-refused') + async function writeFlags(flags: Omit) { + await store.updateSettings(async (settings) => { + await authority() + await lease.assertOwned() + settings.relay = flags + }) + } + // Relay flags and its token live in separate files. Disable first so a crash + // cannot send the new token to the old endpoint. PoolStore checks its + // config/state locks; immediately before replacing the token file, + // also verify the lock that serializes relay readers and writers, and that + // the journal still authorizes using the pool instead of host credentials. + await writeFlags({ ...previous, enabled: false }) + if (patch.token !== undefined) + await updateNativeRuntime( + paths.runtime, + paths.storageId, + (current) => { + if (token) current.relay = { token } + else delete current.relay + return current + }, + { + beforeRename: async () => { + await authority() + await lease.assertOwned() + }, + }, + ) + await writeFlags(next) + }, + ) + }, + async read() { + await authority() + const [read, settings, metadata] = await Promise.all([ + store.read(), + store.readSettings(), + readNativeRuntime(paths.runtime, paths.storageId), + ]) + if (read.status !== 'ready' || settings.status !== 'ready') + throw new NativeRuntimeError('publication-refused') + const mode = ( + settings.settings.claustrum as { mode?: unknown } | undefined + )?.mode + // Status reads use stored account metadata without fetching tokens or + // requesting vault access. Refresh, or first authorization with no stored + // account list, discovers authorized vault accounts. Only offline setup + // may enroll the host and request its grant. + const roster = mode === 'claustrum' ? await vault.read() : undefined + check() + return projectNativeAccountViews({ + paths, + rows: read.rows, + settings: settings.settings, + runtime: metadata.status === 'ready' ? metadata.state : undefined, + roster, + }) + }, + async authorizeLocal(routeId, request = {}) { + await authority(request.signal) + let snapshot = await runtime.read() + let account = snapshot.accounts.find((row) => row.id === routeId) + if (snapshot.mode !== 'local') + return { status: 'refused', reason: 'custody-mode', persisted: false } + if ( + !account?.binding || + account.source !== 'local' || + account.type !== 'oauth' + ) + return { status: 'refused', reason: 'row-ineligible', persisted: false } + const metadata = await readNativeRuntime(paths.runtime, paths.storageId) + const previous = + metadata.status === 'ready' + ? metadata.state.accounts[account.binding.rowId]?.binding + : undefined + if ( + previous?.kind === 'local' && + previous.credentialEpoch < account.binding.credentialEpoch + ) { + // If runtime metadata has an older credentialEpoch, login stopped after + // storing the new pool credentials. Record their runtime binding + // without treating replacement credentials as already validated. + await initializeCredentialEpoch(account.binding.rowId) + snapshot = await runtime.read() + account = snapshot.accounts.find((row) => row.id === routeId) + if (!account?.binding) + return { + status: 'refused', + reason: 'row-ineligible', + persisted: false, + } + } + const baseGate = ( + row: NativeAccountView | undefined, + current: NativeAccountSnapshot, + ): NativeLocalExternalPolicy => { + if (current.mode !== 'local') + return { status: 'blocked', reason: 'local-mode-unavailable' } + if (!row?.enabled) + return { status: 'blocked', reason: 'account-disabled' } + return { status: 'allowed' } + } + const requestGate = ( + row: NativeAccountView | undefined, + current: NativeAccountSnapshot, + ): NativeLocalExternalPolicy => { + const base = baseGate(row, current) + return base.status === 'allowed' && row + ? (request.policy?.(row, current) ?? base) + : base + } + if (requestGate(account, snapshot).status === 'blocked') + return { + status: 'refused', + reason: 'quota-ineligible', + persisted: false, + } + // Each caller evaluates its own model's quota/killswitch gate before and + // after recovery. A joining caller must not inherit another caller's model + // refusal, so only mode/account gates enter the shared refresh job. + const service = createNativeLocalCredentialService({ + paths, + ...options.local, + now, + externalPolicy: () => baseGate(account, snapshot), + failurePolicy: + options.local?.failurePolicy ?? + ((input) => { + const failure = + input.observation.status === 'failed' + ? input.observation.failure + : undefined + const error = Object.assign( + new Error( + failure?.classification === 'invalid-grant' + ? 'invalid_grant' + : 'Local refresh failed', + ), + { + status: failure?.status, + retryAfter: failure?.retryAfter, + ...(failure?.classification === 'transient' && + failure.status === undefined + ? { code: 'ECONNRESET' } + : {}), + }, + ) + const policy = buildRefreshOperationError({ + error, + now: now(), + accountIdentity: input.attribution.subject.binding.identity, + previous: { + message: 'Local refresh failed', + checkedAt: now(), + accountIdentity: input.attribution.subject.binding.identity, + retryCount: Math.max(0, input.observation.attempt - 1), + }, + }) + return { + checkedAt: policy.checkedAt, + nextRetryAt: policy.nextRetryAt, + retryCount: policy.retryCount, + } + }), + }) + const result = await service.authorize({ + intent: request.intent ?? 'serve', + mode: 'local', + binding: account.binding, + rejectedAccessToken: request.rejectedAccessToken, + signal: AbortSignal.any([ + shutdown.signal, + ...(request.signal ? [request.signal] : []), + ]), + }) + check(request.signal) + if (result.status === 'usable') { + const current = await runtime.read() + const row = current.accounts.find((row) => row.id === routeId) + if ( + requestGate(row, current).status === 'blocked' || + !row?.binding || + row.binding.rowId !== result.binding.rowId || + row.binding.credentialEpoch !== result.binding.credentialEpoch || + row.binding.identity !== result.binding.identity + ) + return { + status: 'refused', + reason: 'row-ineligible', + persisted: false, + } + } + return result + }, + async authorizeApi(routeId, signal) { + await authority(signal) + const row = await poolRow(await physicalId(routeId)) + check(signal) + if ( + !row?.enabled || + !row.candidate || + row.stamp !== 'bound' || + row.credential?.type !== 'api' + ) + throw new NativeCustodyError('route-unavailable') + return { + ...row.credential, + subject: { + kind: 'api', + binding: captureNativeLocalPoolBinding(paths, row), + credentialFingerprint: fingerprintOf(row.credential), + baseURL: row.credential.baseURL, + authHeader: row.credential.authHeader, + }, + } + }, + authorizeVault: (id, signal) => { + check(signal) + return vault.authorize(id, signal) + }, + loginOAuth: (input) => + runtime.putAccount({ + ...input, + replace: input.replace ?? true, + credential: { type: 'oauth', ...input.credential }, + }), + async putAccount(input) { + await authority() + const snapshot = await runtime.read() + if (input.credential.type === 'oauth' && snapshot.mode !== 'local') + throw new NativeCustodyError('route-unavailable') + const id = + input.routeId === 'main' + ? await physicalId('main').catch(() => randomUUID()) + : input.routeId + if ( + input.credential.type === 'api' && + (input.routeId === 'main' || id === snapshot.settings.mainAccountId) + ) + throw new NativeCustodyError('route-unavailable') + const old = await poolRow(id) + let resultId = id + if (input.replace && old) { + if (!old.credentialEpoch) + throw new NativeRuntimeError('publication-refused') + await store.replace( + id, + input.credential, + { identity: input.accountIdentity }, + { + attribution: { + credentialEpoch: old.credentialEpoch, + identity: old.identity, + }, + }, + ) + } else { + const added = await store.add({ + id, + credential: input.credential, + identity: input.accountIdentity, + label: input.label, + }) + resultId = added.id + } + if (input.routeId === 'main') + await store.updateSettings((settings) => { + settings.mainAccountId = resultId + }) + await initializeCredentialEpoch(resultId) + const next = await runtime.read() + const row = next.accounts.find((row) => row.binding?.rowId === resultId) + if (!row) throw new NativeCustodyError('route-unavailable') + return row + }, + addApi: (input) => + runtime.putAccount({ + routeId: input.routeId ?? randomUUID(), + label: input.label, + replace: input.replace, + credential: { + type: 'api', + apiKey: input.apiKey, + baseURL: input.baseURL ?? 'https://api.anthropic.com', + authHeader: input.authHeader, + }, + }), + async updateSettings(mutator) { + await authority() + return store.updateSettings(async (settings) => { + function activation(value: Record) { + const custody = value.claustrum as Record | undefined + return [ + value.mainAccountId, + custody?.mode, + custody?.primaryAccount, + custody?.scopedRoster, + custody?.rosterView, + value.relay, + ] + } + const before = JSON.stringify(activation(settings)) + const result = await mutator(settings) + const next = captureNativePlainData(result ?? settings) as Record< + string, + unknown + > + if (JSON.stringify(activation(next)) !== before) + throw new NativeCustodyError('route-unavailable') + return next + }) + }, + async setEnabled(routeId, enabled) { + const current = await runtime.read() + const row = current.accounts.find((row) => row.id === routeId) + if (row?.source === 'vault') return vault.setEnabled(routeId, enabled) + const id = await physicalId(routeId) + if (enabled) await store.enable(id) + else await store.disable(id, 'user-disabled') + }, + async remove(routeId) { + const current = await runtime.read() + if ( + current.accounts.find((row) => row.id === routeId)?.source === 'vault' + ) { + await vault.setEnabled(routeId, false) + return + } + await store.remove(await physicalId(routeId)) + }, + async reorder(routeIds) { + await authority() + const snapshot = await runtime.read() + const read = await store.read() + if (read.status !== 'ready') + throw new NativeRuntimeError('publication-refused') + if (snapshot.mode === 'claustrum') { + const order = [...routeIds] + if ( + snapshot.accounts.some((row) => row.id === 'main') && + !order.includes('main') + ) + order.unshift('main') + if ( + new Set(order).size !== order.length || + order.length !== snapshot.accounts.length || + order.some((id) => !snapshot.accounts.some((row) => row.id === id)) + ) + throw new NativeCustodyError('invalid-state') + await mutateVaultRoster(paths.roster, (roster) => { + if (!roster) throw new NativeCustodyError('route-unavailable') + const primary = nativeVaultPrimaryRow( + roster, + nativeVaultPrimaryPin(snapshot.settings), + ) + const ids = order + .filter( + (id) => + snapshot.accounts.find((row) => row.id === id)?.source === + 'vault', + ) + .map((id) => (id === 'main' ? primary?.routeId : id)) + if ( + ids.length !== roster.rows.length || + ids.some((id) => !roster.rows.some((row) => row.routeId === id)) + ) + throw new NativeCustodyError('identity-changed') + return { + next: { + ...roster, + rows: ids.map((id) => { + const row = roster.rows.find((row) => row.routeId === id) + if (!row) throw new NativeCustodyError('identity-changed') + return row + }), + }, + result: undefined, + } + }) + // Ordering spans the pool account config and the native vault roster. + // Their writes are not a two-file atomic transaction: an interruption + // can leave mixed presentation order, but neither roster loses a member. + await store.updateSettings((settings) => { + settings.nativeAccountOrder = order + }) + return + } + const ids = await Promise.all(routeIds.map(physicalId)) + const main = read.rows.find( + (row) => row.id === snapshot.settings.mainAccountId, + ) + if (main && !ids.includes(main.id)) ids.unshift(main.id) + await store.reorder(ids) + }, + async captureLocalSubject(routeId) { + await authority() + const row = await poolRow(await physicalId(routeId)) + if (row?.credential?.type !== 'oauth' || row.stamp !== 'bound') + throw new NativeCustodyError('route-unavailable') + const credential = row.credential + const binding: NativeLocalPoolBinding = { + kind: 'local', + storageId: paths.storageId, + rowId: row.id, + credentialEpoch: row.credentialEpoch ?? 0, + ...(row.identity !== undefined ? { identity: row.identity } : {}), + } + if (!nativeLocalPoolBindingMatches(binding, paths, row)) + throw new NativeCustodyError('route-unavailable') + return { + binding, + credentialFingerprint: fingerprintOf(credential), + version: { + ...(credential.access + ? { accessFingerprint: tokenFingerprint(credential.access) } + : {}), + ...(credential.expires !== undefined + ? { expires: credential.expires } + : {}), + ...(Object.hasOwn(credential, 'lastRefreshedAt') + ? { lastRefreshedAt: credential.lastRefreshedAt } + : {}), + }, + } + }, + async publishLocal(subject, rawPatch) { + const patch = captureNativeMetadataPatch(rawPatch) + await authority() + const row = await poolRow(subject.binding.rowId) + if ( + !row || + !nativeLocalCredentialValidationMatches( + subject, + subject.binding, + row.credential, + ) + ) + return false + const { quota, ...metadata } = patch + if (quota) { + if (quota.accountIdentity !== subject.binding.identity) + throw new NativeRuntimeError('publication-refused') + // Pool quota belongs to the account/epoch, not a rotating access token. + // The public store fences epoch and UUID and merges observation freshness. + await store.recordQuota( + subject.binding.rowId, + { + credentialEpoch: subject.binding.credentialEpoch, + identity: subject.binding.identity, + }, + toNativeQuotaMap(quota), + ) + } + return localWrite(subject, (old) => + applyNativeMetadataPatch(old, metadata), + ) + }, + publishApi: (subject, patch) => + localWrite(subject, (old) => ({ + ...old, + lastUsed: Math.max(old.lastUsed ?? 0, patch.lastUsed), + })), + async resetBackoff(routeId, fence, kind = 'all') { + if ('accessToken' in fence) return vault.resetBackoff(fence, kind) + if ((await physicalId(routeId)) !== fence.binding.rowId) return false + return localWrite( + fence, + (old) => { + const clear = Math.max( + now(), + old.refreshErrorClearedAt ?? 0, + old.quotaErrorClearedAt ?? 0, + old.lastRefreshError?.checkedAt ?? 0, + old.lastQuotaRefreshError?.checkedAt ?? 0, + ) + const next = { ...old } + if (kind !== 'quota') { + delete next.lastRefreshError + delete next.validationRetry + next.refreshErrorClearedAt = clear + } + if (kind !== 'refresh') { + delete next.lastQuotaRefreshError + next.quotaErrorClearedAt = clear + next.quotaErrorGeneration = (old.quotaErrorGeneration ?? 0) + 1 + } + return next + }, + false, + ) + }, + async getOrCreateAuthLineage(routeId, fence) { + if ('accessToken' in fence) return vault.getOrCreateAuthLineage(fence) + if ((await physicalId(routeId)) !== fence.binding.rowId) return undefined + let lineage: string | undefined + const kept = await localWrite(fence, (old) => { + // The entry's epoch/UUID fence changes on replacement, not ordinary token + // rotation. Keep the account's lineage and counters through refresh. + lineage = old.authLineageId ?? randomUUID() + return { + ...old, + authLineageId: lineage, + } + }) + return kept ? lineage : undefined + }, + async incrementPrimeUsage(routeId, fence, delta = {}) { + if ('accessToken' in fence) return vault.incrementPrimeUsage(fence, delta) + if ((await physicalId(routeId)) !== fence.binding.rowId) return false + const captured = primeDelta(delta) + return localWrite(fence, (old) => ({ + ...old, + prime: { + count: (old.prime?.count ?? 0) + 1, + inputTokens: (old.prime?.inputTokens ?? 0) + captured.inputTokens, + outputTokens: (old.prime?.outputTokens ?? 0) + captured.outputTokens, + since: old.prime?.since ?? now(), + }, + })) + }, + async fetchQuota(routeId, fetchImpl = fetch, signal) { + const snapshot = await runtime.read() + if (snapshot.mode === 'claustrum') + return vault.fetchQuota(routeId, fetchImpl, signal) + const before = snapshot.accounts.find((row) => row.id === routeId) + const result = await fetchReading( + routeId, + fetchImpl, + signal, + (attempt, transport) => + fetchOAuthQuotaSnapshot({ + accessToken: attempt.access, + fetchImpl: transport, + now, + }), + async (error, attempt) => { + const matched = + before?.binding && + before.binding.rowId === attempt.binding.rowId && + before.binding.credentialEpoch === + attempt.binding.credentialEpoch && + before.binding.identity === attempt.binding.identity + ? before + : undefined + const failure = nativeQuotaFailure( + error, + attempt.binding.identity, + Math.max(now(), (matched?.quotaErrorClearedAt ?? -1) + 1), + matched?.lastQuotaRefreshError, + ) + if (failure) + await runtime.publishLocal(attempt.subject, { + lastQuotaRefreshError: failure, + }) + }, + ) + const quota = { + ...result.value, + accountIdentity: result.attempt.binding.identity, + } + const matched = + before?.binding && + before.binding.rowId === result.attempt.binding.rowId && + before.binding.credentialEpoch === + result.attempt.binding.credentialEpoch && + before.binding.identity === result.attempt.binding.identity + ? before + : undefined + await runtime.publishLocal(result.attempt.subject, { + quota, + quotaErrorClearedAt: Math.max( + quota.checkedAt ?? now(), + matched?.quotaErrorClearedAt ?? 0, + matched?.lastQuotaRefreshError?.checkedAt ?? 0, + ), + }) + return quota + }, + async fetchProfile(routeId, fetchImpl = fetch, signal) { + if ((await runtime.read()).mode === 'claustrum') + return vault.fetchProfile(routeId, fetchImpl, signal) + const result = await fetchReading( + routeId, + fetchImpl, + signal, + (attempt, transport) => + fetchOAuthAccountProfile({ + accessToken: attempt.access, + accountIdentity: attempt.binding.identity, + fetchImpl: transport, + now, + signal, + }), + ) + await runtime.publishLocal(result.attempt.subject, { + profile: result.value, + }) + return result.value + }, + close() { + if (!shutdown.signal.aborted) { + shutdown.abort(new NativeCustodyError('closed')) + vault.close() + } + }, + } + async function fetchReading( + id: string, + fetchImpl: typeof fetch, + signal: AbortSignal | undefined, + read: ( + attempt: Extract, + transport: typeof fetch, + ) => Promise, + onFailure?: ( + error: unknown, + attempt: Extract, + ) => Promise, + ) { + const combined = AbortSignal.any([ + shutdown.signal, + ...(signal ? [signal] : []), + ]) + const first = await runtime.authorizeLocal(id, { signal: combined }) + if (first.status !== 'usable') + throw new NativeCustodyError('route-unavailable') + let served = first + let responseStatus: number | undefined + const transport: typeof fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + responseStatus = undefined + let response = await fetchImpl(input, { ...init, signal: combined }) + responseStatus = response.status + if (response.status === 401 && !combined.aborted) { + await response.body?.cancel().catch(() => {}) + check(combined) + const next = await runtime.authorizeLocal(id, { + rejectedAccessToken: served.access, + signal: combined, + }) + if ( + next.status === 'usable' && + next.binding.rowId === served.binding.rowId && + next.binding.credentialEpoch === served.binding.credentialEpoch && + next.binding.identity === served.binding.identity && + next.access !== served.access && + next.subject.version.lastRefreshedAt !== undefined && + (served.subject.version.lastRefreshedAt === undefined || + next.subject.version.lastRefreshedAt > + served.subject.version.lastRefreshedAt) + ) { + served = next + const headers = new Headers(init?.headers) + headers.set('authorization', `Bearer ${served.access}`) + responseStatus = undefined + response = await fetchImpl(input, { + ...init, + headers, + signal: combined, + }) + responseStatus = response.status + } + } + return response + }, + { preconnect: fetchImpl.preconnect }, + ) + let value: T + try { + value = await read(served, transport) + } catch (error) { + check(combined) + const safe = + error instanceof NativeCustodyError || + error instanceof NativeRuntimeError + ? error + : nativeAccountReadError(error, responseStatus) + await onFailure?.(safe, served) + throw safe + } + check(combined) + return { value, attempt: served } + } + return runtime +} + +export function primeDelta(delta: PrimeUsageDelta) { + const inputTokens = delta.inputTokens ?? 0 + const outputTokens = delta.outputTokens ?? 0 + if ( + ![inputTokens, outputTokens].every( + (value) => Number.isSafeInteger(value) && value >= 0, + ) + ) + throw new NativeRuntimeError('publication-refused') + return { inputTokens, outputTokens } +} + +function relayFlags(input: unknown): Omit { + const value = + input && typeof input === 'object' ? (input as Record) : {} + const url = typeof value.url === 'string' ? value.url.trim() : '' + const transport = value.transport === 'http' ? 'http' : 'websocket' + if (url) { + try { + const parsed = new URL(url) + if ( + !['https:', 'http:', 'wss:', 'ws:'].includes(parsed.protocol) || + parsed.username || + parsed.password || + (transport === 'http' && !['https:', 'http:'].includes(parsed.protocol)) + ) + throw new Error('invalid endpoint') + } catch { + throw new NativeRuntimeError('publication-refused') + } + } + return { + enabled: value.enabled === true, + url, + transport, + fallbackToDirect: value.fallbackToDirect !== false, + } +} diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts new file mode 100644 index 000000000..c77730ea6 --- /dev/null +++ b/packages/core/src/native-account-view.ts @@ -0,0 +1,636 @@ +import { types } from 'node:util' +import type { PoolRow } from '@cortexkit/common-auth/store' + +import { + type AccountOperationError, + type AccountStorage, + buildQuotaOperationError, + type FallbackAccount, + type OAuthAccountProfile, + type OAuthQuotaSnapshot, + type PrimeUsageCounters, + type ProviderAccountUuid, +} from './accounts.ts' +import { + fromNativeQuotaMap, + nativeQuotaCodec, + toNativeQuotaMap, +} from './native-quota-codec.ts' +import type { NativeVaultRosterDocument } from './native-roster-seed.ts' +import type { + NativeRuntimeEntry, + NativeRuntimeState, +} from './native-runtime.ts' +import { NativeRuntimeError } from './native-runtime.ts' +import { isTransientNetworkError } from './network-errors.ts' +import { + type NativeLocalPoolBinding, + nativeLocalPoolBindingMatches, +} from './pool-binding.ts' +import type { NativePoolPaths } from './pool-paths.ts' + +/** Display and policy metadata, not evidence that a presented token was accepted for this account. */ +export interface NativeAccountView { + id: string + type: 'oauth' | 'api' + source: 'local' | 'vault' + enabled: boolean + label?: string + addedAt?: number + accountIdentity?: string + binding?: NativeLocalPoolBinding + credentialId?: string + state?: string + quota?: OAuthQuotaSnapshot + profile?: OAuthAccountProfile + prime?: PrimeUsageCounters + lastUsed?: number + lastRefreshedAt?: number + lastRefreshError?: AccountOperationError + lastQuotaRefreshError?: AccountOperationError + quotaErrorClearedAt?: number + quotaErrorGeneration?: number + refreshErrorClearedAt?: number + baseURL?: string + authHeader?: 'authorization-bearer' | 'x-api-key' +} + +export interface NativeAccountSnapshot { + mode: 'local' | 'claustrum' + settings: Record + accounts: NativeAccountView[] + /** AccountStorage-shaped input for pure policy predicates, never a credential or persistence authority. */ + policyStorage: AccountStorage +} + +export interface NativeAccountMetadataPatch { + quota?: OAuthQuotaSnapshot + profile?: OAuthAccountProfile + lastUsed?: number + lastQuotaRefreshError?: AccountOperationError + quotaErrorClearedAt?: number + quotaErrorGeneration?: number + prime?: PrimeUsageCounters + authLineageId?: string +} + +/** Copy data without invoking caller getters, proxies, serializers or callbacks. */ +export function captureNativePlainData(input: unknown): unknown { + const ancestors = new Set() + function copy(value: unknown): unknown { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + value === undefined + ) + return value + if (typeof value === 'number' && Number.isFinite(value)) return value + if ( + !value || + typeof value !== 'object' || + types.isProxy(value) || + ancestors.has(value) + ) + throw new NativeRuntimeError('publication-refused') + ancestors.add(value) + try { + if (Array.isArray(value)) { + if ( + Reflect.ownKeys(value).some( + (key) => + typeof key !== 'string' || + (key !== 'length' && !/^(0|[1-9][0-9]*)$/.test(key)), + ) + ) + throw new NativeRuntimeError('publication-refused') + return Array.from({ length: value.length }, (_, index) => { + const descriptor = Object.getOwnPropertyDescriptor( + value, + String(index), + ) + if (!descriptor || !('value' in descriptor)) + throw new NativeRuntimeError('publication-refused') + return copy(descriptor.value) + }) + } + if ( + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + ) + throw new NativeRuntimeError('publication-refused') + const result: Record = {} + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if ( + typeof key !== 'string' || + !descriptor || + !('value' in descriptor) || + key === '__proto__' + ) + throw new NativeRuntimeError('publication-refused') + result[key] = copy(descriptor.value) + } + return result + } finally { + ancestors.delete(value) + } + } + return copy(input) +} + +export function captureNativeMetadataPatch( + patch: NativeAccountMetadataPatch, +): NativeAccountMetadataPatch { + const result = captureNativePlainData(patch) as NativeAccountMetadataPatch + if ( + !result || + Object.keys(result).some( + (key) => + ![ + 'quota', + 'profile', + 'lastUsed', + 'lastQuotaRefreshError', + 'quotaErrorClearedAt', + 'quotaErrorGeneration', + 'prime', + 'authLineageId', + ].includes(key), + ) + ) + throw new NativeRuntimeError('publication-refused') + return result +} + +export function applyNativeMetadataPatch( + entry: NativeRuntimeEntry, + patch: NativeAccountMetadataPatch, +): NativeRuntimeEntry { + const { quota, ...metadata } = patch + const next = { ...entry, ...metadata } + if (patch.lastUsed !== undefined) { + if (!Number.isFinite(patch.lastUsed) || patch.lastUsed < 0) + throw new NativeRuntimeError('publication-refused') + next.lastUsed = Math.max(entry.lastUsed ?? 0, patch.lastUsed) + } + if (quota) { + const observation = toNativeQuotaMap(quota) + next.quota = nativeQuotaCodec.merge( + entry.quota, + observation, + ) as NativeRuntimeEntry['quota'] + next.quotaCheckedAt = quota.checkedAt + } + if (patch.quotaErrorClearedAt !== undefined) delete next.lastQuotaRefreshError + return next +} + +/** Keep established retry policy without persisting provider bodies or arbitrary exception text. */ +export function nativeQuotaFailure( + error: unknown, + accountIdentity: string | undefined, + checkedAt: number, + previous?: AccountOperationError, +): AccountOperationError | undefined { + const safe = new Error('OAuth quota check failed') + if (error && typeof error === 'object' && !types.isProxy(error)) { + for (const key of ['status', 'retryAfter']) { + const value = Object.getOwnPropertyDescriptor(error, key)?.value + if (typeof value === 'number' && Number.isFinite(value)) + Object.assign(safe, { [key]: value }) + } + const code = Object.getOwnPropertyDescriptor(error, 'code')?.value + const message = Object.getOwnPropertyDescriptor(error, 'message')?.value + if ( + isTransientNetworkError({ + code: typeof code === 'string' ? code : '', + message: typeof message === 'string' ? message : '', + }) + ) + Object.assign(safe, { code: 'ECONNRESET' }) + } + const status = Object.getOwnPropertyDescriptor(safe, 'status')?.value + // Token rejection and account/org policy failures need reauthorization or a + // different account, not a quota-saturation delay. + if (status === 401 || status === 403) return undefined + const policy = buildQuotaOperationError({ + error: safe, + accountIdentity, + now: checkedAt, + previous, + }) + return { + ...policy, + ...(typeof status === 'number' && Number.isFinite(status) + ? { status } + : {}), + } +} + +export class NativeAccountReadError extends Error { + constructor( + public readonly status?: number, + public readonly retryAfter?: number, + public readonly code?: 'ECONNRESET', + ) { + super( + `OAuth metadata read failed${status !== undefined ? `: ${status}` : ''}`, + ) + this.name = 'NativeAccountReadError' + } +} + +/** Keep the HTTP status and retry classification. Omit provider error text and nested causes because they can contain credentials. */ +export function nativeAccountReadError( + error: unknown, + responseStatus?: number, +): NativeAccountReadError { + const fields: Record = {} + if (error && typeof error === 'object' && !types.isProxy(error)) { + for (const key of ['status', 'retryAfter', 'code', 'message']) + fields[key] = Object.getOwnPropertyDescriptor(error, key)?.value + } + const status = + typeof fields.status === 'number' && Number.isFinite(fields.status) + ? fields.status + : undefined + const retryAfter = fields.retryAfter + const code = + status === undefined && + isTransientNetworkError({ + code: typeof fields.code === 'string' ? fields.code : '', + message: typeof fields.message === 'string' ? fields.message : '', + }) + ? 'ECONNRESET' + : undefined + return new NativeAccountReadError( + responseStatus ?? status, + typeof retryAfter === 'number' && Number.isFinite(retryAfter) + ? retryAfter + : undefined, + code, + ) +} + +export const NATIVE_PRIMARY_CREDENTIAL_ID = 'oauth:anthropic' + +export interface NativeVaultPrimaryPin { + routeId: string + credentialId: string + accountIdentity: string +} + +/** Offline setup records the primary account UUID and credential ID. Later vault discovery must not replace that selection. */ +export function nativeVaultPrimaryPin( + settings: Record, +): NativeVaultPrimaryPin | undefined { + const custody = settings.claustrum as + | { primaryAccount?: { credentialId?: unknown; accountId?: unknown } } + | undefined + const primary = custody?.primaryAccount + if ( + typeof settings.mainAccountId !== 'string' || + !settings.mainAccountId || + typeof primary?.credentialId !== 'string' || + !primary.credentialId || + typeof primary.accountId !== 'string' || + !primary.accountId + ) + return undefined + return { + routeId: settings.mainAccountId, + credentialId: primary.credentialId, + accountIdentity: primary.accountId, + } +} + +export function nativeVaultPrimaryRow( + roster: NativeVaultRosterDocument | undefined, + pin: NativeVaultPrimaryPin | undefined, +) { + if (!pin) return undefined + return roster?.rows.find( + (row) => + row.routeId === pin.routeId && + row.accountIdentity === pin.accountIdentity && + (row.credentialId === pin.credentialId || + row.aliases?.includes(pin.credentialId)), + ) +} + +// Select public settings explicitly. Exclude stored tokens, relay authorization +// and arbitrary extra fields so UI and routing snapshots cannot retain credentials. +const settingFields: Readonly> = { + routing: ['mode'], + refresh: ['enabled', 'intervalMinutes', 'refreshBeforeExpiryMinutes'], + quota: [ + 'enabled', + 'checkIntervalMinutes', + 'refreshEveryNRequests', + 'minimumRemaining', + 'failClosedOnUnknownQuota', + 'showToasts', + ], + quotaHeaderFeed: ['enabled'], + claudeCache: ['enabled', 'mode'], + dump: ['enabled'], + logging: ['level'], + claudeFast: ['enabled'], + thinkingBinding: ['prefixMismatchBehavior'], + costZeroing: ['enabled'], + cacheKeep: ['enabled', 'always', 'startHour', 'endHour', 'subagents'], + prime: ['enabled'], + relay: ['enabled', 'url', 'fallbackToDirect', 'transport'], + claustrum: ['mode', 'disabledAccountIdentities'], + killswitch: ['enabled', 'main', 'accounts'], +} + +export function nativeAccountSettings( + settings: Record, +): Record { + const result: Record = {} + if (typeof settings.mainAccountId === 'string') + result.mainAccountId = settings.mainAccountId + if (Array.isArray(settings.nativeAccountOrder)) + result.nativeAccountOrder = settings.nativeAccountOrder.filter( + (value) => typeof value === 'string', + ) + if (Array.isArray(settings.fallbackOn)) + result.fallbackOn = settings.fallbackOn.filter((value) => + Number.isInteger(value), + ) + for (const [key, fields] of Object.entries(settingFields)) { + const value = settings[key] + if (!value || typeof value !== 'object' || Array.isArray(value)) continue + const selected: Record = {} + for (const field of fields) { + const item = Object.getOwnPropertyDescriptor(value, field)?.value + if (item !== undefined) selected[field] = structuredClone(item) + } + result[key] = selected + } + const custody = settings.claustrum as + | { + primaryAccount?: { + credentialId?: unknown + accountId?: unknown + state?: unknown + } + } + | undefined + if (custody?.primaryAccount) { + const selected = result.claustrum as Record + selected.primaryAccount = { + credentialId: custody.primaryAccount.credentialId, + accountId: custody.primaryAccount.accountId, + state: custody.primaryAccount.state, + } + } + return result +} + +function sameLocal( + entry: NativeRuntimeEntry | undefined, + binding: NativeLocalPoolBinding, +) { + const old = entry?.binding + return ( + old?.kind === 'local' && + old.storageId === binding.storageId && + old.rowId === binding.rowId && + old.identity === binding.identity && + old.credentialEpoch === binding.credentialEpoch + ) +} + +function vaultEntry( + input: { paths: NativePoolPaths; runtime?: NativeRuntimeState }, + row: NonNullable['rows'][number], +) { + const stored = input.runtime?.accounts[row.routeId] + const binding = stored?.binding + return binding?.kind === 'custody' && + binding.storageId === input.paths.storageId && + binding.routeId === row.routeId && + binding.accountIdentity === row.accountIdentity && + (binding.credentialId === row.credentialId || + row.aliases?.includes(binding.credentialId)) + ? stored + : undefined +} + +function safeError( + error: AccountOperationError | undefined, +): AccountOperationError | undefined { + if (!error) return undefined + return { + message: 'OAuth operation failed', + checkedAt: error.checkedAt, + nextRetryAt: error.nextRetryAt, + retryCount: error.retryCount, + accountIdentity: error.accountIdentity, + status: error.status, + permanent: error.permanent, + } +} + +function metadata( + entry: NativeRuntimeEntry | undefined, +): Partial { + return { + lastUsed: entry?.lastUsed, + lastRefreshedAt: entry?.lastRefreshedAt, + lastRefreshError: safeError(entry?.lastRefreshError), + lastQuotaRefreshError: safeError(entry?.lastQuotaRefreshError), + quotaErrorClearedAt: entry?.quotaErrorClearedAt, + quotaErrorGeneration: entry?.quotaErrorGeneration, + refreshErrorClearedAt: entry?.refreshErrorClearedAt, + profile: entry?.profile && { + tier: entry.profile.tier, + orgType: entry.profile.orgType, + checkedAt: entry.profile.checkedAt, + accountIdentity: entry.profile.accountIdentity, + providerAccountUuid: entry.profile.providerAccountUuid, + }, + prime: entry?.prime && { ...entry.prime }, + } +} + +/** Join quota, profile and errors only for matching storage, account and credential generation; a reused route must not expose its previous account. */ +export function projectNativeAccountViews(input: { + paths: NativePoolPaths + rows: readonly PoolRow[] + settings: Record + runtime?: NativeRuntimeState + roster?: NativeVaultRosterDocument +}): NativeAccountSnapshot { + const settings = nativeAccountSettings(input.settings) + const mode = + (settings.claustrum as { mode?: unknown } | undefined)?.mode === 'claustrum' + ? 'claustrum' + : 'local' + const accounts: NativeAccountView[] = [] + for (const row of input.rows) { + if (mode === 'claustrum' && row.type === 'oauth') continue + const proposed: NativeLocalPoolBinding | undefined = + row.credentialEpoch !== undefined + ? { + kind: 'local', + storageId: input.paths.storageId, + rowId: row.id, + credentialEpoch: row.credentialEpoch, + ...(row.identity !== undefined ? { identity: row.identity } : {}), + } + : undefined + // Disabled rows still have identity/metadata, but are never dispatch candidates. + const binding = + proposed && nativeLocalPoolBindingMatches(proposed, input.paths, row) + ? proposed + : undefined + const stored = input.runtime?.accounts[row.id] + const entry = binding && sameLocal(stored, binding) ? stored : undefined + const id = row.id === settings.mainAccountId ? 'main' : row.id + accounts.push({ + id, + type: row.type, + source: 'local', + enabled: row.enabled && row.candidate, + label: row.label, + addedAt: row.addedAt, + accountIdentity: row.identity, + binding, + ...metadata(entry), + ...(row.type === 'oauth' && row.quota !== undefined + ? { quota: fromNativeQuotaMap(row.quota) } + : {}), + ...(row.credential?.type === 'api' + ? { + baseURL: row.credential.baseURL, + authHeader: row.credential.authHeader, + } + : {}), + }) + } + if (mode === 'claustrum') { + const primary = nativeVaultPrimaryRow( + input.roster, + nativeVaultPrimaryPin(settings), + ) + for (const row of input.roster?.rows ?? []) { + if (row.credentialType !== 'oauth') continue + const entry = vaultEntry(input, row) + accounts.push({ + id: row === primary ? 'main' : row.routeId, + type: 'oauth', + source: 'vault', + enabled: row.enabled && row.state === 'active', + label: row.label, + addedAt: row.addedAt, + accountIdentity: row.accountIdentity, + credentialId: row.credentialId, + state: row.state, + ...metadata(entry), + ...(entry?.quota ? { quota: fromNativeQuotaMap(entry.quota) } : {}), + }) + } + } + if (Array.isArray(settings.nativeAccountOrder)) { + const order = settings.nativeAccountOrder + accounts.sort((a, b) => { + const left = order.indexOf(a.id), + right = order.indexOf(b.id) + return ( + (left < 0 ? order.length : left) - (right < 0 ? order.length : right) + ) + }) + } + // The empty refresh field lets existing routing and quota checks recognize + // an OAuth account without exposing its token. These display rows cannot + // authorize requests or write credentials; they carry no verified serving token. + const fallbacks: FallbackAccount[] = accounts + .filter((row) => row.id !== 'main') + .map((row) => + row.type === 'oauth' + ? { + id: row.id, + type: 'oauth', + refresh: '', + label: row.label, + enabled: row.enabled, + addedAt: row.addedAt, + anthropicAccountUuid: row.accountIdentity as + | ProviderAccountUuid + | undefined, + claustrumScopedCredentialId: row.credentialId, + claustrumScopedState: row.state, + quota: row.quota, + profile: row.profile, + prime: row.prime, + lastUsed: row.lastUsed, + lastRefreshedAt: row.lastRefreshedAt, + lastRefreshError: row.lastRefreshError, + lastQuotaRefreshError: row.lastQuotaRefreshError, + } + : { + id: row.id, + type: 'api', + label: row.label, + enabled: row.enabled, + addedAt: row.addedAt, + baseURL: row.baseURL ?? '', + authHeader: row.authHeader, + lastUsed: row.lastUsed, + }, + ) + const policyStorage: AccountStorage = { + version: 1, + ...settings, + accounts: fallbacks, + } + const main = accounts.find((row) => row.id === 'main') + const primary = nativeVaultPrimaryRow( + input.roster, + nativeVaultPrimaryPin(settings), + ) + const candidateMainEntry = + main?.source === 'local' && main.binding + ? input.runtime?.accounts[main.binding.rowId] + : primary + ? vaultEntry(input, primary) + : undefined + const mainEntry = + main?.source === 'local' && + main.binding && + !sameLocal(candidateMainEntry, main.binding) + ? undefined + : candidateMainEntry + if (main) { + policyStorage.mainAccountId = main.accountIdentity ?? main.binding?.rowId + policyStorage.main = { + type: 'opencode', + provider: 'anthropic', + profile: main.profile, + } + policyStorage.quota = { + ...policyStorage.quota, + mainQuota: main.quota, + mainQuotaCheckedAt: mainEntry?.quotaCheckedAt ?? main.quota?.checkedAt, + mainQuotaToken: main.accountIdentity, + mainLastQuotaApiError: main.lastQuotaRefreshError, + mainQuotaErrorClearedAt: mainEntry?.quotaErrorClearedAt, + mainQuotaErrorGeneration: mainEntry?.quotaErrorGeneration, + } + policyStorage.refresh = { + ...policyStorage.refresh, + mainLastRefreshError: main.lastRefreshError, + mainRefreshErrorClearedAt: mainEntry?.refreshErrorClearedAt, + } + policyStorage.prime = { + ...policyStorage.prime, + main: main.prime, + mainAuthLineageId: mainEntry?.authLineageId, + } + } + return structuredClone({ mode, settings, accounts, policyStorage }) +} diff --git a/packages/core/src/native-vault-runtime.ts b/packages/core/src/native-vault-runtime.ts new file mode 100644 index 000000000..9b536f00a --- /dev/null +++ b/packages/core/src/native-vault-runtime.ts @@ -0,0 +1,735 @@ +import { randomUUID } from 'node:crypto' +import { ClaustrumClient } from '@cortexkit/claustrum-client' +import { + acceptVaultRoute, + declineVaultRoute, + mutateVaultRoster, + readVaultRoster, + refreshVaultRoster, +} from '@cortexkit/common-auth/claustrum' + +import { + fetchOAuthQuotaSnapshot, + type OAuthAccountProfile, + type OAuthQuotaSnapshot, + type PrimeUsageDelta, +} from './accounts.ts' +import { resolveClaustrumConnectionPath } from './claustrum.ts' +import { + applyNativeMetadataPatch, + captureNativeMetadataPatch, + captureNativePlainData, + type NativeAccountMetadataPatch, + nativeAccountReadError, + nativeQuotaFailure, + nativeVaultPrimaryPin, + nativeVaultPrimaryRow, +} from './native-account-view.ts' +import { + createNativeCustody, + type NativeCustodyClient, + NativeCustodyError, + type NativeCustodyInventory, + type NativeCustodyReceipt, + type NativeCustodyReporterSource, + type NativeCustodyRetry, +} from './native-custody.ts' +import { nativeQuotaCodec } from './native-quota-codec.ts' +import { + buildNativeVaultRosterSeed, + type NativeRosterSeedInput, + type NativeVaultRosterDocument, + type NativeVaultRosterSeed, + validateNativeVaultRosterSeed, +} from './native-roster-seed.ts' +import { + decodeNativeRuntime, + type NativeRuntimeEntry, + NativeRuntimeError, + type NativeRuntimeState, + readNativeRuntime, + updateNativeRuntime, +} from './native-runtime.ts' +import { fetchOAuthAccountProfile } from './oauth-profile.ts' +import { requireNativePoolAuthority } from './pool-authority.ts' +import type { NativePoolPaths } from './pool-paths.ts' +import { createNativePoolStore } from './pool-store.ts' + +export interface NativeVaultRuntimeOptions { + paths: NativePoolPaths + host: 'opencode' | 'pi' + connect?: () => Promise + readToken?: () => Promise<{ token: string; token_generation: number }> + env?: NodeJS.ProcessEnv + now?: () => number +} + +export interface NativeVaultRuntime { + read(): Promise + refresh(signal?: AbortSignal): Promise + authorize( + routeId: string, + signal?: AbortSignal, + ): Promise + reportFailure( + receipt: NativeCustodyReceipt, + status: number, + source: NativeCustodyReporterSource, + ): Promise + prepareRetry( + receipt: NativeCustodyReceipt, + signal?: AbortSignal, + ): Promise + publish( + receipt: NativeCustodyReceipt, + patch: NativeAccountMetadataPatch, + ): Promise + fetchQuota( + routeId: string, + fetchImpl?: typeof fetch, + signal?: AbortSignal, + ): Promise + fetchProfile( + routeId: string, + fetchImpl?: typeof fetch, + signal?: AbortSignal, + ): Promise + setEnabled(routeId: string, enabled: boolean): Promise + resetBackoff( + receipt: NativeCustodyReceipt, + kind?: 'refresh' | 'quota' | 'all', + ): Promise + getOrCreateAuthLineage( + receipt: NativeCustodyReceipt, + ): Promise + incrementPrimeUsage( + receipt: NativeCustodyReceipt, + delta?: PrimeUsageDelta, + ): Promise + close(): void +} + +/** Publish initial account metadata only while the migration controller holds its journal lock and verifies that the host is stopped. */ +export async function publishNativeVaultRosterSeed( + paths: NativePoolPaths, + input: NativeRosterSeedInput, + options: { assertOwned?: () => Promise } = {}, +): Promise { + const seed = buildNativeVaultRosterSeed(input) + return mutateVaultRoster( + paths.roster, + async (current, lease) => { + if (current) { + validateNativeVaultRosterSeed(current, input) + await options.assertOwned?.() + return { result: seed } + } + await options.assertOwned?.() + await lease.assertOwned() + return { next: seed, result: seed } + }, + { beforePublish: options.assertOwned }, + ) +} + +/** Bind imported metadata to the vault account list. The migration controller supplies journal-lock and stopped-host checks; this writer owns neither. */ +export async function publishNativeVaultRuntimeSeed( + paths: NativePoolPaths, + state: NativeRuntimeState, + options: { assertOwned?: () => Promise } = {}, +): Promise { + const imported = decodeNativeRuntime( + captureNativePlainData(state), + paths.storageId, + ) + await mutateVaultRoster(paths.roster, async (roster, lease) => { + for (const [id, entry] of Object.entries(imported.accounts)) { + if (entry.binding.kind !== 'custody') continue + const binding = entry.binding + const row = roster?.rows.find((row) => row.routeId === id) + if ( + !row?.accountIdentity || + row.accountIdentity !== binding.accountIdentity || + (row.credentialId !== binding.credentialId && + !row.aliases?.includes(binding.credentialId)) + ) + throw new NativeRuntimeError('publication-refused') + } + await updateNativeRuntime(paths.runtime, paths.storageId, () => imported, { + beforeRename: async () => { + await options.assertOwned?.() + await lease.assertOwned() + }, + }) + return { result: undefined } + }) +} + +/** Read inventory for offline setup; a list neither authorizes serving nor proves completeness without the controller's checks. */ +export async function discoverNativeVaultInventory( + options: NativeVaultRuntimeOptions, + signal?: AbortSignal, +): Promise { + const env = { ...(options.env ?? process.env) } + const custody = createNativeCustody({ + host: options.host, + readToken: options.readToken, + now: options.now, + env, + connect: + options.connect ?? + (() => + ClaustrumClient.connect({ + connectionFile: resolveClaustrumConnectionPath(undefined, env), + })), + }) + try { + return await custody.discover(signal) + } finally { + custody.close() + } +} + +/** Retain the vault client's original per-attempt receipts and version provenance; access tokens never persist in roster or runtime metadata. */ +export function createNativeVaultRuntime( + options: NativeVaultRuntimeOptions, +): NativeVaultRuntime { + const paths = Object.freeze({ ...options.paths }) + const env = { ...(options.env ?? process.env) } + const shutdown = new AbortController() + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + const custody = createNativeCustody({ + host: options.host, + readToken: options.readToken, + now: options.now, + connect: + options.connect ?? + (() => + ClaustrumClient.connect({ + connectionFile: resolveClaustrumConnectionPath(undefined, env), + })), + env, + }) + const issued = new WeakMap< + NativeCustodyReceipt, + { routeId: string; logicalId: string } + >() + const retryUsed = new WeakSet() + const versions = new Map() + let reservedRouteIds = new Set(['main']) + let refreshing: Promise | undefined + + function check(signal?: AbortSignal) { + if (shutdown.signal.aborted) throw new NativeCustodyError('closed') + signal?.throwIfAborted() + } + function signalFor(signal?: AbortSignal) { + check(signal) + return AbortSignal.any([shutdown.signal, ...(signal ? [signal] : [])]) + } + async function active() { + check() + await requireNativePoolAuthority(paths) + const read = await store.readSettings() + if (read.status !== 'ready') throw new NativeCustodyError('invalid-state') + const rows = await store.read() + if (rows.status !== 'ready') throw new NativeCustodyError('invalid-state') + reservedRouteIds = new Set(['main', ...rows.rows.map((row) => row.id)]) + return ( + (read.settings.claustrum as { mode?: unknown } | undefined)?.mode === + 'claustrum' + ) + } + async function requireActive(signal?: AbortSignal) { + check(signal) + if (!(await active())) throw new NativeCustodyError('route-unavailable') + check(signal) + } + async function rowFor( + roster: NativeVaultRosterDocument | undefined, + routeId: string, + ) { + const settings = await store.readSettings() + if (settings.status !== 'ready') + throw new NativeCustodyError('invalid-state') + const pin = nativeVaultPrimaryPin(settings.settings) + return routeId === 'main' || routeId === pin?.routeId + ? nativeVaultPrimaryRow(roster, pin) + : roster?.rows.find((row) => row.routeId === routeId) + } + function requireIssued(receipt: NativeCustodyReceipt) { + check() + const route = issued.get(receipt) + if (!route) throw new NativeCustodyError('no-receipt') + return route + } + const runtime: NativeVaultRuntime = { + async read() { + await requireActive() + const roster = await readVaultRoster(paths.roster) + check() + return roster + }, + refresh(signal) { + check(signal) + // Cancellation belongs to this waiter, not to a peer using shared discovery. + refreshing ??= refreshVaultRoster({ + path: paths.roster, + custody, + signal: shutdown.signal, + isActive: active, + projection: () => ({ reservedRouteIds }), + }).finally(() => { + refreshing = undefined + }) + return waitFor(refreshing, signalFor(signal)) + }, + async authorize(logicalId, signal) { + const combined = signalFor(signal) + await requireActive(combined) + const roster = + (await readVaultRoster(paths.roster)) ?? + (await runtime.refresh(combined)) + const row = await rowFor(roster, logicalId) + if (row?.credentialType !== 'oauth' || !row.accountIdentity) + throw new NativeCustodyError('route-unavailable') + if (!row.enabled) throw new NativeCustodyError('route-declined') + if (row.state !== 'active') throw new NativeCustodyError('not-active') + const receipt = await custody.authorize( + { + credentialId: row.credentialId, + accountIdentity: row.accountIdentity, + }, + combined, + ) + await requireActive(combined) + const current = await rowFor( + await readVaultRoster(paths.roster), + logicalId, + ) + if ( + !current?.enabled || + current.routeId !== row.routeId || + current.accountIdentity !== row.accountIdentity || + (current.credentialId !== receipt.credentialId && + !current.aliases?.includes(receipt.credentialId)) + ) + throw new NativeCustodyError('identity-changed') + // A fresh GET does not override a roster revocation observed while it was + // pending. The selected row must still be active before its receipt exits. + if (current.state !== 'active') throw new NativeCustodyError('not-active') + check(combined) + issued.set(receipt, { routeId: row.routeId, logicalId }) + const key = JSON.stringify([ + row.routeId, + receipt.credentialId, + receipt.assertedAccountIdentity, + ]) + versions.set(key, Math.max(versions.get(key) ?? 0, receipt.recordVersion)) + return receipt + }, + async reportFailure(receipt, status, source) { + requireIssued(receipt) + await custody.reportFailure(receipt, status, source) + }, + async prepareRetry(served, signal) { + const route = requireIssued(served) + if (retryUsed.has(served)) + return { retry: false, reason: 'version-not-newer' } + retryUsed.add(served) + let next: NativeCustodyReceipt + try { + next = await runtime.authorize(route.logicalId, signal) + } catch (error) { + check(signal) + return { + retry: false, + reason: 'reauthorize-failed', + code: + error instanceof NativeCustodyError ? error.code : 'unavailable', + } + } + if (next.credentialId !== served.credentialId) + return { retry: false, reason: 'credential-changed' } + if (next.assertedAccountIdentity !== served.assertedAccountIdentity) + return { retry: false, reason: 'account-changed' } + if (!(next.recordVersion > served.recordVersion)) + return { retry: false, reason: 'version-not-newer' } + // Mark the retry-issued receipt too, so handing it back cannot restart the + // request's one permitted strict-newer-version 401 retry. + retryUsed.add(next) + return { retry: true, receipt: next } + }, + publish(receipt, rawPatch) { + const patch = captureNativeMetadataPatch(rawPatch) + return mutateReceipt(receipt, (old) => + applyNativeMetadataPatch(old, patch), + ) + }, + resetBackoff(receipt, kind = 'all') { + return mutateReceipt(receipt, (old) => { + const clear = Math.max( + options.now?.() ?? Date.now(), + old.refreshErrorClearedAt ?? 0, + old.quotaErrorClearedAt ?? 0, + old.lastRefreshError?.checkedAt ?? 0, + old.lastQuotaRefreshError?.checkedAt ?? 0, + ) + const next = { ...old } + if (kind !== 'quota') { + delete next.lastRefreshError + next.refreshErrorClearedAt = clear + } + if (kind !== 'refresh') { + delete next.lastQuotaRefreshError + next.quotaErrorClearedAt = clear + next.quotaErrorGeneration = (old.quotaErrorGeneration ?? 0) + 1 + } + return next + }) + }, + async getOrCreateAuthLineage(receipt) { + let lineage: string | undefined + const kept = await mutateReceipt(receipt, (old) => { + lineage = old.authLineageId ?? randomUUID() + return { ...old, authLineageId: lineage } + }) + return kept ? lineage : undefined + }, + incrementPrimeUsage(receipt, delta = {}) { + const inputTokens = delta.inputTokens ?? 0 + const outputTokens = delta.outputTokens ?? 0 + if ( + ![inputTokens, outputTokens].every( + (value) => Number.isSafeInteger(value) && value >= 0, + ) + ) + throw new NativeRuntimeError('publication-refused') + return mutateReceipt(receipt, (old) => ({ + ...old, + prime: { + count: (old.prime?.count ?? 0) + 1, + inputTokens: (old.prime?.inputTokens ?? 0) + inputTokens, + outputTokens: (old.prime?.outputTokens ?? 0) + outputTokens, + since: old.prime?.since ?? options.now?.() ?? Date.now(), + }, + })) + }, + async fetchQuota(routeId, fetchImpl = fetch, signal) { + const row = await rowFor(await runtime.read(), routeId) + const state = await readNativeRuntime(paths.runtime, paths.storageId) + const before = + state.status === 'ready' + ? state.state.accounts[row?.routeId ?? ''] + : undefined + const matchingBefore = (receipt: NativeCustodyReceipt) => { + const route = issued.get(receipt) + const binding = before?.binding + return route && + binding?.kind === 'custody' && + binding.storageId === paths.storageId && + binding.routeId === route.routeId && + binding.credentialId === receipt.credentialId && + binding.accountIdentity === receipt.assertedAccountIdentity && + binding.recordVersion <= receipt.recordVersion + ? before + : undefined + } + const result = await fetchReading( + routeId, + fetchImpl, + signal, + (receipt, transport) => + fetchOAuthQuotaSnapshot({ + accessToken: receipt.accessToken, + fetchImpl: transport, + now: options.now, + }), + async (error, receipt) => { + const matched = matchingBefore(receipt) + const failure = nativeQuotaFailure( + error, + receipt.assertedAccountIdentity, + Math.max( + options.now?.() ?? Date.now(), + (matched?.quotaErrorClearedAt ?? -1) + 1, + ), + matched?.lastQuotaRefreshError, + ) + if (failure) + await runtime.publish(receipt, { lastQuotaRefreshError: failure }) + }, + ) + const quota = { + ...result.value, + accountIdentity: result.receipt.assertedAccountIdentity, + } + const matched = matchingBefore(result.receipt) + await runtime.publish(result.receipt, { + quota, + quotaErrorClearedAt: Math.max( + quota.checkedAt ?? options.now?.() ?? Date.now(), + matched?.quotaErrorClearedAt ?? 0, + matched?.lastQuotaRefreshError?.checkedAt ?? 0, + ), + }) + return quota + }, + async fetchProfile(routeId, fetchImpl = fetch, signal) { + const result = await fetchReading( + routeId, + fetchImpl, + signal, + (receipt, transport) => + fetchOAuthAccountProfile({ + accessToken: receipt.accessToken, + accountIdentity: receipt.assertedAccountIdentity, + fetchImpl: transport, + now: options.now, + signal, + }), + ) + await runtime.publish(result.receipt, { profile: result.value }) + return result.value + }, + async setEnabled(routeId, enabled) { + await requireActive() + const roster = await readVaultRoster(paths.roster) + const row = await rowFor(roster, routeId) + if (!row) throw new NativeCustodyError('route-unavailable') + await (enabled ? acceptVaultRoute : declineVaultRoute)( + paths.roster, + row.routeId, + ) + }, + close() { + if (shutdown.signal.aborted) return + shutdown.abort(new NativeCustodyError('closed')) + custody.close() + }, + } + async function mutateReceipt( + receipt: NativeCustodyReceipt, + change: (old: NativeRuntimeEntry) => NativeRuntimeEntry, + ): Promise { + const route = requireIssued(receipt) + await requireActive() + const key = JSON.stringify([ + route.routeId, + receipt.credentialId, + receipt.assertedAccountIdentity, + ]) + if ((versions.get(key) ?? 0) > receipt.recordVersion) return false + return mutateVaultRoster(paths.roster, async (roster, lease) => { + const row = await rowFor(roster, route.logicalId) + if ( + !row?.accountIdentity || + row.routeId !== route.routeId || + row.accountIdentity !== receipt.assertedAccountIdentity || + (row.credentialId !== receipt.credentialId && + !row.aliases?.includes(receipt.credentialId)) + ) + return { result: false } + const accountIdentity = row.accountIdentity + let kept = false + try { + await updateNativeRuntime( + paths.runtime, + paths.storageId, + (current) => { + const old = current.accounts[route.routeId] + const binding = old?.binding + if ( + binding?.kind === 'custody' && + binding.credentialId === receipt.credentialId && + binding.accountIdentity === receipt.assertedAccountIdentity && + binding.recordVersion > receipt.recordVersion + ) + throw new NativeRuntimeError('runtime-conflict') + const same = + binding?.kind === 'custody' && + binding.accountIdentity === receipt.assertedAccountIdentity + current.accounts[route.routeId] = change({ + ...(same ? old : {}), + binding: { + kind: 'custody', + storageId: paths.storageId, + routeId: route.routeId, + credentialId: receipt.credentialId, + accountIdentity, + recordVersion: receipt.recordVersion, + }, + }) + kept = true + return current + }, + { + beforeRename: async () => { + await requireActive() + const pinned = await rowFor(roster, route.logicalId) + if ( + !pinned || + pinned.routeId !== route.routeId || + pinned.accountIdentity !== receipt.assertedAccountIdentity + ) + throw new NativeRuntimeError('publication-refused') + if ((versions.get(key) ?? 0) > receipt.recordVersion) + throw new NativeRuntimeError('runtime-conflict') + // The roster lease is held across the runtime write. Check it after + // all other work, immediately before the writer's final rename. + await lease.assertOwned() + }, + }, + ) + } catch (error) { + if ( + error instanceof NativeRuntimeError && + error.code === 'runtime-conflict' + ) + return { result: false } + throw error + } + return { result: kept } + }) + } + async function fetchReading( + routeId: string, + fetchImpl: typeof fetch, + signal: AbortSignal | undefined, + read: ( + receipt: NativeCustodyReceipt, + transport: typeof fetch, + ) => Promise, + onFailure?: ( + error: unknown, + receipt: NativeCustodyReceipt, + ) => Promise, + ) { + const combined = signalFor(signal) + let served = await runtime.authorize(routeId, combined) + let responseStatus: number | undefined + const transport: typeof fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + responseStatus = undefined + let response = await fetchImpl(input, { ...init, signal: combined }) + responseStatus = response.status + if (response.status === 401 && !combined.aborted) { + // Cleanup and best-effort telemetry can await. Complete them before + // final retry admission so a decline/revocation during either wins. + await response.body?.cancel().catch(() => {}) + await runtime.reportFailure(served, 401, 'direct').catch(() => { + check(combined) + }) + check(combined) + const retry = await runtime.prepareRetry(served, combined) + if (retry.retry) { + served = retry.receipt + const headers = new Headers(init?.headers) + headers.set('authorization', `Bearer ${served.accessToken}`) + responseStatus = undefined + response = await fetchImpl(input, { + ...init, + headers, + signal: combined, + }) + responseStatus = response.status + if (response.status === 401) + await runtime.reportFailure(served, 401, 'direct').catch(() => { + check(combined) + }) + } + } + return response + }, + { preconnect: fetchImpl.preconnect }, + ) + let value: T + try { + value = await read(served, transport) + } catch (error) { + check(combined) + const safe = + error instanceof NativeCustodyError || + error instanceof NativeRuntimeError + ? error + : nativeAccountReadError(error, responseStatus) + await onFailure?.(safe, served) + throw safe + } + check(combined) + return { value, receipt: served } + } + return runtime +} + +function waitFor(pending: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => { + cleanup() + reject(signal.reason) + } + const cleanup = () => signal.removeEventListener('abort', abort) + signal.addEventListener('abort', abort, { once: true }) + pending.then( + (value) => { + cleanup() + signal.aborted ? reject(signal.reason) : resolve(value) + }, + (error) => { + cleanup() + reject(error) + }, + ) + if (signal.aborted) abort() + }) +} + +const shared = new Map() + +/** Each handle owns one reference; disposing it cannot close another host session. */ +export function acquireNativeVaultRuntime( + options: NativeVaultRuntimeOptions, +): NativeVaultRuntime { + const key = JSON.stringify([options.paths.storageId, options.host]) + let entry = shared.get(key) + if (!entry) { + entry = { runtime: createNativeVaultRuntime(options), users: 0 } + shared.set(key, entry) + } + entry.users++ + const owned = entry + let closed = false + function use(): NativeVaultRuntime { + if (closed) throw new NativeCustodyError('closed') + return owned.runtime + } + return { + read: () => use().read(), + refresh: (signal) => use().refresh(signal), + authorize: (id, signal) => use().authorize(id, signal), + reportFailure: (receipt, status, source) => + use().reportFailure(receipt, status, source), + prepareRetry: (receipt, signal) => use().prepareRetry(receipt, signal), + publish: (receipt, patch) => use().publish(receipt, patch), + fetchQuota: (id, transport, signal) => + use().fetchQuota(id, transport, signal), + fetchProfile: (id, transport, signal) => + use().fetchProfile(id, transport, signal), + setEnabled: (id, enabled) => use().setEnabled(id, enabled), + resetBackoff: (receipt, kind) => use().resetBackoff(receipt, kind), + getOrCreateAuthLineage: (receipt) => use().getOrCreateAuthLineage(receipt), + incrementPrimeUsage: (receipt, delta) => + use().incrementPrimeUsage(receipt, delta), + close() { + if (closed) return + closed = true + if (--owned.users === 0) { + if (shared.get(key) === owned) shared.delete(key) + owned.runtime.close() + } + }, + } +} diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts new file mode 100644 index 000000000..2250d8e25 --- /dev/null +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -0,0 +1,732 @@ +import { expect, spyOn } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { ProviderAccountUuid } from '../claude-code.ts' +import { + createNativeAccountRuntime, + nativeAccountPolicy, +} from '../native-account-runtime.ts' +import { + createNativeLocalCredentialService, + type NativeLocalCredentialServiceOptions, +} from '../native-local-credential-service.ts' +import { nativeQuotaCodec } from '../native-quota-codec.ts' +import * as runtimeWriter from '../native-runtime.ts' +import { readNativeRuntime } from '../native-runtime.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore } from '../pool-store.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup, gate, trackDetached } = createTestLifetimeSuite() +const uuid = '11111111-2222-4333-8444-555555555555' as ProviderAccountUuid +const now = 1_000_000 +async function fixture( + local: Partial = {}, +) { + const parent = new URL( + '../../../../node_modules/.cache/native-account-runtime/', + import.meta.url, + ).pathname + await mkdir(parent, { recursive: true }) + const root = await mkdtemp(join(parent, 'fixture-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const paths = await resolveNativePoolPaths(join(root, 'anthropic-auth.json')) + await writeFile( + paths.journal, + JSON.stringify({ + version: 2, + storageId: paths.storageId, + host: 'opencode', + phase: 'committed', + sources: { config: null, state: null, hostAuth: 'absent', routing: null }, + routingPaths: { + source: join(root, 'old-routing'), + destination: join(root, 'routing'), + }, + hostAuthPath: join(root, 'host-auth'), + expectedHostAuth: 'absent', + expectedRouting: null, + }), + { mode: 0o600 }, + ) + const store = createNativePoolStore({ + paths, + quota: nativeQuotaCodec, + now: () => now, + }) + await store.initialize() + await store.add({ + id: 'imported-main', + identity: uuid, + credential: { + type: 'oauth', + access: 'synthetic-access', + refresh: 'synthetic-refresh', + expires: now + 3_600_000, + }, + }) + await store.updateSettings((settings) => { + settings.mainAccountId = 'imported-main' + }) + const counts = { lookups: 0, refresh: 0 } + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: { + now: () => now, + resolveIdentity: async () => { + counts.lookups++ + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: uuid, + } + }, + refreshToken: async () => { + counts.refresh++ + return { + access: 'synthetic-successor', + refresh: 'synthetic-new-refresh', + expires: now + 4_000_000, + expiresIn: 4000, + } + }, + ...local, + }, + }) + deferCleanup(() => runtime.close()) + return { paths, runtime, store, counts } +} + +test('native facade reads no legacy authority and does not import legacy persistence', async () => { + for (const filename of [ + 'native-account-runtime.ts', + 'native-account-view.ts', + 'native-vault-runtime.ts', + ]) { + const text = await readFile( + new URL(`../${filename}`, import.meta.url), + 'utf8', + ) + const imports = + text.match(/import\s+(?:type\s+)?\{[^}]*\}\s+from\s+['"][^'"]+['"]/g) ?? + [] + expect(imports.join('\n')).not.toMatch( + /\b(?:saveAccounts|loadAccounts|saveAccountState|FallbackAccountManager|saveOAuthProfileState)\b/, + ) + } + const f = await fixture() + await writeFile(f.paths.legacyConfig, '{ invalid legacy credential source') + await writeFile(f.paths.legacyState, '{ invalid legacy credential source') + const read = await f.runtime.read() + expect(read.accounts[0]?.id).toBe('main') + expect(f.counts.lookups).toBe(0) + expect(JSON.stringify(read)).not.toContain('synthetic-access') + const result = await f.runtime.authorizeLocal('main') + expect(result.status).toBe('usable') + expect(f.counts.lookups).toBe(1) + await f.runtime.authorizeLocal('main') + expect(f.counts.lookups).toBe(1) +}) + +test('request-local policy refuses one model without closing another shared account authorization', async () => { + const f = await fixture() + await f.runtime.updateSettings((settings) => { + settings.quota = { enabled: false } + settings.killswitch = { enabled: false } + }) + const blocked = await f.runtime.authorizeLocal('main', { + policy: () => ({ status: 'blocked', reason: 'quota-ineligible' }), + }) + expect(blocked.status).toBe('refused') + const allowed = await f.runtime.authorizeLocal('main', { + policy: (row, snapshot) => + nativeAccountPolicy(row, snapshot, 'different-model'), + }) + expect(allowed.status).toBe('usable') + expect(f.counts.lookups).toBe(1) + await f.runtime.setEnabled('main', false) + expect((await f.runtime.authorizeLocal('main')).status).toBe('refused') +}) + +test('native background quota/profile authorize positively and refresh one rejected local attempt', async () => { + const f = await fixture() + const headers: string[] = [] + const transport: typeof fetch = Object.assign( + async (input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get('authorization') ?? '') + if (headers.length === 1) return new Response('{}', { status: 401 }) + return Response.json( + String(input).endsWith('/profile') + ? { + organization: { + organization_type: 'claude_pro', + rate_limit_tier: 'default', + }, + } + : {}, + ) + }, + { preconnect: fetch.preconnect }, + ) + const quota = await f.runtime.fetchQuota('main', transport) + expect(headers).toEqual([ + 'Bearer synthetic-access', + 'Bearer synthetic-successor', + ]) + expect(quota.scoped).toEqual([]) + expect(quota.checkedAt).toBe(now) + const profile = await f.runtime.fetchProfile('main', transport) + expect(profile.accountIdentity).toBe(uuid) + const read = await f.runtime.read() + expect(read.policyStorage.quota?.mainQuota?.scoped).toEqual([]) + expect(read.policyStorage.main?.profile?.tier).toBe('default') + expect(f.counts.refresh).toBe(1) +}) + +test('shared local recovery never captures request model policy', async () => { + const entered = gate() + const release = gate() + const joined = gate() + let refreshes = 0 + const f = await fixture({ + refreshToken: async () => { + refreshes++ + entered.open() + await release.wait + return { + access: 'synthetic-shared-successor', + refresh: 'synthetic-shared-refresh', + expires: now + 4_000_000, + expiresIn: 4000, + } + }, + }) + await f.store.rotate('imported-main', { + type: 'oauth', + access: 'synthetic-expired', + refresh: 'synthetic-refresh', + expires: now - 1, + }) + let allowA = true + const a = f.runtime.authorizeLocal('main', { + policy: () => + allowA + ? { status: 'allowed' } + : { status: 'blocked', reason: 'quota-ineligible' }, + }) + await entered.wait + const b = f.runtime.authorizeLocal('main', { + policy: () => { + joined.open() + return { status: 'allowed' } + }, + }) + await joined.wait + allowA = false + release.open() + expect((await a).status).toBe('refused') + expect((await b).status).toBe('usable') + expect(refreshes).toBe(1) +}) + +test('native settings/API create replace and attributed lastUsed leave legacy files untouched', async () => { + const f = await fixture() + await f.runtime.updateSettings((settings) => { + settings.claudeFast = { enabled: true } + }) + const row = await f.runtime.addApi({ + routeId: 'api-fallback', + apiKey: 'synthetic-api', + baseURL: 'https://example.test', + authHeader: 'x-api-key', + }) + expect(row.type).toBe('api') + expect(JSON.stringify(row)).not.toContain('synthetic-api') + const served = await f.runtime.authorizeApi(row.id) + expect(served.apiKey).toBe('synthetic-api') + expect(await f.runtime.publishApi(served.subject, { lastUsed: now })).toBe( + true, + ) + await f.runtime.addApi({ + routeId: row.id, + apiKey: 'synthetic-new-api', + baseURL: 'https://other.test', + replace: true, + }) + expect( + await f.runtime.publishApi(served.subject, { lastUsed: now + 1 }), + ).toBe(false) + expect((await f.runtime.read()).settings.claudeFast).toEqual({ + enabled: true, + }) + expect( + await readFile(f.paths.legacyConfig, 'utf8').catch(() => 'missing'), + ).toBe('missing') +}) + +test('quota fetch is a physical poll even with persisted quota and persists fixed-text errors against its admitted version', async () => { + const f = await fixture() + let polls = 0 + const transport: typeof fetch = Object.assign( + async () => { + polls++ + return polls === 2 + ? new Response('synthetic-private-provider-body', { status: 429 }) + : Response.json({}) + }, + { preconnect: fetch.preconnect }, + ) + await f.runtime.fetchQuota('main', transport) + await expect(f.runtime.fetchQuota('main', transport)).rejects.toThrow('429') + expect(polls).toBe(2) + const snapshot = await f.runtime.read() + expect(snapshot.policyStorage.quota?.mainLastQuotaApiError).toMatchObject({ + status: 429, + checkedAt: now + 1, + accountIdentity: uuid, + }) + expect(await readFile(f.paths.runtime, 'utf8')).not.toContain( + 'synthetic-private-provider-body', + ) +}) + +test('backoff reset fences exact refresh lineage and monotonically clears native errors', async () => { + const f = await fixture() + const attempt = await f.runtime.authorizeLocal('main') + if (attempt.status !== 'usable') throw new Error('Fixture not admitted') + await f.runtime.publishLocal(attempt.subject, { + lastQuotaRefreshError: { + message: 'fixed', + checkedAt: now - 1, + nextRetryAt: now + 100, + accountIdentity: uuid, + }, + }) + const old = await f.runtime.captureLocalSubject('main') + expect(await f.runtime.resetBackoff('main', old, 'quota')).toBe(true) + expect( + (await f.runtime.read()).policyStorage.quota?.mainLastQuotaApiError, + ).toBeUndefined() + expect( + (await f.runtime.read()).policyStorage.quota?.mainQuotaErrorGeneration, + ).toBe(1) + await f.store.rotate('imported-main', { + type: 'oauth', + access: 'synthetic-new-access', + refresh: 'synthetic-new-lineage', + expires: now + 4_000_000, + }) + expect(await f.runtime.resetBackoff('main', old)).toBe(false) +}) + +test('native lineage and cumulative Prime updates are atomic and replacement fenced', async () => { + const f = await fixture() + const result = await f.runtime.authorizeLocal('main') + if (result.status !== 'usable') throw new Error('Fixture not admitted') + const lineages = await Promise.all([ + f.runtime.getOrCreateAuthLineage('main', result.subject), + f.runtime.getOrCreateAuthLineage('main', result.subject), + ]) + expect(lineages[0]).toBe(lineages[1]) + expect(lineages[0]).toMatch(/^[a-f0-9-]{36}$/) + await Promise.all([ + f.runtime.incrementPrimeUsage('main', result.subject, { inputTokens: 2 }), + f.runtime.incrementPrimeUsage('main', result.subject, { outputTokens: 3 }), + ]) + expect((await f.runtime.read()).accounts[0]?.prime).toEqual({ + count: 2, + inputTokens: 2, + outputTokens: 3, + since: now, + }) + const subject = await f.runtime.captureLocalSubject('main') + await f.runtime.loginOAuth({ + routeId: 'main', + accountIdentity: uuid, + replace: true, + credential: { + access: 'synthetic-relogin', + refresh: 'synthetic-relogin-refresh', + expires: now + 4_000_000, + }, + }) + expect(await f.runtime.resetBackoff('main', subject)).toBe(false) + expect(await f.runtime.incrementPrimeUsage('main', result.subject)).toBe( + false, + ) + const read = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + expect(read.status).toBe('ready') +}) + +test('missing authority refuses native serving without provider dispatch', async () => { + const f = await fixture() + await rm(f.paths.journal) + await expect(f.runtime.authorizeLocal('main')).rejects.toMatchObject({ + code: 'migration-required', + }) + expect(f.counts.lookups + f.counts.refresh).toBe(0) +}) + +test('ordinary native settings cannot switch custody or replace primary activation authority', async () => { + const f = await fixture() + await expect( + f.runtime.updateSettings((settings) => { + settings.claustrum = { mode: 'claustrum' } + }), + ).rejects.toThrow() + await expect( + f.runtime.updateSettings((settings) => { + settings.mainAccountId = 'other' + }), + ).rejects.toThrow() + expect((await f.runtime.read()).mode).toBe('local') + expect((await f.runtime.read()).settings.mainAccountId).toBe('imported-main') +}) + +test('normal local refresh preserves Prime lineage and counters but relogin starts a new epoch lineage', async () => { + const f = await fixture() + const first = await f.runtime.authorizeLocal('main') + if (first.status !== 'usable') throw new Error('Fixture not admitted') + const lineage = await f.runtime.getOrCreateAuthLineage('main', first.subject) + await f.runtime.incrementPrimeUsage('main', first.subject, { inputTokens: 2 }) + const refreshed = await f.runtime.authorizeLocal('main', { + rejectedAccessToken: first.access, + }) + if (refreshed.status !== 'usable') + throw new Error('Fixture refresh not admitted') + expect(refreshed.binding.credentialEpoch).toBe(first.binding.credentialEpoch) + expect(refreshed.subject.credentialFingerprint).not.toBe( + first.subject.credentialFingerprint, + ) + expect( + await f.runtime.getOrCreateAuthLineage('main', refreshed.subject), + ).toBe(lineage) + expect((await f.runtime.read()).accounts[0]?.prime?.count).toBe(1) + await f.runtime.loginOAuth({ + routeId: 'main', + replace: true, + accountIdentity: uuid, + credential: { + access: 'synthetic-relogin', + refresh: 'synthetic-relogin-refresh', + expires: now + 4_000_000, + }, + }) + const replaced = await f.runtime.authorizeLocal('main') + if (replaced.status !== 'usable') + throw new Error('Fixture relogin not admitted') + expect(replaced.binding.credentialEpoch).toBeGreaterThan( + first.binding.credentialEpoch, + ) + expect( + await f.runtime.getOrCreateAuthLineage('main', replaced.subject), + ).not.toBe(lineage) + expect((await f.runtime.read()).accounts[0]?.prime?.count).toBe(1) +}) + +test('local quota auth failures never arm quota backoff while 429 still does', async () => { + for (const status of [401, 403, 429]) { + const f = await fixture() + const transport: typeof fetch = Object.assign( + async () => new Response('synthetic-private-body', { status }), + { preconnect: fetch.preconnect }, + ) + await expect(f.runtime.fetchQuota('main', transport)).rejects.toMatchObject( + { status }, + ) + const error = (await f.runtime.read()).accounts[0]?.lastQuotaRefreshError + if (status === 429) + expect(error).toMatchObject({ status: 429, nextRetryAt: now + 60_000 }) + else expect(error).toBeUndefined() + } +}) + +test('local quota retry admits only after deferred body cleanup so disable prevents a second HTTP', async () => { + const f = await fixture() + const entered = gate(), + release = gate() + let http = 0 + const transport: typeof fetch = Object.assign( + async () => { + if (++http === 1) + return new Response( + new ReadableStream({ + cancel: async () => { + entered.open() + await release.wait + }, + }), + { status: 401 }, + ) + return Response.json({}) + }, + { preconnect: fetch.preconnect }, + ) + const pending = f.runtime.fetchQuota('main', transport) + await entered.wait + await f.runtime.setEnabled('main', false) + release.open() + await expect(pending).rejects.toMatchObject({ status: 401 }) + expect(http).toBe(1) + expect(f.counts.refresh).toBe(0) +}) + +test('relay transport reads fresh private token while native public snapshots and config stay secret-free', async () => { + const f = await fixture() + expect(await f.runtime.getRelayConfig()).toBeNull() + await f.runtime.updateRelay({ + enabled: true, + url: 'https://relay.example.test', + transport: 'http', + fallbackToDirect: false, + token: 'synthetic-private-relay-A', + }) + expect(await f.runtime.getRelayConfig()).toEqual({ + enabled: true, + url: 'https://relay.example.test', + transport: 'http', + fallbackToDirect: false, + token: 'synthetic-private-relay-A', + }) + const snapshot = await f.runtime.read() + expect(JSON.stringify(snapshot)).not.toContain('synthetic-private-relay') + expect(await readFile(f.paths.config, 'utf8')).not.toContain( + 'synthetic-private-relay', + ) + expect(snapshot.policyStorage.relay?.token).toBeUndefined() + await f.runtime.updateRelay({ + token: 'synthetic-private-relay-B', + url: 'https://new-relay.example.test', + }) + expect((await f.runtime.getRelayConfig())?.token).toBe( + 'synthetic-private-relay-B', + ) + expect((await f.runtime.getRelayConfig())?.url).toBe( + 'https://new-relay.example.test', + ) + await expect( + f.runtime.updateSettings((settings) => { + settings.relay = { token: 'synthetic-public-token' } + }), + ).rejects.toThrow() + await f.runtime.updateRelay({ token: null }) + expect(await f.runtime.getRelayConfig()).toBeNull() + const state = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + expect( + state.status === 'ready' ? state.state.relay : undefined, + ).toBeUndefined() +}) + +test('interrupted relay private publication leaves relay disabled and old token bound to no dispatch', async () => { + const f = await fixture() + await f.runtime.updateRelay({ + enabled: true, + url: 'https://old-relay.example.test', + token: 'synthetic-relay-old', + transport: 'http', + }) + const journal = await readFile(f.paths.journal) + const original = runtimeWriter.updateNativeRuntime + const hook = spyOn(runtimeWriter, 'updateNativeRuntime').mockImplementation( + (path, storageId, change, hooks = {}) => + original(path, storageId, change, { + ...hooks, + beforeRename: async () => { + await rm(f.paths.journal) + await hooks.beforeRename?.() + }, + }), + ) + deferCleanup(() => hook.mockRestore()) + await expect( + f.runtime.updateRelay({ + url: 'https://new-relay.example.test', + token: 'synthetic-relay-new', + }), + ).rejects.toMatchObject({ code: 'publication-refused' }) + hook.mockRestore() + await writeFile(f.paths.journal, journal, { mode: 0o600 }) + expect(await f.runtime.getRelayConfig()).toBeNull() + const state = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + expect(state.status === 'ready' ? state.state.relay?.token : undefined).toBe( + 'synthetic-relay-old', + ) + expect(await readFile(f.paths.config, 'utf8')).not.toContain( + 'synthetic-relay-new', + ) +}) + +test('authorization recovers an interrupted replacement epoch without reusing old validation evidence', async () => { + const f = await fixture() + const first = await f.runtime.authorizeLocal('main') + if (first.status !== 'usable') throw new Error('Fixture not admitted') + const lineage = await f.runtime.getOrCreateAuthLineage('main', first.subject) + await f.runtime.incrementPrimeUsage('main', first.subject) + // Simulate login stopping after PoolStore saves replacement credentials, + // before recording their runtime binding or verifying their account identity. + await f.store.replace( + 'imported-main', + { + type: 'oauth', + access: 'synthetic-replaced-access', + refresh: 'synthetic-replaced-refresh', + expires: now + 4_000_000, + }, + { identity: uuid }, + ) + const next = await f.runtime.authorizeLocal('main') + expect(next.status).toBe('usable') + expect(f.counts.lookups).toBe(2) + if (next.status !== 'usable') + throw new Error('Fixture replacement not admitted') + expect(next.binding.credentialEpoch).toBeGreaterThan( + first.binding.credentialEpoch, + ) + expect(await f.runtime.getOrCreateAuthLineage('main', next.subject)).not.toBe( + lineage, + ) + expect((await f.runtime.read()).accounts[0]?.prime?.count).toBe(1) +}) + +test('private relay access and mutation require current native authority', async () => { + const f = await fixture() + await f.runtime.updateRelay({ + token: 'synthetic-relay-token', + enabled: true, + url: 'https://relay.example.test', + transport: 'http', + }) + await rm(f.paths.journal) + await expect(f.runtime.getRelayConfig()).rejects.toMatchObject({ + code: 'migration-required', + }) + await expect( + f.runtime.updateRelay({ token: 'synthetic-new-relay-token' }), + ).rejects.toMatchObject({ code: 'migration-required' }) + expect(await readFile(f.paths.runtime, 'utf8')).not.toContain( + 'synthetic-new-relay-token', + ) +}) + +test('cross-process remove and readd same native route and UUID creates new Prime lineage and rejects old parent epoch proof', async () => { + const f = await fixture() + const first = await f.runtime.authorizeLocal('main') + if (first.status !== 'usable') throw new Error('Fixture not admitted') + const lineage = await f.runtime.getOrCreateAuthLineage('main', first.subject) + await f.runtime.incrementPrimeUsage('main', first.subject) + // The store refuses reuse of an ID removed by this process. Another process + // may remove it, after which re-adding it must advance the stored credential + // generation. Keep the parent's old binding and identity-verification result + // to prove that neither permits using the replacement credentials. + const child = Bun.spawn( + [ + process.execPath, + '-e', + ` + import { createNativePoolStore } from ${JSON.stringify(new URL('../pool-store.ts', import.meta.url).href)}; + import { nativeQuotaCodec } from ${JSON.stringify(new URL('../native-quota-codec.ts', import.meta.url).href)}; + const store = createNativePoolStore({ paths: ${JSON.stringify(f.paths)}, quota: nativeQuotaCodec }); + await store.remove('imported-main'); + console.log('removed existing native route through public store'); + `, + ], + { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, + ) + const stdout = new Response(child.stdout).text() + const stderr = new Response(child.stderr).text() + const cancel = gate() + const cancellation = cancel.wait.then(() => { + if (child.exitCode === null) child.kill() + }) + trackDetached(cancellation) + try { + const [exit, out, error] = await Promise.all([child.exited, stdout, stderr]) + expect(exit).toBe(0) + expect(out.trim()).toBe( + 'removed existing native route through public store', + ) + expect(error).toBe('') + } finally { + // TestLifetime signals cancellation before joining this test body. Terminate + // the child first so its exit and pipe reads can settle; finish all three + // promises before cleanup may remove the child's temporary files. + cancel.open() + await cancellation + await Promise.allSettled([child.exited, stdout, stderr]) + } + expect((await f.runtime.authorizeLocal('main')).status).toBe('refused') + expect(await f.runtime.publishLocal(first.subject, { lastUsed: now })).toBe( + false, + ) + await f.runtime.loginOAuth({ + routeId: 'main', + accountIdentity: uuid, + credential: { + access: 'synthetic-readded-access', + refresh: 'synthetic-readded-refresh', + expires: now + 4_000_000, + }, + }) + const metadata = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const entry = + metadata.status === 'ready' + ? metadata.state.accounts['imported-main'] + : undefined + expect(entry?.binding).toMatchObject({ + rowId: 'imported-main', + identity: uuid, + }) + expect(entry?.credentialValidation).toBeUndefined() + expect(f.counts.lookups).toBe(1) + let staleCalls = 0 + const service = createNativeLocalCredentialService({ + paths: f.paths, + now: () => now, + externalPolicy: () => ({ status: 'allowed' }), + failurePolicy: () => ({ checkedAt: now }), + resolveIdentity: async () => { + staleCalls++ + return { + accountUuid: uuid, + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + } + }, + refreshToken: async () => { + staleCalls++ + return { + access: 'synthetic-never-used', + refresh: 'synthetic-never-used-refresh', + expires: now + 4_000_000, + expiresIn: 4000, + } + }, + }) + expect( + ( + await service.authorize({ + intent: 'serve', + mode: 'local', + binding: first.binding, + }) + ).status, + ).toBe('refused') + expect(staleCalls).toBe(0) + expect( + await f.runtime.publishLocal(first.subject, { lastUsed: now + 1 }), + ).toBe(false) + expect(await f.runtime.incrementPrimeUsage('main', first.subject)).toBe(false) + const readded = await f.runtime.authorizeLocal('main') + if (readded.status !== 'usable') throw new Error('Fixture readd not admitted') + expect(readded.binding.rowId).toBe(first.binding.rowId) + expect(readded.binding.identity).toBe(first.binding.identity) + expect(readded.binding.credentialEpoch).toBeGreaterThan( + first.binding.credentialEpoch, + ) + expect(f.counts.lookups).toBe(2) + expect( + await f.runtime.getOrCreateAuthLineage('main', readded.subject), + ).not.toBe(lineage) + expect((await f.runtime.read()).accounts[0]?.prime?.count).toBe(1) +}) diff --git a/packages/core/src/tests/native-account-view.test.ts b/packages/core/src/tests/native-account-view.test.ts new file mode 100644 index 000000000..f82e9e68b --- /dev/null +++ b/packages/core/src/tests/native-account-view.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from 'bun:test' +import type { PoolRow } from '@cortexkit/common-auth/store' +import { + applyNativeMetadataPatch, + nativeAccountReadError, + nativeQuotaFailure, + projectNativeAccountViews, +} from '../native-account-view.ts' +import type { NativeRuntimeState } from '../native-runtime.ts' +import type { NativePoolPaths } from '../pool-paths.ts' + +const paths: NativePoolPaths = { + storageId: 'a'.repeat(64), + config: '/synthetic/config', + state: '/synthetic/state', + runtime: '/synthetic/runtime', + roster: '/synthetic/roster', + journal: '/synthetic/journal', + legacyConfig: '/synthetic/legacy', + legacyState: '/synthetic/legacy-state', +} +const uuid = '11111111-2222-4333-8444-555555555555' +const row: PoolRow = { + id: 'primary-row', + type: 'oauth', + enabled: true, + identity: uuid, + credentialEpoch: 2, + hasEntry: true, + candidate: true, + needsFirstReading: false, + stamp: 'bound', + credential: { + type: 'oauth', + access: 'synthetic-secret-access', + refresh: 'synthetic-secret-refresh', + expires: 1000, + }, +} +const runtime: NativeRuntimeState = { + version: 1, + storageId: paths.storageId, + accounts: { + 'primary-row': { + binding: { + kind: 'local', + storageId: paths.storageId, + rowId: row.id, + credentialEpoch: 1, + identity: uuid, + }, + lastUsed: 100, + prime: { count: 7, inputTokens: 8, outputTokens: 9, since: 1 }, + }, + }, +} + +test('secret-free native policy projection preserves primary and fallback order without inheriting stale metadata', () => { + const snapshot = projectNativeAccountViews({ + paths, + rows: [ + row, + { ...row, id: 'fallback-b', enabled: false }, + { ...row, id: 'fallback-a' }, + ], + settings: { + mainAccountId: row.id, + relay: { enabled: true, token: 'synthetic-relay-secret' }, + refresh: { enabled: true, mainRefreshLeaseTokenHash: 'secret-old-state' }, + prime: { enabled: true, main: { count: 99 } }, + access: 'synthetic-secret-access', + }, + runtime, + }) + expect(snapshot.accounts.map((row) => row.id)).toEqual([ + 'main', + 'fallback-b', + 'fallback-a', + ]) + expect(snapshot.accounts[1]?.enabled).toBe(false) + expect(snapshot.accounts[0]?.prime).toBeUndefined() + expect(snapshot.policyStorage.accounts.map((row) => row.id)).toEqual([ + 'fallback-b', + 'fallback-a', + ]) + expect(JSON.stringify(snapshot)).not.toContain('synthetic-secret') + expect(JSON.stringify(snapshot)).not.toContain('synthetic-relay-secret') + expect(snapshot.settings.refresh).toEqual({ enabled: true }) + expect(snapshot.settings.prime).toEqual({ enabled: true }) + expect(snapshot.policyStorage.prime?.main).toBeUndefined() +}) + +test('vault primary alias maps to main while runtime scopes and windowless freshness survive', () => { + const state: NativeRuntimeState = { + version: 1, + storageId: paths.storageId, + accounts: { + 'seeded-primary': { + binding: { + kind: 'custody', + storageId: paths.storageId, + routeId: 'seeded-primary', + credentialId: 'oauth:alias', + accountIdentity: uuid, + recordVersion: 7, + }, + quota: { + limits: [], + anthropic: { + version: 1, + remainingPercent: {}, + scoped: [], + accountIdentity: uuid, + source: 'poll', + checkedAt: 900, + }, + }, + prime: { count: 3, inputTokens: 4, outputTokens: 5, since: 1 }, + }, + }, + } + const snapshot = projectNativeAccountViews({ + paths, + rows: [], + settings: { + mainAccountId: 'seeded-primary', + claustrum: { + mode: 'claustrum', + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: uuid, + state: 'active', + }, + }, + }, + runtime: state, + roster: { + version: 1, + complete: true, + rows: [ + { + routeId: 'seeded-primary', + credentialId: 'oauth:alias', + aliases: ['oauth:anthropic'], + credentialType: 'oauth', + accountIdentity: uuid, + state: 'active', + enabled: true, + label: 'main', + addedAt: 0, + }, + ], + declined: [], + }, + }) + expect(snapshot.accounts[0]?.id).toBe('main') + expect(snapshot.accounts[0]?.quota?.scoped).toEqual([]) + expect(snapshot.policyStorage.quota?.mainQuotaCheckedAt).toBe(900) + expect(snapshot.policyStorage.prime?.main?.count).toBe(3) +}) + +test('safe quota error classification excludes 401 and 403 and keeps healthy 429 retry policy', () => { + for (const status of [401, 403]) { + const safe = nativeAccountReadError( + Object.assign(new Error('synthetic-sensitive-body'), { status }), + ) + expect(safe.status).toBe(status) + expect(safe.message).not.toContain('synthetic-sensitive-body') + expect(nativeQuotaFailure(safe, uuid, 100)).toBeUndefined() + } + expect( + nativeQuotaFailure( + Object.assign(new Error('limited'), { status: 429 }), + uuid, + 100, + )?.nextRetryAt, + ).toBe(60_100) +}) + +test('native lastUsed publication is monotonic for older asynchronous observations', () => { + const entry = runtime.accounts['primary-row'] + if (!entry) throw new Error('Missing fixture entry') + expect( + applyNativeMetadataPatch({ ...entry, lastUsed: 200 }, { lastUsed: 100 }) + .lastUsed, + ).toBe(200) +}) diff --git a/packages/core/src/tests/native-vault-runtime.test.ts b/packages/core/src/tests/native-vault-runtime.test.ts new file mode 100644 index 000000000..ee436296b --- /dev/null +++ b/packages/core/src/tests/native-vault-runtime.test.ts @@ -0,0 +1,659 @@ +import { expect } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + mutateVaultRoster, + projectVaultRoster, + readVaultRoster, +} from '@cortexkit/common-auth/claustrum' +import type { NativeCustodyClient } from '../native-custody.ts' +import { nativeQuotaCodec } from '../native-quota-codec.ts' +import { readNativeRuntime, updateNativeRuntime } from '../native-runtime.ts' +import { + acquireNativeVaultRuntime, + createNativeVaultRuntime, + publishNativeVaultRosterSeed, + publishNativeVaultRuntimeSeed, +} from '../native-vault-runtime.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore } from '../pool-store.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup, gate } = createTestLifetimeSuite() +const uuid = '11111111-2222-4333-8444-555555555555' +const uuid2 = '22222222-2222-4333-8444-555555555555' +const id = 'oauth:anthropic' +const now = 1_000_000 +type Row = Awaited< + ReturnType +>['rows'][number] +const listed: Row = { + id, + accountId: uuid, + credentialType: 'oauth', + categories: ['anthropic-native'], + refreshAdapter: 'anthropic', + serves: ['anthropic'], + providerIds: [], + state: 'active', + operations: ['read'], + recordVersion: 7, + createdAtMs: null, +} + +async function fixture() { + const parent = new URL( + '../../../../node_modules/.cache/native-vault-runtime/', + import.meta.url, + ).pathname + await mkdir(parent, { recursive: true }) + const root = await mkdtemp(join(parent, 'fixture-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const paths = await resolveNativePoolPaths(join(root, 'anthropic-auth.json')) + await writeFile( + paths.journal, + JSON.stringify({ + version: 2, + storageId: paths.storageId, + host: 'opencode', + phase: 'committed', + sources: { config: null, state: null, hostAuth: 'absent', routing: null }, + routingPaths: { + source: join(root, 'old-routing'), + destination: join(root, 'routing'), + }, + hostAuthPath: join(root, 'host-auth'), + expectedHostAuth: 'absent', + expectedRouting: null, + }), + { mode: 0o600 }, + ) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + await store.initialize() + const projected = projectVaultRoster( + undefined, + { + view: 'initial', + credentials: [ + { + credentialId: id, + credentialType: 'oauth', + accountIdentity: uuid, + state: 'active', + }, + ], + skipped: [], + }, + { now }, + ) + const primaryRouteId = projected.rows[0]?.routeId + if (!primaryRouteId) throw new Error('Missing fixture primary route') + await store.updateSettings((settings) => { + settings.mainAccountId = primaryRouteId + settings.claustrum = { + mode: 'claustrum', + primaryAccount: { credentialId: id, accountId: uuid, state: 'active' }, + } + }) + let rows: readonly Row[] = [listed] + let version = 7 + let token = '01'.repeat(32) + const counts = { connects: 0, lists: 0, gets: 0, closes: 0 } + const reports: Parameters< + NativeCustodyClient['reportAuthFailureScoped'] + >[0][] = [] + const client: NativeCustodyClient = { + listScoped: async () => { + counts.lists++ + return { view: `view-${rows.length}-${rows[0]?.accountId}`, rows } + }, + getScoped: async (input) => { + counts.gets++ + return { + credentialId: input.credentialId, + accountId: rows.find((row) => row.id === input.credentialId)?.accountId, + material: `synthetic-bearer-${version}`, + recordVersion: version, + expiresAtMs: now + 3_600_000, + } + }, + reportAuthFailureScoped: async (report) => { + reports.push(report) + }, + close: () => { + counts.closes++ + }, + } + const options = { + paths, + host: 'opencode' as const, + now: () => now, + connect: async () => { + counts.connects++ + return client + }, + readToken: async () => ({ token, token_generation: 1 }), + } + const runtime = createNativeVaultRuntime(options) + deferCleanup(() => runtime.close()) + return { + paths, + options, + runtime, + counts, + reports, + client, + store, + primaryRouteId, + rows: (value: readonly Row[]) => { + rows = value + }, + version: (value: number) => { + version = value + }, + token: (value: string) => { + token = value + }, + } +} + +test('delayed vault fallback GET rejects a newly expired or revoked roster row before HTTP dispatch', async () => { + for (const state of ['expired', 'revoked']) { + const f = await fixture() + const work = { ...listed, id: 'oauth:anthropic:work', accountId: uuid2 } + f.rows([listed, work]) + const entered = gate(), + release = gate() + const backend = { + ...f.client, + getScoped: async ( + input: Parameters[0], + ) => { + const material = await f.client.getScoped(input) + entered.open() + await release.wait + return material + }, + } + const runtime = createNativeVaultRuntime({ + ...f.options, + connect: async () => backend, + }) + deferCleanup(() => runtime.close()) + const roster = await runtime.refresh() + const route = roster?.rows.find( + (row) => row.credentialId === work.id, + )?.routeId + if (!route) throw new Error('Missing fixture fallback route') + let http = 0 + const pending = runtime.authorize(route).then((receipt) => { + http++ + return receipt + }) + await entered.wait + f.rows([listed, { ...work, state }]) + await runtime.refresh() + release.open() + await expect(pending).rejects.toMatchObject({ code: 'not-active' }) + expect(f.counts.gets).toBe(1) + expect(http).toBe(0) + } +}) + +test('vault main authorization preserves offline primary credential UUID and seeded route pin', async () => { + const f = await fixture() + await f.runtime.refresh() + f.rows([{ ...listed, accountId: uuid2 }]) + await f.runtime.refresh() + let http = 0 + const pending = f.runtime.authorize('main').then((receipt) => { + http++ + return receipt + }) + await expect(pending).rejects.toMatchObject({ code: 'route-unavailable' }) + expect(f.counts.gets).toBe(0) + expect(http).toBe(0) + // Reusing the setup-selected primary route must not authorize a different + // account UUID or vault credential than setup recorded. + await mutateVaultRoster(f.paths.roster, (roster) => { + if (!roster?.rows[0]) throw new Error('Missing fixture roster') + roster.rows[0].routeId = f.primaryRouteId + return { next: roster, result: undefined } + }) + await expect(f.runtime.authorize('main')).rejects.toMatchObject({ + code: 'route-unavailable', + }) + await mutateVaultRoster(f.paths.roster, (roster) => { + if (!roster?.rows[0]) throw new Error('Missing fixture roster') + roster.rows[0].accountIdentity = uuid + roster.rows[0].credentialId = 'oauth:anthropic:other' + return { next: roster, result: undefined } + }) + await expect(f.runtime.authorize('main')).rejects.toMatchObject({ + code: 'route-unavailable', + }) + expect(f.counts.gets).toBe(0) +}) + +test('persisted vault read never connects while first authorization discovers and every attempt gets a fresh receipt', async () => { + const f = await fixture() + expect(await f.runtime.read()).toBeUndefined() + expect(f.counts.connects).toBe(0) + const first = await f.runtime.authorize('main') + const second = await f.runtime.authorize('main') + expect(first).not.toBe(second) + expect(f.counts).toEqual({ connects: 1, lists: 1, gets: 2, closes: 0 }) + expect(JSON.stringify(first)).not.toContain('synthetic-bearer') + expect(first.accessToken).toBe('synthetic-bearer-7') + expect(JSON.stringify(await f.runtime.read())).not.toContain( + 'synthetic-bearer', + ) +}) + +test('shared canonical storage and host consumer closes only on final release', async () => { + const f = await fixture() + const a = acquireNativeVaultRuntime(f.options) + const b = acquireNativeVaultRuntime({ ...f.options, paths: { ...f.paths } }) + deferCleanup(() => { + a.close() + b.close() + }) + await a.authorize('main') + a.close() + expect(f.counts.closes).toBe(0) + await b.authorize('main') + expect(f.counts.connects).toBe(1) + b.close() + expect(f.counts.closes).toBe(1) + expect(() => a.read()).toThrow('closed') +}) + +test('vault close interrupts a pending connection and closes its late client exactly once', async () => { + const f = await fixture() + const connected = gate() + const entered = gate() + const lateClosed = gate() + const client = { + ...f.client, + close: () => { + f.client.close() + lateClosed.open() + }, + } + const runtime = createNativeVaultRuntime({ + ...f.options, + connect: async () => { + entered.open() + await connected.wait + return client + }, + }) + deferCleanup(() => runtime.close()) + const pending = runtime.authorize('main') + await entered.wait + runtime.close() + await expect(pending).rejects.toMatchObject({ code: 'closed' }) + connected.open() + await lateClosed.wait + expect(f.counts.closes).toBe(1) +}) + +test('incomplete and unasserted vault lists retain prior accounts and cannot silently delete them', async () => { + const f = await fixture() + const first = await f.runtime.refresh() + const routeId = first?.rows[0]?.routeId + f.rows([{ ...listed, accountId: undefined }]) + const unclaimed = await f.runtime.refresh() + expect(unclaimed?.rows[0]?.routeId).toBe(routeId) + expect(unclaimed?.rows[0]?.unclaimed).toBe(true) + f.rows([{ ...listed, id: '', accountId: undefined }]) + const malformed = await f.runtime.refresh() + expect(malformed?.complete).toBe(false) + expect(malformed?.rows).toHaveLength(1) + f.rows([]) + expect((await f.runtime.refresh())?.rows).toHaveLength(0) +}) + +test('closing a pending vault list cannot publish its late result', async () => { + const f = await fixture() + const entered = gate(), + release = gate(), + settled = gate() + const runtime = createNativeVaultRuntime({ + ...f.options, + connect: async () => ({ + ...f.client, + listScoped: async () => { + entered.open() + await release.wait + settled.open() + return { view: 'late', rows: [listed] } + }, + }), + }) + deferCleanup(() => runtime.close()) + const pending = runtime.refresh() + await entered.wait + runtime.close() + await expect(pending).rejects.toMatchObject({ code: 'closed' }) + release.open() + await settled.wait + expect(await readVaultRoster(f.paths.roster)).toBeUndefined() +}) + +test('vault background 401 reports each exact rejected receipt and retries only once on strict newer version', async () => { + const f = await fixture() + const headers: string[] = [] + const transport: typeof fetch = Object.assign( + async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get('authorization') ?? '') + f.version(headers.length === 1 ? 8 : 9) + return new Response('{}', { status: 401 }) + }, + { preconnect: fetch.preconnect }, + ) + await expect(f.runtime.fetchQuota('main', transport)).rejects.toThrow('401') + expect(headers).toEqual([ + 'Bearer synthetic-bearer-7', + 'Bearer synthetic-bearer-8', + ]) + expect(f.reports.map((report) => report.recordVersion)).toEqual([7, 8]) + expect(f.counts.gets).toBe(2) + const served = await f.runtime.authorize('main') + f.version(9) + expect(await f.runtime.prepareRetry(served)).toMatchObject({ + retry: false, + reason: 'version-not-newer', + }) + expect(await f.runtime.prepareRetry(served)).toMatchObject({ retry: false }) +}) + +test('vault receipt ownership and version fences reject copied, replaced and delayed observations', async () => { + const f = await fixture() + const first = await f.runtime.authorize('main') + f.version(8) + const second = await f.runtime.authorize('main') + expect( + await f.runtime.publish(second, { + quota: { + scoped: [], + checkedAt: now, + source: 'poll', + accountIdentity: uuid, + }, + }), + ).toBe(true) + expect(await f.runtime.publish(first, { lastUsed: now + 1 })).toBe(false) + await expect( + f.runtime.publish({ ...second }, { lastUsed: now }), + ).rejects.toMatchObject({ code: 'no-receipt' }) + const quota = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + expect( + quota.status === 'ready' + ? quota.state.accounts[(await f.runtime.read())!.rows[0]!.routeId] + ?.binding + : undefined, + ).toMatchObject({ recordVersion: 8 }) + f.rows([{ ...listed, accountId: uuid2 }]) + await f.runtime.refresh() + expect(await f.runtime.publish(second, { lastUsed: now + 1 })).toBe(false) +}) + +test('vault quota/profile and atomic Prime lineage remain secret-free in durable runtime', async () => { + const f = await fixture() + const transport: typeof fetch = Object.assign( + async (input: Parameters[0]) => + Response.json( + String(input).endsWith('/profile') + ? { + organization: { + organization_type: 'claude_pro', + rate_limit_tier: 'default', + }, + } + : {}, + ), + { preconnect: fetch.preconnect }, + ) + expect((await f.runtime.fetchQuota('main', transport)).scoped).toEqual([]) + expect( + (await f.runtime.fetchProfile('main', transport)).accountIdentity, + ).toBe(uuid) + const receipt = await f.runtime.authorize('main') + const lineages = await Promise.all([ + f.runtime.getOrCreateAuthLineage(receipt), + f.runtime.getOrCreateAuthLineage(receipt), + ]) + expect(lineages[0]).toBe(lineages[1]) + expect(await f.runtime.incrementPrimeUsage(receipt, { inputTokens: 2 })).toBe( + true, + ) + expect( + await f.runtime.incrementPrimeUsage(receipt, { outputTokens: 3 }), + ).toBe(true) + const state = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + expect( + state.status === 'ready' + ? Object.values(state.state.accounts)[0]?.prime + : undefined, + ).toEqual({ count: 2, inputTokens: 2, outputTokens: 3, since: now }) + expect(await readFile(f.paths.runtime, 'utf8')).not.toContain( + 'synthetic-bearer', + ) +}) + +test('seed publication outer ownership is checked again at actual producer rename', async () => { + const f = await fixture() + const input = { + inventory: { + view: 'seed-view', + credentials: [ + { + credentialId: id, + credentialType: 'oauth' as const, + accountIdentity: uuid, + state: 'active', + }, + ], + skipped: [], + }, + primary: { credentialId: id, accountIdentity: uuid }, + primaryRouteId: 'imported-main', + legacyRows: [], + disabledAccountIdentities: [], + reservedRouteIds: [], + } + let assertions = 0 + await expect( + publishNativeVaultRosterSeed(f.paths, input, { + assertOwned: async () => { + if (++assertions === 2) + throw new Error('synthetic outer lease lost at rename') + }, + }), + ).rejects.toThrow('synthetic outer lease lost at rename') + expect(assertions).toBe(2) + expect(await readVaultRoster(f.paths.roster)).toBeUndefined() + const seed = await publishNativeVaultRosterSeed(f.paths, input) + expect(seed.rows[0]?.routeId).toBe('imported-main') +}) + +test('custody runtime seed rejects unowned publication and descriptor callbacks before any write', async () => { + const f = await fixture() + const roster = await f.runtime.refresh() + const routeId = roster!.rows[0]!.routeId + const state = { + version: 1 as const, + storageId: f.paths.storageId, + accounts: { + [routeId]: { + binding: { + kind: 'custody' as const, + storageId: f.paths.storageId, + routeId, + credentialId: id, + accountIdentity: uuid, + recordVersion: 7, + }, + lastUsed: now, + }, + }, + } + await expect( + publishNativeVaultRuntimeSeed(f.paths, state, { + assertOwned: async () => { + throw new Error('lost outer lease') + }, + }), + ).rejects.toMatchObject({ code: 'publication-refused' }) + expect( + (await readNativeRuntime(f.paths.runtime, f.paths.storageId)).status, + ).toBe('missing') + let reads = 0 + Object.defineProperty(state.accounts[routeId], 'lastUsed', { + get: () => { + reads++ + return now + }, + enumerable: true, + }) + await expect( + publishNativeVaultRuntimeSeed(f.paths, state), + ).rejects.toMatchObject({ code: 'publication-refused' }) + expect(reads).toBe(0) +}) + +test('vault route disable persists decline and a held roster prevents replaced-account runtime publication', async () => { + const f = await fixture() + const receipt = await f.runtime.authorize('main') + await f.runtime.setEnabled('main', false) + await f.runtime.refresh() + await expect(f.runtime.authorize('main')).rejects.toMatchObject({ + code: 'route-declined', + }) + await mutateVaultRoster(f.paths.roster, (current) => { + if (!current) throw new Error('Missing fixture roster') + current.rows[0]!.accountIdentity = uuid2 + return { next: current, result: undefined } + }) + expect(await f.runtime.publish(receipt, { lastUsed: now })).toBe(false) +}) + +test('vault quota auth failures never arm quota backoff while 429 still does', async () => { + for (const status of [401, 403, 429]) { + const f = await fixture() + const transport: typeof fetch = Object.assign( + async () => new Response('synthetic-private-body', { status }), + { preconnect: fetch.preconnect }, + ) + await expect(f.runtime.fetchQuota('main', transport)).rejects.toMatchObject( + { status }, + ) + const state = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const error = + state.status === 'ready' + ? state.state.accounts[f.primaryRouteId]?.lastQuotaRefreshError + : undefined + if (status === 429) + expect(error).toMatchObject({ status: 429, nextRetryAt: now + 60_000 }) + else expect(error).toBeUndefined() + } +}) + +test('vault quota retry admits only after deferred body cleanup so decline prevents a second HTTP', async () => { + const f = await fixture() + const entered = gate(), + release = gate() + let http = 0 + const transport: typeof fetch = Object.assign( + async () => { + if (++http === 1) { + f.version(8) + return new Response( + new ReadableStream({ + cancel: async () => { + entered.open() + await release.wait + }, + }), + { status: 401 }, + ) + } + return Response.json({}) + }, + { preconnect: fetch.preconnect }, + ) + const pending = f.runtime.fetchQuota('main', transport) + await entered.wait + await f.runtime.setEnabled('main', false) + release.open() + await expect(pending).rejects.toMatchObject({ status: 401 }) + expect(http).toBe(1) + expect(f.counts.gets).toBe(1) +}) + +test('vault failed failure-report telemetry cannot block its one fresh retry or change rejected versions', async () => { + const f = await fixture() + const runtime = createNativeVaultRuntime({ + ...f.options, + connect: async () => ({ + ...f.client, + reportAuthFailureScoped: async (input) => { + await f.client.reportAuthFailureScoped(input) + throw new Error('synthetic telemetry unavailable') + }, + }), + }) + deferCleanup(() => runtime.close()) + let http = 0 + const transport: typeof fetch = Object.assign( + async () => { + http++ + f.version(http === 1 ? 8 : 9) + return new Response('{}', { status: 401 }) + }, + { preconnect: fetch.preconnect }, + ) + await expect(runtime.fetchQuota('main', transport)).rejects.toMatchObject({ + status: 401, + }) + expect(http).toBe(2) + expect(f.reports.map((report) => report.recordVersion)).toEqual([7, 8]) +}) + +test('vault quota success and failure cannot inherit another account runtime clear floor', async () => { + for (const status of [200, 429]) { + const f = await fixture() + await f.runtime.refresh() + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { + current.accounts[f.primaryRouteId] = { + binding: { + kind: 'custody', + storageId: f.paths.storageId, + routeId: f.primaryRouteId, + credentialId: id, + accountIdentity: uuid2, + recordVersion: 7, + }, + quotaErrorClearedAt: now + 1_000_000, + } + return current + }) + const transport: typeof fetch = Object.assign( + async () => new Response('{}', { status }), + { preconnect: fetch.preconnect }, + ) + if (status === 200) await f.runtime.fetchQuota('main', transport) + else + await expect( + f.runtime.fetchQuota('main', transport), + ).rejects.toMatchObject({ status: 429 }) + const state = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const entry = + state.status === 'ready' + ? state.state.accounts[f.primaryRouteId] + : undefined + if (status === 200) expect(entry?.quotaErrorClearedAt).toBe(now) + else expect(entry?.lastQuotaRefreshError?.checkedAt).toBe(now) + } +}) From 203409e554298c1cbdec9151b5590fe5da0d3850 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:19:17 +0200 Subject: [PATCH 064/169] fix(core): refuse OAuth credential redirects --- packages/core/src/auth.ts | 2 + packages/core/src/claude-code.ts | 1 + .../core/src/tests/oauth-redirect-fixture.ts | 85 +++++++++++++++++++ .../core/src/tests/oauth-redirect.test.ts | 51 +++++++++++ 4 files changed, 139 insertions(+) create mode 100644 packages/core/src/tests/oauth-redirect-fixture.ts create mode 100644 packages/core/src/tests/oauth-redirect.test.ts diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 5e9ec85af..fc22f157d 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -88,6 +88,7 @@ export async function refreshClaudeOAuthToken(input: { const response = await fetchImpl(TOKEN_URL, { method: 'POST', + redirect: 'error', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*', @@ -185,6 +186,7 @@ async function exchangeCode( ): Promise { const result = await fetch(TOKEN_URL, { method: 'POST', + redirect: 'error', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*', diff --git a/packages/core/src/claude-code.ts b/packages/core/src/claude-code.ts index 20f3e5d8a..0a0086482 100644 --- a/packages/core/src/claude-code.ts +++ b/packages/core/src/claude-code.ts @@ -146,6 +146,7 @@ async function fetchClaudeCodeAccountUuid( const response = await fetch(url, { method: 'GET', + redirect: 'error', headers: { accept: 'application/json, text/plain, */*', authorization: `Bearer ${accessToken}`, diff --git a/packages/core/src/tests/oauth-redirect-fixture.ts b/packages/core/src/tests/oauth-redirect-fixture.ts new file mode 100644 index 000000000..a079f50cc --- /dev/null +++ b/packages/core/src/tests/oauth-redirect-fixture.ts @@ -0,0 +1,85 @@ +import { exchange, refreshClaudeOAuthToken } from '../auth.ts' +import { resolveClaudeCodeIdentity } from '../claude-code.ts' + +const operation = process.argv[2] +const status = Number(process.argv[3]) +if (!['refresh', 'exchange', 'bootstrap'].includes(operation ?? '')) + throw new Error('Invalid fixture operation') +if (![200, 302, 307].includes(status)) throw new Error('Invalid fixture status') + +let entries = 0 +let redirected = 0 +const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch(request) { + if (new URL(request.url).pathname === '/redirected') { + redirected++ + return Response.json({ + access_token: 'synthetic-redirect-access', + refresh_token: 'synthetic-redirect-refresh', + expires_in: 3600, + oauth_account: { account_uuid: 'synthetic-redirect-account' }, + }) + } + entries++ + if (status !== 200) + return new Response(null, { + status, + headers: { location: new URL('/redirected', server.url).href }, + }) + return Response.json({ + access_token: 'synthetic-access', + refresh_token: 'synthetic-refresh', + expires_in: 3600, + oauth_account: { account_uuid: 'synthetic-account' }, + }) + }, +}) +const nativeFetch = globalThis.fetch +const localFetch: typeof fetch = Object.assign( + async ( + _input: Parameters[0], + init?: Parameters[1], + ) => nativeFetch(new URL('/entry', server.url), init), + { preconnect: nativeFetch.preconnect }, +) +// Only this disposable child replaces fetch. Native fetch still performs any +// redirect itself, so following one reaches the second server endpoint. +globalThis.fetch = localFetch +let accepted = false +try { + if (operation === 'refresh') { + const value = await refreshClaudeOAuthToken({ + refreshToken: 'synthetic-refresh', + fetchImpl: localFetch, + maxRetries: 0, + }) + accepted = Boolean(value.access) + } else if (operation === 'exchange') { + accepted = + ( + await exchange( + 'synthetic-code#synthetic-state', + 'synthetic-verifier', + 'https://example.invalid/callback', + 'synthetic-state', + ) + ).type === 'success' + } else { + const identity = await resolveClaudeCodeIdentity( + `sk-ant-oat-synthetic-${status}`, + undefined, + undefined, + ) + accepted = Boolean(identity.accountUuid) + } +} catch { + accepted = false +} finally { + globalThis.fetch = nativeFetch + await server.stop(true) +} +console.log( + JSON.stringify({ operation, status, accepted, entries, redirected }), +) diff --git a/packages/core/src/tests/oauth-redirect.test.ts b/packages/core/src/tests/oauth-redirect.test.ts new file mode 100644 index 000000000..7e262f28f --- /dev/null +++ b/packages/core/src/tests/oauth-redirect.test.ts @@ -0,0 +1,51 @@ +import { expect } from 'bun:test' +import { fileURLToPath } from 'node:url' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, gate, trackDetached } = createTestLifetimeSuite() +for (const operation of ['refresh', 'exchange', 'bootstrap']) { + for (const status of [200, 302, 307]) { + test(`${operation} ${status === 200 ? 'accepts the original endpoint' : `refuses HTTP ${status} redirects before sending to the next endpoint`}`, async () => { + const child = Bun.spawn( + [ + process.execPath, + fileURLToPath( + new URL('./oauth-redirect-fixture.ts', import.meta.url), + ), + operation, + String(status), + ], + { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, + ) + const stdout = new Response(child.stdout).text() + const stderr = new Response(child.stderr).text() + const cancel = gate() + const cancellation = cancel.wait.then(() => { + if (child.exitCode === null) child.kill() + }) + trackDetached(cancellation) + try { + const [exit, output, error] = await Promise.all([ + child.exited, + stdout, + stderr, + ]) + expect(exit).toBe(0) + expect(error).toBe('') + expect(JSON.parse(output)).toEqual({ + operation, + status, + accepted: status === 200, + entries: 1, + redirected: 0, + }) + } finally { + // Cancellation terminates the child before teardown joins its body. + // Drain each pipe and the process before releasing its fixture lifetime. + cancel.open() + await cancellation + await Promise.allSettled([child.exited, stdout, stderr]) + } + }) + } +} From 025fd1e3b1546ce9af87ba6dedcc6f51f753cd00 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:51:10 +0200 Subject: [PATCH 065/169] fix(core): authorize native quota polls at transport dispatch --- packages/core/src/quota-manager.ts | 14 ++--- .../tests/quota-native-transport-fixture.ts | 57 ++++++++++++++++++ .../src/tests/quota-native-transport.test.ts | 59 +++++++++++++++++++ 3 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/tests/quota-native-transport-fixture.ts create mode 100644 packages/core/src/tests/quota-native-transport.test.ts diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts index 7574d9465..fd879119d 100644 --- a/packages/core/src/quota-manager.ts +++ b/packages/core/src/quota-manager.ts @@ -57,7 +57,11 @@ export type QuotaRefreshResult = { export type QuotaManagerOptions = { storage: AccountStorage | null fetchImpl?: typeof fetch - /** Resolve custody credentials at the actual HTTP boundary, after quota gates. */ + /** + * This callback checks or obtains credentials before its own HTTP request. + * accessToken may be empty when the host uses a native pool or vault instead + * of keeping a token in its quota settings snapshot. + */ fetchQuotaSnapshot?: (request: { kind: 'main' | 'fallback' accountId: string | undefined @@ -214,13 +218,9 @@ export class QuotaManager { return this.fallbacks.get(accountId) ?? null } - /** A host transport resolves scoped credentials at dispatch time. */ + /** A configured transport obtains its own credentials; policy snapshots need no tokens. */ canFetchWithoutAccessToken(): boolean { - return Boolean( - this.fetchQuotaSnapshot && - (this.storage === null || - this.storage?.claustrum?.scopedRoster === true), - ) + return Boolean(this.fetchQuotaSnapshot) } getAllFallbacks(): Map { diff --git a/packages/core/src/tests/quota-native-transport-fixture.ts b/packages/core/src/tests/quota-native-transport-fixture.ts new file mode 100644 index 000000000..5bd3943a3 --- /dev/null +++ b/packages/core/src/tests/quota-native-transport-fixture.ts @@ -0,0 +1,57 @@ +import { join } from 'node:path' + +const root = process.argv[2] +const scenario = process.argv[3] +if (!root || !['main', 'fallback', 'missing'].includes(scenario ?? '')) + throw new Error('Invalid fixture input') +process.env.TMPDIR = root +process.env.HOME = root +process.env.USERPROFILE = root +process.env.OPENCODE_CONFIG_DIR = root +process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join(root, 'accounts.json') +const { QuotaManager } = await import('../quota-manager.ts') +const calls: unknown[] = [] +const manager = new QuotaManager({ + storage: { version: 1, accounts: [] }, + ...(scenario !== 'missing' && { + fetchQuotaSnapshot: async (request: { + kind: 'main' | 'fallback' + accountId: string | undefined + accessToken: string + }) => { + calls.push(request) + return { + accountIdentity: request.accountId, + checkedAt: Date.now(), + scoped: [], + } + }, + }), + fetchImpl: Object.assign( + async () => { + throw new Error('Direct token-based transport must not run') + }, + { preconnect: fetch.preconnect }, + ), +}) +let fetched = false +try { + const result = + scenario === 'fallback' + ? await manager.refreshFallbackWithMetadata( + 'synthetic-fallback', + '', + undefined, + ) + : await manager.refreshMainWithMetadata('synthetic-main', '') + fetched = result.fetched +} catch { + fetched = false +} +console.log( + JSON.stringify({ + fetched, + calls, + tokenless: manager.canFetchWithoutAccessToken(), + }), +) diff --git a/packages/core/src/tests/quota-native-transport.test.ts b/packages/core/src/tests/quota-native-transport.test.ts new file mode 100644 index 000000000..cc72eec92 --- /dev/null +++ b/packages/core/src/tests/quota-native-transport.test.ts @@ -0,0 +1,59 @@ +import { expect } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, gate, trackDetached, deferCleanup } = createTestLifetimeSuite() +for (const scenario of ['main', 'fallback', 'missing']) { + test(`native quota ${scenario} transport authorizes without legacy custody flags only when a callback owns dispatch`, async () => { + const root = await mkdtemp(join(tmpdir(), 'native-quota-transport-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const child = Bun.spawn( + [ + process.execPath, + fileURLToPath( + new URL('./quota-native-transport-fixture.ts', import.meta.url), + ), + root, + scenario, + ], + { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, + ) + const stdout = new Response(child.stdout).text() + const stderr = new Response(child.stderr).text() + const cancel = gate() + const cancellation = cancel.wait.then(() => { + if (child.exitCode === null) child.kill() + }) + trackDetached(cancellation) + try { + const [exit, output, error] = await Promise.all([ + child.exited, + stdout, + stderr, + ]) + expect(exit).toBe(0) + expect(error).toBe('') + expect(JSON.parse(output)).toEqual({ + fetched: scenario !== 'missing', + tokenless: scenario !== 'missing', + calls: + scenario === 'missing' + ? [] + : [ + { + kind: scenario, + accountId: `synthetic-${scenario}`, + accessToken: '', + }, + ], + }) + } finally { + cancel.open() + await cancellation + await Promise.allSettled([child.exited, stdout, stderr]) + } + }) +} From 78e8b66031a9a7d5482cd2f88dc92fdd468e4b42 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:18:32 +0200 Subject: [PATCH 066/169] mason: implement offline native migration controller and recovery proofs Replace the closed v2 journal contract with v3 prepared descriptor and canonical imported-state proofs. Update existing authority/service fixtures for the stronger preparation requirement; stock OpenCode API credentials remain host-owned and Pi API entries are never imported as primary. Use public store reads/add orphan recovery and public result-only roster leases. Recheck credentials, runtime, routing, source and ownership through host activation and authority commit, then retire forward without enforcing prepared proofs after commit. Add deterministic source projection and independently named synthetic child crash/ownership controls. Parent source/comment review approved this Core-only commit; setup edits remain separate. --- packages/core/src/migration-source.ts | 1 + packages/core/src/native-migration-proof.ts | 279 ++++ packages/core/src/native-migration-source.ts | 736 +++++++++++ packages/core/src/native-migration.ts | 1124 +++++++++++++++++ packages/core/src/pool-authority.ts | 117 +- .../native-local-credential-service.test.ts | 5 +- .../src/tests/native-migration-proof.test.ts | 147 +++ .../src/tests/native-migration-source.test.ts | 197 +++ .../core/src/tests/native-migration.test.ts | 780 ++++++++++++ .../src/tests/pool-authority-crash-child.ts | 62 +- .../core/src/tests/pool-authority.test.ts | 30 +- 11 files changed, 3442 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/native-migration-proof.ts create mode 100644 packages/core/src/native-migration-source.ts create mode 100644 packages/core/src/native-migration.ts create mode 100644 packages/core/src/tests/native-migration-proof.test.ts create mode 100644 packages/core/src/tests/native-migration-source.test.ts create mode 100644 packages/core/src/tests/native-migration.test.ts diff --git a/packages/core/src/migration-source.ts b/packages/core/src/migration-source.ts index fefed95c2..c1b664aa4 100644 --- a/packages/core/src/migration-source.ts +++ b/packages/core/src/migration-source.ts @@ -9,6 +9,7 @@ export type NativeMigrationSourceRole = | 'state' | 'hostAuth' | 'routing' + | 'journal' export interface NativeMigrationSourceSnapshot { readonly role: NativeMigrationSourceRole diff --git a/packages/core/src/native-migration-proof.ts b/packages/core/src/native-migration-proof.ts new file mode 100644 index 000000000..5da11a1f5 --- /dev/null +++ b/packages/core/src/native-migration-proof.ts @@ -0,0 +1,279 @@ +import { createHash } from 'node:crypto' +import { types } from 'node:util' +import type { PoolRow, StoredCredential } from '@cortexkit/common-auth/store' + +export class NativeMigrationProofError extends Error { + readonly code = 'prepared-proof-refused' + constructor() { + super('Native migration prepared proof is unavailable or changed') + this.name = 'NativeMigrationProofError' + } +} + +/** Credential-derived digests belong only in the owner-only migration journal. */ +export interface NativeMigrationRowProof { + id: string + credentialEpoch: number + identity: string | null + stamp: 'bound' + credentialDigest: string +} + +export interface NativeMigrationPreparedProof { + version: 1 + rows: NativeMigrationRowProof[] + runtimeDigest: string +} + +function refuse(): never { + throw new NativeMigrationProofError() +} + +export function requireNativeMigrationDataObject( + value: unknown, +): asserts value is Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + types.isProxy(value) + ) + refuse() + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) refuse() + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') refuse() + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor || !Object.hasOwn(descriptor, 'value')) refuse() + } +} + +/** Copy inert JSON before decoding; never invoke a getter, proxy trap or toJSON. */ +export function captureNativeMigrationJson(value: unknown): unknown { + const ancestors = new Set() + function copy(item: unknown): unknown { + if (item === null || typeof item === 'string' || typeof item === 'boolean') + return item + if (typeof item === 'number' && Number.isFinite(item)) return item + if (typeof item !== 'object' || types.isProxy(item) || ancestors.has(item)) + refuse() + const prototype = Object.getPrototypeOf(item) + if ( + prototype !== + (Array.isArray(item) ? Array.prototype : Object.prototype) && + !(prototype === null && !Array.isArray(item)) + ) + refuse() + ancestors.add(item) + try { + const fields = new Map() + for (const key of Reflect.ownKeys(item)) { + if (typeof key !== 'string') refuse() + const descriptor = Object.getOwnPropertyDescriptor(item, key) + if (!descriptor || !Object.hasOwn(descriptor, 'value')) refuse() + fields.set(key, descriptor.value) + } + if (Array.isArray(item)) { + const length = fields.get('length') + if (typeof length !== 'number' || fields.size !== length + 1) refuse() + return Array.from({ length }, (_, index) => { + if (!fields.has(String(index))) refuse() + return copy(fields.get(String(index))) + }) + } + const result: Record = Object.create(null) + for (const key of [...fields.keys()].sort()) + result[key] = copy(fields.get(key)) + return result + } finally { + ancestors.delete(item) + } + } + return copy(value) +} + +export function nativeMigrationCanonicalJson(value: unknown): string { + return JSON.stringify(captureNativeMigrationJson(value)) +} + +export function nativeMigrationDigest(value: unknown): string { + return createHash('sha256') + .update(nativeMigrationCanonicalJson(value)) + .digest('hex') +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function optionalField( + value: Record, + key: string, + kind: 'string' | 'number', +): unknown[] { + if (!Object.hasOwn(value, key)) return ['absent'] + if (typeof value[key] !== kind) refuse() + return ['present', value[key]] +} + +/** Fixed-order, complete public descriptor; absence is different from a value. */ +export function nativeMigrationCredentialDigest( + credential: StoredCredential, +): string { + const value = captureNativeMigrationJson(credential) + if (!record(value)) refuse() + if (value.type === 'oauth' && typeof value.refresh === 'string') { + if ( + Object.keys(value).some( + (key) => + !['type', 'access', 'refresh', 'expires', 'lastRefreshedAt'].includes( + key, + ), + ) + ) + refuse() + return nativeMigrationDigest([ + 'anthropic-prepared-credential-v1', + 'oauth', + optionalField(value, 'access', 'string'), + value.refresh, + optionalField(value, 'expires', 'number'), + optionalField(value, 'lastRefreshedAt', 'number'), + ]) + } + if ( + value.type === 'api' && + typeof value.apiKey === 'string' && + typeof value.baseURL === 'string' + ) { + if ( + Object.keys(value).some( + (key) => !['type', 'apiKey', 'baseURL', 'authHeader'].includes(key), + ) + ) + refuse() + if ( + Object.hasOwn(value, 'authHeader') && + value.authHeader !== 'x-api-key' && + value.authHeader !== 'authorization-bearer' + ) + refuse() + return nativeMigrationDigest([ + 'anthropic-prepared-credential-v1', + 'api', + value.apiKey, + value.baseURL, + optionalField(value, 'authHeader', 'string'), + ]) + } + return refuse() +} + +export function captureNativeMigrationPreparedProof( + rows: readonly PoolRow[], + runtimeProjection: unknown, +): NativeMigrationPreparedProof { + // Public store rows may contain optional undefined fields; read only the exact + // descriptor fields below, after rejecting executable row objects. + if ( + types.isProxy(rows) || + !Array.isArray(rows) || + Object.getPrototypeOf(rows) !== Array.prototype + ) + refuse() + const rowFields = Object.getOwnPropertyDescriptors(rows) + if (Reflect.ownKeys(rowFields).length !== rows.length + 1) refuse() + const proofs = Array.from({ length: rows.length }, (_, index) => { + const descriptor = rowFields[String(index)] + if (!descriptor || !Object.hasOwn(descriptor, 'value')) refuse() + const row: PoolRow = descriptor.value + requireNativeMigrationDataObject(row) + if ( + row.stamp !== 'bound' || + row.invalid || + row.torn || + row.unbound || + !row.credential || + typeof row.credentialEpoch !== 'number' || + !Number.isSafeInteger(row.credentialEpoch) || + row.credentialEpoch <= 0 || + typeof row.id !== 'string' || + !row.id || + (row.identity !== undefined && + (typeof row.identity !== 'string' || !row.identity)) + ) + refuse() + return { + id: row.id, + credentialEpoch: row.credentialEpoch, + identity: row.identity ?? null, + stamp: 'bound' as const, + credentialDigest: nativeMigrationCredentialDigest(row.credential), + } + }).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + if (new Set(proofs.map((row) => row.id)).size !== proofs.length) refuse() + return { + version: 1, + rows: proofs, + runtimeDigest: nativeMigrationDigest(runtimeProjection), + } +} + +export function decodeNativeMigrationPreparedProof( + input: unknown, +): NativeMigrationPreparedProof { + const value = captureNativeMigrationJson(input) + const digest = (item: unknown): item is string => + typeof item === 'string' && /^[a-f0-9]{64}$/.test(item) + if ( + !record(value) || + Object.keys(value).length !== 3 || + value.version !== 1 || + !Array.isArray(value.rows) || + !digest(value.runtimeDigest) + ) + refuse() + const rows: NativeMigrationRowProof[] = value.rows.map((item) => { + if ( + !record(item) || + Object.keys(item).length !== 5 || + typeof item.id !== 'string' || + !item.id || + typeof item.credentialEpoch !== 'number' || + !Number.isSafeInteger(item.credentialEpoch) || + item.credentialEpoch <= 0 || + !( + item.identity === null || + (typeof item.identity === 'string' && item.identity.length > 0) + ) || + item.stamp !== 'bound' || + typeof item.credentialDigest !== 'string' || + !digest(item.credentialDigest) + ) + refuse() + return { + id: item.id, + credentialEpoch: item.credentialEpoch, + identity: item.identity, + stamp: 'bound', + credentialDigest: item.credentialDigest, + } + }) + if (new Set(rows.map((row) => row.id)).size !== rows.length) refuse() + return { version: 1, rows, runtimeDigest: value.runtimeDigest } +} + +export function requireNativeMigrationPreparedProof( + proof: NativeMigrationPreparedProof | null, + rows: readonly PoolRow[], + runtimeProjection: unknown, +): void { + if ( + !proof || + nativeMigrationCanonicalJson(decodeNativeMigrationPreparedProof(proof)) !== + nativeMigrationCanonicalJson( + captureNativeMigrationPreparedProof(rows, runtimeProjection), + ) + ) + refuse() +} diff --git a/packages/core/src/native-migration-source.ts b/packages/core/src/native-migration-source.ts new file mode 100644 index 000000000..d4688bcf8 --- /dev/null +++ b/packages/core/src/native-migration-source.ts @@ -0,0 +1,736 @@ +import { createHash } from 'node:crypto' +import { + fingerprintOf, + type PoolCredential, + type PoolRow, +} from '@cortexkit/common-auth/store' + +import { isValidApiBaseURL } from './accounts.ts' +import { CACHE_1H_MODES } from './constants.ts' +import { inspectNativeHostAuthEntry } from './native-host-auth.ts' +import { + captureNativeMigrationJson, + nativeMigrationCanonicalJson, + requireNativeMigrationDataObject, +} from './native-migration-proof.ts' +import { + isNativeOAuthQuotaSnapshot, + type NativeQuotaMap, + toNativeQuotaMap, +} from './native-quota-codec.ts' +import type { + NativeRosterSeedLegacyRow, + NativeVaultRosterSeed, +} from './native-roster-seed.ts' +import { + decodeNativeRuntime, + type NativeRuntimeState, +} from './native-runtime.ts' +import type { NativePoolPaths } from './pool-paths.ts' +import { tokenFingerprint } from './token-fingerprint.ts' + +export class NativeMigrationProjectionError extends Error { + readonly code = 'invalid-migration-source' + constructor() { + super('Native migration source cannot be imported safely') + this.name = 'NativeMigrationProjectionError' + } +} + +export interface NativeMigrationAccount { + id: string + enabled: boolean + identity?: string + label?: string + credential?: PoolCredential + quota?: NativeQuotaMap + runtime: Record +} + +export interface NativeMigrationProjection { + accounts: NativeMigrationAccount[] + settings: Record + mainRouteId: string + custodyRows: NativeRosterSeedLegacyRow[] + custodyPrimary?: { credentialId: string; accountIdentity: string } + disabledAccountIdentities: string[] + relay?: { token: string } +} + +export interface NativeMigrationSourceInput { + host: 'opencode' | 'pi' + storageId: string + configDigest: string | null + config: unknown + state: unknown + hostAuth: unknown +} + +function refuse(): never { + throw new NativeMigrationProjectionError() +} +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} +function object(value: unknown): Record { + if (value === undefined) return Object.create(null) + if (!record(value)) refuse() + return value +} +function text(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.trim() === value +} +function id(value: unknown): string { + if (!text(value) || ['__proto__', 'constructor', 'prototype'].includes(value)) + refuse() + return value +} +function time(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : 0 +} +function identity(value: unknown): string | undefined { + if (value === undefined) return undefined + if (!text(value)) refuse() + return value +} +function pick( + source: Record, + keys: readonly string[], +): Record { + return Object.fromEntries( + keys + .filter((key) => Object.hasOwn(source, key)) + .map((key) => [key, source[key]]), + ) +} + +const settingFields: Record = { + routing: ['mode'], + refresh: ['enabled', 'intervalMinutes', 'refreshBeforeExpiryMinutes'], + quota: [ + 'enabled', + 'checkIntervalMinutes', + 'refreshEveryNRequests', + 'minimumRemaining', + 'failClosedOnUnknownQuota', + 'showToasts', + ], + quotaHeaderFeed: ['enabled'], + claudeCache: ['enabled', 'mode'], + dump: ['enabled'], + logging: ['level'], + claudeFast: ['enabled'], + thinkingBinding: ['prefixMismatchBehavior'], + costZeroing: ['enabled'], + cacheKeep: ['enabled', 'always', 'startHour', 'endHour', 'subagents'], + prime: ['enabled'], + relay: ['enabled', 'url', 'fallbackToDirect', 'transport'], + killswitch: ['enabled', 'main', 'accounts'], +} + +function validateSettings(settings: Record): void { + const threshold = (value: unknown) => + record(value) && + Object.entries(value).every( + ([key, amount]) => + ['five_hour', 'seven_day', '5h', '1w', 'scoped'].includes(key) && + typeof amount === 'number' && + Number.isFinite(amount) && + amount >= 0 && + amount <= 100, + ) + const enums: Record = { + 'routing.mode': ['main-first', 'fallback-first', 'sticky-balanced'], + 'claudeCache.mode': CACHE_1H_MODES, + 'logging.level': ['debug', 'info', 'warn', 'error', 'trace'], + 'thinkingBinding.prefixMismatchBehavior': [ + 'account-default', + 'error', + 'drop_block', + ], + 'relay.transport': ['http', 'websocket'], + } + for (const [section, fields] of Object.entries(settingFields)) { + if (settings[section] === undefined) continue + const value = object(settings[section]) + for (const key of fields) { + if (value[key] === undefined) continue + const choices = enums[`${section}.${key}`] + if (choices) { + if (typeof value[key] !== 'string' || !choices.includes(value[key])) + refuse() + continue + } + if ( + [ + 'enabled', + 'always', + 'subagents', + 'failClosedOnUnknownQuota', + 'showToasts', + 'fallbackToDirect', + ].includes(key) + ) { + if (typeof value[key] !== 'boolean') refuse() + } else if (key === 'url') { + if (typeof value[key] !== 'string' || !isValidApiBaseURL(value[key])) + refuse() + } else if (key === 'minimumRemaining' || key === 'main') { + if (!threshold(value[key])) refuse() + } else if (key === 'accounts') { + if ( + !record(value[key]) || + !Object.entries(value[key]).every( + ([route, thresholds]) => text(route) && threshold(thresholds), + ) + ) + refuse() + } else if ( + typeof value[key] !== 'number' || + !Number.isFinite(value[key]) || + value[key] < 0 + ) + refuse() + } + } +} + +function credential(source: Record): PoolCredential { + if (source.type === 'oauth' && text(source.refresh)) { + if ( + (source.access !== undefined && typeof source.access !== 'string') || + (source.expires !== undefined && + (typeof source.expires !== 'number' || + !Number.isFinite(source.expires))) + ) + refuse() + return { + type: 'oauth', + refresh: source.refresh, + ...(typeof source.access === 'string' ? { access: source.access } : {}), + ...(typeof source.expires === 'number' + ? { expires: source.expires } + : {}), + } + } + if ( + source.type === 'api' && + text(source.apiKey) && + typeof source.baseURL === 'string' && + isValidApiBaseURL(source.baseURL) + ) { + if ( + source.authHeader !== undefined && + source.authHeader !== 'authorization-bearer' && + source.authHeader !== 'x-api-key' + ) + refuse() + return { + type: 'api', + apiKey: source.apiKey, + baseURL: source.baseURL, + // The public store decodes an absent header as bearer; compare that public value. + authHeader: source.authHeader ?? 'authorization-bearer', + } + } + return refuse() +} + +function runtime( + source: Record, + accountIdentity: string | undefined, + current: PoolCredential | undefined, +): Record { + const result = pick(source, [ + 'lastUsed', + 'lastRefreshedAt', + 'refreshErrorClearedAt', + 'quotaErrorClearedAt', + 'quotaErrorGeneration', + 'prime', + 'authLineageId', + 'primeAuthLineageRefreshTokenFingerprint', + ]) + const expectedQuotaToken = + current?.type === 'oauth' + ? tokenFingerprint(current.access ?? '') + : accountIdentity + if ( + source.quotaToken !== undefined && + source.quotaToken === expectedQuotaToken + ) { + result.quotaToken = source.quotaToken + if (source.quotaCheckedAt !== undefined) + result.quotaCheckedAt = source.quotaCheckedAt + } + // A profile is reusable only for the exact known account, and for the main + // profile the caller additionally fences its access-token observation. + if ( + record(source.profile) && + accountIdentity !== undefined && + (source.profile.providerAccountUuid === accountIdentity || + source.profile.accountIdentity === accountIdentity) + ) { + result.profile = pick(source.profile, [ + 'tier', + 'orgType', + 'checkedAt', + 'accountIdentity', + 'providerAccountUuid', + 'tokenFingerprint', + ]) + } + for (const field of ['lastRefreshError', 'lastQuotaRefreshError']) { + const error = source[field] + const clear = + result[ + field === 'lastRefreshError' + ? 'refreshErrorClearedAt' + : 'quotaErrorClearedAt' + ] + if ( + !record(error) || + (typeof clear === 'number' && time(error.checkedAt) <= clear) || + (error.accountIdentity !== undefined && + error.accountIdentity !== accountIdentity) + ) + continue + const safe = pick(error, [ + 'checkedAt', + 'nextRetryAt', + 'retryCount', + 'accountIdentity', + 'status', + 'permanent', + ]) + // Free-form upstream messages can contain credentials; retain retry semantics, + // not the message or opaque legacy token identifiers. + safe.message = + field === 'lastRefreshError' + ? 'Imported refresh failure' + : 'Imported quota failure' + if ( + field === 'lastRefreshError' && + current?.type === 'oauth' && + error.tokenHash === + createHash('sha256').update(current.refresh).digest('hex') + ) + safe.credentialFingerprint = fingerprintOf(current) + result[field] = safe + } + return result +} + +function quota( + source: unknown, + accountIdentity: string | undefined, +): NativeQuotaMap | undefined { + if (source === undefined) return undefined + if (!isNativeOAuthQuotaSnapshot(source)) refuse() + // Slot labels such as "main" are not Claude UUIDs. Importing them would make + // the next genuine UUID observation fail the codec's ownership check. + if ( + accountIdentity === undefined || + source.accountIdentity !== accountIdentity + ) + return undefined + return toNativeQuotaMap(source) +} + +/** Deterministic projection of captured JSON, without invoking legacy loaders or callbacks. */ +export function projectNativeMigrationSource( + input: NativeMigrationSourceInput, +): NativeMigrationProjection { + try { + requireNativeMigrationDataObject(input) + const config = object( + input.config === undefined + ? undefined + : captureNativeMigrationJson(input.config), + ) + const state = object( + input.state === undefined + ? undefined + : captureNativeMigrationJson(input.state), + ) + const auth = object( + input.hostAuth === undefined + ? undefined + : captureNativeMigrationJson(input.hostAuth), + ) + const hostEntry = inspectNativeHostAuthEntry(input.host, auth) + if ( + (config.version !== undefined && config.version !== 1) || + (state.version !== undefined && state.version !== 1) + ) + refuse() + const formerMain = createHash('sha256') + .update( + JSON.stringify([input.storageId, input.configDigest, 'former-main']), + ) + .digest('hex') + const mainRouteId = + config.mainAccountId === undefined + ? `${formerMain.slice(0, 8)}-${formerMain.slice(8, 12)}-${formerMain.slice(12, 16)}-${formerMain.slice(16, 20)}-${formerMain.slice(20, 32)}` + : id(config.mainAccountId) + const settings: Record = { + mainAccountId: mainRouteId, + formerMainRowId: mainRouteId, + } + for (const [key, fields] of Object.entries(settingFields)) { + if (config[key] !== undefined) + settings[key] = pick(object(config[key]), fields) + } + if (config.fallbackOn !== undefined) { + if ( + !Array.isArray(config.fallbackOn) || + config.fallbackOn.some( + (value) => typeof value !== 'number' || !Number.isInteger(value), + ) + ) + refuse() + settings.fallbackOn = config.fallbackOn + } + const claustrum = object(config.claustrum) + const custodyPrimaryValue = claustrum.primaryAccount + let custodyPrimary: NativeMigrationProjection['custodyPrimary'] + if (custodyPrimaryValue !== undefined) { + const primary = object(custodyPrimaryValue) + if (!text(primary.credentialId) || !text(primary.accountId)) refuse() + custodyPrimary = { + credentialId: primary.credentialId, + accountIdentity: primary.accountId, + } + } + const disabled = claustrum.disabledAccountIdentities ?? [] + if (!Array.isArray(disabled) || disabled.some((value) => !text(value))) + refuse() + const disabledAccountIdentities: string[] = [] + for (const value of disabled) { + if (!text(value)) refuse() + disabledAccountIdentities.push(value) + } + if (claustrum.mode === 'claustrum' && !custodyPrimary) refuse() + settings.claustrum = { + mode: custodyPrimary ? 'claustrum' : 'local', + ...(custodyPrimaryValue ? { primaryAccount: custodyPrimaryValue } : {}), + disabledAccountIdentities: disabled, + } + const accounts: NativeMigrationAccount[] = [] + const custodyRows: NativeRosterSeedLegacyRow[] = [] + const main = { ...object(config.main), ...object(state.main) } + // Leave OpenCode's stored Anthropic API key under host control. Pi serves + // OAuth accounts; remove its conflicting stored key only after the user + // explicitly agrees to that removal during offline setup. + if (custodyPrimary || hostEntry.kind === 'oauth') { + let current: PoolCredential | undefined + if (!custodyPrimary) { + const entry = object(auth.anthropic) + current = + hostEntry.kind === 'oauth' + ? credential(entry) + : credential({ + type: 'api', + apiKey: entry.key, + baseURL: 'https://api.anthropic.com', + authHeader: 'x-api-key', + }) + } + const mainIdentity = + custodyPrimary?.accountIdentity ?? + (record(main.profile) && + current?.type === 'oauth' && + (main.profileToken === tokenFingerprint(current.access ?? '') || + main.profile.tokenFingerprint === + tokenFingerprint(current.access ?? '')) + ? identity(main.profile.providerAccountUuid) + : undefined) + const quotaConfig = object(config.quota) + const refreshConfig = object(config.refresh) + const primeConfig = object(config.prime) + const mainRuntime = { ...pick(refreshConfig, []), ...main } + for (const [target, source] of Object.entries({ + lastRefreshError: 'mainLastRefreshError', + refreshErrorClearedAt: 'mainRefreshErrorClearedAt', + })) + if ( + !Object.hasOwn(mainRuntime, target) && + refreshConfig[source] !== undefined + ) + mainRuntime[target] = refreshConfig[source] + for (const [target, source] of Object.entries({ + lastQuotaRefreshError: 'mainLastQuotaApiError', + quotaErrorClearedAt: 'mainQuotaErrorClearedAt', + quotaErrorGeneration: 'mainQuotaErrorGeneration', + })) + if ( + !Object.hasOwn(mainRuntime, target) && + quotaConfig[source] !== undefined + ) + mainRuntime[target] = quotaConfig[source] + if (main.lastQuotaApiError !== undefined) + mainRuntime.lastQuotaRefreshError = main.lastQuotaApiError + mainRuntime.authLineageId = + main.primeAuthLineageId ?? primeConfig.mainAuthLineageId + mainRuntime.primeAuthLineageRefreshTokenFingerprint = + main.primeAuthLineageRefreshTokenFingerprint ?? + primeConfig.mainAuthLineageRefreshTokenFingerprint + if (mainRuntime.prime === undefined && primeConfig.main !== undefined) + mainRuntime.prime = primeConfig.main + const account: NativeMigrationAccount = { + id: mainRouteId, + enabled: true, + runtime: runtime(mainRuntime, mainIdentity, current), + ...(mainIdentity !== undefined ? { identity: mainIdentity } : {}), + ...(current ? { credential: current } : {}), + } + const mainQuota = main.quota ?? quotaConfig.mainQuota + const observed = main.quotaToken ?? quotaConfig.mainQuotaToken + if ( + custodyPrimary || + (current?.type === 'oauth' && + observed === tokenFingerprint(current.access ?? '')) + ) + account.quota = quota(mainQuota, mainIdentity) + accounts.push(account) + } + if (config.accounts !== undefined && !Array.isArray(config.accounts)) + refuse() + const stateAccounts = object(state.accounts) + for (const raw of Array.isArray(config.accounts) ? config.accounts : []) { + const row = object(raw) + const rowId = id(row.id) + if (row.enabled !== undefined && typeof row.enabled !== 'boolean') + refuse() + const stateRow = pick(object(stateAccounts[rowId]), [ + 'access', + 'refresh', + 'expires', + 'apiKey', + 'lastUsed', + 'lastRefreshedAt', + 'anthropicAccountUuid', + 'claustrumScopedCredentialId', + 'claustrumScopedState', + 'authLineageId', + 'lastRefreshError', + 'lastQuotaRefreshError', + 'quota', + 'profile', + 'prime', + 'refreshErrorClearedAt', + 'quotaErrorClearedAt', + 'quotaErrorGeneration', + ]) + const scoped = row.claustrumScopedCredentialId !== undefined + const sameScope = + scoped && + stateRow.claustrumScopedCredentialId === + row.claustrumScopedCredentialId && + stateRow.anthropicAccountUuid === row.anthropicAccountUuid + const configClock = Math.max( + time(row.lastRefreshedAt), + time(row.lastUsed), + time(row.addedAt), + ) + const stateClock = Math.max( + time(stateRow.lastRefreshedAt), + time(stateRow.lastUsed), + time(stateRow.addedAt), + ) + const changedSecret = ['access', 'refresh', 'apiKey'].some( + (key) => + row[key] !== undefined && + stateRow[key] !== undefined && + row[key] !== stateRow[key], + ) + const configNewer = changedSecret && configClock > stateClock + if (changedSecret && configClock === stateClock) refuse() + const merged = scoped + ? { ...(sameScope ? stateRow : {}), ...row } + : configNewer + ? { ...stateRow, ...row } + : { ...row, ...stateRow } + if (configNewer) { + delete merged.quota + delete merged.lastRefreshError + delete merged.lastQuotaRefreshError + } + const accountIdentity = identity( + row.anthropicAccountUuid ?? merged.anthropicAccountUuid, + ) + if ( + row.anthropicAccountUuid !== undefined && + stateRow.anthropicAccountUuid !== undefined && + row.anthropicAccountUuid !== stateRow.anthropicAccountUuid + ) + refuse() + if (custodyPrimary && row.type === 'oauth' && !scoped) refuse() + const current = scoped ? undefined : credential(merged) + if (scoped) { + if ( + row.type !== 'oauth' || + !text(row.claustrumScopedCredentialId) || + !accountIdentity || + text(row.refresh) || + row.access !== undefined + ) + refuse() + custodyRows.push({ + routeId: rowId, + scopedCredentialId: row.claustrumScopedCredentialId, + anthropicAccountUuid: accountIdentity, + enabled: row.enabled !== false, + ...(text(row.label) ? { label: row.label } : {}), + ...(typeof row.addedAt === 'number' ? { addedAt: row.addedAt } : {}), + }) + } + const account: NativeMigrationAccount = { + id: rowId, + enabled: row.enabled !== false, + runtime: runtime(merged, accountIdentity, current), + ...(accountIdentity !== undefined ? { identity: accountIdentity } : {}), + ...(text(row.label) ? { label: row.label } : {}), + ...(current ? { credential: current } : {}), + } + const observation = quota(merged.quota, accountIdentity) + if (observation) account.quota = observation + accounts.push(account) + } + if (new Set(accounts.map((account) => account.id)).size !== accounts.length) + refuse() + const enabledIdentity = new Set() + const secrets = new Set() + for (const account of accounts) { + if (account.enabled && account.identity) { + if (enabledIdentity.has(account.identity)) refuse() + enabledIdentity.add(account.identity) + } + if (account.credential) { + const secret = fingerprintOf(account.credential) + if (secrets.has(secret)) refuse() + secrets.add(secret) + Object.defineProperty(account, 'credential', { enumerable: false }) + } + for (const key of Object.keys(account.runtime)) + if (account.runtime[key] === undefined) delete account.runtime[key] + if (account.quota === undefined) delete account.quota + } + validateSettings(settings) + const relay = object(config.relay) + return { + accounts, + settings, + mainRouteId, + custodyRows, + disabledAccountIdentities, + ...(custodyPrimary ? { custodyPrimary } : {}), + ...(typeof relay.token === 'string' + ? { relay: { token: relay.token } } + : {}), + } + } catch { + return refuse() + } +} + +/** Ignore lastRefreshedAt here: adding an OAuth row generates it, so it is not source data. */ +export function requireNativeMigrationSourceRows( + projection: NativeMigrationProjection, + rows: readonly PoolRow[], + checkQuota = true, +): void { + const local = projection.accounts.filter( + (account) => account.credential !== undefined, + ) + if (rows.length !== local.length) refuse() + for (const expected of local) { + const actual = rows.find((row) => row.id === expected.id) + if ( + actual?.stamp !== 'bound' || + actual.invalid || + actual.torn || + actual.unbound || + actual.identity !== expected.identity || + actual.enabled !== expected.enabled || + !actual.credential + ) + refuse() + let descriptor: PoolCredential + if (actual.credential.type === 'oauth') { + const { lastRefreshedAt: _stamp, ...value } = actual.credential + descriptor = value + } else descriptor = actual.credential + if ( + nativeMigrationCanonicalJson(descriptor) !== + nativeMigrationCanonicalJson(expected.credential) + ) + refuse() + if ( + checkQuota && + nativeMigrationCanonicalJson(actual.quota ?? null) !== + nativeMigrationCanonicalJson(expected.quota ?? null) + ) + refuse() + } +} + +/** Use row.credentialEpoch to associate imported quota and errors with the stored credentials. The store’s internal integrity hash is not a public account version. */ +export function projectNativeMigrationRuntime( + paths: NativePoolPaths, + projection: NativeMigrationProjection, + rows: readonly PoolRow[], + seed?: NativeVaultRosterSeed, +): NativeRuntimeState { + const accounts: Record = Object.create(null) + for (const account of projection.accounts.filter( + (account) => account.credential, + )) { + const row = rows.find((row) => row.id === account.id) + if (row?.stamp !== 'bound' || row.credentialEpoch === undefined) refuse() + accounts[account.id] = { + binding: { + kind: 'local', + storageId: paths.storageId, + rowId: row.id, + credentialEpoch: row.credentialEpoch, + ...(row.identity !== undefined ? { identity: row.identity } : {}), + }, + ...account.runtime, + } + } + if (seed) + for (const account of projection.accounts.filter( + (account) => !account.credential, + )) { + const row = seed.rows.find((row) => row.routeId === account.id) + if (!row?.accountIdentity || row.accountIdentity !== account.identity) + refuse() + // Old quota and profile records identify the account, but not the vault + // token version used. Zero marks that missing version; it cannot authorize + // a request or identify a rejected token in a vault failure report. + accounts[account.id] = { + binding: { + kind: 'custody', + storageId: paths.storageId, + routeId: row.routeId, + credentialId: row.credentialId, + accountIdentity: row.accountIdentity, + recordVersion: 0, + }, + ...account.runtime, + ...(account.quota ? { quota: account.quota } : {}), + } + } + return decodeNativeRuntime( + { + version: 1, + storageId: paths.storageId, + accounts, + ...(projection.relay ? { relay: projection.relay } : {}), + }, + paths.storageId, + ) +} diff --git a/packages/core/src/native-migration.ts b/packages/core/src/native-migration.ts new file mode 100644 index 000000000..8ce2495d5 --- /dev/null +++ b/packages/core/src/native-migration.ts @@ -0,0 +1,1124 @@ +import { createHash } from 'node:crypto' +import { lstat, mkdir, unlink } from 'node:fs/promises' +import { dirname, isAbsolute } from 'node:path' +import { + mutateVaultRoster, + readVaultRoster, +} from '@cortexkit/common-auth/claustrum' +import { withLock, writeJsonAtomic } from '@cortexkit/common-auth/fs' +import { POOL_LOCK_DEFAULTS, type PoolRow } from '@cortexkit/common-auth/store' + +import { custodyTombstoneOAuth } from './claustrum.ts' +import { + NativeMigrationSourceError, + type NativeMigrationSourceSnapshot, + readNativeMigrationSource, + requireUnchangedNativeMigrationSource, +} from './migration-source.ts' +import type { NativeCustodyInventory } from './native-custody.ts' +import { + inspectNativeHostAuthEntry, + requireNoSupervisedAuthContentSnapshot, +} from './native-host-auth.ts' +import { + type NativeHostAuthWriteHooks, + writeNativeHostAuth, +} from './native-host-auth-write.ts' +import { + captureNativeMigrationPreparedProof, + NativeMigrationProofError, + nativeMigrationCanonicalJson, + requireNativeMigrationPreparedProof, +} from './native-migration-proof.ts' +import { + type NativeMigrationProjection, + NativeMigrationProjectionError, + projectNativeMigrationRuntime, + projectNativeMigrationSource, + requireNativeMigrationSourceRows, +} from './native-migration-source.ts' +import { nativeQuotaCodec } from './native-quota-codec.ts' +import { + buildNativeVaultRosterSeed, + type NativeRosterSeedInput, + type NativeVaultRosterSeed, + validateNativeVaultRosterSeed, +} from './native-roster-seed.ts' +import { + type NativeRuntimeState, + readNativeRuntime, + updateNativeRuntime, +} from './native-runtime.ts' +import { + advanceNativeMigration, + beginNativeMigration, + type NativeMigrationJournal, + readNativeMigrationJournal, + recordNativeMigrationExpectations, +} from './pool-authority.ts' +import { + canonicalPath, + type NativePoolPaths, + resolveNativePoolPaths, +} from './pool-paths.ts' +import { createNativePoolStore, nativePoolStoreLocks } from './pool-store.ts' +import { migrateStickyRoutingState } from './sticky-routing.ts' + +export class NativeMigrationError extends Error { + constructor( + public readonly code: + | 'invalid-source' + | 'unsafe-source' + | 'consent-required' + | 'migration-busy' + | 'ownership-lost' + | 'retirement-refused', + ) { + super(`Native migration: ${code}`) + this.name = 'NativeMigrationError' + } +} + +export interface NativeMigrationCustody { + /** Use the host’s approved Claustrum token and account-list permission to discover accounts; never open the vault’s credential files. */ + discover: () => Promise + /** Use publishNativeVaultRosterSeed to verify that this controller still owns the migration journal lock before replacing the account list. */ + publishSeed: ( + paths: NativePoolPaths, + input: NativeRosterSeedInput, + options: { assertOwned: () => Promise }, + ) => Promise + /** Use publishNativeVaultRuntimeSeed to match metadata to its vault account while holding the account-list and migration-journal locks. */ + publishRuntimeSeed: ( + paths: NativePoolPaths, + state: NativeRuntimeState, + options: { assertOwned: () => Promise }, + ) => Promise +} + +export interface NativeMigrationOptions { + paths: NativePoolPaths + /** Keep the configured config and state paths so the reader rejects either file if it is a symbolic link. */ + legacyConfigPath?: string + legacyStatePath?: string + host: 'opencode' | 'pi' + hostAuthPath: string + routingSourcePath: string + routingDestinationPath: string + env: NodeJS.ProcessEnv + processFence: () => Promise + removePiAnthropicAuth: boolean + custody?: NativeMigrationCustody + preflight?: NativeMigrationPreflight +} + +/** Result of read-only checks for the selected storage and host. Contains no account data or credential digests. */ +export interface NativeMigrationPreflight { + readonly storageId: string + readonly host: 'opencode' | 'pi' +} + +export interface NativeMigrationHooks { + /** Test hook for interruptions and lease loss. Receives step labels, never credentials or their digests. */ + onStep?: (step: string) => Promise + hostWrite?: NativeHostAuthWriteHooks + store?: Pick< + Parameters[0], + 'onStep' | 'onLockStep' + > +} + +interface Captured { + config: NativeMigrationSourceSnapshot + state: NativeMigrationSourceSnapshot + routing: NativeMigrationSourceSnapshot + auth: NativeMigrationSourceSnapshot + projection?: NativeMigrationProjection + seedInput?: NativeRosterSeedInput + journal?: NativeMigrationJournal + hostAuthPath: string + routingSourcePath: string + routingDestinationPath: string +} + +const preflights = new WeakMap() + +function refuse( + code: ConstructorParameters< + typeof NativeMigrationError + >[0] = 'invalid-source', +): never { + throw new NativeMigrationError(code) +} +function owned(snapshot: NativeMigrationSourceSnapshot): void { + if ( + snapshot.metadata && + process.getuid && + snapshot.metadata.uid !== process.getuid() + ) + refuse('unsafe-source') +} +async function absent(path: string): Promise { + try { + await lstat(path) + return false + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return true + return refuse('unsafe-source') + } +} + +/** Preflight is read-only so setup can validate all selected hosts before any begin. */ +export async function prepareNativeMigration( + input: NativeMigrationOptions, +): Promise { + const captured = await capture(input) + const token = Object.freeze({ + storageId: input.paths.storageId, + host: input.host, + }) + preflights.set(token, captured) + return token +} + +export function releaseNativeMigrationPreflight( + token: NativeMigrationPreflight, +): void { + preflights.delete(token) +} + +async function capture(input: NativeMigrationOptions): Promise { + requireNoSupervisedAuthContentSnapshot(input.env) + await input.processFence() + const resolved = await resolveNativePoolPaths( + input.paths.legacyConfig, + input.paths.legacyState, + ) + for (const key of Object.keys(resolved) as Array) + if (input.paths[key] !== resolved[key]) refuse() + for (const path of [ + input.hostAuthPath, + input.routingSourcePath, + input.routingDestinationPath, + ]) + if (!isAbsolute(path) || /\p{Cc}/u.test(path)) refuse() + const journal = await readNativeMigrationJournal(input.paths) + const auth = await readNativeMigrationSource('hostAuth', input.hostAuthPath) + owned(auth) + const entry = inspectNativeHostAuthEntry(input.host, auth.data) + if ( + input.host === 'pi' && + entry.kind !== 'absent' && + !input.removePiAnthropicAuth + ) + refuse('consent-required') + const hostAuthPath = await canonicalPath(input.hostAuthPath) + const routingSourcePath = await canonicalPath(input.routingSourcePath) + const routingDestinationPath = await canonicalPath( + input.routingDestinationPath, + ) + const all = [ + input.paths.legacyConfig, + input.paths.legacyState, + input.paths.config, + input.paths.state, + input.paths.runtime, + input.paths.roster, + input.paths.journal, + hostAuthPath, + routingSourcePath, + ] + if ( + new Set(all).size !== all.length || + (routingDestinationPath !== routingSourcePath && + all.includes(routingDestinationPath)) + ) + refuse() + if ( + journal && + (journal.host !== input.host || + journal.hostAuthPath !== hostAuthPath || + journal.routingPaths.source !== routingSourcePath || + journal.routingPaths.destination !== routingDestinationPath) + ) + refuse() + // Once the pool becomes authoritative, requests can update it. Restoring + // the host’s token-free activation marker must not import credentials from + // a later host login or replace the pool’s current tokens. + if (journal?.phase === 'committed' || journal?.phase === 'retired') { + const missing = ( + role: 'config' | 'state' | 'routing', + path: string, + ): NativeMigrationSourceSnapshot => ({ + role, + path, + digest: null, + data: undefined, + metadata: undefined, + }) + return { + config: missing('config', input.paths.legacyConfig), + state: missing('state', input.paths.legacyState), + routing: missing('routing', input.routingSourcePath), + auth, + journal, + hostAuthPath, + routingSourcePath, + routingDestinationPath, + } + } + if (!journal) { + for (const path of [ + input.paths.config, + input.paths.state, + input.paths.runtime, + input.paths.roster, + ]) + if (!(await absent(path))) refuse() + if ( + routingSourcePath !== routingDestinationPath && + !(await absent(input.routingDestinationPath)) + ) + refuse() + } + const config = await readNativeMigrationSource( + 'config', + input.legacyConfigPath ?? input.paths.legacyConfig, + ) + const state = await readNativeMigrationSource( + 'state', + input.legacyStatePath ?? input.paths.legacyState, + ) + const routing = await readNativeMigrationSource( + 'routing', + input.routingSourcePath, + ) + for (const source of [config, state, routing]) owned(source) + if ( + (await canonicalPath(config.path)) !== input.paths.legacyConfig || + (await canonicalPath(state.path)) !== input.paths.legacyState + ) + refuse() + if ( + journal && + (journal.sources.config !== config.digest || + journal.sources.state !== state.digest || + (journal.expectedRouting === 'unprepared' && + journal.sources.routing !== routing.digest)) + ) + refuse() + if ( + journal && + entry.digest !== journal.sources.hostAuth && + entry.digest !== journal.expectedHostAuth + ) + refuse() + let projection: NativeMigrationProjection | undefined + let seedInput: NativeRosterSeedInput | undefined + // After replacing host auth, resume verifies credentials already imported + // into the pool against recorded row and runtime hashes. It never restores + // the old host secret. + if ( + !journal || + journal.expectedHostAuth === 'unprepared' || + entry.digest === journal.sources.hostAuth + ) { + projection = projectNativeMigrationSource({ + host: input.host, + storageId: input.paths.storageId, + configDigest: config.digest, + config: config.data, + state: state.data, + hostAuth: auth.data, + }) + if (projection.custodyPrimary) { + if (!input.custody) refuse() + seedInput = { + inventory: await input.custody.discover(), + primary: projection.custodyPrimary, + primaryRouteId: projection.mainRouteId, + legacyRows: projection.custodyRows, + disabledAccountIdentities: projection.disabledAccountIdentities, + reservedRouteIds: projection.accounts + .filter((account) => account.credential?.type === 'api') + .map((account) => account.id), + } + buildNativeVaultRosterSeed(seedInput) + } + // Validate imported quota, profile and error metadata before starting the + // transaction, so malformed data cannot leave partly imported accounts. + const syntheticRows: PoolRow[] = projection.accounts + .filter((account) => account.credential) + .map((account) => ({ + id: account.id, + type: account.credential?.type ?? 'oauth', + credential: account.credential, + identity: account.identity, + credentialEpoch: 1, + enabled: account.enabled, + stamp: 'bound', + hasEntry: true, + needsFirstReading: false, + candidate: account.enabled, + })) + projectNativeMigrationRuntime( + input.paths, + projection, + syntheticRows, + seedInput ? buildNativeVaultRosterSeed(seedInput) : undefined, + ) + } + return { + config, + state, + routing, + auth, + journal, + projection, + seedInput, + hostAuthPath, + routingSourcePath, + routingDestinationPath, + } +} + +async function publicRows( + store: ReturnType, +): Promise { + const result = await store.read() + if (result.status !== 'ready') refuse() + return result.rows +} +async function runtimeState( + paths: NativePoolPaths, +): Promise { + const result = await readNativeRuntime(paths.runtime, paths.storageId) + if (result.status !== 'ready') refuse() + return result.state +} + +async function importedRuntimeProjection( + paths: NativePoolPaths, + store: ReturnType, + rows: PoolRow[], +): Promise { + const settings = await store.readSettings() + if (settings.status !== 'ready') refuse() + const roster = await readNativeMigrationSource('state', paths.roster) + owned(roster) + const publicRoster = roster.data ? await readVaultRoster(paths.roster) : null + if (roster.data && !publicRoster) refuse() + return { + runtime: await runtimeState(paths), + settings: settings.settings, + accounts: rows.map((row) => ({ + id: row.id, + type: row.type, + enabled: row.enabled, + label: row.label ?? null, + addedAt: row.addedAt ?? null, + disabledReason: row.disabledReason ?? null, + quota: row.quota ?? null, + })), + roster: publicRoster, + } +} + +async function heldPool( + paths: NativePoolPaths, + outer: () => Promise, + body: (assertOwned: () => Promise) => Promise, +): Promise { + const locks = nativePoolStoreLocks(paths) + async function enter( + index: number, + checks: Array<() => Promise>, + ): Promise { + const lock = locks[index] + if (!lock) + return mutateVaultRoster(paths.roster, async (_current, rosterLease) => { + const result = await withLock( + paths.runtime, + { name: 'native-runtime', ...POOL_LOCK_DEFAULTS, renew: true }, + (runtimeLease) => + body(async () => { + await outer() + for (const check of checks) await check() + await rosterLease.assertOwned() + await runtimeLease.assertOwned() + }), + ) + // Returning only result keeps common-auth’s account-list lock held until + // our work finishes. It does not replace or normalize that list. + return { result } + }) + return withLock( + lock.path, + { ...POOL_LOCK_DEFAULTS, ...lock, renew: true }, + (handle) => enter(index + 1, [...checks, () => handle.assertOwned()]), + ) + } + return enter(0, []) +} + +function routingBytes(captured: Captured): string | undefined { + if (!captured.routing.data) return undefined + if (!captured.projection) refuse() + const map = new Map( + captured.projection.accounts.map((account) => [account.id, account.id]), + ) + map.set('main', captured.projection.mainRouteId) + return migrateStickyRoutingState(captured.routing.data, map) +} +function bytesDigest(bytes: string | undefined): string | null { + return bytes === undefined + ? null + : createHash('sha256').update(bytes).digest('hex') +} +function expectedAuth( + input: NativeMigrationOptions, + snapshot: NativeMigrationSourceSnapshot, +): string { + const entry = inspectNativeHostAuthEntry(input.host, snapshot.data) + return input.host === 'pi' + ? 'absent' + : entry.kind === 'api' + ? entry.digest + : inspectNativeHostAuthEntry('opencode', { + anthropic: custodyTombstoneOAuth('anthropic'), + }).digest +} + +async function checkLegacy(captured: Captured): Promise { + await requireUnchangedNativeMigrationSource(captured.config) + await requireUnchangedNativeMigrationSource(captured.state) +} + +async function checkPaths( + input: NativeMigrationOptions, + captured: Captured, +): Promise { + if ( + (await canonicalPath(input.hostAuthPath)) !== captured.hostAuthPath || + (await canonicalPath(input.routingSourcePath)) !== + captured.routingSourcePath || + (await canonicalPath(input.routingDestinationPath)) !== + captured.routingDestinationPath + ) + refuse() +} + +async function retire( + input: NativeMigrationOptions, + journal: NativeMigrationJournal, + assertOwned: () => Promise, + hooks: NativeMigrationHooks, +): Promise { + const sources = [ + { + role: 'config' as const, + path: input.legacyConfigPath ?? input.paths.legacyConfig, + digest: journal.sources.config, + }, + { + role: 'state' as const, + path: input.legacyStatePath ?? input.paths.legacyState, + digest: journal.sources.state, + }, + ...(journal.routingPaths.source === journal.routingPaths.destination + ? [] + : [ + { + role: 'routing' as const, + path: input.routingSourcePath, + digest: journal.sources.routing, + }, + ]), + ] + // Validate every surviving source before the first unlink. A partial retirement + // from an earlier crash is allowed, but a changed source is never deleted. + const snapshots = await Promise.all( + sources.map(async (source) => { + const snapshot = await readNativeMigrationSource(source.role, source.path) + owned(snapshot) + if (snapshot.data && snapshot.digest !== source.digest) + refuse('retirement-refused') + return snapshot + }), + ) + for (const snapshot of snapshots) { + if (!snapshot.data) continue + await hooks.onStep?.(`before:retire:${snapshot.role}`) + await requireUnchangedNativeMigrationSource(snapshot) + await input.processFence() + await assertOwned() + await unlink(snapshot.path) + await hooks.onStep?.(`after:retire:${snapshot.role}`) + } + return advanceNativeMigration(input.paths, 'committed', 'retired', { + assertOwned: async () => { + for (const source of sources) + if (!(await absent(source.path))) refuse('retirement-refused') + await input.processFence() + await assertOwned() + }, + onWriteStep: async (step) => hooks.onStep?.(`${step}:journal:retired`), + }) +} + +async function repair( + input: NativeMigrationOptions, + assertOwned: () => Promise, + hooks: NativeMigrationHooks, +): Promise { + const auth = await readNativeMigrationSource('hostAuth', input.hostAuthPath) + owned(auth) + const entry = inspectNativeHostAuthEntry(input.host, auth.data) + await writeNativeHostAuth( + { + host: input.host, + authPath: input.hostAuthPath, + sourceDigest: entry.digest, + expectedDigest: expectedAuth(input, auth), + env: input.env, + removePiAnthropicAuth: input.removePiAnthropicAuth, + processFence: async () => { + await input.processFence() + await assertOwned() + }, + }, + hooks.hostWrite, + ) +} + +/** Offline, crash-forward transaction. Native serving never calls this controller. */ +export async function runNativeMigration( + input: NativeMigrationOptions, + hooks: NativeMigrationHooks = {}, +): Promise { + const paths = { ...input.paths } + const operation = { ...input, paths, env: { ...input.env } } + try { + return await withLock( + paths.journal, + { + name: 'native-migration', + ttlMs: 30_000, + timeoutMs: 15_000, + renew: true, + }, + async (lease) => { + const assertMigration = async () => { + try { + await lease.assertOwned() + } catch { + refuse('ownership-lost') + } + } + const captured = await capture(operation) + if (operation.preflight) { + const previous = preflights.get(operation.preflight) + if ( + !previous || + operation.preflight.storageId !== paths.storageId || + operation.preflight.host !== operation.host || + previous.config.digest !== captured.config.digest || + previous.state.digest !== captured.state.digest || + previous.routing.digest !== captured.routing.digest || + inspectNativeHostAuthEntry(operation.host, previous.auth.data) + .digest !== + inspectNativeHostAuthEntry(operation.host, captured.auth.data) + .digest || + previous.hostAuthPath !== captured.hostAuthPath || + previous.routingSourcePath !== captured.routingSourcePath || + previous.routingDestinationPath !== captured.routingDestinationPath + ) + refuse() + } + let journal = captured.journal + const step = async (label: string) => hooks.onStep?.(label) + const journalHooks = { + assertOwned: async () => { + await checkPaths(operation, captured) + await checkLegacy(captured) + await operation.processFence() + await assertMigration() + }, + onWriteStep: async ( + write: 'before-write' | 'after-write', + value: NativeMigrationJournal, + ) => { + await step( + value.phase === 'verified' && value.preparedProof + ? `${write}:proof` + : `${write}:journal:${value.phase}`, + ) + }, + } + if (journal?.phase === 'retired') { + await repair(operation, assertMigration, hooks) + return journal + } + if (journal?.phase === 'committed') { + journal = await retire(operation, journal, assertMigration, hooks) + await repair(operation, assertMigration, hooks) + return journal + } + if (!journal) { + await assertMigration() + journal = await beginNativeMigration( + paths, + { + host: operation.host, + sources: { + config: captured.config.digest, + state: captured.state.digest, + hostAuth: inspectNativeHostAuthEntry( + operation.host, + captured.auth.data, + ).digest, + routing: captured.routing.digest, + }, + hostAuthPath: captured.hostAuthPath, + routingPaths: { + source: captured.routingSourcePath, + destination: captured.routingDestinationPath, + }, + }, + journalHooks, + ) + } + const store = createNativePoolStore({ + paths, + quota: nativeQuotaCodec, + onLockStep: hooks.store?.onLockStep, + onStep: async (write, info) => { + await hooks.store?.onStep?.(write, info) + await step(`${write}:store:${info.operation}`) + if (write.startsWith('before-')) { + await checkLegacy(captured) + await operation.processFence() + await assertMigration() + } + }, + }) + let bytes: string | undefined + let seed: NativeVaultRosterSeed | undefined + if (journal.phase === 'building') { + const projection = captured.projection + if (!projection) refuse() + const beforeMutator = async () => { + await checkPaths(operation, captured) + await checkLegacy(captured) + await operation.processFence() + await assertMigration() + } + await beforeMutator() + await store.initialize() + // Disabled aliases go first and are disabled immediately, so a later + // enabled alias does not inherit a transient duplicate-identity refusal. + for (const account of [...projection.accounts] + .filter((account) => account.credential) + .sort((a, b) => Number(a.enabled) - Number(b.enabled))) { + const current = (await publicRows(store)).find( + (row) => row.id === account.id, + ) + if (current) + requireNativeMigrationSourceRows( + { + ...projection, + accounts: [ + { + ...account, + credential: account.credential, + enabled: current.enabled, + }, + ], + }, + [current], + false, + ) + else { + await beforeMutator() + if (!account.credential) refuse() + const result = await store.add({ + id: account.id, + credential: account.credential, + ...(account.identity !== undefined + ? { identity: account.identity } + : {}), + ...(account.label !== undefined + ? { label: account.label } + : {}), + }) + if (result.id !== account.id) refuse() + await step(`after:add:${account.id}`) + } + await beforeMutator() + if (!account.enabled) + await store.disable(account.id, 'user-disabled') + else await store.enable(account.id) + if (account.quota) { + const row = (await publicRows(store)).find( + (row) => row.id === account.id, + ) + if (!row?.credentialEpoch) refuse() + await beforeMutator() + await store.recordQuota( + account.id, + { + credentialEpoch: row.credentialEpoch, + ...(row.identity !== undefined + ? { identity: row.identity } + : {}), + }, + account.quota, + ) + } + } + await beforeMutator() + await store.reorder( + projection.accounts + .filter((account) => account.credential) + .map((account) => account.id), + ) + await beforeMutator() + await store.updateSettings(() => projection.settings) + if (captured.seedInput) { + await beforeMutator() + await step('before:roster') + if (!operation.custody) refuse() + seed = await operation.custody.publishSeed( + paths, + captured.seedInput, + { assertOwned: beforeMutator }, + ) + await assertMigration() + await step('after:roster') + } + const rows = await publicRows(store) + requireNativeMigrationSourceRows(projection, rows) + const projected = projectNativeMigrationRuntime( + paths, + projection, + rows, + seed, + ) + await beforeMutator() + await step('before:runtime') + if (captured.seedInput) { + if (!operation.custody) refuse() + await operation.custody.publishRuntimeSeed(paths, projected, { + assertOwned: beforeMutator, + }) + } else + await updateNativeRuntime( + paths.runtime, + paths.storageId, + (current) => { + if ( + Object.keys(current.accounts).length > 0 && + nativeMigrationCanonicalJson(current) !== + nativeMigrationCanonicalJson(projected) + ) + refuse() + return projected + }, + { beforeRename: beforeMutator }, + ) + await step('after:runtime') + bytes = routingBytes(captured) + await heldPool(paths, assertMigration, async (assertOwned) => { + await checkLegacy(captured) + requireNativeMigrationSourceRows( + projection, + await publicRows(store), + ) + if ( + nativeMigrationCanonicalJson(await runtimeState(paths)) !== + nativeMigrationCanonicalJson(projected) + ) + refuse() + journal = await advanceNativeMigration( + paths, + 'building', + 'verified', + { + ...journalHooks, + assertOwned: async () => { + await checkLegacy(captured) + requireNativeMigrationSourceRows( + projection, + await publicRows(store), + ) + if ( + nativeMigrationCanonicalJson(await runtimeState(paths)) !== + nativeMigrationCanonicalJson(projected) + ) + refuse() + await operation.processFence() + await assertOwned() + }, + }, + ) + }) + } + // Releasing the pool leases above permits no host write. Re-acquire them, + // re-read all public rows/runtime and retain them through durable commit. + journal = await heldPool( + paths, + assertMigration, + async (assertOwned) => { + if (!journal) refuse() + await checkLegacy(captured) + let rows = await publicRows(store) + let runtime = await importedRuntimeProjection(paths, store, rows) + if (journal.expectedHostAuth === 'unprepared') { + if (!captured.projection) refuse() + requireNativeMigrationSourceRows(captured.projection, rows) + const importedSettings = await store.readSettings() + if ( + importedSettings.status !== 'ready' || + nativeMigrationCanonicalJson(importedSettings.settings) !== + nativeMigrationCanonicalJson(captured.projection.settings) + ) + refuse() + if (captured.seedInput && !seed) { + // Publication adopts an existing verified seed without rewriting it. + if (!operation.custody) refuse() + // No roster mutator runs under the pool/runtime leases. On verified + // resume, the retained seed is public JSON and was published already. + const retained = await readNativeMigrationSource( + 'state', + paths.roster, + ) + if (!retained.data) refuse() + const document = await readVaultRoster(paths.roster) + if (!document) refuse() + validateNativeVaultRosterSeed(document, captured.seedInput) + seed = buildNativeVaultRosterSeed(captured.seedInput) + } + if ( + nativeMigrationCanonicalJson(await runtimeState(paths)) !== + nativeMigrationCanonicalJson( + projectNativeMigrationRuntime( + paths, + captured.projection, + rows, + seed, + ), + ) + ) + refuse() + await requireUnchangedNativeMigrationSource(captured.routing) + bytes ??= routingBytes(captured) + const preparedProof = captureNativeMigrationPreparedProof( + rows, + runtime, + ) + journal = await recordNativeMigrationExpectations( + paths, + { + expectedHostAuth: expectedAuth(operation, captured.auth), + expectedRouting: bytesDigest(bytes), + preparedProof, + }, + { + ...journalHooks, + assertOwned: async () => { + await checkLegacy(captured) + await requireUnchangedNativeMigrationSource( + captured.routing, + ) + const current = await publicRows(store) + requireNativeMigrationPreparedProof( + preparedProof, + current, + await importedRuntimeProjection(paths, store, current), + ) + await operation.processFence() + await assertOwned() + }, + }, + ) + } + requireNativeMigrationPreparedProof( + journal.preparedProof, + rows, + runtime, + ) + const authoritative = journal + const recheck = async () => { + await checkPaths(operation, captured) + await checkLegacy(captured) + rows = await publicRows(store) + runtime = await importedRuntimeProjection(paths, store, rows) + requireNativeMigrationPreparedProof( + authoritative.preparedProof, + rows, + runtime, + ) + await operation.processFence() + await assertOwned() + } + await withLock( + operation.routingDestinationPath, + { name: 'write', ttlMs: 30_000, timeoutMs: 15_000, renew: true }, + async (routingLease) => { + const destination = await readNativeMigrationSource( + 'routing', + operation.routingDestinationPath, + ) + owned(destination) + if (destination.digest !== authoritative.expectedRouting) { + if ( + authoritative.phase !== 'verified' || + (captured.routingSourcePath !== + captured.routingDestinationPath + ? destination.digest !== null + : destination.digest !== authoritative.sources.routing) + ) + refuse() + await requireUnchangedNativeMigrationSource(captured.routing) + bytes ??= routingBytes(captured) + if ( + bytesDigest(bytes) !== authoritative.expectedRouting || + !bytes + ) + refuse() + await step('before:routing') + await writeJsonAtomic( + operation.routingDestinationPath, + JSON.parse(bytes), + { + beforeRename: async () => { + await requireUnchangedNativeMigrationSource(destination) + await recheck() + await routingLease.assertOwned() + }, + }, + ) + // Both writers use the same pretty JSON/newline contract; resume + // recognizes the durable bytes without pruning or reserialization. + await step('after:routing') + } + const auth = await readNativeMigrationSource( + 'hostAuth', + operation.hostAuthPath, + ) + owned(auth) + const entry = inspectNativeHostAuthEntry( + operation.host, + auth.data, + ) + if ( + entry.digest !== authoritative.sources.hostAuth && + entry.digest !== authoritative.expectedHostAuth + ) + refuse() + if ( + authoritative.phase === 'activation-installed' && + entry.digest !== authoritative.expectedHostAuth + ) + refuse() + await step('before:host') + await recheck() + await mkdir(dirname(operation.hostAuthPath), { + recursive: true, + mode: 0o700, + }) + if (entry.digest !== authoritative.expectedHostAuth) + await writeNativeHostAuth( + { + host: operation.host, + authPath: operation.hostAuthPath, + sourceDigest: authoritative.sources.hostAuth, + expectedDigest: authoritative.expectedHostAuth, + env: operation.env, + removePiAnthropicAuth: operation.removePiAnthropicAuth, + processFence: async () => { + await recheck() + await routingLease.assertOwned() + }, + }, + hooks.hostWrite, + ) + await step('after:host') + await recheck() + const effect = await readNativeMigrationSource( + 'hostAuth', + operation.hostAuthPath, + ) + if ( + inspectNativeHostAuthEntry(operation.host, effect.data) + .digest !== authoritative.expectedHostAuth + ) + refuse() + const routed = await readNativeMigrationSource( + 'routing', + operation.routingDestinationPath, + ) + if (routed.digest !== authoritative.expectedRouting) refuse() + const fencedHooks = { + ...journalHooks, + assertOwned: async () => { + await recheck() + const currentHost = await readNativeMigrationSource( + 'hostAuth', + operation.hostAuthPath, + ) + const currentRouting = await readNativeMigrationSource( + 'routing', + operation.routingDestinationPath, + ) + if ( + inspectNativeHostAuthEntry( + operation.host, + currentHost.data, + ).digest !== authoritative.expectedHostAuth || + currentRouting.digest !== authoritative.expectedRouting + ) + refuse() + await routingLease.assertOwned() + }, + } + if (journal?.phase === 'verified') + journal = await advanceNativeMigration( + paths, + 'verified', + 'activation-installed', + fencedHooks, + ) + journal = await advanceNativeMigration( + paths, + 'activation-installed', + 'committed', + fencedHooks, + ) + }, + ) + return journal + }, + ) + return await retire(operation, journal, assertMigration, hooks) + }, + ) + } catch (error) { + if (error instanceof NativeMigrationSourceError) + refuse( + error.code === 'unsafe-source' ? 'unsafe-source' : 'invalid-source', + ) + if (error instanceof NativeMigrationProjectionError) refuse() + if ( + error instanceof NativeMigrationProofError || + error instanceof NativeMigrationError + ) + throw error + if (error instanceof Error && error.name === 'LockContentionError') + refuse('migration-busy') + throw error + } finally { + if (operation.preflight) preflights.delete(operation.preflight) + } +} diff --git a/packages/core/src/pool-authority.ts b/packages/core/src/pool-authority.ts index bb7486641..14036f3fc 100644 --- a/packages/core/src/pool-authority.ts +++ b/packages/core/src/pool-authority.ts @@ -1,8 +1,14 @@ -import { readFile } from 'node:fs/promises' import { isAbsolute, normalize, parse, sep } from 'node:path' import { withLock, writeJsonAtomic } from '@cortexkit/common-auth/fs' -import { parseJsonRedacted } from './json.ts' +import { readNativeMigrationSource } from './migration-source.ts' +import { + captureNativeMigrationJson, + decodeNativeMigrationPreparedProof, + type NativeMigrationPreparedProof, + nativeMigrationCanonicalJson, + requireNativeMigrationDataObject, +} from './native-migration-proof.ts' import type { NativePoolPaths } from './pool-paths.ts' const PHASES = [ @@ -37,10 +43,11 @@ export interface NativeMigrationInput { export interface NativeMigrationExpectations { expectedHostAuth: string expectedRouting: string | null + preparedProof: NativeMigrationPreparedProof } export interface NativeMigrationJournal { - version: 2 + version: 3 storageId: string host: 'opencode' | 'pi' phase: NativeMigrationPhase @@ -49,6 +56,7 @@ export interface NativeMigrationJournal { hostAuthPath: string expectedHostAuth: string expectedRouting: string | null + preparedProof: NativeMigrationPreparedProof | null } export class NativeAuthorityError extends Error { @@ -77,6 +85,22 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } +function captureJournalJson(value: unknown): unknown { + try { + return captureNativeMigrationJson(value) + } catch { + throw new NativeAuthorityError('invalid-journal') + } +} + +function decodePreparedProof(value: unknown): NativeMigrationPreparedProof { + try { + return decodeNativeMigrationPreparedProof(value) + } catch { + throw new NativeAuthorityError('invalid-journal') + } +} + function isPhase(value: unknown): value is NativeMigrationPhase { return PHASES.some((phase) => phase === value) } @@ -139,9 +163,10 @@ function isRoutingPaths(value: unknown): value is NativeMigrationRoutingPaths { } function decodeJournal( - value: unknown, + input: unknown, storageId: string, ): NativeMigrationJournal { + const value = captureJournalJson(input) if ( !isRecord(value) || !hasKeys(value, [ @@ -154,8 +179,9 @@ function decodeJournal( 'hostAuthPath', 'expectedHostAuth', 'expectedRouting', + 'preparedProof', ]) || - value.version !== 2 || + value.version !== 3 || value.storageId !== storageId || (value.host !== 'opencode' && value.host !== 'pi') || !isPhase(value.phase) || @@ -170,8 +196,13 @@ function decodeJournal( throw new NativeAuthorityError('invalid-journal') } const unprepared = value.expectedHostAuth === 'unprepared' + const preparedProof = + value.preparedProof === null + ? null + : decodePreparedProof(value.preparedProof) if ( unprepared !== (value.expectedRouting === 'unprepared') || + unprepared !== (preparedProof === null) || (value.phase === 'building' && !unprepared) || (value.phase !== 'building' && value.phase !== 'verified' && unprepared) || (!unprepared && @@ -180,7 +211,7 @@ function decodeJournal( throw new NativeAuthorityError('invalid-journal') } return { - version: 2, + version: 3, storageId, host: value.host, phase: value.phase, @@ -197,22 +228,23 @@ function decodeJournal( hostAuthPath: value.hostAuthPath, expectedHostAuth: value.expectedHostAuth, expectedRouting: value.expectedRouting, + preparedProof, } } export async function readNativeMigrationJournal( paths: NativePoolPaths, ): Promise { - let text: string try { - text = await readFile(paths.journal, 'utf8') - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') - return undefined - throw new NativeAuthorityError('invalid-journal') - } - try { - return decodeJournal(parseJsonRedacted(text), paths.storageId) + const snapshot = await readNativeMigrationSource('journal', paths.journal) + if (!snapshot.data) return undefined + if ( + !snapshot.metadata || + (snapshot.metadata.mode & 0o777) !== 0o600 || + (process.getuid && snapshot.metadata.uid !== process.getuid()) + ) + throw new NativeAuthorityError('invalid-journal') + return decodeJournal(snapshot.data, paths.storageId) } catch { throw new NativeAuthorityError('invalid-journal') } @@ -230,6 +262,8 @@ export async function requireNativePoolAuthority( } interface JournalHooks { + /** Outer controller and pool leases remain held through the atomic journal rename. */ + assertOwned?: () => Promise lockOptions?: { ttlMs: number; timeoutMs: number } onWriteStep?: ( step: 'before-write' | 'after-write', @@ -243,13 +277,25 @@ async function writeJournal( assertOwned: () => Promise, hooks: JournalHooks, ): Promise { - await writeJsonAtomic(paths.journal, journal, { + // Hooks observe a detached immutable value, not the object the controller will + // use for its next phase. A hook cannot alter the proof being published. + const snapshot = decodeJournal(journal, paths.storageId) + if (snapshot.preparedProof) { + for (const row of snapshot.preparedProof.rows) Object.freeze(row) + Object.freeze(snapshot.preparedProof.rows) + Object.freeze(snapshot.preparedProof) + } + Object.freeze(snapshot.sources) + Object.freeze(snapshot.routingPaths) + Object.freeze(snapshot) + await writeJsonAtomic(paths.journal, snapshot, { beforeRename: async () => { - await hooks.onWriteStep?.('before-write', journal) + await hooks.onWriteStep?.('before-write', snapshot) + await hooks.assertOwned?.() await assertOwned() }, }) - await hooks.onWriteStep?.('after-write', journal) + await hooks.onWriteStep?.('after-write', snapshot) } /** Migration resume retains the original digests and resolved paths so a retry cannot adopt changed inputs or redirect writes. */ @@ -258,9 +304,14 @@ export async function beginNativeMigration( input: NativeMigrationInput, hooks: JournalHooks = {}, ): Promise { + try { + requireNativeMigrationDataObject(input) + } catch { + throw new NativeAuthorityError('invalid-journal') + } const initial = decodeJournal( { - version: 2, + version: 3, storageId: paths.storageId, host: input.host, phase: 'building', @@ -269,6 +320,7 @@ export async function beginNativeMigration( hostAuthPath: input.hostAuthPath, expectedHostAuth: 'unprepared', expectedRouting: 'unprepared', + preparedProof: null, }, paths.storageId, ) @@ -311,17 +363,23 @@ export async function recordNativeMigrationExpectations( expectations: NativeMigrationExpectations, hooks: JournalHooks = {}, ): Promise { + const captured = captureJournalJson(expectations) if ( - !isRecord(expectations) || - !hasKeys(expectations, ['expectedHostAuth', 'expectedRouting']) || - !isHostEntry(expectations.expectedHostAuth) || - !isFileDigest(expectations.expectedRouting) + !isRecord(captured) || + !hasKeys(captured, [ + 'expectedHostAuth', + 'expectedRouting', + 'preparedProof', + ]) || + !isHostEntry(captured.expectedHostAuth) || + !isFileDigest(captured.expectedRouting) ) { throw new NativeAuthorityError('invalid-journal') } // Capture values before waiting for the lock; callers cannot change the pair // between validation and publication by mutating their input object. - const { expectedHostAuth, expectedRouting } = expectations + const { expectedHostAuth, expectedRouting } = captured + const preparedProof = decodePreparedProof(captured.preparedProof) return withLock( paths.journal, { @@ -343,13 +401,20 @@ export async function recordNativeMigrationExpectations( if (current.expectedHostAuth !== 'unprepared') { if ( current.expectedHostAuth !== expectedHostAuth || - current.expectedRouting !== expectedRouting + current.expectedRouting !== expectedRouting || + nativeMigrationCanonicalJson(current.preparedProof) !== + nativeMigrationCanonicalJson(preparedProof) ) { throw new NativeAuthorityError('journal-conflict') } return current } - const updated = { ...current, expectedHostAuth, expectedRouting } + const updated = { + ...current, + expectedHostAuth, + expectedRouting, + preparedProof, + } await writeJournal(paths, updated, () => lock.assertOwned(), hooks) return updated }, diff --git a/packages/core/src/tests/native-local-credential-service.test.ts b/packages/core/src/tests/native-local-credential-service.test.ts index d16aa3f5c..a93871cf0 100644 --- a/packages/core/src/tests/native-local-credential-service.test.ts +++ b/packages/core/src/tests/native-local-credential-service.test.ts @@ -108,12 +108,15 @@ async function writeJournal(paths: NativePoolPaths, authority: Authority) { authority === 'invalid' ? '{"version":2' : JSON.stringify({ - version: 2, + version: 3, storageId: paths.storageId, ...migrationInput(paths), phase: committed ? 'committed' : 'building', expectedHostAuth: committed ? 'absent' : 'unprepared', expectedRouting: committed ? null : 'unprepared', + preparedProof: committed + ? { version: 1, rows: [], runtimeDigest: 'f'.repeat(64) } + : null, }), { mode: 0o600 }, ) diff --git a/packages/core/src/tests/native-migration-proof.test.ts b/packages/core/src/tests/native-migration-proof.test.ts new file mode 100644 index 000000000..7269b66d8 --- /dev/null +++ b/packages/core/src/tests/native-migration-proof.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from 'bun:test' +import type { PoolRow, StoredCredential } from '@cortexkit/common-auth/store' +import { + captureNativeMigrationPreparedProof, + decodeNativeMigrationPreparedProof, + nativeMigrationCredentialDigest, + requireNativeMigrationPreparedProof, +} from '../native-migration-proof.ts' + +const oauth: StoredCredential = { + type: 'oauth', + access: 'synthetic-access', + refresh: 'synthetic-refresh', + expires: 100, + lastRefreshedAt: 10, +} +function row(credential: StoredCredential = oauth): PoolRow { + return { + id: 'main', + type: credential.type, + credential, + credentialEpoch: 1, + stamp: 'bound', + identity: 'synthetic-identity', + enabled: true, + needsFirstReading: false, + hasEntry: true, + candidate: true, + } +} +const runtime = { version: 1, accounts: { main: { lastUsed: 1 } } } + +test('complete public descriptor detects access, expiry, refresh and refresh-stamp changes', () => { + const original = nativeMigrationCredentialDigest(oauth) + for (const change of [ + { access: 'changed' }, + { expires: 101 }, + { refresh: 'changed' }, + { lastRefreshedAt: 11 }, + ]) + expect(nativeMigrationCredentialDigest({ ...oauth, ...change })).not.toBe( + original, + ) + const { lastRefreshedAt: _stamp, ...unstamped } = oauth + expect(nativeMigrationCredentialDigest(unstamped)).not.toBe(original) +}) + +test('API proof detects key, endpoint, header and absent header independently', () => { + const api: StoredCredential = { + type: 'api', + apiKey: 'synthetic-key', + baseURL: 'https://synthetic.invalid', + authHeader: 'x-api-key', + } + const original = nativeMigrationCredentialDigest(api) + for (const change of [ + { apiKey: 'changed' }, + { baseURL: 'https://other.invalid' }, + { authHeader: 'authorization-bearer' as const }, + ]) + expect(nativeMigrationCredentialDigest({ ...api, ...change })).not.toBe( + original, + ) + const { authHeader: _header, ...absent } = api + expect(nativeMigrationCredentialDigest(absent)).not.toBe(original) +}) + +test('canonical projection accepts serialization order but detects runtime disagreement', () => { + const proof = captureNativeMigrationPreparedProof([row()], runtime) + expect(() => + requireNativeMigrationPreparedProof(proof, [row()], { + accounts: { main: { lastUsed: 1 } }, + version: 1, + }), + ).not.toThrow() + expect(() => + requireNativeMigrationPreparedProof(proof, [row()], { + ...runtime, + accounts: {}, + }), + ).toThrow('prepared proof') +}) + +test('prepared recovery refuses identity absence, epoch re-add, missing proof and non-bound rows', () => { + const proof = captureNativeMigrationPreparedProof([row()], runtime) + for (const changed of [ + { ...row(), identity: undefined }, + { ...row(), identity: 'other' }, + { ...row(), credentialEpoch: 2 }, + { ...row(), stamp: 'mismatched' as const }, + { ...row(), torn: true as const }, + { ...row(), unbound: true as const }, + ]) + expect(() => + requireNativeMigrationPreparedProof(proof, [changed], runtime), + ).toThrow('prepared proof') + expect(() => + requireNativeMigrationPreparedProof(null, [row()], runtime), + ).toThrow('prepared proof') +}) + +test('proof decoding never invokes getters, proxy traps or callbacks', () => { + let calls = 0 + const proof = captureNativeMigrationPreparedProof([row()], runtime) + const getter = { + ...proof, + get rows() { + calls++ + return [] + }, + } + const proxy = new Proxy(proof, { + ownKeys() { + calls++ + return [] + }, + get() { + calls++ + return 1 + }, + }) + for (const value of [ + getter, + proxy, + { + ...proof, + toJSON() { + calls++ + return {} + }, + }, + ]) + expect(() => decodeNativeMigrationPreparedProof(value)).toThrow( + 'prepared proof', + ) + expect(calls).toBe(0) +}) + +test('proof contains neither original tokens nor private stamp fields', () => { + const text = JSON.stringify( + captureNativeMigrationPreparedProof([row()], runtime), + ) + expect(text).not.toContain('synthetic-access') + expect(text).not.toContain('synthetic-refresh') + expect(text).not.toContain('dispatch') + expect(text).not.toContain('fingerprint') +}) diff --git a/packages/core/src/tests/native-migration-source.test.ts b/packages/core/src/tests/native-migration-source.test.ts new file mode 100644 index 000000000..932e70a91 --- /dev/null +++ b/packages/core/src/tests/native-migration-source.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from 'bun:test' +import { projectNativeMigrationSource } from '../native-migration-source.ts' + +const hostAuth = { + anthropic: { + type: 'oauth', + access: 'synthetic-main-access', + refresh: 'synthetic-main-refresh', + expires: 123, + }, +} +function source(config: unknown = {}, state: unknown = {}) { + return projectNativeMigrationSource({ + host: 'opencode', + storageId: 'a'.repeat(64), + configDigest: null, + config, + state, + hostAuth, + }) +} + +test('actual legacy main, fallback order, disable preferences and empty owned arrays survive projection', () => { + const projected = source( + { + version: 1, + mainAccountId: 'primary-route', + fallbackOn: [], + quota: { enabled: false }, + accounts: [ + { id: 'second', type: 'oauth', enabled: false }, + { id: 'third', type: 'api', baseURL: 'https://synthetic.invalid' }, + ], + }, + { + accounts: { + second: { + access: 'synthetic-fallback-access', + refresh: 'synthetic-fallback-refresh', + expires: 45, + }, + third: { apiKey: 'synthetic-key' }, + }, + }, + ) + expect( + projected.accounts.map((account) => [account.id, account.enabled]), + ).toEqual([ + ['primary-route', true], + ['second', false], + ['third', true], + ]) + expect(projected.accounts[1]?.credential).toMatchObject({ + refresh: 'synthetic-fallback-refresh', + }) + expect(projected.settings).toMatchObject({ + fallbackOn: [], + mainAccountId: 'primary-route', + quota: { enabled: false }, + }) + expect(JSON.stringify(projected.accounts)).not.toContain( + 'synthetic-fallback-refresh', + ) +}) + +test('newer config credentials discard stale observations; equal-time secret disagreement refuses', () => { + const config = { + accounts: [ + { + id: 'fallback', + type: 'oauth', + access: 'new', + refresh: 'new-refresh', + expires: 2, + lastRefreshedAt: 20, + }, + ], + } + const state = { + accounts: { + fallback: { + access: 'old', + refresh: 'old-refresh', + expires: 1, + lastRefreshedAt: 10, + quota: { accountIdentity: 'slot' }, + lastRefreshError: { checkedAt: 5, message: 'secret' }, + }, + }, + } + expect(source(config, state).accounts[1]?.credential).toMatchObject({ + access: 'new', + expires: 2, + }) + expect(source(config, state).accounts[1]?.runtime).not.toHaveProperty( + 'lastRefreshError', + ) + expect(() => + source(config, { + accounts: { + fallback: { ...state.accounts.fallback, lastRefreshedAt: 20 }, + }, + }), + ).toThrow('cannot be imported') +}) + +test('enabled identity conflicts refuse before import, disabled duplicate identity is retained', () => { + const rows = [ + { + id: 'one', + type: 'oauth', + refresh: 'one-refresh', + anthropicAccountUuid: 'same-identity', + }, + { + id: 'two', + type: 'oauth', + refresh: 'two-refresh', + anthropicAccountUuid: 'same-identity', + }, + ] + expect(() => source({ accounts: rows })).toThrow('cannot be imported') + expect( + source({ accounts: [{ ...rows[0], enabled: false }, rows[1]] }).accounts[1] + ?.enabled, + ).toBe(false) +}) + +test('incompatible slot-tagged quota is omitted while exact ownership and [] scope survive', () => { + const config = { + accounts: [ + { + id: 'one', + type: 'oauth', + refresh: 'one-refresh', + anthropicAccountUuid: 'uuid-one', + }, + ], + } + expect( + source(config, { + accounts: { one: { quota: { accountIdentity: 'one', scoped: [] } } }, + }).accounts[1]?.quota, + ).toBeUndefined() + expect( + source(config, { + accounts: { one: { quota: { accountIdentity: 'uuid-one', scoped: [] } } }, + }).accounts[1]?.quota?.anthropic.scoped, + ).toEqual([]) +}) + +test('getter and proxy sources invoke no executable behavior and error contains no source', () => { + let calls = 0 + for (const config of [ + { + get accounts() { + calls++ + return [] + }, + }, + new Proxy( + {}, + { + ownKeys() { + calls++ + return [] + }, + }, + ), + ]) + expect(() => source(config)).toThrow('cannot be imported') + expect(calls).toBe(0) +}) + +test('settings and imported errors exclude token fields and free-form upstream messages', () => { + const projected = source({ + relay: { enabled: true, token: 'synthetic-relay-token' }, + prime: { enabled: true, mainAuthLineageId: 'legacy' }, + refresh: { + mainLastRefreshError: { + checkedAt: 1, + nextRetryAt: 2, + message: 'synthetic-secret-in-message', + }, + }, + }) + expect(projected.settings.relay).toEqual({ enabled: true }) + expect(projected.settings.prime).toEqual({ enabled: true }) + expect(projected.accounts[0]?.runtime.lastRefreshError).toEqual({ + checkedAt: 1, + nextRetryAt: 2, + message: 'Imported refresh failure', + }) + expect(JSON.stringify(projected.accounts)).not.toContain( + 'synthetic-secret-in-message', + ) +}) diff --git a/packages/core/src/tests/native-migration.test.ts b/packages/core/src/tests/native-migration.test.ts new file mode 100644 index 000000000..44c420e7b --- /dev/null +++ b/packages/core/src/tests/native-migration.test.ts @@ -0,0 +1,780 @@ +import { expect } from 'bun:test' +import { + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readVaultRoster } from '@cortexkit/common-auth/claustrum' +import { lockPathFor, writeJsonAtomic } from '@cortexkit/common-auth/fs' +import type { NativeCustodyInventory } from '../native-custody.ts' +import { + type NativeMigrationOptions, + prepareNativeMigration, + runNativeMigration, +} from '../native-migration.ts' +import { nativeQuotaCodec } from '../native-quota-codec.ts' +import { + buildNativeVaultRosterSeed, + validateNativeVaultRosterSeed, +} from '../native-roster-seed.ts' +import { readNativeRuntime, updateNativeRuntime } from '../native-runtime.ts' +import { + readNativeMigrationJournal, + requireNativePoolAuthority, +} from '../pool-authority.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore } from '../pool-store.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() +const oauth = { + type: 'oauth' as const, + access: 'synthetic-access-main', + refresh: 'synthetic-refresh-main', + expires: 2000, +} +async function fixture( + host: 'opencode' | 'pi' = 'opencode', + hostEntry: unknown = oauth, + routing = false, +) { + const created = await mkdtemp(join(tmpdir(), 'native-offline-migration-')) + deferCleanup(() => rm(created, { recursive: true, force: true })) + const root = await realpath(created) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + const input: NativeMigrationOptions = { + paths, + host, + hostAuthPath: join(root, 'host-auth.json'), + routingSourcePath: join(root, 'legacy-routing.json'), + routingDestinationPath: join(root, 'native-routing.json'), + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: true, + } + await writeFile( + paths.legacyConfig, + JSON.stringify({ + version: 1, + mainAccountId: 'primary-route', + fallbackOn: [], + accounts: [ + { + id: 'fallback-route', + type: 'oauth', + enabled: false, + label: 'Disabled account', + }, + ], + costZeroing: { enabled: false }, + }), + { mode: 0o600 }, + ) + await writeFile( + paths.legacyState, + JSON.stringify({ + version: 1, + accounts: { + 'fallback-route': { + access: 'synthetic-access-fallback', + refresh: 'synthetic-refresh-fallback', + expires: 4000, + lastUsed: 50, + }, + }, + }), + { mode: 0o600 }, + ) + if (hostEntry !== undefined) + await writeFile( + input.hostAuthPath, + JSON.stringify({ + anthropic: hostEntry, + other: { key: 'synthetic-other-provider' }, + }), + { mode: 0o600 }, + ) + if (routing) + await writeFile( + input.routingSourcePath, + JSON.stringify({ version: 1, updatedAt: 10, assignments: {} }), + { mode: 0o600 }, + ) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + return { root, input, paths, store } +} + +async function stopAt(input: NativeMigrationOptions, point: string) { + await expect( + runNativeMigration(input, { + onStep: async (step) => { + if (step === point) throw new Error('synthetic interruption') + }, + }), + ).rejects.toThrow() +} +async function expireChildLocks(root: string) { + for (const name of await readdir(root)) { + if (!name.endsWith('.lock')) continue + const path = join(root, name) + const value: unknown = JSON.parse(await readFile(path, 'utf8')) + if ( + !value || + typeof value !== 'object' || + !('ownerId' in value) || + !('expiresAt' in value) + ) + continue + await writeFile(path, JSON.stringify({ ...value, expiresAt: 0 })) + } +} + +test('offline transaction imports actual source, commits and retires in the same invocation', async () => { + const { input, paths, store } = await fixture('opencode', oauth, true) + const journal = await runNativeMigration(input) + expect(journal.phase).toBe('retired') + await requireNativePoolAuthority(paths) + const rows = await store.read() + expect(rows).toMatchObject({ + status: 'ready', + rows: [ + { id: 'primary-route', stamp: 'bound' }, + { id: 'fallback-route', enabled: false, stamp: 'bound' }, + ], + }) + expect(await store.readSettings()).toMatchObject({ + settings: { + fallbackOn: [], + costZeroing: { enabled: false }, + formerMainRowId: 'primary-route', + }, + }) + expect(await readNativeRuntime(paths.runtime, paths.storageId)).toMatchObject( + { + status: 'ready', + state: { accounts: { 'fallback-route': { lastUsed: 50 } } }, + }, + ) + for (const path of [ + paths.legacyConfig, + paths.legacyState, + input.routingSourcePath, + ]) + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + const text = await readFile(paths.journal, 'utf8') + for (const secret of [ + oauth.access, + oauth.refresh, + 'synthetic-refresh-fallback', + ]) + expect(text).not.toContain(secret) + expect((await stat(paths.journal)).mode & 0o777).toBe(0o600) + expect(JSON.parse(await readFile(input.hostAuthPath, 'utf8')).other).toEqual({ + key: 'synthetic-other-provider', + }) +}) + +test('preflight conflicts and unaccounted native material publish no journal', async () => { + for (const pathName of ['config', 'state', 'runtime', 'roster'] as const) { + const { input, paths } = await fixture() + await writeFile(paths[pathName], '{}', { mode: 0o600 }) + await expect(prepareNativeMigration(input)).rejects.toMatchObject({ + code: 'invalid-source', + }) + await expect(stat(paths.journal)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(paths[pathName], 'utf8')).toBe('{}') + } + const { input, paths } = await fixture() + const bytes = JSON.stringify({ + accounts: [ + { id: 'one', type: 'oauth', refresh: 'same' }, + { id: 'two', type: 'oauth', refresh: 'same' }, + ], + }) + await writeFile(paths.legacyConfig, bytes) + await expect(runNativeMigration(input)).rejects.toMatchObject({ + code: 'invalid-source', + }) + expect(await readNativeMigrationJournal(paths)).toBeUndefined() +}) + +test('stock OpenCode API remains host-owned; Pi API-primary is never imported and requires explicit consent', async () => { + const stock = await fixture('opencode', { + type: 'api', + key: 'synthetic-stock-key', + }) + const bytes = await readFile(stock.input.hostAuthPath, 'utf8') + await runNativeMigration(stock.input) + expect(await readFile(stock.input.hostAuthPath, 'utf8')).toBe(bytes) + expect(await stock.store.read()).toMatchObject({ + rows: [{ id: 'fallback-route' }], + }) + for (const entry of [ + { type: 'api_key' }, + { type: 'api_key', env: { ANTHROPIC_API_KEY: 'SYNTHETIC_ENV' } }, + { type: 'api_key', key: 'synthetic-pi-key' }, + ]) { + const pi = await fixture('pi', entry) + await expect( + runNativeMigration({ ...pi.input, removePiAnthropicAuth: false }), + ).rejects.toMatchObject({ code: 'consent-required' }) + expect(await readNativeMigrationJournal(pi.paths)).toBeUndefined() + await runNativeMigration(pi.input) + expect(await pi.store.read()).toMatchObject({ + rows: [{ id: 'fallback-route' }], + }) + expect(JSON.parse(await readFile(pi.input.hostAuthPath, 'utf8'))).toEqual({ + other: { key: 'synthetic-other-provider' }, + }) + } +}) + +test('prepared recovery accepts serialization-only rewrites and unrelated provider changes', async () => { + const { input, paths } = await fixture() + await stopAt(input, 'after:host') + for (const path of [paths.config, paths.state, paths.runtime]) { + const value: Record = JSON.parse( + await readFile(path, 'utf8'), + ) + await writeFile( + path, + JSON.stringify(Object.fromEntries(Object.entries(value).reverse())), + { mode: 0o600 }, + ) + } + const host = JSON.parse(await readFile(input.hostAuthPath, 'utf8')) + await writeFile( + input.hostAuthPath, + JSON.stringify({ ...host, newProvider: { key: 'synthetic-new-provider' } }), + ) + expect((await runNativeMigration(input)).phase).toBe('retired') + expect( + JSON.parse(await readFile(input.hostAuthPath, 'utf8')).newProvider, + ).toEqual({ key: 'synthetic-new-provider' }) +}) + +test('prepared host-replacement recovery refuses public descriptor, epoch, identity and runtime drift with zero further writes', async () => { + for (const change of [ + 'access', + 'expires', + 'lastRefreshedAt', + 'refresh', + 'identity', + 'epoch', + 'runtime', + 'missing-proof', + ]) { + const { root, input, paths, store } = await fixture() + await stopAt(input, 'after:host') + if (['access', 'expires', 'lastRefreshedAt', 'refresh'].includes(change)) { + const state = JSON.parse(await readFile(paths.state, 'utf8')) + state.accounts['primary-route'][change] = [ + 'expires', + 'lastRefreshedAt', + ].includes(change) + ? 3 + : 'synthetic-changed' + await writeFile(paths.state, JSON.stringify(state)) + } else if (change === 'identity') { + await store.recordIdentity( + 'primary-route', + 'synthetic-changed-identity', + { credentialEpoch: 1 }, + ) + } else if (change === 'epoch') { + await store.remove('primary-route') + const child = Bun.spawn( + [ + process.execPath, + join(import.meta.dir, 'pool-authority-crash-child.ts'), + root, + 'native-readd', + 'before-write', + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + deferCleanup(async () => { + if (child.exitCode === null) child.kill() + await child.exited + }) + expect({ + code: await child.exited, + stderr: await new Response(child.stderr).text(), + }).toEqual({ code: 0, stderr: '' }) + } else if (change === 'runtime') { + const runtime = JSON.parse(await readFile(paths.runtime, 'utf8')) + runtime.accounts['primary-route'].lastUsed = 99 + await writeFile(paths.runtime, JSON.stringify(runtime)) + } else { + const journal = JSON.parse(await readFile(paths.journal, 'utf8')) + journal.preparedProof = null + await writeFile(paths.journal, JSON.stringify(journal)) + } + const before = await Promise.all( + [ + paths.journal, + paths.config, + paths.state, + paths.runtime, + input.hostAuthPath, + ].map((path) => readFile(path, 'utf8')), + ) + await expect(runNativeMigration(input)).rejects.toThrow() + expect( + await Promise.all( + [ + paths.journal, + paths.config, + paths.state, + paths.runtime, + input.hostAuthPath, + ].map((path) => readFile(path, 'utf8')), + ), + ).toEqual(before) + } +}) + +test('lost migration and pool ownership fence physical host write and authority rename', async () => { + for (const [pathName, name] of [ + ['journal', 'native-migration'], + ['config', 'pool-config'], + ['state', 'pool-state'], + ['runtime', 'native-runtime'], + ] as const) { + const { input, paths } = await fixture() + const host = await readFile(input.hostAuthPath, 'utf8') + await expect( + runNativeMigration(input, { + onStep: async (step) => { + if (step !== 'before:host') return + const path = lockPathFor(paths[pathName], name) + const lease = JSON.parse(await readFile(path, 'utf8')) + await writeFile( + path, + JSON.stringify({ ...lease, ownerId: 'synthetic-other-owner' }), + ) + }, + }), + ).rejects.toThrow() + expect(await readFile(input.hostAuthPath, 'utf8')).toBe(host) + expect((await readNativeMigrationJournal(paths))?.phase).toBe('verified') + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + } +}) + +test('committed retirement ignores prepared proof after legitimate serving writes and repairs host without importing', async () => { + const { input, paths, store } = await fixture() + await stopAt(input, 'after-write:journal:committed') + await store.rotate('primary-route', { + ...oauth, + access: 'synthetic-serving-access', + }) + const before = await readFile(paths.state, 'utf8') + await writeFile( + input.hostAuthPath, + JSON.stringify({ + anthropic: { ...oauth, access: 'synthetic-host-relogin' }, + other: { key: 'synthetic-other' }, + }), + ) + expect((await runNativeMigration(input)).phase).toBe('retired') + expect(await readFile(paths.state, 'utf8')).toBe(before) + expect(await readFile(input.hostAuthPath, 'utf8')).not.toContain( + 'synthetic-host-relogin', + ) +}) + +test('changed retirement source remains untouched and authority remains committed', async () => { + const { input, paths } = await fixture() + await stopAt(input, 'after-write:journal:committed') + const changed = '{"version":1,"accounts":[],"changed":true}' + await writeFile(paths.legacyState, changed) + await expect(runNativeMigration(input)).rejects.toMatchObject({ + code: 'retirement-refused', + }) + expect(await readFile(paths.legacyState, 'utf8')).toBe(changed) + expect((await readNativeMigrationJournal(paths))?.phase).toBe('committed') + await requireNativePoolAuthority(paths) +}) + +test('fresh all-absent install reaches retired without fabricating a credential', async () => { + const { input, paths, store } = await fixture() + await Promise.all( + [paths.legacyConfig, paths.legacyState, input.hostAuthPath].map((path) => + rm(path), + ), + ) + expect((await runNativeMigration(input)).phase).toBe('retired') + expect(await store.read()).toMatchObject({ status: 'ready', rows: [] }) + expect((await readNativeMigrationJournal(paths))?.sources).toEqual({ + config: null, + state: null, + routing: null, + hostAuth: 'absent', + }) + expect(await readNativeRuntime(paths.runtime, paths.storageId)).toMatchObject( + { status: 'ready', state: { accounts: {} } }, + ) +}) + +test('prepared authority rename rechecks proof and ownership after host effect', async () => { + for (const change of ['runtime', 'migration', 'pool']) { + const { input, paths } = await fixture() + await expect( + runNativeMigration(input, { + onStep: async (step) => { + if (step !== 'before-write:journal:committed') return + if (change === 'runtime') { + const value = JSON.parse(await readFile(paths.runtime, 'utf8')) + value.accounts['primary-route'].lastUsed = 321 + await writeFile(paths.runtime, JSON.stringify(value)) + } else { + const path = lockPathFor( + change === 'pool' ? paths.config : paths.journal, + change === 'pool' ? 'pool-config' : 'native-migration', + ) + const value = JSON.parse(await readFile(path, 'utf8')) + await writeFile( + path, + JSON.stringify({ ...value, ownerId: 'synthetic-successor' }), + ) + } + }, + }), + ).rejects.toThrow() + expect((await readNativeMigrationJournal(paths))?.phase).toBe( + 'activation-installed', + ) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + } +}) + +test('prepared API endpoint and header changes refuse even with a valid new public stamp', async () => { + for (const change of [ + { baseURL: 'https://changed.invalid' }, + { authHeader: 'x-api-key' as const }, + ]) { + const { input, paths, store } = await fixture() + const api = { + type: 'api' as const, + apiKey: 'synthetic-api-fallback', + baseURL: 'https://original.invalid', + authHeader: 'authorization-bearer' as const, + } + await writeFile( + paths.legacyConfig, + JSON.stringify({ + mainAccountId: 'primary-route', + accounts: [{ id: 'api-route', ...api }], + }), + ) + await stopAt(input, 'after:host') + await store.replace('api-route', { ...api, ...change }) + const journal = await readFile(paths.journal, 'utf8') + await expect(runNativeMigration(input)).rejects.toMatchObject({ + code: 'prepared-proof-refused', + }) + expect(await readFile(paths.journal, 'utf8')).toBe(journal) + } +}) + +test('prepared disabled intent remains fenced after host replacement with unchanged credentials', async () => { + const { input, paths, store } = await fixture() + await stopAt(input, 'after:host') + await store.enable('fallback-route') + const before = await readFile(paths.journal, 'utf8') + await expect(runNativeMigration(input)).rejects.toMatchObject({ + code: 'prepared-proof-refused', + }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) +}) + +test('retired setup preserves serving-owned routing changes and imports no host relogin', async () => { + const { input, paths, store } = await fixture('opencode', oauth, true) + await runNativeMigration(input) + const routing = '{"version":1,"updatedAt":999,"assignments":{}}' + await writeFile(input.routingDestinationPath, routing) + await store.rotate('primary-route', { + ...oauth, + access: 'synthetic-legitimate-access', + }) + const before = await Promise.all( + [paths.config, paths.state, paths.runtime, paths.journal].map((path) => + readFile(path, 'utf8'), + ), + ) + await writeFile( + input.hostAuthPath, + JSON.stringify({ + anthropic: { ...oauth, access: 'synthetic-unimported-login' }, + }), + ) + await runNativeMigration(input) + expect(await readFile(input.routingDestinationPath, 'utf8')).toBe(routing) + expect( + await Promise.all( + [paths.config, paths.state, paths.runtime, paths.journal].map((path) => + readFile(path, 'utf8'), + ), + ), + ).toEqual(before) +}) + +async function custodyFixture() { + const result = await fixture() + await writeFile( + result.paths.legacyConfig, + JSON.stringify({ + mainAccountId: 'primary-route', + claustrum: { + mode: 'claustrum', + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: 'primary-identity', + state: 'active', + }, + }, + accounts: [ + { + id: 'vault-fallback', + type: 'oauth', + refresh: '', + enabled: false, + claustrumScopedCredentialId: 'oauth:secondary', + anthropicAccountUuid: 'secondary-identity', + }, + { + id: 'api-route', + type: 'api', + apiKey: 'synthetic-api-fallback', + baseURL: 'https://synthetic.invalid', + }, + ], + }), + ) + await writeFile( + result.paths.legacyState, + JSON.stringify({ + version: 1, + accounts: { + 'vault-fallback': { + claustrumScopedCredentialId: 'oauth:secondary', + anthropicAccountUuid: 'secondary-identity', + lastUsed: 100, + quota: { + accountIdentity: 'secondary-identity', + checkedAt: 50, + scoped: [], + }, + }, + }, + }), + ) + const inventory: { value: NativeCustodyInventory } = { + value: { + view: 'synthetic-view-one', + skipped: [], + credentials: [ + { + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: 'primary-identity', + state: 'active', + }, + { + credentialId: 'oauth:secondary', + credentialType: 'oauth', + accountIdentity: 'secondary-identity', + state: 'active', + }, + ], + }, + } + result.input.custody = { + discover: async () => inventory.value, + // These injected file writers test the controller's resume sequence without + // a daemon. Host tests must also use the real vault publishers to verify + // lock ownership immediately before the shared store replaces each file. + publishSeed: async (paths, input, options) => { + const seed = buildNativeVaultRosterSeed(input) + const existing = await readVaultRoster(paths.roster) + if (existing) { + validateNativeVaultRosterSeed(existing, input) + return seed + } + await writeJsonAtomic(paths.roster, seed, { + beforeRename: options.assertOwned, + }) + return seed + }, + publishRuntimeSeed: async (paths, state, options) => { + await updateNativeRuntime(paths.runtime, paths.storageId, () => state, { + beforeRename: options.assertOwned, + }) + }, + } + return { ...result, inventory } +} + +test('custody consumes durable seed and runtime publication seams without importing OAuth secrets', async () => { + const { input, paths, store } = await custodyFixture() + expect((await runNativeMigration(input)).phase).toBe('retired') + expect(await store.read()).toMatchObject({ + rows: [{ id: 'api-route', stamp: 'bound' }], + }) + const runtime = await readNativeRuntime(paths.runtime, paths.storageId) + expect(runtime).toMatchObject({ + status: 'ready', + state: { + accounts: { + 'vault-fallback': { + lastUsed: 100, + binding: { credentialId: 'oauth:secondary', recordVersion: 0 }, + quota: { anthropic: { scoped: [] } }, + }, + }, + }, + }) + for (const path of [ + paths.runtime, + paths.roster, + paths.state, + paths.journal, + ]) { + const text = await readFile(path, 'utf8') + expect(text).not.toContain(oauth.access) + expect(text).not.toContain(oauth.refresh) + } +}) + +test('custody seed resume retains published bytes after inventory view/state changes', async () => { + const { input, paths, inventory } = await custodyFixture() + await stopAt(input, 'after:roster') + const original = await readFile(paths.roster, 'utf8') + inventory.value = { + ...inventory.value, + view: 'synthetic-view-two', + credentials: inventory.value.credentials.map((row) => ({ + ...row, + state: 'cold', + })), + } + expect((await runNativeMigration(input)).phase).toBe('retired') + expect(await readFile(paths.roster, 'utf8')).toBe(original) +}) + +test('custody publication receives final migration assertion and refuses a successor owner', async () => { + const { input, paths } = await custodyFixture() + if (!input.custody) throw new Error('Missing synthetic custody fixture') + input.custody.publishSeed = async (paths, seedInput, options) => { + const lock = lockPathFor(paths.journal, 'native-migration') + const value = JSON.parse(await readFile(lock, 'utf8')) + await writeFile( + lock, + JSON.stringify({ ...value, ownerId: 'synthetic-successor' }), + ) + const seed = buildNativeVaultRosterSeed(seedInput) + await writeJsonAtomic(paths.roster, seed, { + beforeRename: options.assertOwned, + }) + return seed + } + await expect(runNativeMigration(input)).rejects.toMatchObject({ + code: 'ownership-lost', + }) + await expect(stat(paths.roster)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await readNativeMigrationJournal(paths))?.phase).toBe('building') + expect(await readFile(input.hostAuthPath, 'utf8')).toContain(oauth.access) +}) + +test('selected-host preflight tokens refuse later drift before second host begin', async () => { + const first = await fixture('opencode') + const second = await fixture('pi') + first.input.preflight = await prepareNativeMigration(first.input) + second.input.preflight = await prepareNativeMigration(second.input) + await runNativeMigration(first.input) + await writeFile(second.paths.legacyState, '{"version":1,"changed":true}') + await expect(runNativeMigration(second.input)).rejects.toMatchObject({ + code: 'invalid-source', + }) + expect(await readNativeMigrationJournal(second.paths)).toBeUndefined() + expect((await readNativeMigrationJournal(first.paths))?.phase).toBe('retired') +}) + +const crashCases: Array<[string, 'opencode' | 'pi']> = [ + ['before-write:journal:building', 'opencode'], + ['after-state-write:store:add', 'opencode'], + ['after:runtime', 'opencode'], + ['before-write:proof', 'opencode'], + ['after-write:proof', 'opencode'], + ['after:routing', 'opencode'], + ['host:before-rename', 'opencode'], + ['host:after-rename', 'opencode'], + ['after-write:journal:activation-installed', 'opencode'], + ['before-write:journal:committed', 'opencode'], + ['after-write:journal:committed', 'opencode'], + ['after:retire:config', 'opencode'], + ['after-write:journal:retired', 'opencode'], + ['host:after-unlink', 'pi'], +] +for (const [point, host] of crashCases) + test(`real child crash-forward recovery at ${host} ${point}`, async () => { + const { root, input, paths, store } = await fixture(host, oauth, true) + if (point === 'host:after-unlink') + await writeFile(input.hostAuthPath, JSON.stringify({ anthropic: oauth })) + const child = Bun.spawn( + [ + process.execPath, + join(import.meta.dir, 'pool-authority-crash-child.ts'), + root, + 'native-controller', + point, + host, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + deferCleanup(async () => { + if (child.exitCode === null) child.kill() + await child.exited + }) + const code = await child.exited + const stderr = await new Response(child.stderr).text() + expect({ point, code, stderr }).toEqual({ point, code: 19, stderr: '' }) + const journal = await readNativeMigrationJournal(paths) + if ( + !journal || + (journal.phase !== 'committed' && journal.phase !== 'retired') + ) + await expect(requireNativePoolAuthority(paths)).rejects.toThrow() + await expireChildLocks(root) + expect((await runNativeMigration(input)).phase).toBe('retired') + await requireNativePoolAuthority(paths) + const rows = await store.read() + expect(rows).toMatchObject({ + rows: [ + { + id: 'primary-route', + stamp: 'bound', + credential: { refresh: oauth.refresh }, + }, + { id: 'fallback-route', enabled: false }, + ], + }) + if (point === 'host:after-unlink') + await expect(stat(input.hostAuthPath)).rejects.toMatchObject({ + code: 'ENOENT', + }) + }) diff --git a/packages/core/src/tests/pool-authority-crash-child.ts b/packages/core/src/tests/pool-authority-crash-child.ts index 6a78b6b84..3efbb5931 100644 --- a/packages/core/src/tests/pool-authority-crash-child.ts +++ b/packages/core/src/tests/pool-authority-crash-child.ts @@ -133,7 +133,14 @@ async function checkWindowsPaths(): Promise { const root = process.argv[2] const action = process.argv[3] const point = process.argv[4] -if (!root || !action || (point !== 'before-write' && point !== 'after-write')) +if ( + !root || + !action || + !point || + (action !== 'native-controller' && + point !== 'before-write' && + point !== 'after-write') +) throw new Error('Invalid fixture arguments') if (action === 'windows-paths') { await checkWindowsPaths() @@ -148,6 +155,53 @@ const paths = await resolveNativePoolPaths( join(root, 'anthropic-auth.json'), join(root, 'anthropic-auth-state.json'), ) +if (action === 'native-readd') { + const { createNativePoolStore } = await import('../pool-store.ts') + const { nativeQuotaCodec } = await import('../native-quota-codec.ts') + await createNativePoolStore({ paths, quota: nativeQuotaCodec }).add({ + id: 'primary-route', + credential: { + type: 'oauth', + access: 'synthetic-access-main', + refresh: 'synthetic-refresh-main', + expires: 2000, + }, + }) + process.exit(0) +} +if (action === 'native-controller') { + const { runNativeMigration } = await import('../native-migration.ts') + const host = process.argv[5] === 'pi' ? 'pi' : 'opencode' + await runNativeMigration( + { + paths, + host, + hostAuthPath: join(root, 'host-auth.json'), + routingSourcePath: join(root, 'legacy-routing.json'), + routingDestinationPath: join(root, 'native-routing.json'), + env: {}, + removePiAnthropicAuth: true, + processFence: async () => {}, + }, + { + onStep: async (step) => { + if (step === point) process.exit(19) + }, + hostWrite: { + beforeRename: async () => { + if (point === 'host:before-rename') process.exit(19) + }, + afterRename: async () => { + if (point === 'host:after-rename') process.exit(19) + }, + afterUnlink: async () => { + if (point === 'host:after-unlink') process.exit(19) + }, + }, + }, + ) + throw new Error('Expected controller fixture exit') +} const hooks = { onWriteStep: async (step: 'before-write' | 'after-write') => { if (step === point) process.exit(19) @@ -175,7 +229,11 @@ if (action === 'building') { } else if (action === 'expectations') { await recordNativeMigrationExpectations( paths, - { expectedHostAuth: 'd'.repeat(64), expectedRouting: 'e'.repeat(64) }, + { + expectedHostAuth: 'd'.repeat(64), + expectedRouting: 'e'.repeat(64), + preparedProof: { version: 1, rows: [], runtimeDigest: 'f'.repeat(64) }, + }, hooks, ) } else { diff --git a/packages/core/src/tests/pool-authority.test.ts b/packages/core/src/tests/pool-authority.test.ts index a0bcec744..5de47e0cc 100644 --- a/packages/core/src/tests/pool-authority.test.ts +++ b/packages/core/src/tests/pool-authority.test.ts @@ -27,9 +27,15 @@ import { createNativePoolStore } from '../pool-store.ts' import { createTestLifetimeSuite } from './test-lifetime.ts' const { test, deferCleanup } = createTestLifetimeSuite() +const preparedProof = { + version: 1 as const, + rows: [], + runtimeDigest: 'f'.repeat(64), +} const expectations = { expectedHostAuth: 'd'.repeat(64), expectedRouting: 'e'.repeat(64), + preparedProof, } function captureInput(paths: NativePoolPaths): NativeMigrationInput { @@ -230,13 +236,13 @@ test('concurrent starts publish one journal without rewriting its baselines', as ) }) -test('v2 persists exactly nine token-free fields and explicit captured bindings', async () => { +test('v3 persists ten fields including an initially absent prepared proof', async () => { const paths = await fixture() const input = captureInput(paths) const journal = await beginNativeMigration(paths, input) const text = await readFile(paths.journal, 'utf8') expect(JSON.parse(text)).toEqual({ - version: 2, + version: 3, storageId: paths.storageId, host: 'opencode', phase: 'building', @@ -250,8 +256,9 @@ test('v2 persists exactly nine token-free fields and explicit captured bindings' hostAuthPath: input.hostAuthPath, expectedHostAuth: 'unprepared', expectedRouting: 'unprepared', + preparedProof: null, }) - expect(Object.keys(journal)).toHaveLength(9) + expect(Object.keys(journal)).toHaveLength(10) expect(Object.keys(journal.sources).sort()).toEqual([ 'config', 'hostAuth', @@ -299,7 +306,7 @@ test('canonical Anthropic entry digests persist without any credential strings', expect(text).not.toContain(secret) }) -test('v1 journals refuse unchanged rather than supplying v2 defaults', async () => { +test('v1 journals refuse unchanged rather than supplying v3 defaults', async () => { const paths = await fixture() const input = captureInput(paths) const text = JSON.stringify({ @@ -639,6 +646,7 @@ test('expectation recording publishes one complete pair at verified with no-writ ...verified, expectedHostAuth: 'd'.repeat(64), expectedRouting: 'e'.repeat(64), + preparedProof, }) expect(await readNativeMigrationJournal(paths)).toEqual(recorded) expect(writes).toBe(1) @@ -739,7 +747,11 @@ test('absent routing and absent entry remain distinct from unprepared expectatio recordNativeMigrationExpectations(paths, expectations), ).rejects.toMatchObject({ code: 'journal-conflict' }) expect(await readFile(paths.journal, 'utf8')).toBe(before) - const absent = { expectedHostAuth: 'absent', expectedRouting: null } + const absent = { + expectedHostAuth: 'absent', + expectedRouting: null, + preparedProof, + } const prepared = await recordNativeMigrationExpectations(paths, absent) expect(prepared).toMatchObject(absent) await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ @@ -820,7 +832,11 @@ test('concurrent expectation recorders publish only one immutable pair', async ( await expect( recordNativeMigrationExpectations( paths, - { expectedHostAuth: 'absent', expectedRouting: 'f'.repeat(64) }, + { + expectedHostAuth: 'absent', + expectedRouting: 'f'.repeat(64), + preparedProof, + }, hooks, ), ).rejects.toMatchObject({ code: 'journal-conflict' }) @@ -944,7 +960,7 @@ for (const point of ['before-write', 'after-write'] as const) { const text = await readFile(paths.journal, 'utf8') const raw = JSON.parse(text) - expect(Object.keys(raw)).toHaveLength(9) + expect(Object.keys(raw)).toHaveLength(10) expect(raw.phase).toBe('verified') expect([raw.expectedHostAuth, raw.expectedRouting]).toEqual( point === 'before-write' From d3220b8dee7745b10a508c720a2d30879e593b17 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:47:32 +0200 Subject: [PATCH 067/169] mason: add reviewed offline setup enrollment and path helpers Use the public enrollment ceremony and explicit permission commands without legacy account writes. Add read-only host path planning with cross-host exclusion and portable fail-closed process inspection that redacts command arguments and stderr. Parent approved six helper paths and narrowed remaining worker scope; fresh/post-retired vault mode activation and final setup wiring are parent-owned, not implemented by these helpers. Preserve the reviewed Core migration controller in ancestry; no live cutover or release is performed. --- .../src/setup/native-enrollment.test.ts | 164 ++++++++++++++++++ .../opencode/src/setup/native-enrollment.ts | 164 ++++++++++++++++++ .../opencode/src/setup/native-paths.test.ts | 64 +++++++ packages/opencode/src/setup/native-paths.ts | 98 +++++++++++ .../opencode/src/setup/process-fence.test.ts | 62 +++++++ packages/opencode/src/setup/process-fence.ts | 50 ++++-- 6 files changed, 590 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/src/setup/native-enrollment.test.ts create mode 100644 packages/opencode/src/setup/native-enrollment.ts create mode 100644 packages/opencode/src/setup/native-paths.test.ts create mode 100644 packages/opencode/src/setup/native-paths.ts create mode 100644 packages/opencode/src/setup/process-fence.test.ts diff --git a/packages/opencode/src/setup/native-enrollment.test.ts b/packages/opencode/src/setup/native-enrollment.test.ts new file mode 100644 index 000000000..25b7f1b1c --- /dev/null +++ b/packages/opencode/src/setup/native-enrollment.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { enrollNativeVaultForHost } from './native-enrollment.ts' +import type { CommandRunner } from './types.ts' + +async function fixture(body: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'native-offline-enrollment-')) + try { + await body(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +test('explicit offline enrollment uses public ceremony and permission APIs without legacy account writes', async () => { + await fixture(async (root) => { + const paths = { + tokenPath: join(root, 'enrollment.json'), + statePath: join(root, 'enrollment-state.json'), + } + const calls: string[] = [] + let approved = false + let fences = 0 + const runner: CommandRunner = { + run: async (_command, args) => { + calls.push(args.slice(0, 3).join(' ')) + if (args.includes('approve')) approved = true + return { exitCode: 0, stdout: '', stderr: '' } + }, + } + await enrollNativeVaultForHost('opencode', { + paths, + env: {}, + runner, + processFence: async () => { + fences++ + }, + client: { + enrollPropose: async (input) => { + expect(input.name).toBe('anthropic-auth-opencode') + calls.push('propose') + return { requestId: 'synthetic-request-id' } + }, + enrollPoll: async () => { + calls.push('poll') + return approved + ? { + status: 'approved', + name: 'anthropic-auth-opencode', + token: 'a'.repeat(64), + tokenGeneration: 3, + } + : { status: 'pending' } + }, + }, + }) + expect(calls).toEqual([ + 'propose', + 'poll', + 'auth enroll approve', + 'poll', + 'auth grant --principal', + ]) + expect(fences).toBeGreaterThanOrEqual(calls.length) + expect(JSON.parse(await readFile(paths.tokenPath, 'utf8'))).toEqual({ + token: 'a'.repeat(64), + token_generation: 3, + }) + expect((await stat(paths.tokenPath)).mode & 0o777).toBe(0o600) + expect((await readdir(root)).sort()).toEqual([ + 'enrollment-state.json', + 'enrollment.json', + ]) + }) +}) + +test('offline enrollment refuses initial process fence with no ceremony, permission or file effect', async () => { + await fixture(async (root) => { + let calls = 0 + await expect( + enrollNativeVaultForHost('pi', { + env: {}, + paths: { + tokenPath: join(root, 'token.json'), + statePath: join(root, 'state.json'), + }, + processFence: async () => { + throw new Error('synthetic running host') + }, + runner: { + run: async () => { + calls++ + return { exitCode: 0, stdout: '', stderr: '' } + }, + }, + client: { + enrollPropose: async () => { + calls++ + return { requestId: 'synthetic' } + }, + enrollPoll: async () => { + calls++ + return { status: 'pending' } + }, + }, + }), + ).rejects.toMatchObject({ code: 'enrollment-refused' }) + expect(calls).toBe(0) + expect(await readdir(root)).toEqual([]) + }) +}) + +test('approval and grant failures retain fixed errors without runner stderr or enrollment secrets', async () => { + for (const failure of ['approve', 'grant']) + await fixture(async (root) => { + let approved = false + const runner: CommandRunner = { + run: async (_command, args) => { + if (args.includes(failure)) + return { + exitCode: 1, + stdout: 'synthetic-secret-output', + stderr: 'synthetic-secret-stderr', + } + if (args.includes('approve')) approved = true + return { exitCode: 0, stdout: '', stderr: '' } + }, + } + let caught: unknown + try { + await enrollNativeVaultForHost('pi', { + env: {}, + runner, + processFence: async () => {}, + paths: { + tokenPath: join(root, 'token.json'), + statePath: join(root, 'state.json'), + }, + client: { + enrollPropose: async () => ({ requestId: 'synthetic-id' }), + enrollPoll: async () => + approved + ? { + status: 'approved', + name: 'anthropic-auth-pi', + token: 'b'.repeat(64), + tokenGeneration: 1, + } + : { status: 'pending' }, + }, + }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ + code: failure === 'approve' ? 'approval-refused' : 'permission-refused', + }) + expect(String(caught)).not.toContain('synthetic-secret') + expect(String(caught)).not.toContain('b'.repeat(64)) + }) +}) diff --git a/packages/opencode/src/setup/native-enrollment.ts b/packages/opencode/src/setup/native-enrollment.ts new file mode 100644 index 000000000..400ec0bf9 --- /dev/null +++ b/packages/opencode/src/setup/native-enrollment.ts @@ -0,0 +1,164 @@ +import { + getHostClaustrumEnrollmentPaths, + resolveClaustrumConnectionPath, +} from '@cortexkit/anthropic-auth-core' +import { writeEnrollmentTokenFile } from '@cortexkit/claustrum-client' +import { + type ClaustrumEnrollmentClient, + ClaustrumEnrollmentManager, + type ClaustrumEnrollmentPaths, + connectClaustrumEnrollmentClient, + readClaustrumEnrollmentStatus, +} from '@cortexkit/common-auth/claustrum' + +import type { CommandRunner, HarnessKind } from './types.ts' + +export interface NativeVaultEnrollmentOptions { + env: NodeJS.ProcessEnv + runner: CommandRunner + /** + * Check that selected host processes are stopped and their inspection succeeded, + * and reject an inherited OPENCODE_AUTH_CONTENT snapshot. These checks run + * before each enrollment request, token write and permission change. + */ + processFence: () => Promise + paths?: ClaustrumEnrollmentPaths + client?: ClaustrumEnrollmentClient + ckBinary?: string + connectionFile?: string +} + +export class NativeVaultEnrollmentError extends Error { + constructor( + public readonly code: + | 'enrollment-refused' + | 'approval-refused' + | 'permission-refused', + ) { + super(`Native vault setup: ${code}`) + this.name = 'NativeVaultEnrollmentError' + } +} + +/** + * Enroll this stopped host and grant permission to list native Claude accounts. + * This function does not fetch OAuth tokens or change which store serves requests; + * account discovery and the credential-store switch are separate setup steps. + */ +export async function enrollNativeVaultForHost( + host: HarnessKind, + options: NativeVaultEnrollmentOptions, +): Promise { + const env = { ...options.env } + const paths = options.paths ?? getHostClaustrumEnrollmentPaths(host, env) + const name = `anthropic-auth-${host}` + const processFence = options.processFence + const runner = options.runner + const ck = options.ckBinary ?? 'ck' + let ownedClient: + | Awaited> + | undefined + let client = options.client + try { + await processFence() + let status = await readClaustrumEnrollmentStatus(paths, name) + const recoverable = new Set(['superseded', 'not_found', 'already_consumed']) + if ( + status.state === 'idle' || + status.state === 'pending' || + (status.state === 'blocked' && recoverable.has(status.code)) + ) { + if (!client) { + ownedClient = await connectClaustrumEnrollmentClient({ + connectionFile: resolveClaustrumConnectionPath( + options.connectionFile ?? + (host === 'pi' + ? env.PI_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + : undefined), + host === 'pi' + ? { + ...env, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: undefined, + } + : env, + ), + }) + client = ownedClient + } + const connected = client + const manager = new ClaustrumEnrollmentManager({ + paths, + proposedName: name, + client: { + enrollPropose: async (input) => { + await processFence() + return connected.enrollPropose(input) + }, + enrollPoll: async (input) => { + await processFence() + return connected.enrollPoll(input) + }, + }, + writeTokenFile: async (path, file) => { + await processFence() + await writeEnrollmentTokenFile(path, file) + }, + }) + if (status.state === 'blocked') { + await processFence() + if ((await manager.resetTerminal()) !== 'reset') + throw new NativeVaultEnrollmentError('enrollment-refused') + } + await processFence() + status = await manager.reconcile() + if (status.state === 'pending' && status.requestId) { + await processFence() + const approved = await runner.run( + ck, + [ + 'auth', + 'enroll', + 'approve', + '--request-id', + status.requestId, + '--name', + name, + ], + { env }, + ) + if (approved.exitCode !== 0) + throw new NativeVaultEnrollmentError('approval-refused') + await processFence() + status = await manager.reconcile() + } + } + if (status.state !== 'approved') + throw new NativeVaultEnrollmentError('enrollment-refused') + await processFence() + const grant = await runner.run( + ck, + [ + 'auth', + 'grant', + '--principal', + `enrolled:${name}`, + '--selector-kind', + 'category', + '--selector', + 'anthropic-native', + '--operation', + 'read', + ], + { env }, + ) + if (grant.exitCode !== 0) + throw new NativeVaultEnrollmentError('permission-refused') + } catch (error) { + if (error instanceof NativeVaultEnrollmentError) throw error + // Never propagate a daemon, runner or fence exception that could contain a + // request secret, enrollment bearer or command stderr. + throw new NativeVaultEnrollmentError('enrollment-refused') + } finally { + ownedClient?.close() + } +} diff --git a/packages/opencode/src/setup/native-paths.test.ts b/packages/opencode/src/setup/native-paths.test.ts new file mode 100644 index 000000000..84888d8a3 --- /dev/null +++ b/packages/opencode/src/setup/native-paths.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + requireDisjointNativeSetupPaths, + resolveNativeSetupPaths, +} from './native-paths.ts' + +async function fixture(body: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'native-setup-paths-')) + try { + await body(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +test('selected host paths use injected configuration, data and Pi overrides without ambient mutation', async () => { + await fixture(async (root) => { + const env = { + OPENCODE_CONFIG_DIR: join(root, 'opencode'), + XDG_DATA_HOME: join(root, 'data'), + PI_AGENT_DIR: join(root, 'pi'), + } + const before = process.env.OPENCODE_CONFIG_DIR + const opencode = await resolveNativeSetupPaths('opencode', env) + const pi = await resolveNativeSetupPaths('pi', env) + expect(opencode.legacyConfigPath).toBe( + join(root, 'opencode', 'anthropic-auth.json'), + ) + expect(opencode.hostAuthPath).toBe( + join(root, 'data', 'opencode', 'auth.json'), + ) + expect(pi.legacyConfigPath).toBe(join(root, 'pi', 'anthropic-auth.json')) + expect(pi.hostAuthPath).toBe(join(root, 'pi', 'auth.json')) + expect(opencode.routingSourcePath).not.toBe(opencode.routingDestinationPath) + expect(process.env.OPENCODE_CONFIG_DIR).toBe(before) + await expect( + requireDisjointNativeSetupPaths([opencode, pi]), + ).resolves.toBeUndefined() + }) +}) + +test('an explicit routing override remains same-path and shared legacy state refuses cross-host setup', async () => { + await fixture(async (root) => { + const route = join(root, 'routing.json') + const env = { + OPENCODE_CONFIG_DIR: join(root, 'opencode'), + XDG_DATA_HOME: join(root, 'data'), + PI_AGENT_DIR: join(root, 'pi'), + OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE: route, + OPENCODE_ANTHROPIC_AUTH_STATE_FILE: join(root, 'shared-state.json'), + } + const opencode = await resolveNativeSetupPaths('opencode', env) + const pi = await resolveNativeSetupPaths('pi', env) + expect(opencode.routingSourcePath).toBe(route) + expect(opencode.routingDestinationPath).toBe(route) + await expect( + requireDisjointNativeSetupPaths([opencode, pi]), + ).rejects.toThrow('overlap') + }) +}) diff --git a/packages/opencode/src/setup/native-paths.ts b/packages/opencode/src/setup/native-paths.ts new file mode 100644 index 000000000..fef33702a --- /dev/null +++ b/packages/opencode/src/setup/native-paths.ts @@ -0,0 +1,98 @@ +import { dirname, join, resolve } from 'node:path' +import { + ACCOUNT_FILE_NAME, + ACCOUNT_STATE_FILE_NAME, + canonicalPath, + getStickyRoutingStatePath, + type NativePoolPaths, + resolveNativePoolPaths, +} from '@cortexkit/anthropic-auth-core' + +import { + getOpenCodeAuthPath, + getOpenCodeConfigDir, + getPiAgentDir, + getPiAuthPath, +} from './paths.ts' +import type { HarnessKind } from './types.ts' + +export interface NativeSetupPaths { + host: HarnessKind + paths: NativePoolPaths + legacyConfigPath: string + legacyStatePath: string + hostAuthPath: string + routingSourcePath: string + routingDestinationPath: string +} + +/** Resolve actor-supplied environment without changing ambient process variables. */ +export async function resolveNativeSetupPaths( + host: HarnessKind, + env: NodeJS.ProcessEnv, +): Promise { + const directory = + host === 'pi' ? getPiAgentDir(env) : getOpenCodeConfigDir(env) + const configured = + host === 'pi' + ? env.PI_ANTHROPIC_AUTH_FILE?.trim() + : env.OPENCODE_ANTHROPIC_AUTH_FILE?.trim() + const legacyConfigPath = resolve( + configured || join(directory, ACCOUNT_FILE_NAME), + ) + // Pi also stored credentials at OPENCODE_ANTHROPIC_AUTH_STATE_FILE when set. + // Read that source during migration rather than assuming a sibling file. + const legacyStatePath = resolve( + env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE?.trim() || + (legacyConfigPath.endsWith(ACCOUNT_FILE_NAME) + ? join(dirname(legacyConfigPath), ACCOUNT_STATE_FILE_NAME) + : `${legacyConfigPath}.state.json`), + ) + const paths = await resolveNativePoolPaths(legacyConfigPath, legacyStatePath) + const routingOverride = + host === 'pi' + ? env.PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE?.trim() + : env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE?.trim() + return { + host, + paths, + legacyConfigPath, + legacyStatePath, + hostAuthPath: host === 'pi' ? getPiAuthPath(env) : getOpenCodeAuthPath(env), + routingSourcePath: resolve( + routingOverride || getStickyRoutingStatePath(legacyConfigPath), + ), + routingDestinationPath: resolve( + routingOverride || getStickyRoutingStatePath(paths.config), + ), + } +} + +/** Reject plans where OpenCode and Pi share credential, journal or routing files. */ +export async function requireDisjointNativeSetupPaths( + plans: readonly NativeSetupPaths[], +): Promise { + const owners = new Map() + for (const plan of plans) { + for (const path of [ + plan.paths.legacyConfig, + plan.paths.legacyState, + plan.paths.config, + plan.paths.state, + plan.paths.runtime, + plan.paths.roster, + plan.paths.journal, + plan.hostAuthPath, + plan.routingSourcePath, + plan.routingDestinationPath, + ]) { + const canonical = await canonicalPath(path) + const owner = owners.get(canonical) + if (owner !== undefined && owner !== plan.host) + throw new Error( + 'Native setup storage paths overlap between selected hosts', + ) + owners.set(canonical, plan.host) + } + } +} diff --git a/packages/opencode/src/setup/process-fence.test.ts b/packages/opencode/src/setup/process-fence.test.ts new file mode 100644 index 000000000..76fb80676 --- /dev/null +++ b/packages/opencode/src/setup/process-fence.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'bun:test' +import { + assertHostsStopped, + createProcessFence, + ProcessFenceViolationError, +} from './process-fence.ts' + +test('portable Windows process fence recognizes executable suffixes and blocks stopped-host preflight', async () => { + const opencodePid = process.pid + 100_000 + const piPid = opencodePid + 1 + const calls: Array<[string, string[]]> = [] + const fence = createProcessFence( + { + run: async (command, args) => { + calls.push([command, args]) + return { + exitCode: 0, + stdout: `"opencode.exe","${opencodePid}","Console","1","1 K"\n"pi.exe","${piPid}","Console","1","1 K"`, + stderr: '', + } + }, + }, + { platform: 'win32' }, + ) + expect(await fence.listRunningHosts()).toEqual([ + { pid: opencodePid, command: 'opencode.exe' }, + { pid: piPid, command: 'pi.exe' }, + ]) + await expect(assertHostsStopped(fence)).rejects.toBeInstanceOf( + ProcessFenceViolationError, + ) + expect(calls[0]).toEqual(['tasklist', ['/fo', 'csv', '/nh']]) +}) + +test('process fence fails closed on inspection failure and malformed output without echoing stderr', async () => { + for (const output of [ + { exitCode: 1, stdout: '', stderr: 'synthetic-bearer-in-stderr' }, + { exitCode: 0, stdout: 'unexpected process output', stderr: '' }, + ]) { + const fence = createProcessFence( + { run: async () => output }, + { platform: 'linux' }, + ) + let caught: unknown + try { + await fence.listRunningHosts() + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ code: 'process-inspection-failed' }) + expect(String(caught)).not.toContain('synthetic-bearer') + } +}) + +test('active process diagnostics never serialize command arguments', () => { + const error = new ProcessFenceViolationError([ + { pid: 123, command: '/synthetic/opencode --key synthetic-command-secret' }, + ]) + expect(error.message).toContain('PID 123: opencode') + expect(error.message).not.toContain('synthetic-command-secret') + expect(JSON.stringify(error)).not.toContain('synthetic-command-secret') +}) diff --git a/packages/opencode/src/setup/process-fence.ts b/packages/opencode/src/setup/process-fence.ts index ce8840b87..9e94b9b1e 100644 --- a/packages/opencode/src/setup/process-fence.ts +++ b/packages/opencode/src/setup/process-fence.ts @@ -1,37 +1,58 @@ import { defaultCommandRunner } from './command-runner.ts' import type { CommandRunner, ProcessFence } from './types.ts' -const HOST_PATTERN = /(?:^|\/)(opencode|litecode|opencode2|pi)(?:$|\s)/i +const HOST_PATTERN = + /(?:^|[/\\])(opencode|litecode|opencode2|pi)(?:\.exe)?(?=$|\s|")/i + +export class ProcessInspectionError extends Error { + readonly code = 'process-inspection-failed' + constructor() { + super('Process inspection failed; setup cannot prove hosts are stopped.') + this.name = 'ProcessInspectionError' + } +} export class ProcessFenceViolationError extends Error { - constructor(readonly activeHosts: Array<{ pid: number; command: string }>) { - const list = activeHosts - .map((h) => ` - PID ${h.pid}: ${h.command.slice(0, 80)}`) + readonly activeHosts: Array<{ pid: number; command: string }> + constructor(activeHosts: Array<{ pid: number; command: string }>) { + // Process arguments can contain bearer material. Retain only the recognized + // executable name in diagnostics, never the full command or runner stderr. + const safeHosts = activeHosts.map((host) => ({ + pid: host.pid, + command: HOST_PATTERN.exec(host.command)?.[1] ?? 'host', + })) + const list = safeHosts + .map((h) => ` - PID ${h.pid}: ${h.command}`) .join('\n') super( `OpenCode or Pi processes are currently running:\n${list}\n\n` + `Please quit all OpenCode, LiteCode, and Pi processes before running setup.`, ) + this.activeHosts = safeHosts this.name = 'ProcessFenceViolationError' } } export function createProcessFence( runner: CommandRunner = defaultCommandRunner, + options: { platform?: NodeJS.Platform } = {}, ): ProcessFence { + const isWin = (options.platform ?? process.platform) === 'win32' return { async listRunningHosts( env?: Record, ): Promise> { - const isWin = process.platform === 'win32' const cmd = isWin ? 'tasklist' : 'ps' const args = isWin ? ['/fo', 'csv', '/nh'] : ['-eo', 'pid,command'] - const result = await runner.run(cmd, args, { env }) + let result: Awaited> + try { + result = await runner.run(cmd, args, { env }) + } catch { + throw new ProcessInspectionError() + } if (result.exitCode !== 0) { - throw new Error( - `Process inspection failed (${result.stderr.trim() || `exit ${result.exitCode}`})`, - ) + throw new ProcessInspectionError() } const lines = result.stdout.split(/\r?\n/) @@ -48,17 +69,22 @@ export function createProcessFence( if (isWin) { // CSV format: "Image Name","PID","Session Name","Session#","Mem Usage" const parts = trimmed.split(',').map((p) => p.replace(/^"|"$/g, '')) - if (parts.length < 2) continue + if (parts.length < 2) throw new ProcessInspectionError() command = parts[0] ?? '' pid = Number(parts[1]) } else { const match = /^(\d+)\s+(.+)$/.exec(trimmed) - if (!match) continue + if (!match) { + if (/^PID\s+COMMAND$/i.test(trimmed)) continue + throw new ProcessInspectionError() + } pid = Number(match[1]) command = match[2] ?? '' } - if (!Number.isInteger(pid) || pid === currentPid) continue + if (!Number.isInteger(pid) || pid < 0) + throw new ProcessInspectionError() + if (pid === currentPid) continue if (HOST_PATTERN.test(command)) { running.push({ pid, command }) } From 24cb57fd62efae0bd3e061d23dc7171b8a70d515 Mon Sep 17 00:00:00 2001 From: Mason Date: Thu, 8 Oct 2026 23:51:47 +0200 Subject: [PATCH 068/169] mason: complete native Pi authority and single Claude menu Use the accepted native runtime for local/vault admission, quota/profile, physical transport attempts, CacheKeep, settings and account mutations. Retain the original primary-general-exhaustion API gate and request-local routing policy; recheck each exact request model after delayed vault authorization. Native host activation carries no bearer material. Replace legacy host/sidecar fixture authority and command aliases with real offline-controller journal-v3 initialization and the native /claude menu. Online custody toggles now give offline-consent guidance; Prime stays status-only. Initial rejected 401 versions are reported even when rotation succeeds, deliberately replacing old final-only assertions. Apply the owner's Anthropic-only OAuth origin/redirect ruling; API-key destinations and explicit relays remain unchanged. Adjust the sticky fixture to exercise native-owned invalid_grant attribution: an unavailable main is no longer resent as host auth on the next session. No tests deleted, type suppressions added or test deadlines raised. Parent source/comment review cleared the staged nineteen-file scope; full-host/exact-commit CI remains parent-owned. --- packages/pi/src/commands.ts | 434 +------ packages/pi/src/custody.ts | 56 +- packages/pi/src/index.ts | 136 +-- packages/pi/src/native-commands.ts | 398 +++++++ packages/pi/src/native.ts | 313 +++++ packages/pi/src/paths.ts | 9 + packages/pi/src/stream.ts | 1042 +++++++---------- packages/pi/src/tests/commands.test.ts | 785 +++++-------- packages/pi/src/tests/custody.test.ts | 88 +- packages/pi/src/tests/haiku55.test.ts | 6 +- packages/pi/src/tests/index.test.ts | 33 +- packages/pi/src/tests/native-fixture.ts | 267 +++++ .../pi/src/tests/native-host-auth.test.ts | 48 + packages/pi/src/tests/native-serving.test.ts | 349 ++++++ .../src/tests/request-history-stream.test.ts | 8 +- packages/pi/src/tests/scoped-stream.test.ts | 491 +++++++- packages/pi/src/tests/stream.test.ts | 51 +- packages/pi/src/tests/transport.test.ts | 56 + packages/pi/src/transport.ts | 42 + 19 files changed, 2783 insertions(+), 1829 deletions(-) create mode 100644 packages/pi/src/native-commands.ts create mode 100644 packages/pi/src/native.ts create mode 100644 packages/pi/src/tests/native-fixture.ts create mode 100644 packages/pi/src/tests/native-host-auth.test.ts create mode 100644 packages/pi/src/tests/native-serving.test.ts create mode 100644 packages/pi/src/tests/transport.test.ts create mode 100644 packages/pi/src/transport.ts diff --git a/packages/pi/src/commands.ts b/packages/pi/src/commands.ts index 8c575316a..932669a0a 100644 --- a/packages/pi/src/commands.ts +++ b/packages/pi/src/commands.ts @@ -1,407 +1,55 @@ import { - buildClaudeQuotaSummary, - buildFallbackQuotaSummaries, - buildPrimeAccountStatuses, - CLAUDE_ACCOUNT_COMMAND_NAME, - CLAUDE_CACHE_KEEP_COMMAND_NAME, - CLAUDE_PRIME_COMMAND_NAME, - CLAUDE_ROUTING_COMMAND_NAME, - createEmptyStorage, - detectClaustrumConnection, - executeAccountCommand, - executeCache1hCommand, - executeCacheKeepCommand, - executeDumpCommand, - executeFastModeCommand, - executeLoggingCommand, - executePrimeCommand, - executeRoutingCommand, - formatEnrollmentStatus, - getCache1hPersistentMode, - getCacheKeepWindow, - getClaustrumMode, - getPersistedLogLevel, - getRoutingMode, - isCache1hPersistentlyEnabled, - isCacheKeepAlways, - isCacheKeepHybridActive, - isCacheKeepPersistentlyEnabled, - isDumpPersistentlyEnabled, - isFastModePersistentlyEnabled, - isPrimePersistentlyEnabled, - loadAccounts, - mergeMainQuotaErrorClearedAt, - mergeMainRefreshErrorClearedAt, - parseAccountCommandAction, - parseCache1hCommandAction, - parseCacheKeepCommandAction, - parseDumpCommandAction, - parseFastModeCommandAction, - parseLoggingCommandAction, - parseRoutingCommandAction, - removeAccountPersistent, - reorderAccountsPersistent, - saveAccountState, - setAccountEnabledPersistent, - setCache1hPersistentEnabled, - setCache1hPersistentMode, - setCacheKeepPersistentAlways, - setCacheKeepPersistentEnabled, - setCacheKeepPersistentWindow, - setCacheKeepSubagentsEnabled, - setDumpEnabled, - setDumpPersistentEnabled, - setFastModePersistentEnabled, - setLogLevelPersistent, - setRoutingMode, + createNativeUi, + type NativeMenuCommandId, + type NativeMenuDispatch, + runPiCommandMenu, } from '@cortexkit/anthropic-auth-core' -import type { - ExtensionAPI, - ExtensionCommandContext, -} from '@earendil-works/pi-coding-agent' -import type { PiCustodyCommands } from './custody.ts' -import { getPiAccountStoragePath } from './paths.ts' -import { - clearPiStickyRoutingSession, - getPiTrackedCacheKeepSessions, -} from './stream.ts' - -type NotifyKind = 'info' | 'warning' | 'error' - -function notify( - ctx: ExtensionCommandContext, - message: string, - kind: NotifyKind = 'info', -) { - ctx.ui.notify(message, kind) +import type { ExtensionAPI } from '@earendil-works/pi-coding-agent' + +/** Backend callbacks use native authority; the renderer never receives credentials. */ +export interface PiNativeCommands { + dispatch: NativeMenuDispatch + readStatus: ( + command: Exclude, + invocation: { sessionId?: string }, + ) => Promise } -export function registerCommands( - pi: ExtensionAPI, - custody?: PiCustodyCommands, -) { - pi.registerCommand('claude-cache', { - description: 'Show or configure Claude 1-hour prompt cache mode', - handler: async (args, ctx) => { - const path = getPiAccountStoragePath() - const storage = await loadAccounts(path) - const action = parseCache1hCommandAction(args ?? '') - const enabled = isCache1hPersistentlyEnabled(storage) - const mode = getCache1hPersistentMode(storage) - - const nextEnabled = - action.type === 'enable' - ? true - : action.type === 'disable' - ? false - : enabled - const nextMode = action.type === 'mode' ? action.mode : mode - - if (action.type === 'enable') { - await setCache1hPersistentEnabled(true, undefined, path) - } else if (action.type === 'disable') { - await setCache1hPersistentEnabled(false, undefined, path) - } else if (action.type === 'mode') { - await setCache1hPersistentMode(action.mode, path) - } - - notify( - ctx, - executeCache1hCommand({ - argumentsText: args ?? '', - enabled: nextEnabled, - mode: nextMode, - }), - action.type === 'usage' ? 'warning' : 'info', - ) - }, - }) - - pi.registerCommand(CLAUDE_CACHE_KEEP_COMMAND_NAME, { +export function registerCommands(pi: ExtensionAPI, backend: PiNativeCommands) { + pi.registerCommand('claude', { description: - 'Keep hybrid Claude cache warm always or during a local time window', + 'Claude accounts, quota, routing, limits, cache and diagnostics', handler: async (args, ctx) => { - const path = getPiAccountStoragePath() - let storage = await loadAccounts(path) - const action = parseCacheKeepCommandAction(args ?? '') - - if (action.type === 'window') { - storage = await setCacheKeepPersistentWindow( - action.startHour, - action.endHour, - path, - ) - } else if (action.type === 'always') { - storage = await setCacheKeepPersistentAlways(path) - } else if (action.type === 'disable') { - storage = await setCacheKeepPersistentEnabled(false, path) - } else if (action.type === 'subagents') { - storage = await setCacheKeepSubagentsEnabled(action.enabled, path) - } - - const trackedSessionDetails = await getPiTrackedCacheKeepSessions() - const nextPrewarmAt = trackedSessionDetails.length - ? Math.min( - ...trackedSessionDetails.map((session) => session.nextPrewarmAt), - ) - : undefined - notify( - ctx, - executeCacheKeepCommand({ - argumentsText: args ?? '', - enabled: isCacheKeepPersistentlyEnabled(storage), - always: isCacheKeepAlways(storage), - window: getCacheKeepWindow(storage), - hybridActive: isCacheKeepHybridActive(storage), - trackedSessions: trackedSessionDetails.length, - trackedSessionDetails, - nextPrewarmAt, - }), - action.type === 'usage' ? 'warning' : 'info', - ) - }, - }) - - pi.registerCommand('claude-dump', { - description: 'Show or configure Anthropic request/relay dumping', - handler: async (args, ctx) => { - const path = getPiAccountStoragePath() - const storage = await loadAccounts(path) - const action = parseDumpCommandAction(args ?? '') - const enabled = isDumpPersistentlyEnabled(storage) - - const nextEnabled = - action.type === 'enable' - ? true - : action.type === 'disable' - ? false - : enabled - - if (action.type === 'enable') { - await setDumpPersistentEnabled(true, path) - setDumpEnabled(true) - } else if (action.type === 'disable') { - await setDumpPersistentEnabled(false, path) - setDumpEnabled(false) - } else { - setDumpEnabled(enabled) - } - - notify( - ctx, - executeDumpCommand({ argumentsText: args ?? '', enabled: nextEnabled }), - action.type === 'usage' ? 'warning' : 'info', - ) - }, - }) - - pi.registerCommand('claude-fast', { - description: - 'Show or configure Anthropic fast mode for supported Opus models', - handler: async (args, ctx) => { - const path = getPiAccountStoragePath() - const storage = await loadAccounts(path) - const action = parseFastModeCommandAction(args ?? '') - const enabled = isFastModePersistentlyEnabled(storage) - - const nextEnabled = - action.type === 'enable' - ? true - : action.type === 'disable' - ? false - : enabled - - if (action.type === 'enable') { - await setFastModePersistentEnabled(true, path) - } else if (action.type === 'disable') { - await setFastModePersistentEnabled(false, path) - } - - notify( - ctx, - executeFastModeCommand({ - argumentsText: args ?? '', - enabled: nextEnabled, - }), - action.type === 'usage' ? 'warning' : 'info', - ) - }, - }) - - pi.registerCommand(CLAUDE_ROUTING_COMMAND_NAME, { - description: 'Show or change Claude account routing mode', - handler: async (args, ctx) => { - const path = getPiAccountStoragePath() - let storage = await loadAccounts(path) - const action = parseRoutingCommandAction(args ?? '') - - if (action.type === 'mode') { - storage = await setRoutingMode(action.mode, path) - } else if (action.type === 'reset') { - await clearPiStickyRoutingSession( - path, - ctx.sessionManager.getSessionId(), - ) - } - - notify( - ctx, - executeRoutingCommand({ - argumentsText: args ?? '', - mode: getRoutingMode(storage), - }), - action.type === 'usage' ? 'warning' : 'info', - ) - }, - }) - - pi.registerCommand('claude-quota', { - description: 'Show Claude quota state for fallback accounts', - handler: async (_args, ctx) => { - const storage = await loadAccounts(getPiAccountStoragePath()) - notify( - ctx, - buildClaudeQuotaSummary({ - accounts: buildFallbackQuotaSummaries(storage), - now: Date.now(), - }), - ) - }, - }) - - pi.registerCommand(CLAUDE_ACCOUNT_COMMAND_NAME, { - description: 'List, enable/disable, reorder, or remove fallback accounts', - handler: async (args, ctx) => { - const path = getPiAccountStoragePath() - const storage = await loadAccounts(path) - const action = parseAccountCommandAction(args ?? '') - const result = await executeAccountCommand({ - argumentsText: args ?? '', - storage: storage ?? createEmptyStorage(), - path, - transition: - custody?.transition ?? - (async () => ({ - text: 'Refused: Pi custody controller is unavailable.', - })), - resetEnrollment: custody?.reset, - claustrum: - action.type === 'status' - ? await detectClaustrumConnection() - : undefined, - }) - - if (action.type === 'status' && custody) { - try { - result.text += `\n${formatEnrollmentStatus(await custody.status(), getClaustrumMode(storage) === 'claustrum').join('\n')}` - } catch { - result.text += '\n- Enrollment: unavailable' - } - } - if (!result.updated) { - notify( - ctx, - result.text, - result.text.startsWith('Refused:') ? 'error' : 'info', + if (args?.trim()) { + ctx.ui.notify( + 'Use /claude to open the menu. Action parameters are collected in the selected action; custody changes require offline setup.', + 'warning', ) return } - - // Wire persistent mutations via core helpers - const { id, action: mutationAction } = result.updated - - if (mutationAction === 'enable') { - await setAccountEnabledPersistent(id, true, path) - } else if (mutationAction === 'disable') { - await setAccountEnabledPersistent(id, false, path) - } else if (mutationAction === 'remove') { - const existed = await removeAccountPersistent(id, path) - if (!existed) { - notify(ctx, `Account "${id}" not found.`, 'warning') - return - } - } else if (mutationAction === 'reorder') { - const newOrder = result.updated.newOrder - if (newOrder) { - await reorderAccountsPersistent(newOrder, path) - } - } else if (mutationAction === 'reset-backoff') { - const nextStorage = storage ?? createEmptyStorage() - nextStorage.refresh = nextStorage.refresh ?? {} - nextStorage.refresh.mainLastRefreshError = undefined - nextStorage.refresh.mainRefreshErrorClearedAt = - mergeMainRefreshErrorClearedAt( - nextStorage.refresh.mainRefreshErrorClearedAt, - Date.now(), - ) - const mainIdentity = nextStorage.mainAccountId - const quotaError = nextStorage.quota?.mainLastQuotaApiError - if ( - !quotaError?.accountIdentity || - !mainIdentity || - quotaError.accountIdentity === mainIdentity - ) { - nextStorage.quota = nextStorage.quota ?? {} - nextStorage.quota.mainLastQuotaApiError = undefined - const nextQuotaErrorGeneration = - (nextStorage.quota.mainQuotaErrorGeneration ?? 0) + 1 - nextStorage.quota.mainQuotaErrorGeneration = Math.max( - nextStorage.quota.mainQuotaErrorGeneration ?? 0, - nextQuotaErrorGeneration, - ) - nextStorage.quota.mainQuotaErrorClearedAt = - mergeMainQuotaErrorClearedAt( - nextStorage.quota.mainQuotaErrorClearedAt, - Date.now(), - ) - } - await saveAccountState(nextStorage, path, { - mainRefresh: true, - mainQuota: true, + const menu = createNativeUi({ + host: 'pi', + dispatch: backend.dispatch, + readStatus: backend.readStatus, + interactive: ctx.hasUI, + }) + const sessionId = ctx.sessionManager.getSessionId() + if (ctx.hasUI) { + await runPiCommandMenu(menu, ctx.ui, { sessionId }) + } else { + // A scripted invocation can inspect status, but cannot answer prompts or + // silently confirm a destructive action. + const payload = await menu.open({ + sessionId, + notify: (message, kind) => ctx.ui.notify(message, kind), }) + ctx.ui.notify( + payload.menu.sections + .map((section) => `${section.title}\n${section.lines.join('\n')}`) + .join('\n\n'), + 'info', + ) } - - notify(ctx, result.text) - }, - }) - - pi.registerCommand('claude-logging', { - description: 'Show or set the plugin log level', - handler: async (args, ctx) => { - const path = getPiAccountStoragePath() - const storage = await loadAccounts(path) - const action = parseLoggingCommandAction(args ?? '') - const currentLevel = getPersistedLogLevel(storage) ?? 'info' - - // Wire persistent log-level mutation - let level = currentLevel - if (action.type === 'level') { - await setLogLevelPersistent(action.level, path) - level = action.level - } - - notify(ctx, executeLoggingCommand({ argumentsText: args ?? '', level })) - }, - }) - - pi.registerCommand(CLAUDE_PRIME_COMMAND_NAME, { - description: "Show each OAuth account's five-hour quota window state", - handler: async (_args, ctx) => { - // Pi has no live PrimeManager; render persisted counters and the - // next-due timestamp from stored quota snapshots. Always pass - // `argumentsText: 'status'` to the pure executor so on/off args - // are display-only — Pi cannot toggle the setting. - const path = getPiAccountStoragePath() - const storage = await loadAccounts(path) - const accounts = buildPrimeAccountStatuses(storage, { now: Date.now() }) - notify( - ctx, - executePrimeCommand({ - argumentsText: 'status', - enabled: isPrimePersistentlyEnabled(storage), - accounts, - }).text, - ) }, }) } diff --git a/packages/pi/src/custody.ts b/packages/pi/src/custody.ts index d89b5e65e..a9aee13e4 100644 --- a/packages/pi/src/custody.ts +++ b/packages/pi/src/custody.ts @@ -3,19 +3,10 @@ import { CLAUSTRUM_PI_ENROLLMENT_NAME, type ClaustrumEnrollmentStatus, type ClaustrumMode, - type ClaustrumScopedClient, - ClaustrumScopedCustody, - connectClaustrumScopedClient, getHostClaustrumEnrollmentPaths, readClaustrumEnrollmentStatus, resetClaustrumEnrollmentState, - setClaustrumModePersistent, } from '@cortexkit/anthropic-auth-core' -import { - getPiAccountStoragePath, - getPiClaustrumConnectionOptions, - hasPiLocalAnthropicOAuth, -} from './paths.ts' export async function requirePiEnrollment() { const status = await readClaustrumEnrollmentStatus( @@ -39,12 +30,7 @@ export interface PiCustodyCommands { reset(): Promise } -export function createPiCustodyCommands(options: { - reconfigure: () => Promise - storagePath?: string - connect?: () => Promise -}): PiCustodyCommands { - const storagePath = options.storagePath ?? getPiAccountStoragePath() +export function createPiCustodyCommands(): PiCustodyCommands { const paths = () => getHostClaustrumEnrollmentPaths('pi') return { status: () => @@ -66,44 +52,10 @@ export function createPiCustodyCommands(options: { return { text: messages[result] } }, async transition(mode) { - if (mode === 'claustrum') { - if (await hasPiLocalAnthropicOAuth()) { - return { - text: 'Refused: Pi still has a local Anthropic OAuth credential. Run `bunx @cortexkit/opencode-anthropic-auth setup` to approve its removal and enable Claustrum.', - } - } - // A slash command never invokes the administrative CLI or approves itself. - // Existing enrollment and grants must be verified before committing mode. - await requirePiEnrollment() - const client = await (options.connect?.() ?? - connectClaustrumScopedClient( - getPiClaustrumConnectionOptions(storagePath), - )) - const custody = new ClaustrumScopedCustody({ - client, - tokenPath: paths().tokenPath, - }) - try { - const inventory = await custody.discover() - const active = inventory.accounts.filter( - (account) => account.state === 'active', - ) - if (!active.length) - return { - text: 'Refused: Claustrum has no accessible active Anthropic accounts.', - } - for (const account of active) await custody.authorize(account) - } finally { - custody.close() - } - } - await setClaustrumModePersistent(mode, storagePath) - await options.reconfigure() + // Mode changes can remove host credentials and require offline consent. + // A menu never enrolls, grants custody or writes the authority journal. return { - text: - mode === 'claustrum' - ? 'Pi now uses Claustrum for Anthropic authentication.' - : 'Pi now uses local Anthropic authentication. Use /login anthropic if needed.', + text: `Refused: Changing Pi authentication to ${mode} requires offline setup. Run \`bunx @cortexkit/opencode-anthropic-auth setup\` and review the migration and host OAuth removal consent.`, } }, } diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 68401ce0e..473d66f5a 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -1,5 +1,4 @@ import { - authorize, CLAUDE_FABLE_MYTHOS_5_1_PRICING, CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW, CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS, @@ -15,110 +14,41 @@ import { CLAUDE_SONNET_5_5_MAX_OUTPUT_TOKENS, CLAUDE_SONNET_5_5_MODEL_ID, CLAUDE_SONNET_5_5_PRICING, - type ClaustrumScopedClient, - exchange, - getClaustrumMode, isClaudeFableOrMythos51Model, - loadAccounts, type MidConversationEffortTransition, - refreshClaudeOAuthToken, + type NativeCustodyClient, } from '@cortexkit/anthropic-auth-core' -import type { - OAuthCredentials, - OAuthLoginCallbacks, - Provider, - SimpleStreamOptions, -} from '@earendil-works/pi-ai' +import type { Provider, SimpleStreamOptions } from '@earendil-works/pi-ai' import type { ExtensionAPI, ProviderConfig, } from '@earendil-works/pi-coding-agent' import { registerCommands } from './commands.ts' -import { createPiCustodyCommands, requirePiEnrollment } from './custody.ts' import { collectPiEffortHistory, deriveContextEntries, } from './effort-history.ts' -import { getPiAccountStoragePath } from './paths.ts' -import { - closePiScopedRuntime, - getPiScopedRuntime, - streamCortexKitAnthropic, -} from './stream.ts' - -async function assertLocalAuthentication(): Promise { - if ( - getClaustrumMode(await loadAccounts(getPiAccountStoragePath())) === - 'claustrum' - ) { - throw new Error( - 'Local Anthropic login and refresh are disabled while Claustrum custody is active', - ) - } -} - -async function loginAnthropic( - callbacks: OAuthLoginCallbacks, -): Promise { - await assertLocalAuthentication() - const auth = await authorize('max') - callbacks.onAuth({ url: auth.url }) - const callback = await callbacks.onPrompt({ - message: 'Paste the Claude OAuth callback URL or code:', - }) - const result = await exchange( - callback, - auth.verifier, - auth.redirectUri, - auth.state, - ) - if (result.type !== 'success') { - throw new Error('Anthropic OAuth exchange failed') - } - await assertLocalAuthentication() - return { - refresh: result.refresh, - access: result.access, - expires: result.expires, - } -} +import { closePiNativeRuntime, getPiNativeRuntime } from './native.ts' +import { createPiNativeCommands } from './native-commands.ts' +import { getPiAccountStoragePath, requirePiNativeHostAuth } from './paths.ts' +import { streamCortexKitAnthropic } from './stream.ts' function textImageInput(): Array<'text' | 'image'> { return ['text', 'image'] } -async function refreshAnthropicToken( - credentials: OAuthCredentials, -): Promise { - await assertLocalAuthentication() - const refreshed = await refreshClaudeOAuthToken({ - refreshToken: credentials.refresh, - }) - - return { - refresh: refreshed.refresh, - access: refreshed.access, - expires: refreshed.expires, - } -} - export default async function cortexKitPiAnthropicAuth( pi: ExtensionAPI, options: { - connectScoped?: () => Promise - pollIntervalMs?: number + connectScoped?: () => Promise } = {}, ) { const storagePath = getPiAccountStoragePath() - registerCommands( - pi, - createPiCustodyCommands({ - storagePath, - reconfigure: configureProvider, - connect: options.connectScoped, - }), - ) + getPiNativeRuntime(storagePath, { + ...(options.connectScoped && { connect: options.connectScoped }), + }) + registerCommands(pi, createPiNativeCommands(storagePath)) const effortHistoryBySession = new Map< string, MidConversationEffortTransition[] @@ -154,7 +84,7 @@ export default async function cortexKitPiAnthropicAuth( pi.on('session_shutdown', async (_event, ctx) => { const sessionId = ctx.sessionManager.getSessionId() if (sessionId) effortHistoryBySession.delete(sessionId) - closePiScopedRuntime(storagePath) + closePiNativeRuntime(storagePath) }) const configuration: ProviderConfig = { @@ -285,12 +215,6 @@ export default async function cortexKitPiAnthropicAuth( maxTokens: CLAUDE_SONNET_5_5_MAX_OUTPUT_TOKENS, }, ], - oauth: { - name: 'Anthropic Claude Pro/Max (CortexKit)', - login: loginAnthropic, - refreshToken: refreshAnthropicToken, - getApiKey: (credentials) => credentials.access, - }, streamSimple: (model, context, options) => streamCortexKitAnthropic( model, @@ -303,37 +227,41 @@ export default async function cortexKitPiAnthropicAuth( } async function configureProvider() { - if (getClaustrumMode(await loadAccounts(storagePath)) !== 'claustrum') { - closePiScopedRuntime(storagePath) - pi.registerProvider('anthropic', configuration) - return - } const streamSimple = configuration.streamSimple if (!streamSimple) throw new Error('Anthropic stream implementation is unavailable') + // Read the account list installed by offline migration before registering + // the provider. Token authorization happens separately for each request. + await getPiNativeRuntime(storagePath).view() const configured = async () => { - if (getClaustrumMode(await loadAccounts(storagePath)) !== 'claustrum') - return false - await requirePiEnrollment() - return true + await requirePiNativeHostAuth() + const snapshot = await ( + await getPiNativeRuntime(storagePath).service() + ).read() + return snapshot.accounts.some( + (account) => + account.type === 'oauth' && + account.enabled && + (account.source !== 'vault' || account.state === 'active'), + ) } const provider: Provider = { id: 'anthropic', - name: 'Anthropic (Claustrum)', + name: 'Anthropic (CortexKit Native)', baseUrl: 'https://api.anthropic.com', auth: { // Native ambient auth avoids fake keys and local OAuth refresh. Pi refuses // a leftover stored OAuth credential because this provider has no OAuth // handler; setup must obtain consent before removing that local entry. apiKey: { - name: 'Claustrum', + name: 'CortexKit Native', check: async () => (await configured()) - ? { type: 'api_key', source: 'Claustrum' } + ? { type: 'api_key', source: 'CortexKit Native' } : undefined, resolve: async () => (await configured()) - ? { auth: {}, source: 'Claustrum' } + ? { auth: {}, source: 'CortexKit Native' } : undefined, }, }, @@ -350,12 +278,6 @@ export default async function cortexKitPiAnthropicAuth( streamSimple, } pi.registerProvider(provider) - getPiScopedRuntime(storagePath, { - ...(options.connectScoped && { connect: options.connectScoped }), - ...(options.pollIntervalMs !== undefined && { - pollIntervalMs: options.pollIntervalMs, - }), - }).start() } await configureProvider() } diff --git a/packages/pi/src/native-commands.ts b/packages/pi/src/native-commands.ts new file mode 100644 index 000000000..ede2bd58d --- /dev/null +++ b/packages/pi/src/native-commands.ts @@ -0,0 +1,398 @@ +import { randomUUID } from 'node:crypto' +import { + authorize, + buildClaudeQuotaSummary, + buildPrimeAccountStatuses, + DEFAULT_KILLSWITCH_THRESHOLDS, + exchange, + executeCacheKeepCommand, + executePrimeCommand, + formatEnrollmentStatus, + formatOAuthAccountTier, + getCache1hPersistentMode, + getCacheKeepWindow, + getPersistedLogLevel, + getRoutingMode, + isCache1hPersistentlyEnabled, + isCacheKeepAlways, + isCacheKeepHybridActive, + isCacheKeepPersistentlyEnabled, + isDumpPersistentlyEnabled, + isFastModePersistentlyEnabled, + isKillswitchEnabled, + isPrimePersistentlyEnabled, + type NativeMenuDispatch, + type NativeMenuKillswitchEntry, + setDumpEnabled, + setLogLevel, +} from '@cortexkit/anthropic-auth-core' +import type { PiNativeCommands } from './commands.ts' +import { createPiCustodyCommands } from './custody.ts' +import { getPiNativeRuntime } from './native.ts' +import { getPiAccountStoragePath } from './paths.ts' +import { + clearPiStickyRoutingSession, + getPiTrackedCacheKeepSessions, +} from './stream.ts' +import { fetchPiOAuth } from './transport.ts' + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +export function createPiNativeCommands( + storagePath = getPiAccountStoragePath(), +): PiNativeCommands { + const native = getPiNativeRuntime(storagePath) + const custody = createPiCustodyCommands() + const pending = new Map< + string, + { authorization: Awaited>; expires: number } + >() + const setSection = async (key: string, patch: Record) => { + await (await native.service()).updateSettings((settings) => ({ + ...settings, + [key]: { ...record(settings[key]), ...patch }, + })) + } + const setLimits = async (entries: readonly NativeMenuKillswitchEntry[]) => { + const snapshot = await native.view() + await (await native.service()).updateSettings((settings) => { + const limits = record(settings.killswitch) + let accounts = { ...record(limits.accounts) } + let main = limits.main + for (const entry of entries) { + const threshold = { + five_hour: entry.fh, + seven_day: entry.sd, + ...(entry.scoped !== undefined && { scoped: entry.scoped }), + } + if (entry.account === 'main' || entry.account === 'all') + main = threshold + if (entry.account === 'all') { + for (const account of snapshot.accounts) + if (account.id !== 'main') + accounts = { ...accounts, [account.id]: threshold } + } else if (entry.account !== 'main') + accounts = { ...accounts, [entry.account]: threshold } + } + return { + ...settings, + killswitch: { ...limits, enabled: true, main, accounts }, + } + }) + } + const dispatch: NativeMenuDispatch = async (request, options) => { + const runtime = await native.service() + switch (request.action) { + case 'enable': + case 'disable': + await runtime.setEnabled(request.values.id, request.action === 'enable') + return { ok: true, text: `Native account ${request.action}d.` } + case 'remove': + await runtime.remove(request.values.id) + return { ok: true, text: 'Native account removed.' } + case 'move-up': + case 'move-down': { + const order = (await native.view()).accounts.map( + (account) => account.id, + ) + const index = order.indexOf(request.values.id) + if (index < 0) return { ok: false, text: 'Native account not found.' } + const target = index + (request.action === 'move-up' ? -1 : 1) + if (target < 0 || target >= order.length) + return { + ok: true, + text: 'Native account is already at that end of the order.', + } + const id = order[index] + const other = order[target] + if (!id || !other) + return { ok: false, text: 'Native account order is unavailable.' } + order[index] = other + order[target] = id + await runtime.reorder(order) + return { ok: true, text: 'Native account order updated.' } + } + case 'add-apikey': + await runtime.addApi({ + apiKey: request.values.apiKey, + ...(request.values.baseURL && { baseURL: request.values.baseURL }), + ...(request.values.authHeader && { + authHeader: request.values.authHeader, + }), + ...(request.values.label && { label: request.values.label }), + }) + return { + ok: true, + text: 'API-key fallback saved in the native pool. It is used only after confirmed general OAuth exhaustion.', + } + case 'reset-backoff': { + const snapshot = await native.view() + const main = snapshot.accounts.find((account) => account.id === 'main') + if (main?.type !== 'oauth') + return { + ok: false, + text: 'Native main OAuth account is unavailable.', + } + const fence = + main.source === 'vault' + ? await runtime.authorizeVault('main', options.signal) + : await runtime.captureLocalSubject('main') + const reset = await runtime.resetBackoff('main', fence, 'all') + return { + ok: reset, + text: reset + ? 'Native main OAuth refresh and quota backoff cleared.' + : 'Native account changed before backoff could be cleared.', + } + } + case 'enrollment-reset': { + const result = await custody.reset() + return { ok: !result.text.startsWith('Refused:'), text: result.text } + } + case 'add-oauth-start': { + if ((await native.view()).mode !== 'local') + return { + ok: false, + text: 'Local OAuth login is disabled in Claustrum mode. Use offline setup.', + } + const authorization = await authorize('max') + pending.delete(request.sessionId) + pending.set(request.sessionId, { + authorization, + expires: Date.now() + 10 * 60_000, + }) + while (pending.size > 32) { + const first = pending.keys().next().value + if (first === undefined) break + pending.delete(first) + } + return { + ok: true, + text: `Open this OAuth URL, then complete Add OAuth finish in the same session:\n${authorization.url}`, + } + } + case 'add-oauth-finish': { + const flow = pending.get(request.sessionId) + pending.delete(request.sessionId) + if (!flow || flow.expires <= Date.now()) + return { + ok: false, + text: 'No pending OAuth login in this session. Start a new login.', + } + const snapshot = await native.view() + if (snapshot.mode !== 'local') + return { + ok: false, + text: 'Local OAuth login is disabled in Claustrum mode. Use offline setup.', + } + const result = await exchange( + request.values.code, + flow.authorization.verifier, + flow.authorization.redirectUri, + flow.authorization.state, + ) + if (result.type !== 'success') + return { ok: false, text: 'Anthropic OAuth exchange failed.' } + const account = await runtime.loginOAuth({ + routeId: snapshot.accounts.some((account) => account.id === 'main') + ? randomUUID() + : 'main', + credential: { + access: result.access, + refresh: result.refresh, + expires: result.expires, + }, + ...(request.values.label && { label: request.values.label }), + }) + const admission = await runtime.authorizeLocal(account.id, { + signal: options.signal, + }) + if (admission.status !== 'usable') + return { + ok: false, + text: 'OAuth material was saved in the native pool, but account validation did not complete. It cannot serve until native validation succeeds.', + } + return { + ok: true, + text: 'OAuth account validated and saved in the native pool. Pi host auth was not changed.', + } + } + case 'quota-refresh': { + const snapshot = await native.view() + for (const account of snapshot.accounts) { + if (!account.enabled || account.type !== 'oauth') continue + await runtime.fetchQuota(account.id, fetchPiOAuth, options.signal) + await runtime.fetchProfile(account.id, fetchPiOAuth, options.signal) + } + return { ok: true, text: 'Native OAuth quota and profile refreshed.' } + } + case 'routing-mode': + await setSection('routing', { mode: request.values.mode }) + return { ok: true, text: 'Native routing mode updated.' } + case 'routing-reset': + await clearPiStickyRoutingSession(storagePath, request.sessionId) + return { + ok: true, + text: 'This session’s sticky assignment was cleared.', + } + case 'killswitch-on': + await runtime.updateSettings((settings) => { + const limits = record(settings.killswitch) + return { + ...settings, + killswitch: { + ...limits, + enabled: true, + main: limits.main ?? { ...DEFAULT_KILLSWITCH_THRESHOLDS }, + }, + } + }) + return { ok: true, text: 'Native killswitch enabled.' } + case 'killswitch-off': + await setSection('killswitch', { enabled: false }) + return { ok: true, text: 'Native killswitch disabled.' } + case 'killswitch-set': + await setLimits(request.values.entries) + return { ok: true, text: 'Native killswitch thresholds updated.' } + case 'cache-on': + case 'cache-off': + await setSection('claudeCache', { + enabled: request.action === 'cache-on', + }) + return { ok: true, text: 'Native prompt cache setting updated.' } + case 'cache-mode': + await setSection('claudeCache', { mode: request.values.mode }) + return { ok: true, text: 'Native prompt cache mode updated.' } + case 'cachekeep-always': + await runtime.updateSettings((settings) => { + const { + startHour: _start, + endHour: _end, + ...keep + } = record(settings.cacheKeep) + return { + ...settings, + cacheKeep: { ...keep, enabled: true, always: true }, + } + }) + return { ok: true, text: 'Native CacheKeep is always enabled.' } + case 'cachekeep-off': + await setSection('cacheKeep', { enabled: false }) + return { ok: true, text: 'Native CacheKeep disabled.' } + case 'cachekeep-window': + await setSection('cacheKeep', { + enabled: true, + always: false, + startHour: request.values.startHour, + endHour: request.values.endHour, + }) + return { ok: true, text: 'Native CacheKeep local time window updated.' } + case 'cachekeep-subagents': + await setSection('cacheKeep', { + subagents: request.values.enabled === 'on', + }) + return { ok: true, text: 'Native CacheKeep subagent setting updated.' } + case 'dump-on': + case 'dump-off': { + const enabled = request.action === 'dump-on' + await setSection('dump', { enabled }) + setDumpEnabled(enabled) + return { ok: true, text: 'Native request dumping setting updated.' } + } + case 'logging-level': + await setSection('logging', { level: request.values.level }) + setLogLevel(request.values.level) + return { ok: true, text: 'Native logging level updated.' } + case 'fast-on': + case 'fast-off': + await setSection('claudeFast', { + enabled: request.action === 'fast-on', + }) + return { ok: true, text: 'Native fast mode setting updated.' } + case 'prime-on': + case 'prime-off': + case 'start-fire': + return { + ok: false, + text: 'Pi supports Prime status only and has no lane-start action.', + } + } + } + return { + dispatch, + async readStatus(command) { + const runtime = await native.service() + const snapshot = await runtime.read() + if (snapshot.mode === 'claustrum' && !(await runtime.vault.read())) + return 'Claustrum roster is unavailable; no verified account view has been discovered.' + const storage = snapshot.policyStorage + switch (command) { + case 'account': + return [ + `Custody: ${snapshot.mode}`, + ...snapshot.accounts.map( + (account) => + `${account.id}: ${account.label ?? account.id} (${account.type}, ${account.enabled ? 'enabled' : 'disabled'}, ${account.source}${account.state ? `, ${account.state}` : ''}${formatOAuthAccountTier(account.profile) ? `, ${formatOAuthAccountTier(account.profile)}` : ''}); identity: ${account.accountIdentity ?? 'unverified'}${account.lastRefreshError ? `; refresh: ${account.lastRefreshError.permanent ? 'requires login' : 'backoff'}` : ''}`, + ), + ...formatEnrollmentStatus( + await custody.status(), + snapshot.mode === 'claustrum', + ), + ].join('\n') + case 'quota': + return buildClaudeQuotaSummary({ + accounts: snapshot.accounts + .filter((account) => account.type === 'oauth') + .map((account) => ({ + name: account.label ?? account.id, + role: account.id === 'main' ? 'main' : 'fallback', + enabled: account.enabled, + quota: account.quota, + lastRefreshedAt: account.lastRefreshedAt, + tierLabel: formatOAuthAccountTier(account.profile), + error: + account.lastQuotaRefreshError?.message ?? + account.lastRefreshError?.message, + })), + }) + case 'routing': + return `Routing: ${getRoutingMode(storage)}` + case 'killswitch': + return `Killswitch: ${isKillswitchEnabled(storage) ? 'enabled' : 'disabled'}\n${JSON.stringify(storage.killswitch ?? {})}` + case 'cache': + return `1-hour cache: ${isCache1hPersistentlyEnabled(storage) ? 'enabled' : 'disabled'}; mode: ${getCache1hPersistentMode(storage)}` + case 'cachekeep': { + const sessions = await getPiTrackedCacheKeepSessions() + return executeCacheKeepCommand({ + argumentsText: '', + enabled: isCacheKeepPersistentlyEnabled(storage), + always: isCacheKeepAlways(storage), + hybridActive: isCacheKeepHybridActive(storage), + window: getCacheKeepWindow(storage), + trackedSessions: sessions.length, + trackedSessionDetails: sessions, + nextPrewarmAt: sessions.length + ? Math.min(...sessions.map((session) => session.nextPrewarmAt)) + : undefined, + }) + } + case 'dump': + return `Request dumping: ${isDumpPersistentlyEnabled(storage) ? 'enabled' : 'disabled'}` + case 'logging': + return `Logging: ${getPersistedLogLevel(storage) ?? 'info'}` + case 'fast': + return `Fast mode: ${isFastModePersistentlyEnabled(storage) ? 'enabled' : 'disabled'}` + case 'prime': + return executePrimeCommand({ + argumentsText: 'status', + enabled: isPrimePersistentlyEnabled(storage), + accounts: buildPrimeAccountStatuses(storage, { now: Date.now() }), + }).text + } + }, + } +} diff --git a/packages/pi/src/native.ts b/packages/pi/src/native.ts new file mode 100644 index 000000000..9cfcaa2a1 --- /dev/null +++ b/packages/pi/src/native.ts @@ -0,0 +1,313 @@ +import { + connectClaustrumScopedClient, + createNativeAccountRuntime, + isKillswitchEnabled, + killswitchPassesPolicy, + logger, + type NativeAccountRuntime, + type NativeAccountSnapshot, + type NativeCustodyClient, + type NativeCustodyReceipt, + type NativeCustodyReporterSource, + type NativeLocalExternalPolicy, + type NativePoolPaths, + type NativeRefreshResult, + quotaSnapshotHasStandardWindows, + quotaSnapshotPassesModelScope, + quotaSnapshotPassesPolicy, + readNativeMigrationJournal, + resolveNativePoolPaths, +} from '@cortexkit/anthropic-auth-core' +import { requirePiEnrollment } from './custody.ts' +import { + getPiAccountStoragePath, + getPiClaustrumConnectionOptions, + requirePiNativeHostAuth, +} from './paths.ts' + +export type PiNativeAttempt = + | { + source: 'local' + accountIdentity: string + admission: Extract + } + | { + source: 'vault' + accountIdentity: string + receipt: NativeCustodyReceipt + } + +export function piAttemptAccessToken(attempt: PiNativeAttempt): string { + return attempt.source === 'vault' + ? attempt.receipt.accessToken + : attempt.admission.access +} + +function requestPolicy(routeId: string, modelId?: string) { + return ( + account: NativeAccountSnapshot['accounts'][number], + current: NativeAccountSnapshot, + ): NativeLocalExternalPolicy => { + if (!account.enabled) + return { status: 'blocked', reason: 'account-disabled' } + if (account.source === 'local' && current.mode !== 'local') + return { status: 'blocked', reason: 'local-mode-unavailable' } + const storage = current.policyStorage + const needsQuotaPolicy = + quotaSnapshotHasStandardWindows(account.quota) && + (account.source !== 'vault' || + Boolean(storage.quota?.minimumRemaining) || + isKillswitchEnabled(storage)) + const blocked = + (isKillswitchEnabled(storage) && + !killswitchPassesPolicy( + account.quota, + storage, + routeId === 'main' ? undefined : routeId, + modelId, + )) || + (routeId !== 'main' && + ((needsQuotaPolicy && + !quotaSnapshotPassesPolicy(account.quota, storage)) || + !quotaSnapshotPassesModelScope(account.quota, modelId))) + return blocked + ? { status: 'blocked', reason: 'quota-ineligible' } + : { status: 'allowed' } + } +} + +const runtimes = new Map() +const RUNTIME_LIMIT = 16 + +/** Checks Pi host authentication and enrollment before delegating credential authorization and metadata writes to the shared account runtime. */ +export class PiNativeRuntime { + private readonly paths: Promise + private readonly initialization: Promise + private closed = false + + constructor( + storagePath: string, + options: { connect?: () => Promise } = {}, + ) { + this.paths = resolveNativePoolPaths(storagePath) + this.initialization = this.paths.then((paths) => { + const runtime = createNativeAccountRuntime({ + paths, + host: 'pi', + vault: { + connect: async () => { + await requirePiEnrollment() + return options.connect + ? options.connect() + : connectClaustrumScopedClient( + getPiClaustrumConnectionOptions(storagePath), + ) + }, + }, + }) + if (this.closed) runtime.close() + return runtime + }) + // Initialization is lazy from the host's perspective; a path failure is + // surfaced by service(), not as an unhandled background rejection. + void this.initialization.catch(() => {}) + } + + async service(): Promise { + if (this.closed) throw new Error('Pi native authentication is closed') + await requirePiNativeHostAuth() + const runtime = await this.initialization + if (this.closed) throw new Error('Pi native authentication is closed') + return runtime + } + + async routingPath(): Promise { + // Read the recorded destination of the session-to-account routing file. + // A guessed path would lose the account assignments preserved by migration. + await this.service() + const journal = await readNativeMigrationJournal(await this.paths) + if ( + !journal || + (journal.phase !== 'committed' && journal.phase !== 'retired') || + !journal.routingPaths.destination + ) + throw new Error('Pi native routing requires committed offline migration') + return journal.routingPaths.destination + } + + async view(): Promise { + const runtime = await this.service() + // Refresh before reading routes so an uninitialized vault list is not + // mistaken for a complete empty pool. + await runtime.vault.refresh() + return runtime.read() + } + + async authorize( + routeId: string, + signal?: AbortSignal, + modelId?: string, + ): Promise { + const runtime = await this.service() + const snapshot = await runtime.read() + const account = snapshot.accounts.find( + (candidate) => candidate.id === routeId, + ) + if (!account?.enabled || account.type !== 'oauth') + throw new Error('Pi native OAuth route is unavailable') + const policy = requestPolicy(routeId, modelId) + if (policy(account, snapshot).status === 'blocked') + throw new Error('Pi native quota policy refused the OAuth route') + if (snapshot.mode === 'claustrum') { + const receipt = await runtime.authorizeVault(routeId, signal) + if (!receipt.assertedAccountIdentity) + throw new Error('Pi native OAuth identity is unasserted') + const current = await runtime.read() + const admitted = current.accounts.find( + (candidate) => candidate.id === routeId, + ) + // After the vault lookup, recheck quota limits for this request’s model. + // A policy refusal must not report the OAuth token as invalid. + if ( + !admitted || + current.mode !== 'claustrum' || + admitted.accountIdentity !== receipt.assertedAccountIdentity || + requestPolicy(routeId, modelId)(admitted, current).status === 'blocked' + ) + throw new Error('Pi native quota policy refused the OAuth route') + return { + source: 'vault', + accountIdentity: receipt.assertedAccountIdentity, + receipt, + } + } + const admission = await runtime.authorizeLocal(routeId, { signal, policy }) + if (admission.status !== 'usable' || !admission.binding.identity) + throw new Error('Pi native OAuth admission refused') + return { + source: 'local', + accountIdentity: admission.binding.identity, + admission, + } + } + + async retry( + routeId: string, + served: PiNativeAttempt, + signal?: AbortSignal, + modelId?: string, + ): Promise { + const runtime = await this.service() + if (served.source === 'vault') { + const current = await runtime.read() + const account = current.accounts.find( + (candidate) => candidate.id === routeId, + ) + if ( + !account || + current.mode !== 'claustrum' || + requestPolicy(routeId, modelId)(account, current).status === 'blocked' + ) + return undefined + const decision = await runtime.vault.prepareRetry(served.receipt, signal) + logger.debug('native-auth', 'Pi 401 retry decision', { + source: 'vault', + servedVersion: served.receipt.recordVersion, + currentVersion: decision.retry + ? decision.receipt.recordVersion + : undefined, + retry: decision.retry, + reason: decision.retry ? 'rotated' : decision.reason, + }) + if (!decision.retry || !decision.receipt.assertedAccountIdentity) + return undefined + const after = await runtime.read() + const admitted = after.accounts.find( + (candidate) => candidate.id === routeId, + ) + if ( + !admitted || + after.mode !== 'claustrum' || + admitted.accountIdentity !== decision.receipt.assertedAccountIdentity || + requestPolicy(routeId, modelId)(admitted, after).status === 'blocked' + ) + return undefined + return { + source: 'vault', + accountIdentity: decision.receipt.assertedAccountIdentity, + receipt: decision.receipt, + } + } + const admission = await runtime.authorizeLocal(routeId, { + intent: 'refresh', + rejectedAccessToken: served.admission.access, + signal, + policy: requestPolicy(routeId, modelId), + }) + if ( + admission.status !== 'usable' || + admission.binding.identity !== served.accountIdentity || + admission.access === served.admission.access + ) + return undefined + return { + source: 'local', + accountIdentity: served.accountIdentity, + admission, + } + } + + async report( + served: PiNativeAttempt, + status: number, + source: NativeCustodyReporterSource, + ): Promise { + if (served.source === 'vault' && status === 401) + await (await this.service()).vault.reportFailure( + served.receipt, + status, + source, + ) + } + + async markUsed(served: PiNativeAttempt): Promise { + const runtime = await this.service() + const patch = { lastUsed: Date.now() } + if (served.source === 'vault') + await runtime.vault.publish(served.receipt, patch) + else await runtime.publishLocal(served.admission.subject, patch) + } + + close(): void { + if (this.closed) return + this.closed = true + void this.initialization.then( + (runtime) => runtime.close(), + () => {}, + ) + } +} + +export function getPiNativeRuntime( + storagePath = getPiAccountStoragePath(), + options: { connect?: () => Promise } = {}, +): PiNativeRuntime { + const existing = runtimes.get(storagePath) + if (existing) return existing + const runtime = new PiNativeRuntime(storagePath, options) + while (runtimes.size >= RUNTIME_LIMIT) { + const oldest = runtimes.keys().next().value + if (oldest === undefined) break + runtimes.get(oldest)?.close() + runtimes.delete(oldest) + } + runtimes.set(storagePath, runtime) + return runtime +} + +export function closePiNativeRuntime( + storagePath = getPiAccountStoragePath(), +): void { + runtimes.get(storagePath)?.close() + runtimes.delete(storagePath) +} diff --git a/packages/pi/src/paths.ts b/packages/pi/src/paths.ts index 04c57b7e6..90b44a6f5 100644 --- a/packages/pi/src/paths.ts +++ b/packages/pi/src/paths.ts @@ -65,3 +65,12 @@ export async function hasPiLocalAnthropicOAuth(): Promise { throw new Error('Cannot safely inspect Pi authentication metadata') } } + +/** The user must approve removing the host’s Anthropic OAuth credential before the shared account store can serve requests. */ +export async function requirePiNativeHostAuth(): Promise { + if (await hasPiLocalAnthropicOAuth()) { + throw new Error( + 'Pi still has a host Anthropic OAuth credential. Run offline setup to migrate it and approve its removal before using the native pool.', + ) + } +} diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index f5f785bf2..664a0a336 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -1,36 +1,24 @@ import { + type AccountStorage, type ApiKeyAccount, applyClaudeCodeHeaders, applyCustomHeaders, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, CacheKeepSessionRegistry, - type ClaustrumScopedAttempt, - ClaustrumScopedRuntime, - type ClaustrumScopedRuntimeOptions, - connectClaustrumScopedClient, createStickyNoRouteResponse, - decideScopedRetryAfter401, decideStickyQuotaFailure, dumpDirectRequest, FAST_MODE_BETA, - FallbackAccountManager, - fetchOAuthQuotaSnapshot, getCache1hPersistentMode, getClaudeCodeIdentityForVerifiedAccount, - getClaustrumMode, getDefaultCacheKeepRegistryDirectory, getFallbackReauthLabels, - getHostClaustrumEnrollmentPaths, - getOrCreateMainAccountId, getQuotaCheckIntervalMs, - getRelayConfig, getRoutingMode, getScopedQuotaWindowForModel, - getStickyRoutingStatePath, getThinkingPrefixMismatchBehavior, hasThinkingBindingControls, - isApiKeyAccount, isCache1hPersistentlyEnabled, isCacheKeepHybridActive, isDumpPersistentlyEnabled, @@ -40,22 +28,23 @@ import { isPermanentRefreshError, isValidApiBaseURL, killswitchPassesPolicy, - loadAccounts, logger, MID_CONVERSATION_OUTPUT_CONFIG_BETA, type MidConversationEffortTransition, mergeAnthropicBetas, + type NativeAccountSnapshot, + type NativeAccountView, + type NativeApiSubject, type OAuthAccount, type OAuthQuotaSnapshot, type ProviderAccountUuid, QuotaManager, type QuotaState, + quotaBackoffActive, quotaSnapshotHasStandardWindows, quotaSnapshotModelScopeIsExhausted, quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, - refreshBackoffActive, - resolveClaudeCodeIdentity, STICKY_ROUTING_MAIN_ACCOUNT_ID, type StickyRouteCandidate, StickySessionRouter, @@ -66,7 +55,6 @@ import { stickyRetryAfterWithJitter, stickyRouteFamilyForModel, THINKING_BINDING_CONTROLS_BETA, - tokenFingerprint, usesMidConversationOutputConfig, } from '@cortexkit/anthropic-auth-core' import { @@ -84,13 +72,18 @@ import { type Tool, type ToolCall, } from '@earendil-works/pi-ai' - import { buildAnthropicRequest, fromClaudeCodeToolName } from './convert.ts' -import { requirePiEnrollment } from './custody.ts' import { - getPiAccountStoragePath, - getPiClaustrumConnectionOptions, -} from './paths.ts' + getPiNativeRuntime, + type PiNativeAttempt, + piAttemptAccessToken, +} from './native.ts' +import { getPiAccountStoragePath } from './paths.ts' +import { + buildPiOAuthMessagesUrl, + fetchPiOAuth, + requirePiOAuthOrigin, +} from './transport.ts' function errorText(error: unknown) { return error instanceof Error ? error.message : String(error) @@ -99,65 +92,11 @@ function errorText(error: unknown) { let cacheKeepRegistry: CacheKeepSessionRegistry | undefined let cacheKeepRegistryDirectory: string | undefined const stickyRouters = new Map() -const quotaManagers = new Map() -const fallbackManagers = new Map() -const mainAccountIdInitializations = new Map< +const quotaManagers = new Map< string, - Promise + { identityKey: string; manager: QuotaManager } >() const PI_SERVICE_CACHE_LIMIT = 16 -const scopedRuntimes = new Map() - -export function getPiScopedRuntime( - storagePath = getPiAccountStoragePath(), - overrides: Partial< - Pick< - ClaustrumScopedRuntimeOptions, - 'connect' | 'pollIntervalMs' | 'setTimeoutImpl' | 'clearTimeoutImpl' - > - > = {}, -): ClaustrumScopedRuntime { - const existing = scopedRuntimes.get(storagePath) - if (existing) return existing - let warned = false - const { connect, ...runtimeOverrides } = overrides - const runtime = new ClaustrumScopedRuntime({ - storagePath, - tokenPath: getHostClaustrumEnrollmentPaths('pi').tokenPath, - connect: async () => { - await requirePiEnrollment() - return connect - ? connect() - : connectClaustrumScopedClient( - getPiClaustrumConnectionOptions(storagePath), - ) - }, - onRoster: () => { - warned = false - }, - onError: () => { - if (!warned) - logger.warn('claustrum', 'Pi scoped credential service unavailable') - warned = true - }, - ...runtimeOverrides, - }) - while (scopedRuntimes.size >= PI_SERVICE_CACHE_LIMIT) { - const oldest = scopedRuntimes.keys().next().value - if (oldest === undefined) break - scopedRuntimes.get(oldest)?.close() - scopedRuntimes.delete(oldest) - } - scopedRuntimes.set(storagePath, runtime) - return runtime -} - -export function closePiScopedRuntime( - storagePath = getPiAccountStoragePath(), -): void { - scopedRuntimes.get(storagePath)?.close() - scopedRuntimes.delete(storagePath) -} function setBoundedService(map: Map, key: string, value: T) { map.delete(key) @@ -169,80 +108,95 @@ function setBoundedService(map: Map, key: string, value: T) { } } -function ensurePiMainAccountId(storagePath: string) { - let initialization = mainAccountIdInitializations.get(storagePath) - if (!initialization) { - initialization = getOrCreateMainAccountId(storagePath).catch((error) => { - if (mainAccountIdInitializations.get(storagePath) === initialization) { - mainAccountIdInitializations.delete(storagePath) - } - throw error - }) - setBoundedService(mainAccountIdInitializations, storagePath, initialization) - } - return initialization -} - function getPiRoutingServices( storagePath: string, - storage: Awaited>, + snapshot: NativeAccountSnapshot, ) { - let quotaManager = quotaManagers.get(storagePath) - let fallbackManager = fallbackManagers.get(storagePath) - if (!quotaManager || !fallbackManager) { + const storage = snapshot.policyStorage + const native = getPiNativeRuntime(storagePath) + const identityKey = JSON.stringify( + snapshot.accounts.map((account) => [ + account.id, + account.accountIdentity, + account.source, + account.credentialId, + account.binding?.rowId, + account.binding?.credentialEpoch, + ]), + ) + const existing = quotaManagers.get(storagePath) + let quotaManager = + existing?.identityKey === identityKey ? existing.manager : undefined + if (!quotaManager) { quotaManager = new QuotaManager({ storage, fetchQuotaSnapshot: async (request) => { - const current = await loadAccounts(storagePath) - if (getClaustrumMode(current) === 'claustrum') { - const id = request.kind === 'main' ? 'main' : request.accountId - if (!id) - throw new Error('Claustrum quota request has no account identity') - return getPiScopedRuntime(storagePath).fetchQuota(id) - } - if (!request.accessToken) - throw new Error('Missing local OAuth quota credential') - return fetchOAuthQuotaSnapshot({ accessToken: request.accessToken }) + const id = request.kind === 'main' ? 'main' : request.accountId + if (!id) throw new Error('Native quota request has no account identity') + return (await native.service()).fetchQuota(id, fetchPiOAuth) }, }) - fallbackManager = new FallbackAccountManager({ - configPath: storagePath, - quotaManager, - isFallbackAccountVaultEnabled: (_id, current) => - getClaustrumMode(current) === 'claustrum', - isFallbackAccountVaultServed: (id, current) => { - const account = current.accounts.find( - (candidate) => candidate.id === id, - ) - return ( - getClaustrumMode(current) === 'claustrum' && - Boolean( - account && - isOAuthAccount(account) && - account.claustrumScopedCredentialId && - account.claustrumScopedState === 'active', - ) - ) - }, - resolveFallbackAccessToken: (account, current) => - getClaustrumMode(current) === 'claustrum' - ? undefined - : account.access - ? { token: account.access, source: 'sidecar' } - : undefined, + setBoundedService(quotaManagers, storagePath, { + identityKey, + manager: quotaManager, }) - setBoundedService(quotaManagers, storagePath, quotaManager) - setBoundedService(fallbackManagers, storagePath, fallbackManager) } else { quotaManager.updateStorage(storage) } - return { quotaManager, fallbackManager } + const quotas = quotaManager + const getUsableFallbackAccounts = async ( + current: AccountStorage, + options: { modelId?: string } = {}, + ) => { + const snapshot = await native.view() + const accounts = new Map( + snapshot.accounts.map((account) => [account.id, account]), + ) + const usable: OAuthAccount[] = [] + for (const account of current.accounts) { + const view = accounts.get(account.id) + if (!isOAuthAccount(account) || !view?.enabled || view.type !== 'oauth') + continue + if (view.source === 'vault' && view.state !== 'active') continue + if ( + isPermanentRefreshError(view.lastRefreshError) || + (view.source === 'local' && + (view.lastRefreshError?.nextRetryAt ?? 0) > Date.now()) + ) + continue + const needsProbe = + view.source !== 'vault' || + Boolean(current.quota?.minimumRemaining) || + isKillswitchEnabled(current) + if ( + needsProbe && + quotas.isFallbackStale( + account.id, + undefined, + options.modelId, + account, + ) && + !quotaBackoffActive(account.lastQuotaRefreshError, Date.now()) + ) { + try { + await quotas.refreshFallback(account.id, '', account) + } catch {} + } + const quota = + quotas.getFallback(account.id, account)?.quota ?? account.quota + if ( + (!needsProbe || quotaSnapshotPassesPolicy(quota, current)) && + quotaSnapshotPassesModelScope(quota, options.modelId) + ) + usable.push({ ...account, quota }) + } + return usable + } + return { quotaManager, getUsableFallbackAccounts } } -function getPiStickyRouter(storagePath: string) { - const path = - process.env.PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE || - getStickyRoutingStatePath(storagePath) +async function getPiStickyRouter(storagePath: string) { + const path = await getPiNativeRuntime(storagePath).routingPath() let router = stickyRouters.get(path) if (!router) { router = new StickySessionRouter({ path }) @@ -255,7 +209,7 @@ export async function clearPiStickyRoutingSession( storagePath: string, sessionId: string, ) { - await getPiStickyRouter(storagePath).clear(sessionId) + await (await getPiStickyRouter(storagePath)).clear(sessionId) } function getPiCacheKeepRegistry() { @@ -269,132 +223,103 @@ function getPiCacheKeepRegistry() { return cacheKeepRegistry } -const scopedPrewarmAttempts = new Map< +const nativePrewarmAttempts = new Map< number, - { runtime: ClaustrumScopedRuntime; receipt: ClaustrumScopedAttempt } + { + runtime: ReturnType + served: PiNativeAttempt + reported: boolean + } >() const cacheKeepManager = new CacheKeepManager({ - loadStorage: () => loadAccounts(getPiAccountStoragePath()), + fetchImpl: fetchPiOAuth, + loadStorage: async () => (await getPiNativeRuntime().view()).policyStorage, onTrackedSessionsChanged: (sessions) => getPiCacheKeepRegistry().publish(sessions), prepareHeaders: async (headers, target, attempt) => { - const storagePath = target.accountStoragePath ?? getPiAccountStoragePath() - const current = await loadAccounts(storagePath) - if (target.accountStoragePath && !current) return undefined - if (getClaustrumMode(current) === 'claustrum') { - const runtime = getPiScopedRuntime(storagePath) - const id = target.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID - const receipt = await runtime.authorize(id, attempt.signal) - if ( - !target.oauthAccountIdentity || - receipt.accountId !== target.oauthAccountIdentity - ) { - throw new Error('CacheKeep account identity changed') - } - const body = JSON.parse(target.bodyText) as Record - const identity = getClaudeCodeIdentityForVerifiedAccount( - id === STICKY_ROUTING_MAIN_ACCOUNT_ID - ? (current?.mainAccountId ?? id) - : id, - receipt.accountId as ProviderAccountUuid, - ) - headers.delete('anthropic-beta') - applyClaudeCodeHeaders(headers, receipt.accessToken, { - body, - identity, - extraBetas: [ - CACHE_KEEP_EXTENDED_TTL_BETA, - ...(hasThinkingBindingControls(body) - ? [THINKING_BINDING_CONTROLS_BETA] - : []), - ], - }) - if (body.speed === 'fast') - headers.set( - 'anthropic-beta', - mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]), - ) - scopedPrewarmAttempts.set(attempt.id, { runtime, receipt }) - return headers - } - const authorization = headers.get('authorization') ?? '' - const match = /^Bearer\s+(.+)$/i.exec(authorization) - const accessToken = match?.[1] - if (!accessToken) return undefined - try { - const body = JSON.parse(target.bodyText) as Record - await ensurePiMainAccountId(storagePath) - const storage = await loadAccounts(storagePath) - const identity = await resolveClaudeCodeIdentity( - accessToken, - typeof body.model === 'string' ? body.model : undefined, - target.oauthAccountId ?? storage?.mainAccountId, + requirePiOAuthOrigin(target.url) + const runtime = getPiNativeRuntime( + target.accountStoragePath ?? getPiAccountStoragePath(), + ) + const id = target.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID + const current = await runtime.view() + const body = JSON.parse(target.bodyText) as Record + const served = await runtime.authorize( + id, + attempt.signal, + typeof body.model === 'string' ? body.model : undefined, + ) + if ( + !target.oauthAccountIdentity || + served.accountIdentity !== target.oauthAccountIdentity + ) + throw new Error('CacheKeep account identity changed') + const identity = getClaudeCodeIdentityForVerifiedAccount( + id === STICKY_ROUTING_MAIN_ACCOUNT_ID + ? (current.policyStorage.mainAccountId ?? id) + : id, + served.accountIdentity as ProviderAccountUuid, + ) + headers.delete('anthropic-beta') + applyClaudeCodeHeaders(headers, piAttemptAccessToken(served), { + body, + identity, + extraBetas: [ + CACHE_KEEP_EXTENDED_TTL_BETA, + ...(hasThinkingBindingControls(body) + ? [THINKING_BINDING_CONTROLS_BETA] + : []), + ], + }) + if (body.speed === 'fast') + headers.set( + 'anthropic-beta', + mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]), ) - headers.delete('anthropic-beta') - applyClaudeCodeHeaders(headers, accessToken, { - body, - identity, - extraBetas: [ - CACHE_KEEP_EXTENDED_TTL_BETA, - ...(hasThinkingBindingControls(body) - ? [THINKING_BINDING_CONTROLS_BETA] - : []), - ], - }) - if (body.speed === 'fast') { - headers.set( - 'anthropic-beta', - mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]), - ) - } - } catch { - applyClaudeCodeHeaders(headers, accessToken) - } + nativePrewarmAttempts.set(attempt.id, { runtime, served, reported: false }) return headers }, retryOnUnauthorized: async ({ target, headers, attempt }) => { - const entry = scopedPrewarmAttempts.get(attempt.id) + const entry = nativePrewarmAttempts.get(attempt.id) if (!entry) return undefined - let current: ClaustrumScopedAttempt | undefined - try { - current = await entry.runtime.authorize( + await entry.runtime.report(entry.served, 401, 'direct').catch(() => { + logger.warn('native-auth', 'Pi CacheKeep auth-failure report unavailable') + }) + entry.reported = true + const retryBody = JSON.parse(target.bodyText) as Record + const rotated = await entry.runtime + .retry( target.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID, + entry.served, attempt.signal, + typeof retryBody.model === 'string' ? retryBody.model : undefined, ) - } catch { - // 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 - logger.info( - 'claustrum', - 'retrying Pi CacheKeep after scoped credential rotation', - { - accountId: target.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID, - previousVersion: entry.receipt.recordVersion, - newVersion: current.recordVersion, - }, - ) + .catch(() => undefined) + if (!rotated) return undefined const rotatedHeaders = new Headers(headers) - rotatedHeaders.set('authorization', `Bearer ${current.accessToken}`) - scopedPrewarmAttempts.set(attempt.id, { + rotatedHeaders.set( + 'authorization', + `Bearer ${piAttemptAccessToken(rotated)}`, + ) + nativePrewarmAttempts.set(attempt.id, { runtime: entry.runtime, - receipt: current, + served: rotated, + reported: false, }) return rotatedHeaders }, onResponse: async ({ attempt, status }) => { - const entry = scopedPrewarmAttempts.get(attempt.id) - if (entry && status === 401) - await entry.runtime - .reportFailure(entry.receipt, status, 'direct') - .catch(() => { - logger.warn('claustrum', 'Pi CacheKeep scoped 401 report unavailable') - }) + const entry = nativePrewarmAttempts.get(attempt.id) + if (entry && status === 401 && !entry.reported) + await entry.runtime.report(entry.served, status, 'direct').catch(() => { + logger.warn( + 'native-auth', + 'Pi CacheKeep auth-failure report unavailable', + ) + }) }, onComplete: ({ attempt }) => { - scopedPrewarmAttempts.delete(attempt.id) + nativePrewarmAttempts.delete(attempt.id) }, }) @@ -560,49 +485,42 @@ async function sendAnthropicRequest(options: { model: Model context: Context streamOptions?: SimpleStreamOptions - accessToken?: string - apiAccount?: ApiKeyAccount + apiAccount?: NativeAccountView storagePath: string oauthAccountId?: string route?: string effortTransitions?: readonly MidConversationEffortTransition[] onResolvedTools?: (tools: Tool[]) => void }): Promise { - await ensurePiMainAccountId(options.storagePath) - const storage = await loadAccounts(options.storagePath) + const native = getPiNativeRuntime(options.storagePath) + const snapshot = await native.view() + const storage = snapshot.policyStorage setDumpEnabled(isDumpPersistentlyEnabled(storage)) - const accountIdentity = - options.oauthAccountId === STICKY_ROUTING_MAIN_ACCOUNT_ID - ? storage?.mainAccountId - : options.oauthAccountId - const scoped = - !options.apiAccount && getClaustrumMode(storage) === 'claustrum' - ? getPiScopedRuntime(options.storagePath) - : undefined const routeId = options.oauthAccountId ?? STICKY_ROUTING_MAIN_ACCOUNT_ID - const roster = scoped - ? (scoped.snapshot() ?? (await scoped.refresh())) + const selected = snapshot.accounts.find((account) => account.id === routeId) + let verifiedUuid = options.apiAccount ? undefined : selected?.accountIdentity + if (!options.apiAccount && !verifiedUuid) { + if (selected?.source === 'vault') + throw new Error('Pi vault route identity is unasserted') + // The local service may learn a UUID by validating an unidentified row. + // This lookup supplies identity only; dispatch still authorizes afresh. + verifiedUuid = ( + await native.authorize( + routeId, + options.streamOptions?.signal, + options.model.id, + ) + ).accountIdentity + } + let nextAttempt: PiNativeAttempt | undefined + const identity = verifiedUuid + ? getClaudeCodeIdentityForVerifiedAccount( + routeId === STICKY_ROUTING_MAIN_ACCOUNT_ID + ? (storage.mainAccountId ?? routeId) + : routeId, + verifiedUuid as ProviderAccountUuid, + ) : undefined - const verifiedUuid = - routeId === STICKY_ROUTING_MAIN_ACCOUNT_ID - ? roster?.primary?.accountId - : roster?.accounts.find((account) => account.id === routeId) - ?.anthropicAccountUuid - if (scoped && !verifiedUuid) - throw new Error('Claustrum route has no verified provider identity') - const identity = - scoped && verifiedUuid - ? getClaudeCodeIdentityForVerifiedAccount( - accountIdentity ?? routeId, - verifiedUuid as ProviderAccountUuid, - ) - : options.accessToken - ? await resolveClaudeCodeIdentity( - options.accessToken, - options.model.id, - accountIdentity, - ) - : undefined const { body, bodyText, hostTools } = await buildAnthropicRequest( options.model.id, options.context, @@ -623,86 +541,124 @@ async function sendAnthropicRequest(options: { options.onResolvedTools?.(hostTools) const fastMode = body.speed === 'fast' const headers = options.apiAccount - ? configureApiRouteHeaders(options.apiAccount, fastMode) - : applyClaudeCodeHeaders( - new Headers(), - scoped ? '' : (options.accessToken ?? ''), - { - body, - identity, - extraBetas: [ - ...(hasThinkingBindingControls(body) - ? [THINKING_BINDING_CONTROLS_BETA] - : []), - ...(usesMidConversationOutputConfig(body) - ? [MID_CONVERSATION_OUTPUT_CONFIG_BETA] - : []), - ], - }, - ) - if (!options.apiAccount && fastMode) { + ? new Headers() + : applyClaudeCodeHeaders(new Headers(), '', { + body, + identity, + extraBetas: [ + ...(hasThinkingBindingControls(body) + ? [THINKING_BINDING_CONTROLS_BETA] + : []), + ...(usesMidConversationOutputConfig(body) + ? [MID_CONVERSATION_OUTPUT_CONFIG_BETA] + : []), + ], + }) + if (!options.apiAccount && fastMode) headers.set( 'anthropic-beta', mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]), ) - } const relayAffinity = options.streamOptions?.sessionId ?? null - - const input = options.apiAccount - ? buildExplicitBaseMessagesUrl(options.apiAccount.baseURL) - : new URL('/v1/messages?beta=true', options.model.baseUrl) + let input = options.apiAccount + ? new URL('https://api.anthropic.com/v1/messages') + : buildPiOAuthMessagesUrl(options.model.baseUrl) const init: RequestInit = { method: 'POST', headers, body: bodyText, signal: options.streamOptions?.signal, + ...(!options.apiAccount && { redirect: 'error' as const }), } + if (!options.apiAccount) + await cacheKeepManager.track({ + sessionId: relayAffinity, + url: input.toString(), + headers, + bodyText, + storage, + cacheMode: isCacheKeepHybridActive(storage) ? 'hybrid' : 'disabled', + oauthAccountId: routeId, + oauthAccountIdentity: verifiedUuid, + accountStoragePath: options.storagePath, + }) - await cacheKeepManager.track({ - sessionId: relayAffinity, - url: input.toString(), - headers, - bodyText, - storage, - cacheMode: - !options.apiAccount && isCacheKeepHybridActive(storage) - ? 'hybrid' - : 'disabled', - oauthAccountId: options.oauthAccountId, - oauthAccountIdentity: verifiedUuid ?? identity?.accountUuid, - accountStoragePath: options.storagePath, - }) - + const reported = new Set() + let retried401 = false + let lastTransport: 'direct' | 'relay' | undefined + const used = new Set() const report = async ( - attempt: ClaustrumScopedAttempt, + attempt: PiNativeAttempt, status: number, source: 'direct' | 'relay_status_field', ) => { - if (!scoped || status !== 401) return - await scoped.reportFailure(attempt, status, source).catch(() => { - logger.warn('claustrum', 'Pi scoped auth-failure report unavailable') + if (status !== 401 || reported.has(attempt)) return + reported.add(attempt) + await native.report(attempt, status, source).catch(() => { + logger.warn('native-auth', 'Pi auth-failure report unavailable') + }) + } + const markUsed = async (attempt: PiNativeAttempt) => { + if (used.has(attempt)) return + used.add(attempt) + await native.markUsed(attempt).catch(() => { + logger.warn('native-auth', 'Pi account usage publication unavailable') }) } + const takeAttempt = async () => { + const attempt = + nextAttempt ?? + (await native.authorize( + routeId, + init.signal ?? undefined, + options.model.id, + )) + nextAttempt = undefined + if (attempt.accountIdentity !== verifiedUuid) + throw new Error('Pi native OAuth route identity changed') + return attempt + } const directFetch = async () => { - // Every physical attempt, including relay-to-direct fallback, is authorized. - let attempt = scoped - ? await scoped.authorize(routeId, options.streamOptions?.signal) - : undefined - if (attempt) headers.set('authorization', `Bearer ${attempt.accessToken}`) + let attempt: PiNativeAttempt | undefined + let apiSubject: NativeApiSubject | undefined + if (options.apiAccount) { + const credential = await (await native.service()).authorizeApi( + options.apiAccount.id, + init.signal ?? undefined, + ) + apiSubject = credential.subject + input = buildExplicitBaseMessagesUrl(credential.baseURL) + const authorized = configureApiRouteHeaders( + { + ...credential, + id: options.apiAccount.id, + }, + fastMode, + ) + init.headers = authorized + } else { + attempt = await takeAttempt() + headers.set('authorization', `Bearer ${piAttemptAccessToken(attempt)}`) + } + lastTransport = 'direct' try { let response = await fetch(input, init) - if (attempt && response.status === 401 && !init.signal?.aborted) { - let rotated: ClaustrumScopedAttempt | undefined - try { - rotated = await scoped?.authorize( - routeId, - options.streamOptions?.signal, - ) - } catch { - // A failed lookup is not proof of a replacement: report the token - // that received the genuine 401 below, without using local material. - } - if (decideScopedRetryAfter401('pi-model', attempt, rotated)) { + if (attempt && response.status === 401) { + // Report the rejected credential version before retrying, even if its replacement later succeeds. + await report(attempt, response.status, 'direct') + const rotated = + !retried401 && !init.signal?.aborted + ? await native + .retry( + routeId, + attempt, + init.signal ?? undefined, + options.model.id, + ) + .catch(() => undefined) + : undefined + if (rotated) { + retried401 = true await dumpDirectRequest({ affinity: relayAffinity, route: options.route ?? 'oauth', @@ -713,22 +669,27 @@ async function sendAnthropicRequest(options: { headers, }) await response.body?.cancel().catch(() => {}) - logger.info( - 'claustrum', - 'retrying after scoped credential rotation', - { - accountId: routeId, - previousVersion: attempt.recordVersion, - newVersion: rotated.recordVersion, - transport: 'direct', - }, - ) attempt = rotated - headers.set('authorization', `Bearer ${rotated.accessToken}`) + headers.set( + 'authorization', + `Bearer ${piAttemptAccessToken(rotated)}`, + ) response = await fetch(input, init) } } - if (attempt) await report(attempt, response.status, 'direct') + if (attempt) { + await report(attempt, response.status, 'direct') + if (response.ok) await markUsed(attempt) + } + if (apiSubject && response.ok) + await (await native.service()) + .publishApi(apiSubject, { lastUsed: Date.now() }) + .catch(() => { + logger.warn( + 'native-auth', + 'Pi API account usage publication unavailable', + ) + }) await dumpDirectRequest({ affinity: relayAffinity, route: @@ -738,7 +699,7 @@ async function sendAnthropicRequest(options: { bodyText, url: input.toString(), method: init.method, - headers, + headers: init.headers, }) return response } catch (error) { @@ -751,77 +712,75 @@ async function sendAnthropicRequest(options: { bodyText, url: input.toString(), method: init.method, - headers, + headers: init.headers, }) throw error } } - if (options.apiAccount) return directFetch() - let relay401Attempt: ClaustrumScopedAttempt | undefined - let relayReturned = false - const sendRelayAttempt = () => + let relay401Attempt: PiNativeAttempt | undefined + let relayAttempt: PiNativeAttempt | undefined + const relayReports: Promise[] = [] + const sendRelayAttempt = async () => sendViaRelay({ - config: getRelayConfig(storage), + config: await (await native.service()).getRelayConfig(), input, init, headers, body: bodyText, fallback: directFetch, affinity: relayAffinity, - authorizeAttempt: scoped - ? async () => { - const attempt = await scoped.authorize( - routeId, - options.streamOptions?.signal, - ) - const authorizedHeaders = new Headers(headers) - authorizedHeaders.set( - 'authorization', - `Bearer ${attempt.accessToken}`, - ) - return { - headers: authorizedHeaders, - onUpstreamStatus: (status) => { - if (status !== 401) return - relay401Attempt = attempt - // WebSocket upstream status can arrive after the response was - // returned; HTTP status is held until a rotation check finishes. - if (relayReturned) - void report(attempt, status, 'relay_status_field') - }, + authorizeAttempt: async () => { + const attempt = await takeAttempt() + relayAttempt = attempt + lastTransport = 'relay' + const authorizedHeaders = new Headers(headers) + authorizedHeaders.set( + 'authorization', + `Bearer ${piAttemptAccessToken(attempt)}`, + ) + return { + headers: authorizedHeaders, + onUpstreamStatus: (status) => { + if (status === 401) { + relay401Attempt = attempt + // A WebSocket status may arrive after the response has been returned. + relayReports.push(report(attempt, status, 'relay_status_field')) } - } - : undefined, + }, + } + }, }) let response = await sendRelayAttempt() + await Promise.all(relayReports) if ( - scoped && relay401Attempt && response.status === 401 && + !retried401 && !init.signal?.aborted ) { - let rotated: ClaustrumScopedAttempt | undefined - try { - rotated = await scoped.authorize(routeId, options.streamOptions?.signal) - } catch { - // Report the actual rejected record below if no replacement can be read. - } - if (decideScopedRetryAfter401('pi-model-relay', relay401Attempt, rotated)) { + const rotated = await native + .retry( + routeId, + relay401Attempt, + init.signal ?? undefined, + options.model.id, + ) + .catch(() => undefined) + if (rotated) { + retried401 = true await response.body?.cancel().catch(() => {}) - logger.info('claustrum', 'retrying after scoped credential rotation', { - accountId: routeId, - previousVersion: relay401Attempt.recordVersion, - newVersion: rotated.recordVersion, - transport: 'relay', - }) + // Use the replacement credential for the next request attempt only. A + // relay-to-direct fallback obtains a separate authorization. + nextAttempt = rotated relay401Attempt = undefined response = await sendRelayAttempt() + await Promise.all(relayReports) } } - relayReturned = true - if (relay401Attempt) await report(relay401Attempt, 401, 'relay_status_field') + if (response.ok && lastTransport === 'relay' && relayAttempt) + await markUsed(relayAttempt) return response } @@ -866,42 +825,34 @@ async function executeWithFallback(options: { model: Model context: Context streamOptions?: SimpleStreamOptions - primaryAccessToken: string storagePath: string effortTransitions?: readonly MidConversationEffortTransition[] onResolvedTools?: (tools: Tool[]) => void }): Promise { - await ensurePiMainAccountId(options.storagePath) - let storage = await loadAccounts(options.storagePath) - const scoped = - getClaustrumMode(storage) === 'claustrum' - ? getPiScopedRuntime(options.storagePath) - : undefined - let roster = scoped ? await scoped.refresh() : undefined - if (scoped && !roster) - throw new Error('Claustrum custody changed during request preparation') - if (roster) storage = roster.storage - const primaryCredential = scoped ? '' : options.primaryAccessToken - const scopedReady = (account: OAuthAccount) => - Boolean( - scoped && - account.enabled !== false && - account.claustrumScopedCredentialId && - account.claustrumScopedState === 'active', + const native = getPiNativeRuntime(options.storagePath) + let snapshot = await native.view() + let storage = snapshot.policyStorage + const accountView = (id: string) => + snapshot.accounts.find((account) => account.id === id) + const nativeReady = (id: string) => { + const account = accountView(id) + return Boolean( + account?.type === 'oauth' && + account.enabled && + (account.source !== 'vault' || account.state === 'active') && + !isPermanentRefreshError(account.lastRefreshError) && + (account.source !== 'local' || + (account.lastRefreshError?.nextRetryAt ?? 0) <= Date.now()), ) - const primaryAvailable = () => - !scoped || - (roster?.primary?.state === 'active' && - !storage?.claustrum?.disabledAccountIdentities?.includes( - roster.primary.accountId, - )) - const { quotaManager, fallbackManager: manager } = getPiRoutingServices( + } + const primaryAvailable = () => nativeReady(STICKY_ROUTING_MAIN_ACCOUNT_ID) + let { quotaManager, getUsableFallbackAccounts } = getPiRoutingServices( options.storagePath, - storage, + snapshot, ) - let mainAccountId = scoped - ? roster?.primary?.accountId - : storage?.mainAccountId + let mainAccountId = accountView( + STICKY_ROUTING_MAIN_ACCOUNT_ID, + )?.accountIdentity quotaManager.seedMainFromStorage(storage, mainAccountId) quotaManager.seedFallbacksFromAccounts( (storage?.accounts ?? []).filter(isOAuthAccount), @@ -909,7 +860,6 @@ async function executeWithFallback(options: { type PiStickyRoute = { id: string - access: string quota?: OAuthQuotaSnapshot order: number account?: OAuthAccount @@ -939,18 +889,22 @@ async function executeWithFallback(options: { ) } + async function refreshRoutingSnapshot() { + snapshot = await native.view() + storage = snapshot.policyStorage + mainAccountId = accountView(STICKY_ROUTING_MAIN_ACCOUNT_ID)?.accountIdentity + ;({ quotaManager, getUsableFallbackAccounts } = getPiRoutingServices( + options.storagePath, + snapshot, + )) + quotaManager.seedMainFromStorage(storage, mainAccountId) + quotaManager.seedFallbacksFromAccounts( + storage.accounts.filter(isOAuthAccount), + ) + } + async function buildStickyRoutes(modelId: string) { - if (scoped) { - roster = await scoped.refresh() - if (!roster) throw new Error('Claustrum custody changed during routing') - storage = roster.storage - mainAccountId = roster.primary?.accountId - quotaManager.updateStorage(storage) - quotaManager.seedMainFromStorage(storage, mainAccountId) - quotaManager.seedFallbacksFromAccounts( - storage.accounts.filter(isOAuthAccount), - ) - } + await refreshRoutingSnapshot() const mainEntry = quotaManager.getMain(mainAccountId) let mainQuota = mainEntry?.quota if ( @@ -963,27 +917,19 @@ async function executeWithFallback(options: { ) ) { try { - mainQuota = await quotaManager.refreshMain( - mainAccountId, - primaryCredential, - ) + mainQuota = await quotaManager.refreshMain(mainAccountId, '') } catch {} } - const usableFallbacks = await manager.getUsableFallbackAccounts(storage, { + const usableFallbacks = await getUsableFallbackAccounts(storage, { modelId, }) const usableById = new Map( usableFallbacks.map((account) => [account.id, account]), ) const allRoutes: PiStickyRoute[] = [] - if ( - primaryAvailable() && - (scoped || - !isPermanentRefreshError(storage?.refresh?.mainLastRefreshError)) - ) { + if (primaryAvailable()) { allRoutes.push({ id: STICKY_ROUTING_MAIN_ACCOUNT_ID, - access: primaryCredential, quota: mainQuota, order: 0, }) @@ -991,12 +937,7 @@ async function executeWithFallback(options: { for (const [index, configured] of (storage?.accounts ?? []).entries()) { if (configured.enabled === false || !isOAuthAccount(configured)) continue const account = usableById.get(configured.id) ?? configured - if ( - scoped - ? !scopedReady(account) - : !account.access || isPermanentRefreshError(account.lastRefreshError) - ) - continue + if (!nativeReady(account.id)) continue let accountQuota = quotaManager.getFallback(account.id, account)?.quota ?? account.quota if ( @@ -1005,14 +946,13 @@ async function executeWithFallback(options: { try { accountQuota = await quotaManager.refreshFallback( account.id, - scoped ? '' : (account.access ?? ''), + '', account, ) } catch {} } allRoutes.push({ id: account.id, - access: scoped ? '' : (account.access ?? ''), quota: accountQuota, order: index + 1, account, @@ -1024,7 +964,7 @@ async function executeWithFallback(options: { route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? storage?.refresh?.mainLastRefreshError : route.account?.lastRefreshError - if (!scoped && isPermanentRefreshError(refreshError)) return [] + if (isPermanentRefreshError(refreshError)) return [] if ( stickyQuotaSnapshotIsFresh( route.quota, @@ -1090,12 +1030,10 @@ async function executeWithFallback(options: { } async function primaryQuotaRefreshConfirmsExhausted() { - if (!primaryAvailable()) return false + await refreshRoutingSnapshot() + if (!primaryAvailable() || !mainAccountId) return false try { - const quota = await quotaManager.refreshMain( - mainAccountId, - primaryCredential, - ) + const quota = await quotaManager.refreshMain(mainAccountId, '') const entry = quotaManager.getMain(mainAccountId) return Boolean( entry && @@ -1108,12 +1046,10 @@ async function executeWithFallback(options: { } async function primaryQuotaRefreshConfirmsModelScopeExhausted() { + await refreshRoutingSnapshot() if (!primaryAvailable()) return false try { - const quota = await quotaManager.refreshMain( - mainAccountId, - primaryCredential, - ) + const quota = await quotaManager.refreshMain(mainAccountId, '') const entry = quotaManager.getMain(mainAccountId) return Boolean( entry && @@ -1147,101 +1083,55 @@ async function executeWithFallback(options: { async function tryFallbackAccounts( routeOptions: { includeApiRoutes?: boolean; apiOnly?: boolean } = {}, ) { - const usableOAuth = await manager.getUsableFallbackAccounts(storage, { + const usableOAuth = await getUsableFallbackAccounts(storage, { modelId: options.model.id, }) const usableOAuthById = new Map( usableOAuth.map((account) => [account.id, account]), ) - for (const configured of storage?.accounts ?? []) { + for (const configured of storage.accounts) { if (configured.enabled === false) continue - let response: Response | null = null - let account: OAuthAccount | ApiKeyAccount | undefined = isOAuthAccount( - configured, - ) - ? usableOAuthById.get(configured.id) - : configured - if (!account) { - if ( - !isOAuthAccount(configured) || - (scoped - ? !scopedReady(configured) - : !configured.access || - (configured.expires !== undefined && - configured.expires <= Date.now()) || - isPermanentRefreshError(configured.lastRefreshError) || - refreshBackoffActive( - configured.lastRefreshError, - configured.id, - Date.now(), - tokenFingerprint(configured.refresh), - )) - ) - continue - account = configured + let response: Response | undefined + if (isOAuthAccount(configured)) { + if (routeOptions.apiOnly || !nativeReady(configured.id)) continue + const account = usableOAuthById.get(configured.id) ?? configured const quota = - quotaManager.getFallback(configured.id, configured)?.quota ?? - configured.quota - if (quotaSnapshotHasStandardWindows(quota)) continue + quotaManager.getFallback(account.id, account)?.quota ?? account.quota if ( - isKillswitchEnabled(storage) && - !killswitchPassesPolicy( - quota, - storage, - configured.id, - options.model.id, - ) + !usableOAuthById.has(account.id) && + quotaSnapshotHasStandardWindows(quota) ) continue - } else if ( - isOAuthAccount(account) && - isKillswitchEnabled(storage) && - !killswitchPassesPolicy( - quotaManager.getFallback(account.id, account)?.quota ?? account.quota, - storage, - account.id, - options.model.id, - ) - ) { - continue - } - - if (isOAuthAccount(account)) { if ( - routeOptions.apiOnly === true || - (scoped ? !scopedReady(account) : !account.access) + isKillswitchEnabled(storage) && + !killswitchPassesPolicy(quota, storage, account.id, options.model.id) ) continue response = await sendAnthropicRequest({ ...options, - accessToken: scoped ? undefined : account.access, oauthAccountId: account.id, }) - } else if ( - routeOptions.includeApiRoutes === true && - isApiKeyAccount(account) && - account.enabled !== false && - account.apiKey && - isValidApiBaseURL(account.baseURL) - ) { + } else if (routeOptions.includeApiRoutes) { + const account = accountView(configured.id) + if ( + !account?.enabled || + account.type !== 'api' || + !isValidApiBaseURL(account.baseURL) + ) + continue response = await sendAnthropicRequest({ ...options, apiAccount: account, }) } if (!response) continue - const preflight = await firstStreamingError(response) - if (preflight instanceof Response && preflight.ok) { - await manager.markUsed(account) - return preflight - } + if (preflight instanceof Response && preflight.ok) return preflight if ( preflight instanceof Response && !shouldFallbackStatus(preflight.status, storage) - ) { + ) return preflight - } await response.body?.cancel().catch(() => {}) } return null @@ -1250,15 +1140,13 @@ async function executeWithFallback(options: { const routingMode = getRoutingMode(storage) if (routingMode === 'sticky-balanced' && options.streamOptions?.sessionId) { const sessionId = options.streamOptions.sessionId - const router = getPiStickyRouter(options.storagePath) + const router = await getPiStickyRouter(options.storagePath) const initialInputBytes = Math.max( 1, Buffer.byteLength(JSON.stringify(options.context)), ) let routes = await buildStickyRoutes(options.model.id) - const mainPermanentlyUnavailable = scoped - ? !primaryAvailable() - : isPermanentRefreshError(storage?.refresh?.mainLastRefreshError) + const mainPermanentlyUnavailable = !primaryAvailable() const incompleteQuotaPool = (routes.allRoutes.length === 0 && !mainPermanentlyUnavailable) || routes.allRoutes.some( @@ -1289,9 +1177,7 @@ async function executeWithFallback(options: { } if (!resolution) { return createStickyNoRouteResponse({ - mainRefreshError: scoped - ? undefined - : storage?.refresh?.mainLastRefreshError, + mainRefreshError: storage?.refresh?.mainLastRefreshError, fallbackReauthLabels: getFallbackReauthLabels(storage), routeQuotas: routes.allRoutes.flatMap((route) => route.quota ? [route.quota] : [], @@ -1306,19 +1192,14 @@ async function executeWithFallback(options: { const sendRoute = (selected: PiStickyRoute) => sendAnthropicRequest({ ...options, - accessToken: selected.access, oauthAccountId: selected.id, route: `sticky:${selected.id}`, }) const completeRoute = async ( - selected: PiStickyRoute, + _selected: PiStickyRoute, response: Response, - markUsed = true, - ) => { - if (markUsed && selected.account) - await manager.markUsed(selected.account) - return response - } + _markUsed = true, + ) => response const proactiveQuotaDecision = stickyQuotaSnapshotIsFresh( route.quota, storage, @@ -1359,53 +1240,14 @@ async function executeWithFallback(options: { ) } - let response = await sendRoute(route) - let preflight = await firstStreamingError(response) + const response = await sendRoute(route) + const preflight = await firstStreamingError(response) if (preflight instanceof Response && preflight.ok) { return completeRoute(route, preflight) } - let permanentAuthFailure = - preflight instanceof Response && - preflight.status === 401 && - (Boolean(scoped) || route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID) - if ( - preflight instanceof Response && - preflight.status === 401 && - route.account && - storage && - !scoped - ) { - const authRouteId = route.id - try { - const refreshed = await manager.refreshAccount( - route.account, - storage, - { - force: true, - }, - ) - if (refreshed.access) { - route = { ...route, access: refreshed.access, account: refreshed } - } - await preflight.body?.cancel().catch(() => {}) - response = await sendRoute(route) - preflight = await firstStreamingError(response) - if (preflight instanceof Response && preflight.ok) { - return completeRoute(route, preflight) - } - permanentAuthFailure = - preflight instanceof Response && preflight.status === 401 - } catch (error) { - const latest = await loadAccounts(options.storagePath) - const refreshError = latest?.accounts.find( - (account): account is OAuthAccount => - account.id === authRouteId && isOAuthAccount(account), - )?.lastRefreshError - if (!isPermanentRefreshError(refreshError)) throw error - permanentAuthFailure = true - } - } + const permanentAuthFailure = + preflight instanceof Response && preflight.status === 401 let migrate = (preflight instanceof Response && preflight.status === 403) || @@ -1415,12 +1257,8 @@ async function executeWithFallback(options: { try { quota = route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID - ? await quotaManager.refreshMain(mainAccountId, route.access) - : await quotaManager.refreshFallback( - route.id, - route.access, - route.account, - ) + ? await quotaManager.refreshMain(mainAccountId, '') + : await quotaManager.refreshFallback(route.id, '', route.account) } catch { // Retain affinity when the quota probe itself is unavailable. quota = undefined @@ -1555,10 +1393,7 @@ async function executeWithFallback(options: { let mainQuota = quotaManager.getMain(mainAccountId)?.quota if (!mainQuota || quotaManager.isMainStale(options.model.id)) { try { - mainQuota = await quotaManager.refreshMain( - mainAccountId, - primaryCredential, - ) + mainQuota = await quotaManager.refreshMain(mainAccountId, '') } catch {} } if ( @@ -1589,7 +1424,6 @@ async function executeWithFallback(options: { const primary = await sendAnthropicRequest({ ...options, - accessToken: primaryCredential, oauthAccountId: STICKY_ROUTING_MAIN_ACCOUNT_ID, }) const primaryPreflight = await firstStreamingError(primary) @@ -1634,19 +1468,13 @@ export function streamCortexKitAnthropic( stream.push({ type: 'start', partial: output }) try { - const accessToken = options?.apiKey ?? '' + buildPiOAuthMessagesUrl(model.baseUrl) const storagePath = getPiAccountStoragePath() - if ( - !accessToken && - getClaustrumMode(await loadAccounts(storagePath)) !== 'claustrum' - ) - throw new Error('Missing Anthropic OAuth access token') let hostTools: Tool[] = [] const response = await executeWithFallback({ model, context, streamOptions: options, - primaryAccessToken: accessToken, storagePath, effortTransitions, onResolvedTools: (tools) => { diff --git a/packages/pi/src/tests/commands.test.ts b/packages/pi/src/tests/commands.test.ts index c30457341..c7ae04f8e 100644 --- a/packages/pi/src/tests/commands.test.ts +++ b/packages/pi/src/tests/commands.test.ts @@ -1,386 +1,231 @@ -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - test, -} from 'bun:test' -import { createHash } from 'node:crypto' -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { CacheKeepSessionRegistry } from '@cortexkit/anthropic-auth-core' +import { + CacheKeepSessionRegistry, + createNativeMenuExecutor, + type NativeMenuRequest, + resolveNativePoolPaths, + StickySessionRouter, +} from '@cortexkit/anthropic-auth-core' import type { ExtensionAPI, ExtensionCommandContext, } from '@earendil-works/pi-coding-agent' -import { registerCommands } from '../commands' - -const ENV_KEY = 'PI_ANTHROPIC_AUTH_FILE' -const CACHEKEEP_REGISTRY_ENV_KEY = 'PI_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR' -const ROUTING_STATE_ENV_KEY = 'PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE' - -function mockNotify(): { ctx: ExtensionCommandContext; notified: string[] } { - const notified: string[] = [] - const ctx = { - ui: { notify: (msg: string) => notified.push(msg) }, - sessionManager: { getSessionId: () => 'pi-session-1' }, - } as unknown as ExtensionCommandContext - return { ctx, notified } -} - -function mockPi(): { - pi: ExtensionAPI - commands: Map< - string, - { handler: (args: string, ctx: ExtensionCommandContext) => Promise } - > -} { - const commands = new Map< - string, - { handler: (args: string, ctx: ExtensionCommandContext) => Promise } - >() - const pi = { - registerCommand: ( - name: string, - def: { - description?: string - handler: (args: string, ctx: ExtensionCommandContext) => Promise - }, - ) => { - commands.set(name, def) - }, - } as ExtensionAPI - return { pi, commands } -} - -let tempDir: string -let accountPath: string -let statePath: string +import { registerCommands } from '../commands.ts' +import { closePiNativeRuntime, getPiNativeRuntime } from '../native.ts' +import { createPiNativeCommands } from '../native-commands.ts' +import { readNativePiFixture, saveNativePiFixture } from './native-fixture.ts' +let directory: string +let storagePath: string beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'pi-commands-test-')) - accountPath = join(tempDir, 'anthropic-auth.json') - // The core's getAccountStatePath places the state file alongside - // the config when the config ends with the account file name. - statePath = join(tempDir, 'anthropic-auth-state.json') - process.env[ENV_KEY] = accountPath - process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = statePath - process.env[CACHEKEEP_REGISTRY_ENV_KEY] = join(tempDir, 'cachekeep-registry') - process.env[ROUTING_STATE_ENV_KEY] = join(tempDir, 'routing-state.json') -}) - -afterEach(async () => { - delete process.env[ENV_KEY] - delete process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE - delete process.env[CACHEKEEP_REGISTRY_ENV_KEY] - delete process.env[ROUTING_STATE_ENV_KEY] - await rm(tempDir, { recursive: true, force: true }) -}) - -afterAll(() => { - expect(process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE).toBeUndefined() -}) - -describe('claude-account persistence', () => { - test('refuses custody changes without a host controller and preserves Pi config and state', async () => { - const initial = { + directory = await mkdtemp(join(tmpdir(), 'pi-native-commands-')) + storagePath = join(directory, 'anthropic-auth.json') + process.env.PI_ANTHROPIC_AUTH_FILE = storagePath + process.env.PI_CODING_AGENT_DIR = directory + process.env.PI_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( + directory, + 'cachekeep', + ) + process.env.PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE = join( + directory, + 'captured-routing.json', + ) + await saveNativePiFixture( + { version: 1, - claustrum: { accounts: { unrelated: { enabled: true } } }, accounts: [ { - id: 'oauth-work', + id: 'fallback', type: 'oauth', - access: 'access', - refresh: 'refresh', - enabled: true, + refresh: 'fixture-fallback-refresh', + access: 'fixture-fallback-access', + expires: Date.now() + 3_600_000, }, ], - } - await writeFile(accountPath, JSON.stringify(initial), 'utf8') - await writeFile(statePath, JSON.stringify({ version: 1 }), 'utf8') - const beforeConfig = await readFile(accountPath, 'utf8') - const beforeState = await readFile(statePath, 'utf8') - const beforeConfigMtime = (await stat(accountPath)).mtimeMs - const beforeStateMtime = (await stat(statePath)).mtimeMs - const { pi, commands } = mockPi() - registerCommands(pi) - const handler = commands.get('claude-account')?.handler - const { ctx, notified } = mockNotify() - - await handler!('claustrum', ctx) - await handler!('local', ctx) + }, + storagePath, + ) +}) +afterEach(async () => { + closePiNativeRuntime(storagePath) + delete process.env.PI_ANTHROPIC_AUTH_FILE + delete process.env.PI_CODING_AGENT_DIR + delete process.env.PI_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR + delete process.env.PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE + await rm(directory, { recursive: true, force: true }) +}) - expect(notified).toEqual([ - 'Refused: Pi custody controller is unavailable.', - 'Refused: Pi custody controller is unavailable.', - ]) - expect(await readFile(accountPath, 'utf8')).toBe(beforeConfig) - expect(await readFile(statePath, 'utf8')).toBe(beforeState) - expect((await stat(accountPath)).mtimeMs).toBe(beforeConfigMtime) - expect((await stat(statePath)).mtimeMs).toBe(beforeStateMtime) +async function bytes() { + const paths = await resolveNativePoolPaths(storagePath) + return Promise.all( + [paths.config, paths.state, paths.runtime, paths.journal].map((path) => + readFile(path, 'utf8'), + ), + ) +} +function controller() { + const backend = createPiNativeCommands(storagePath) + const executor = createNativeMenuExecutor({ + host: 'pi', + dispatch: backend.dispatch, }) - - test('disable persists to storage', async () => { - await writeFile( - accountPath, - JSON.stringify({ - version: 1, - accounts: [ - { - id: 'a1', - label: 'One', - type: 'oauth', - access: 'tok', - refresh: 'rtok', - enabled: true, - addedAt: 1, - }, - { - id: 'a2', - label: 'Two', - type: 'api', - baseURL: 'https://api.example.com', - authHeader: 'authorization-bearer', - enabled: true, - addedAt: 2, - }, - ], + return { + backend, + execute: (request: NativeMenuRequest) => + executor.execute(request, { + interactive: true, + confirmed: true, + sessionId: 'command-session', }), - 'utf8', - ) - - const { pi, commands } = mockPi() - registerCommands(pi) - - const handler = commands.get('claude-account')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('disable a2', ctx) - - expect(notified[0] ?? '').toInclude('disabled') - expect(notified[0] ?? '').toInclude('Two') + } +} +function registration() { + const commands = new Map< + string, + Parameters[1] + >() + const pi = { + registerCommand: ( + name: string, + definition: Parameters[1], + ) => commands.set(name, definition), + } as unknown as ExtensionAPI + const notified: string[] = [] + const ctx = { + hasUI: false, + ui: { notify: (message: string) => notified.push(message) }, + sessionManager: { getSessionId: () => 'command-session' }, + } as unknown as ExtensionCommandContext + registerCommands(pi, createPiNativeCommands(storagePath)) + return { commands, ctx, notified } +} - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - const account = storage.accounts.find((a: { id: string }) => a.id === 'a2') - expect(account.enabled).toBe(false) +describe('native account command persistence', () => { + test('custody changes give offline guidance and preserve native config and state', async () => { + const before = await bytes() + const result = await controller().execute({ + action: 'custody-mode', + values: { mode: 'claustrum' }, + }) + expect(result.status).toBe('guidance') + expect(await bytes()).toEqual(before) + expect( + (await readNativePiFixture(storagePath)).claustrum?.mode ?? 'local', + ).toBe('local') }) - - test('remove persists to storage', async () => { - await writeFile( - accountPath, - JSON.stringify({ - version: 1, - accounts: [ - { - id: 'a1', - label: 'One', - type: 'oauth', - access: 'tok', - refresh: 'rtok', - enabled: true, - addedAt: 1, - }, - { - id: 'a2', - label: 'Two', - type: 'api', - baseURL: 'https://api.example.com', - authHeader: 'authorization-bearer', - enabled: true, - addedAt: 2, - }, - ], - }), - 'utf8', - ) - - const { pi, commands } = mockPi() - registerCommands(pi) - - const handler = commands.get('claude-account')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('remove a2', ctx) - - expect(notified[0] ?? '').toInclude('removed') - expect(notified[0] ?? '').toInclude('Two') - - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.accounts).toHaveLength(1) - expect(storage.accounts[0].id).toBe('a1') + test('disable persists to native storage without writing legacy files', async () => { + expect( + ( + await controller().execute({ + action: 'disable', + values: { id: 'fallback' }, + }) + ).status, + ).toBe('executed') + expect( + (await readNativePiFixture(storagePath)).accounts.find( + (account) => account.id === 'fallback', + )?.enabled, + ).toBe(false) + await expect(readFile(storagePath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }) }) - - test('enable persists to storage', async () => { - await writeFile( - accountPath, - JSON.stringify({ - version: 1, - accounts: [ - { - id: 'a1', - label: 'One', - type: 'oauth', - access: 'tok', - refresh: 'rtok', - enabled: false, - addedAt: 1, - }, - ], - }), - 'utf8', - ) - - const { pi, commands } = mockPi() - registerCommands(pi) - - const handler = commands.get('claude-account')?.handler - expect(handler).toBeDefined() - - const { ctx } = mockNotify() - await handler!('enable a1', ctx) - - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.accounts[0].enabled).toBe(true) + test('remove persists to native storage', async () => { + expect( + ( + await controller().execute({ + action: 'remove', + values: { id: 'fallback' }, + }) + ).status, + ).toBe('executed') + expect( + (await readNativePiFixture(storagePath)).accounts.find( + (account) => account.id === 'fallback', + ), + ).toBeUndefined() }) - - test('reset-backoff clears and persists main refresh and quota errors', async () => { - await writeFile( - accountPath, - JSON.stringify({ + test('enable persists to native storage', async () => { + const c = controller() + await c.execute({ action: 'disable', values: { id: 'fallback' } }) + await c.execute({ action: 'enable', values: { id: 'fallback' } }) + expect( + (await readNativePiFixture(storagePath)).accounts.find( + (account) => account.id === 'fallback', + )?.enabled, + ).not.toBe(false) + }) + test('reset-backoff clears native main refresh and quota errors with a lineage fence', async () => { + await saveNativePiFixture( + { version: 1, - mainAccountId: 'main-account-id', + accounts: [], refresh: { mainLastRefreshError: { message: 'invalid_grant', - checkedAt: 1, - nextRetryAt: 2, - accountIdentity: 'main-account-id', + checkedAt: Date.now(), permanent: true, }, }, quota: { mainLastQuotaApiError: { message: 'quota unavailable', - checkedAt: 1, - nextRetryAt: 2, - accountIdentity: 'main-account-id', + checkedAt: Date.now(), }, }, - accounts: [], - }), - 'utf8', - ) - const { pi, commands } = mockPi() - registerCommands(pi) - const handler = commands.get('claude-account')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('reset-backoff', ctx) - - expect(notified[0]).toContain( - 'Main OAuth refresh and quota backoff cleared.', + }, + storagePath, ) - const state = JSON.parse(await readFile(statePath, 'utf8')) - expect(state.main.lastRefreshError).toBeUndefined() - expect(state.main.lastQuotaApiError).toBeUndefined() + const before = await readNativePiFixture(storagePath) + expect(before.refresh?.mainLastRefreshError).toBeDefined() + expect(before.quota?.mainLastQuotaApiError).toBeDefined() + const result = await controller().execute({ action: 'reset-backoff' }) + expect(result).toMatchObject({ status: 'executed', ok: true }) + const after = await readNativePiFixture(storagePath) + expect(after.refresh?.mainLastRefreshError).toBeUndefined() + expect(after.quota?.mainLastQuotaApiError).toBeUndefined() }) - - test('status is display-only (no mutation)', async () => { - const original = { - version: 1, - accounts: [ - { - id: 'a1', - label: 'One', - type: 'oauth', - access: 'tok', - refresh: 'rtok', - enabled: true, - addedAt: 1, - }, - ], - } - await writeFile(accountPath, JSON.stringify(original), 'utf8') - - const { pi, commands } = mockPi() - registerCommands(pi) - - const handler = commands.get('claude-account')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('', ctx) - - expect(notified[0] ?? '').toInclude('Claude Accounts') - expect(notified[0] ?? '').toInclude('One') - - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage).toEqual(original) + test('account status is display-only and contains no credential material', async () => { + const before = await bytes() + const text = await controller().backend.readStatus('account', {}) + expect(text).toContain('fallback') + expect(text).not.toContain('fixture-fallback-access') + expect(text).not.toContain('fixture-fallback-refresh') + expect(await bytes()).toEqual(before) }) }) -describe('claude-logging persistence', () => { - test('sets log level and persists', async () => { - await writeFile( - accountPath, - JSON.stringify({ version: 1, accounts: [] }), - 'utf8', +describe('native diagnostics persistence', () => { + test('sets log level and persists in native settings', async () => { + await controller().execute({ + action: 'logging-level', + values: { level: 'debug' }, + }) + expect((await readNativePiFixture(storagePath)).logging?.level).toBe( + 'debug', ) - - const { pi, commands } = mockPi() - registerCommands(pi) - - const handler = commands.get('claude-logging')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('debug', ctx) - - expect(notified[0] ?? '').toInclude('debug') - - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.logging?.level).toBe('debug') }) - - test('status shows current level without mutating', async () => { - await writeFile( - accountPath, - JSON.stringify({ - version: 1, - accounts: [], - logging: { level: 'warn' }, - }), - 'utf8', + test('logging status shows current level without mutating', async () => { + await controller().execute({ + action: 'logging-level', + values: { level: 'warn' }, + }) + const before = await bytes() + expect(await controller().backend.readStatus('logging', {})).toContain( + 'warn', ) - - const { pi, commands } = mockPi() - registerCommands(pi) - - const handler = commands.get('claude-logging')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('', ctx) - - expect(notified[0] ?? '').toInclude('warn') - - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.logging?.level).toBe('warn') + expect(await bytes()).toEqual(before) }) }) -describe('claude-cachekeep status', () => { +describe('native CacheKeep and routing commands', () => { test('lists tracked sessions from all live Pi instances', async () => { - const registryDirectory = process.env[CACHEKEEP_REGISTRY_ENV_KEY] - if (!registryDirectory) - throw new Error('missing cachekeep registry directory') const registry = new CacheKeepSessionRegistry({ - directory: registryDirectory, + directory: join(directory, 'cachekeep'), instanceId: 'other-pi-instance', }) const cacheExpiresAt = Date.now() + 60 * 60_000 @@ -391,204 +236,96 @@ describe('claude-cachekeep status', () => { nextPrewarmAt: cacheExpiresAt - 5 * 60_000, }, ]) - - await writeFile( - accountPath, - JSON.stringify({ - version: 1, - accounts: [], - claudeCache: { enabled: true, mode: 'hybrid' }, - cacheKeep: { enabled: true, startHour: 0, endHour: 23 }, - }), - 'utf8', - ) - const { pi, commands } = mockPi() - registerCommands(pi) - const handler = commands.get('claude-cachekeep')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('', ctx) - - expect(notified[0]).toContain('Tracked sessions: 1') - expect(notified[0]).toContain('Sessions:\n- pi-session-1') + const text = await controller().backend.readStatus('cachekeep', {}) + expect(text).toContain('Tracked sessions: 1') + expect(text).toContain('Sessions:\n- pi-session-1') }) - - test('persists and reports the always schedule', async () => { - const { pi, commands } = mockPi() - registerCommands(pi) - const handler = commands.get('claude-cachekeep')?.handler - const { ctx, notified } = mockNotify() - - await handler!('always', ctx) - - expect(notified[0]).toContain( + test('persists and reports the always schedule in native settings', async () => { + await controller().execute({ action: 'cachekeep-always' }) + expect((await readNativePiFixture(storagePath)).cacheKeep).toMatchObject({ + enabled: true, + always: true, + }) + expect(await controller().backend.readStatus('cachekeep', {})).toContain( 'Schedule: always (while this process is running)', ) - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.cacheKeep).toEqual({ enabled: true, always: true }) }) -}) - -describe('claude-routing persistence', () => { - test('reset clears the current Pi session assignment without changing mode', async () => { - await writeFile( - accountPath, - JSON.stringify({ - version: 1, - accounts: [], - routing: { mode: 'sticky-balanced' }, - }), - 'utf8', - ) - const routingPath = process.env[ROUTING_STATE_ENV_KEY] - if (!routingPath) throw new Error('missing routing state path') - const key = createHash('sha256').update('pi-session-1').digest('hex') - const now = Date.now() - await writeFile( - routingPath, - JSON.stringify({ - version: 1, - updatedAt: now, - assignments: { - [key]: { - accountId: 'main', - family: 'general', - assignedAt: now, - lastSeenAt: now, - initialInputBytes: 1, - quotaCheckedAt: 1, - }, - }, - }), - 'utf8', + test('reset clears only the current session assignment at the journal destination without changing mode', async () => { + const path = await getPiNativeRuntime(storagePath).routingPath() + const router = new StickySessionRouter({ path }) + for (const sessionId of ['command-session', 'other-session']) + await router.resolve({ + sessionId, + family: 'opus', + modelId: 'claude-opus-5-5', + inputBytes: 1, + candidates: [ + { accountId: 'main', order: 0, quota: { kind: 'unknown' } }, + ], + retainAccountIds: new Set(['main']), + storage: await readNativePiFixture(storagePath), + }) + const before = await readNativePiFixture(storagePath) + await controller().execute({ action: 'routing-reset' }) + const cleared = await router.resolve({ + sessionId: 'command-session', + family: 'opus', + modelId: 'claude-opus-5-5', + inputBytes: 1, + candidates: [], + retainAccountIds: new Set(['main']), + storage: before, + }) + expect(cleared).toBeNull() + const other = await router.resolve({ + sessionId: 'other-session', + family: 'opus', + modelId: 'claude-opus-5-5', + inputBytes: 1, + candidates: [], + retainAccountIds: new Set(['main']), + storage: before, + }) + expect(other?.accountId).toBe('main') + expect((await readNativePiFixture(storagePath)).routing).toEqual( + before.routing, ) - - const { pi, commands } = mockPi() - registerCommands(pi) - const handler = commands.get('claude-routing')?.handler - const { ctx, notified } = mockNotify() - await handler!('reset', ctx) - - expect(notified[0]).toContain('Claude Routing Assignment Reset') - const state = JSON.parse(await readFile(routingPath, 'utf8')) - expect(state.assignments).toEqual({}) - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.routing).toEqual({ mode: 'sticky-balanced' }) }) }) -describe('claude-prime — Pi display-only contract', () => { - // The plan + rev-1 / rev-2 require that Pi's `/claude-prime` handler - // is display-only: it must NEVER call `setPrimePersistentEnabled`, - // and on/off/status args must all return the same status text. The - // byte-for-byte config + runtime-state file invariance ensures a - // future edit that wires Pi to the persistent setter would fail this - // test. - - function readConfigAndState() { - return Promise.all([ - readFile(accountPath, 'utf8').then((text) => JSON.parse(text)), - readFile(statePath, 'utf8') - .then((text) => JSON.parse(text)) - .catch(() => null), - ]).then(([config, state]) => ({ config, state })) - } - - test('registers a claude-prime command (sanity)', async () => { - await writeFile( - accountPath, - JSON.stringify({ version: 1, accounts: [] }), - 'utf8', - ) - const { pi, commands } = mockPi() - registerCommands(pi) - expect(commands.get('claude-prime')).toBeDefined() +describe('single /claude menu and Pi Prime status', () => { + test('registers only claude, not a legacy claude-prime alias', () => { + expect([...registration().commands.keys()]).toEqual(['claude']) }) - - test('status arg notifies the current status; config + state bytes are unchanged', async () => { - const initial = { - version: 1, - accounts: [], - prime: { enabled: true }, - } - await writeFile(accountPath, JSON.stringify(initial), 'utf8') - const { pi, commands } = mockPi() - registerCommands(pi) - const handler = commands.get('claude-prime')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('status', ctx) - - expect(notified[0] ?? '').toContain('## Claude Prime Status') - const { config, state } = await readConfigAndState() - expect(config).toEqual(initial) - // state file either unchanged (no runtime state) or non-existent. - expect(state === null || state.version === 1).toBe(true) + test('Prime status leaves native config and state bytes unchanged', async () => { + const before = await bytes() + expect(await controller().backend.readStatus('prime', {})).toContain( + 'Prime', + ) + expect(await bytes()).toEqual(before) }) - - test('on arg in Pi is display-only — never toggles the persistent setting', async () => { - const initial = { - version: 1, - accounts: [], - prime: { enabled: false }, - } - await writeFile(accountPath, JSON.stringify(initial), 'utf8') - const { pi, commands } = mockPi() - registerCommands(pi) - const handler = commands.get('claude-prime')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('on', ctx) - - // The handler MUST notify, but the persistent state MUST stay off. - expect(notified.length).toBeGreaterThan(0) - const { config, state } = await readConfigAndState() - expect(config.prime?.enabled).toBe(false) - expect(state === null || state.prime?.enabled !== true).toBe(true) + test('Prime on is unsupported in Pi and never toggles the persistent setting', async () => { + const before = await bytes() + expect(await controller().execute({ action: 'prime-on' })).toMatchObject({ + status: 'refused', + code: 'unsupported-action', + }) + expect(await bytes()).toEqual(before) }) - - test('off arg in Pi is display-only — never toggles the persistent setting', async () => { - const initial = { - version: 1, - accounts: [], - prime: { enabled: true }, - } - await writeFile(accountPath, JSON.stringify(initial), 'utf8') - const { pi, commands } = mockPi() - registerCommands(pi) - const handler = commands.get('claude-prime')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('off', ctx) - - expect(notified.length).toBeGreaterThan(0) - const { config, state } = await readConfigAndState() - expect(config.prime?.enabled).toBe(true) - expect(state === null || state.prime?.enabled !== false).toBe(true) + test('Prime off is unsupported in Pi and never toggles the persistent setting', async () => { + const before = await bytes() + expect(await controller().execute({ action: 'prime-off' })).toMatchObject({ + status: 'refused', + code: 'unsupported-action', + }) + expect(await bytes()).toEqual(before) }) - - test('empty arg in Pi shows the status and does not mutate storage', async () => { - const initial = { - version: 1, - accounts: [], - prime: { enabled: true }, - } - await writeFile(accountPath, JSON.stringify(initial), 'utf8') - const { pi, commands } = mockPi() - registerCommands(pi) - const handler = commands.get('claude-prime')?.handler - expect(handler).toBeDefined() - - const { ctx, notified } = mockNotify() - await handler!('', ctx) - - expect(notified[0] ?? '').toContain('## Claude Prime Status') - const { config, state } = await readConfigAndState() - expect(config).toEqual(initial) - expect(state === null || state.version === 1).toBe(true) + test('empty headless claude invocation shows status without mutating native storage', async () => { + const before = await bytes() + const { commands, ctx, notified } = registration() + await commands.get('claude')?.handler('', ctx) + expect(notified.join('\n')).toContain('Prime') + expect(notified.join('\n')).not.toContain('fixture-fallback-access') + expect(await bytes()).toEqual(before) }) }) diff --git a/packages/pi/src/tests/custody.test.ts b/packages/pi/src/tests/custody.test.ts index acc1e80ec..48d5b96fd 100644 --- a/packages/pi/src/tests/custody.test.ts +++ b/packages/pi/src/tests/custody.test.ts @@ -3,7 +3,6 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { - type ClaustrumScopedClient, getClaustrumMode, getHostClaustrumEnrollmentPaths, loadAccounts, @@ -30,69 +29,26 @@ async function fixture() { JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), { mode: 0o600 }, ) - let connections = 0, - gets = 0, - reconfigurations = 0 - const client: ClaustrumScopedClient = { - listScoped: async () => ({ - view: 'v', - rows: [ - { - id: 'oauth:anthropic', - accountId: 'provider-account', - credentialType: 'oauth', - categories: ['anthropic-native'], - serves: ['anthropic'], - providerIds: [], - refreshAdapter: 'anthropic', - state: 'active', - operations: ['read'], - recordVersion: 1, - createdAtMs: null, - }, - ], - }), - getScoped: async () => { - gets++ - return { - material: 'vault-test-access', - credentialId: 'oauth:anthropic', - accountId: 'provider-account', - recordVersion: 1, - expiresAtMs: Date.now() + 600_000, - } - }, - reportAuthFailureScoped: async () => {}, - close: () => {}, - } - const commands = createPiCustodyCommands({ - storagePath, - connect: async () => { - connections++ - return client - }, - reconfigure: async () => { - reconfigurations++ - }, - }) return { storagePath, tokenPath, authPath: join(dir, 'auth.json'), - commands, - client, - counts: () => ({ connections, gets, reconfigurations }), + commands: createPiCustodyCommands(), } } -test('a custody command verifies existing grants and credentials before changing mode', async () => { +test('custody mode commands give offline guidance without granting or changing authority', async () => { const f = await fixture() - await f.commands.transition('claustrum') - expect(getClaustrumMode(await loadAccounts(f.storagePath))).toBe('claustrum') - expect(f.counts()).toEqual({ connections: 1, gets: 1, reconfigurations: 1 }) - await f.commands.transition('local') + const before = await readFile(f.storagePath, 'utf8') + const tokenBefore = await readFile(f.tokenPath, 'utf8') + expect((await f.commands.transition('claustrum')).text).toContain( + 'offline setup', + ) expect(getClaustrumMode(await loadAccounts(f.storagePath))).toBe('local') - expect(f.counts()).toEqual({ connections: 1, gets: 1, reconfigurations: 2 }) + expect((await f.commands.transition('local')).text).toContain('offline setup') + expect(getClaustrumMode(await loadAccounts(f.storagePath))).toBe('local') + expect(await readFile(f.storagePath, 'utf8')).toBe(before) + expect(await readFile(f.tokenPath, 'utf8')).toBe(tokenBefore) }) test('local OAuth requires explicit setup consent; a slash command never deletes it', async () => { @@ -110,21 +66,20 @@ test('local OAuth requires explicit setup consent; a slash command never deletes expect((await f.commands.transition('claustrum')).text).toContain('Refused:') expect(await readFile(f.authPath, 'utf8')).toBe(auth) expect(await readFile(f.storagePath, 'utf8')).toBe(before) - expect(f.counts()).toEqual({ connections: 0, gets: 0, reconfigurations: 0 }) }) -test('failed preflight does not commit mode or reconfigure the provider', async () => { +test('offline custody guidance does not need a live vault preflight or reconfigure the provider', async () => { const f = await fixture() - f.client.getScoped = async () => ({ - material: 'vault-test-access', - credentialId: 'oauth:anthropic', - accountId: 'wrong-account', - recordVersion: 1, - expiresAtMs: Date.now() + 600_000, - }) - await expect(f.commands.transition('claustrum')).rejects.toThrow('identity') + await rm(f.tokenPath) + const before = await readFile(f.storagePath, 'utf8') + expect((await f.commands.transition('claustrum')).text).toContain( + 'offline setup', + ) expect(getClaustrumMode(await loadAccounts(f.storagePath))).toBe('local') - expect(f.counts().reconfigurations).toBe(0) + expect(await readFile(f.storagePath, 'utf8')).toBe(before) + await expect(readFile(f.tokenPath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }) }) test('terminal enrollment reset is local, locked and independent of daemon availability', async () => { @@ -143,5 +98,4 @@ test('terminal enrollment reset is local, locked and independent of daemon avail ) expect((await f.commands.reset()).text).toContain('cleared') expect(await f.commands.status()).toEqual({ state: 'idle' }) - expect(f.counts()).toEqual({ connections: 0, gets: 0, reconfigurations: 0 }) }) diff --git a/packages/pi/src/tests/haiku55.test.ts b/packages/pi/src/tests/haiku55.test.ts index 208b27d3c..6cc52554d 100644 --- a/packages/pi/src/tests/haiku55.test.ts +++ b/packages/pi/src/tests/haiku55.test.ts @@ -7,7 +7,6 @@ import { CLAUDE_HAIKU_5_5_MODEL_ID, CLAUDE_HAIKU_5_5_PRICING, hasThinkingBindingControls, - saveAccounts, THINKING_BINDING_CONTROLS_BETA, } from '@cortexkit/anthropic-auth-core' import { @@ -20,6 +19,7 @@ import { import { buildAnthropicRequest } from '../convert.ts' import { getPiAccountStoragePath } from '../paths.ts' import { streamCortexKitAnthropic } from '../stream.ts' +import { saveNativePiFixture } from './native-fixture.ts' import { trackPiTestBody } from './setup.ts' const context: Context = { @@ -86,7 +86,7 @@ const pricedModel: Model<'cortexkit-anthropic-messages'> = { name: 'Claude Haiku 5.5', api: 'cortexkit-anthropic-messages', provider: 'anthropic', - baseUrl: 'https://example.invalid', + baseUrl: 'https://api.anthropic.com', reasoning: true, input: ['text', 'image'], contextWindow: CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, @@ -243,7 +243,7 @@ test('Pi Haiku first turns, account-default replay and API-key replay receive no test('Pi Haiku physical OAuth dispatch carries its prefix control and beta together', () => trackPiTestBody( (async () => { - await saveAccounts( + await saveNativePiFixture( { version: 1, main: { type: 'opencode', provider: 'anthropic' }, diff --git a/packages/pi/src/tests/index.test.ts b/packages/pi/src/tests/index.test.ts index 2491d243c..9f9aa616c 100644 --- a/packages/pi/src/tests/index.test.ts +++ b/packages/pi/src/tests/index.test.ts @@ -1,11 +1,13 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { saveAccounts } from '@cortexkit/anthropic-auth-core' +import type { Provider } from '@earendil-works/pi-ai' import type { ExtensionAPI } from '@earendil-works/pi-coding-agent' - import cortexKitPiAnthropicAuth from '../index' +import { closePiNativeRuntime } from '../native.ts' +import { getPiAccountStoragePath } from '../paths.ts' +import { saveNativePiFixture } from './native-fixture.ts' let tempDir: string | undefined const originalFetch = globalThis.fetch @@ -26,7 +28,15 @@ const fableModel = { } const messagesUrl = `${fableModel.baseUrl}/v1/messages` +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'pi-native-index-')) + const storagePath = join(tempDir, 'anthropic-auth.json') + process.env.PI_ANTHROPIC_AUTH_FILE = storagePath + await saveNativePiFixture({ version: 1, accounts: [] }, storagePath) +}) + afterEach(async () => { + closePiNativeRuntime(getPiAccountStoragePath()) globalThis.fetch = originalFetch delete process.env.PI_ANTHROPIC_AUTH_FILE if (tempDir) await rm(tempDir, { recursive: true, force: true }) @@ -46,13 +56,20 @@ function mockPi() { const pi = { registerCommand: () => {}, registerProvider: ( - name: string, - config: { + name: string | Provider, + config?: { models?: Array> streamSimple?: (...args: any[]) => unknown }, ) => { - providers.set(name, config) + if (typeof name === 'string') { + if (config) providers.set(name, config) + } else { + providers.set(name.id, { + models: name.getModels().map((model) => ({ ...model })), + streamSimple: name.streamSimple, + }) + } }, on: (name: string, handler: (...args: any[]) => unknown) => { events.set(name, handler) @@ -218,10 +235,10 @@ describe('cortexKitPiAnthropicAuth provider registration', () => { // getSessionId/getBranch are assumed here — the accessors both hosts expose. describe('cortexKitPiAnthropicAuth turn_start effort history', () => { test('carries transitions from a getBranch-only host into the request', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'pi-turn-start-effort-')) + if (!tempDir) throw new Error('Native index fixture is missing') const storagePath = join(tempDir, 'anthropic-auth.json') process.env.PI_ANTHROPIC_AUTH_FILE = storagePath - await saveAccounts( + await saveNativePiFixture( { version: 1, main: { type: 'opencode', provider: 'anthropic' }, diff --git a/packages/pi/src/tests/native-fixture.ts b/packages/pi/src/tests/native-fixture.ts new file mode 100644 index 000000000..1acc3f7ce --- /dev/null +++ b/packages/pi/src/tests/native-fixture.ts @@ -0,0 +1,267 @@ +import { createHash } from 'node:crypto' +import { dirname, join } from 'node:path' +import { + type AccountStorage, + createNativeAccountRuntime, + createNativePoolStore, + getClaudeCodeIdentityForVerifiedAccount, + type NativeAccountMetadataPatch, + type NativeAccountRuntime, + type NativePoolPaths, + type ProviderAccountUuid, + readNativeMigrationJournal, + resolveNativePoolPaths, + runNativeMigration, +} from '@cortexkit/anthropic-auth-core' +import { nativeQuotaCodec } from '../../../core/src/native-quota-codec.ts' +import { updateNativeRuntime } from '../../../core/src/native-runtime.ts' +import { getPiAccountStoragePath } from '../paths.ts' + +export function fixtureAccountIdentity(id: string): ProviderAccountUuid { + const digest = createHash('sha256') + .update(`pi-native-fixture:${id}`) + .digest('hex') + return `${digest.slice(0, 8)}-${digest.slice(8, 12)}-${digest.slice(12, 16)}-${digest.slice(16, 20)}-${digest.slice(20, 32)}` as ProviderAccountUuid +} + +export async function initializeNativePiFixture( + paths: NativePoolPaths, +): Promise { + if (await readNativeMigrationJournal(paths)) return + // Run the real offline migration on this empty disposable installation + // before adding accounts; do not fabricate an authorized journal. + let processFenceChecks = 0 + await runNativeMigration({ + paths, + host: 'pi', + hostAuthPath: join(dirname(paths.config), 'auth.json'), + routingSourcePath: join(dirname(paths.config), 'legacy-routing.json'), + routingDestinationPath: + process.env.PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE ?? + join(dirname(paths.config), 'native-routing.json'), + env: {}, + removePiAnthropicAuth: false, + // These unit fixtures never start a host process. Count each stopped-host + // check so omitting the check fails the fixture. + processFence: async () => { + processFenceChecks++ + }, + }) + if (processFenceChecks === 0) + throw new Error('Fixture migration did not check its process fence') +} + +export async function saveNativePiFixture( + storage: AccountStorage, + storagePath = getPiAccountStoragePath(), + options: { primary?: boolean; mainAccess?: string } = {}, +): Promise { + process.env.PI_CODING_AGENT_DIR = dirname(storagePath) + // Core reads OPENCODE_ANTHROPIC_AUTH_STATE_FILE even in Pi. Bind it to this fixture’s isolated state file instead of inheriting another suite’s path. + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = join( + dirname(storagePath), + 'anthropic-auth-state.json', + ) + const paths = await resolveNativePoolPaths(storagePath) + await initializeNativePiFixture(paths) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + await store.initialize() + const identityByToken = new Map() + const runtime = createNativeAccountRuntime({ + paths, + host: 'pi', + local: { + resolveIdentity: async (token, _model, routeId) => { + const uuid = identityByToken.get(token) + if (!uuid) throw new Error('Fixture identity lookup has no token') + return getClaudeCodeIdentityForVerifiedAccount(routeId ?? 'main', uuid) + }, + }, + }) + const publish = async (id: string, metadata: NativeAccountMetadataPatch) => { + const admitted = await runtime.authorizeLocal(id) + if (admitted.status !== 'usable') + throw new Error( + `Fixture credential was not admitted: ${admitted.status}${admitted.status === 'refused' ? ` (${admitted.reason})` : admitted.status === 'failed' ? ` (${admitted.failure.kind}, ${admitted.failure.classification}, reconciliation=${admitted.reconciliationFailed})` : ''}`, + ) + const accountIdentity = admitted.binding.identity + if (!accountIdentity) + throw new Error('Fixture admission has no account identity') + const patch: NativeAccountMetadataPatch = { + ...(metadata.quota !== undefined && { + quota: { ...metadata.quota, accountIdentity }, + }), + ...(metadata.profile !== undefined && { profile: metadata.profile }), + ...(metadata.prime !== undefined && { prime: metadata.prime }), + ...(metadata.lastQuotaRefreshError !== undefined && { + lastQuotaRefreshError: metadata.lastQuotaRefreshError, + }), + ...(metadata.quotaErrorClearedAt !== undefined && { + quotaErrorClearedAt: metadata.quotaErrorClearedAt, + }), + ...(metadata.quotaErrorGeneration !== undefined && { + quotaErrorGeneration: metadata.quotaErrorGeneration, + }), + } + // Publish quota and errors for the account and credential version returned by the identity lookup. + await runtime.publishLocal(admitted.subject, patch) + } + try { + const { + version: _version, + accounts, + main, + quota, + refresh, + prime, + relay, + ...settings + } = storage + await store.updateSettings((current) => ({ + ...current, + ...settings, + ...(quota && { + quota: { + enabled: quota.enabled, + checkIntervalMinutes: quota.checkIntervalMinutes, + refreshEveryNRequests: quota.refreshEveryNRequests, + minimumRemaining: quota.minimumRemaining, + failClosedOnUnknownQuota: quota.failClosedOnUnknownQuota, + }, + }), + ...(refresh && { + refresh: { + enabled: refresh.enabled, + intervalMinutes: refresh.intervalMinutes, + refreshBeforeExpiryMinutes: refresh.refreshBeforeExpiryMinutes, + }, + }), + ...(prime && { prime: { enabled: prime.enabled } }), + })) + if (relay) await runtime.updateRelay(relay) + if (options.primary !== false) { + const access = options.mainAccess ?? 'main-access' + identityByToken.set(access, fixtureAccountIdentity('main')) + await runtime.loginOAuth({ + routeId: 'main', + accountIdentity: fixtureAccountIdentity('main'), + replace: (await runtime.read()).accounts.some( + (account) => account.id === 'main', + ), + credential: { + access, + refresh: 'synthetic-native-main-refresh', + expires: Date.now() + 3_600_000, + }, + }) + await publish('main', { + quota: quota?.mainQuota, + profile: main?.profile, + prime: prime?.main, + ...(quota?.mainLastQuotaApiError && { + lastQuotaRefreshError: { + ...quota.mainLastQuotaApiError, + accountIdentity: fixtureAccountIdentity('main'), + }, + }), + quotaErrorClearedAt: quota?.mainQuotaErrorClearedAt, + quotaErrorGeneration: quota?.mainQuotaErrorGeneration, + }) + if (refresh?.mainLastRefreshError) { + const refreshError = refresh.mainLastRefreshError + const subject = await runtime.captureLocalSubject('main') + await updateNativeRuntime(paths.runtime, paths.storageId, (state) => ({ + ...state, + accounts: { + ...state.accounts, + [subject.binding.rowId]: { + ...state.accounts[subject.binding.rowId], + binding: subject.binding, + lastRefreshError: { + ...refreshError, + credentialFingerprint: subject.credentialFingerprint, + }, + }, + }, + })) + } + } + for (const account of accounts) { + if (account.type === 'api') { + await runtime.addApi({ + routeId: account.id, + apiKey: account.apiKey ?? 'synthetic-api-key', + baseURL: account.baseURL, + authHeader: account.authHeader, + label: account.label, + replace: (await runtime.read()).accounts.some( + (current) => current.id === account.id, + ), + }) + } else { + const access = account.access ?? `synthetic-${account.id}-access` + identityByToken.set(access, fixtureAccountIdentity(account.id)) + await runtime.loginOAuth({ + routeId: account.id, + accountIdentity: fixtureAccountIdentity(account.id), + replace: (await runtime.read()).accounts.some( + (current) => current.id === account.id, + ), + label: account.label, + credential: { + access, + refresh: account.refresh, + expires: account.expires ?? Date.now() + 3_600_000, + }, + }) + await publish(account.id, { + quota: account.quota, + profile: account.profile, + prime: account.prime, + }) + } + if (account.enabled === false) await runtime.setEnabled(account.id, false) + } + } finally { + runtime.close() + } +} + +export async function readNativePiFixture( + storagePath = getPiAccountStoragePath(), +): Promise { + const runtime: NativeAccountRuntime = createNativeAccountRuntime({ + paths: await resolveNativePoolPaths(storagePath), + host: 'pi', + }) + try { + return (await runtime.read()).policyStorage + } finally { + runtime.close() + } +} + +/** Change only the fixture’s local or Claustrum mode; the live menu does not offer this operation. */ +export async function setNativePiFixtureMode( + storagePath: string, + mode: 'local' | 'claustrum', + primary?: { routeId: string; credentialId: string; accountIdentity: string }, +): Promise { + const store = createNativePoolStore({ + paths: await resolveNativePoolPaths(storagePath), + quota: nativeQuotaCodec, + }) + await store.updateSettings((settings) => ({ + ...settings, + ...(primary && { mainAccountId: primary.routeId }), + claustrum: { + mode, + ...(primary && { + primaryAccount: { + credentialId: primary.credentialId, + accountId: primary.accountIdentity, + }, + }), + }, + })) +} diff --git a/packages/pi/src/tests/native-host-auth.test.ts b/packages/pi/src/tests/native-host-auth.test.ts new file mode 100644 index 000000000..cc0f2e8dc --- /dev/null +++ b/packages/pi/src/tests/native-host-auth.test.ts @@ -0,0 +1,48 @@ +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 { requirePiNativeHostAuth } from '../paths.ts' + +let directory: string | undefined +const priorAgentDirectory = process.env.PI_CODING_AGENT_DIR +afterEach(async () => { + if (priorAgentDirectory === undefined) delete process.env.PI_CODING_AGENT_DIR + else process.env.PI_CODING_AGENT_DIR = priorAgentDirectory + if (directory) await rm(directory, { recursive: true, force: true }) + directory = undefined +}) + +async function fixture(entry?: unknown) { + directory = await mkdtemp(join(tmpdir(), 'pi-native-host-auth-')) + process.env.PI_CODING_AGENT_DIR = directory + const path = join(directory, 'auth.json') + if (entry !== undefined) + await writeFile(path, JSON.stringify({ anthropic: entry }), { mode: 0o600 }) + return path +} + +test('native Pi admission refuses leftover host OAuth without changing its file', async () => { + const path = await fixture({ + type: 'oauth', + access: 'synthetic-host-access', + refresh: 'synthetic-host-refresh', + expires: 1, + }) + const before = await readFile(path, 'utf8') + await expect(requirePiNativeHostAuth()).rejects.toThrow('offline setup') + expect(await readFile(path, 'utf8')).toBe(before) +}) + +test('native Pi admission permits an absent host auth slot without creating it', async () => { + const path = await fixture() + await requirePiNativeHostAuth() + await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +test('native Pi admission refuses malformed host metadata with a redacted error', async () => { + await fixture({ type: 'unknown', secret: 'synthetic-secret' }) + await expect(requirePiNativeHostAuth()).rejects.toThrow( + 'Cannot safely inspect Pi authentication metadata', + ) +}) diff --git a/packages/pi/src/tests/native-serving.test.ts b/packages/pi/src/tests/native-serving.test.ts new file mode 100644 index 000000000..208165388 --- /dev/null +++ b/packages/pi/src/tests/native-serving.test.ts @@ -0,0 +1,349 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createNativeAccountRuntime, + getClaudeCodeIdentityForVerifiedAccount, + resolveNativePoolPaths, + runNativeMigration, +} from '@cortexkit/anthropic-auth-core' +import type { Api, Context, Model } from '@earendil-works/pi-ai' +import { closePiNativeRuntime } from '../native.ts' +import { streamCortexKitAnthropic } from '../stream.ts' +import { + fixtureAccountIdentity, + saveNativePiFixture, +} from './native-fixture.ts' +import { trackPiTestBody } from './setup.ts' + +const model: Model = { + id: 'claude-fable-5-1', + name: 'Fable', + provider: 'anthropic', + api: 'cortexkit-anthropic-messages', + baseUrl: 'https://api.anthropic.com', + reasoning: true, + input: ['text'], + cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 }, + contextWindow: 1_000_000, + maxTokens: 128_000, +} +const context: Context = { + messages: [{ role: 'user', content: 'hello', timestamp: 0 }], + tools: [], +} +const success = () => + new Response( + 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":1,"output_tokens":0}}}\n\nevent: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n', + ) +const originalFetch = globalThis.fetch +let directory: string +let storagePath: string +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'pi-native-serving-')) + storagePath = join(directory, 'anthropic-auth.json') + process.env.PI_CODING_AGENT_DIR = directory + process.env.PI_ANTHROPIC_AUTH_FILE = storagePath + await saveNativePiFixture( + { + version: 1, + accounts: [ + { + type: 'api', + id: 'paid', + apiKey: 'synthetic-native-paid-key', + baseURL: 'https://paid.invalid', + }, + ], + }, + storagePath, + { mainAccess: 'synthetic-native-main-access' }, + ) +}) +afterEach(async () => { + globalThis.fetch = originalFetch + closePiNativeRuntime(storagePath) + delete process.env.PI_CODING_AGENT_DIR + delete process.env.PI_ANTHROPIC_AUTH_FILE + await rm(directory, { recursive: true, force: true }) +}) + +test('native Pi serving ignores host and legacy OAuth material after committed migration', () => + trackPiTestBody( + (async () => { + await writeFile( + storagePath, + JSON.stringify({ + version: 1, + claustrum: { mode: 'claustrum' }, + accounts: [ + { + type: 'oauth', + id: 'legacy-rescue', + access: 'synthetic-legacy-access', + refresh: 'synthetic-legacy-refresh', + expires: Date.now() + 3_600_000, + }, + ], + }), + { mode: 0o600 }, + ) + const sent: string[] = [] + globalThis.fetch = Object.assign( + async ( + _input: Parameters[0], + init?: Parameters[1], + ) => { + sent.push(new Headers(init?.headers).get('authorization') ?? '') + return success() + }, + { preconnect: originalFetch.preconnect }, + ) + expect( + ( + await streamCortexKitAnthropic(model, context, { + apiKey: 'synthetic-host-access', + }).result() + ).stopReason, + ).toBe('stop') + expect(sent).toEqual(['Bearer synthetic-native-main-access']) + expect(await readFile(storagePath, 'utf8')).toContain( + 'synthetic-legacy-access', + ) + })(), + )) + +test.each(['missing', 'building', 'malformed'] as const)( + 'native Pi authority %s refuses before any physical dispatch and cannot use a paid key', + (authority) => + trackPiTestBody( + (async () => { + const paths = await resolveNativePoolPaths(storagePath) + if (authority === 'missing') await rm(paths.journal) + else if (authority === 'malformed') + await writeFile(paths.journal, '{broken', { mode: 0o600 }) + else { + const journal = JSON.parse( + await readFile(paths.journal, 'utf8'), + ) as Record + await writeFile( + paths.journal, + JSON.stringify({ + ...journal, + phase: 'building', + expectedHostAuth: 'unprepared', + expectedRouting: 'unprepared', + }), + { mode: 0o600 }, + ) + } + let sends = 0 + globalThis.fetch = Object.assign( + async () => { + sends++ + return success() + }, + { preconnect: originalFetch.preconnect }, + ) + expect( + ( + await streamCortexKitAnthropic(model, context, { + apiKey: 'synthetic-host-access', + }).result() + ).stopReason, + ).toBe('error') + expect(sends).toBe(0) + })(), + ), +) + +test('native Pi serving refuses leftover host OAuth without deleting it or dispatching', () => + trackPiTestBody( + (async () => { + const authPath = join(directory, 'auth.json') + const auth = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'synthetic-host-access', + refresh: 'synthetic-host-refresh', + expires: Date.now() + 3_600_000, + }, + }) + await writeFile(authPath, auth, { mode: 0o600 }) + let sends = 0 + globalThis.fetch = Object.assign( + async () => { + sends++ + return success() + }, + { preconnect: originalFetch.preconnect }, + ) + const result = await streamCortexKitAnthropic(model, context, { + apiKey: 'synthetic-host-access', + }).result() + expect(result.stopReason).toBe('error') + expect(result.errorMessage).toContain('offline setup') + expect(sends).toBe(0) + expect(await readFile(authPath, 'utf8')).toBe(auth) + })(), + )) + +test.each([ + { name: 'general exhaustion', status: 429, utilization: 100, paid: true }, + { + name: 'model-scope exhaustion only', + status: 429, + utilization: 10, + paid: false, + }, + { + name: 'authentication failure', + status: 401, + utilization: 100, + paid: false, + }, + { name: 'server failure', status: 500, utilization: 100, paid: false }, +])( + 'native Pi paid fallback requires confirmed general OAuth exhaustion: $name', + (scenario) => + trackPiTestBody( + (async () => { + const modelRequests: string[] = [] + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const url = String(input) + if (url.includes('/api/oauth/usage')) + return new Response( + JSON.stringify({ + five_hour: { utilization: scenario.utilization }, + seven_day: { utilization: scenario.utilization }, + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 100, + scope: { model: { display_name: 'Fable' } }, + }, + ], + }), + ) + if (!url.includes('/v1/messages')) + return new Response('{}', { status: 503 }) + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + modelRequests.push(authorization) + return url.startsWith('https://paid.invalid') + ? success() + : new Response('primary refused', { status: scenario.status }) + }, + { preconnect: originalFetch.preconnect }, + ) + const result = await streamCortexKitAnthropic(model, context, { + apiKey: 'synthetic-host-access', + }).result() + expect(result.stopReason).toBe(scenario.paid ? 'stop' : 'error') + expect(modelRequests).toEqual( + scenario.paid + ? [ + 'Bearer synthetic-native-main-access', + 'Bearer synthetic-native-paid-key', + ] + : ['Bearer synthetic-native-main-access'], + ) + })(), + ), +) + +test('offline controller imports synthetic Pi host OAuth with consent before native serving', () => + trackPiTestBody( + (async () => { + const root = join(directory, 'host-import') + await mkdir(root, { recursive: true, mode: 0o700 }) + storagePath = join(root, 'anthropic-auth.json') + process.env.PI_CODING_AGENT_DIR = root + process.env.PI_ANTHROPIC_AUTH_FILE = storagePath + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = join( + root, + 'anthropic-auth-state.json', + ) + const paths = await resolveNativePoolPaths(storagePath) + const hostAuthPath = join(root, 'auth.json') + await writeFile( + hostAuthPath, + JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'synthetic-imported-host-access', + refresh: 'synthetic-imported-host-refresh', + expires: Date.now() + 3_600_000, + }, + other: { type: 'api_key', key: 'synthetic-other-provider-key' }, + }), + { mode: 0o600 }, + ) + let fences = 0 + const journal = await runNativeMigration({ + paths, + host: 'pi', + hostAuthPath, + env: {}, + removePiAnthropicAuth: true, + routingSourcePath: join(root, 'legacy-routing.json'), + routingDestinationPath: join(root, 'native-routing.json'), + processFence: async () => { + fences++ + }, + }) + expect(journal.version).toBe(3) + expect(journal.phase).toBe('retired') + expect(fences).toBeGreaterThan(0) + expect(JSON.parse(await readFile(hostAuthPath, 'utf8'))).toEqual({ + other: { type: 'api_key', key: 'synthetic-other-provider-key' }, + }) + const validator = createNativeAccountRuntime({ + paths, + host: 'pi', + local: { + refreshToken: async () => ({ + access: 'synthetic-imported-native-access', + refresh: 'synthetic-imported-native-refresh', + expires: Date.now() + 3_600_000, + expiresIn: 3600, + }), + resolveIdentity: async () => + getClaudeCodeIdentityForVerifiedAccount( + 'main', + fixtureAccountIdentity('imported-main'), + ), + }, + }) + try { + expect((await validator.authorizeLocal('main')).status).toBe('usable') + } finally { + validator.close() + } + const sent: string[] = [] + globalThis.fetch = Object.assign( + async ( + _input: Parameters[0], + init?: Parameters[1], + ) => { + sent.push(new Headers(init?.headers).get('authorization') ?? '') + return success() + }, + { preconnect: originalFetch.preconnect }, + ) + expect( + ( + await streamCortexKitAnthropic(model, context, { + apiKey: 'synthetic-imported-host-access', + }).result() + ).stopReason, + ).toBe('stop') + expect(sent).toEqual(['Bearer synthetic-imported-native-access']) + })(), + )) diff --git a/packages/pi/src/tests/request-history-stream.test.ts b/packages/pi/src/tests/request-history-stream.test.ts index 5886af1f3..63cec175c 100644 --- a/packages/pi/src/tests/request-history-stream.test.ts +++ b/packages/pi/src/tests/request-history-stream.test.ts @@ -2,10 +2,7 @@ import { afterEach, expect, mock, test } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { - saveAccounts, - TRAILING_ASSISTANT_HISTORY_MESSAGE, -} from '@cortexkit/anthropic-auth-core' +import { TRAILING_ASSISTANT_HISTORY_MESSAGE } from '@cortexkit/anthropic-auth-core' import type { Api, AssistantMessage, @@ -13,6 +10,7 @@ import type { Model, } from '@earendil-works/pi-ai' import { streamCortexKitAnthropic } from '../stream.ts' +import { saveNativePiFixture } from './native-fixture.ts' const originalFetch = globalThis.fetch const originalPath = process.env.PI_ANTHROPIC_AUTH_FILE @@ -64,7 +62,7 @@ for (const apiKey of ['sk-ant-oat-fixture-history', 'fixture-api-key']) { directory = await mkdtemp(join(tmpdir(), 'pi-history-boundary-')) const storagePath = join(directory, 'anthropic-auth.json') process.env.PI_ANTHROPIC_AUTH_FILE = storagePath - await saveAccounts( + await saveNativePiFixture( { version: 1, main: { type: 'opencode', provider: 'anthropic' }, diff --git a/packages/pi/src/tests/scoped-stream.test.ts b/packages/pi/src/tests/scoped-stream.test.ts index e1271dbca..f779666e0 100644 --- a/packages/pi/src/tests/scoped-stream.test.ts +++ b/packages/pi/src/tests/scoped-stream.test.ts @@ -4,13 +4,9 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { __setLogTestSink, - type ClaustrumScopedClient, + type NativeCustodyClient as ClaustrumScopedClient, getLogLevel, type LogTestRecord, - loadAccounts, - saveAccounts, - setAccountEnabledPersistent, - setClaustrumModePersistent, setLogLevel, } from '@cortexkit/anthropic-auth-core' import { @@ -32,18 +28,24 @@ import { type ProviderConfig, } from '@earendil-works/pi-coding-agent' import cortexKitPiAnthropicAuth from '../index.ts' +import { closePiNativeRuntime, getPiNativeRuntime } from '../native.ts' import { __prewarmPiCacheKeepForTest, - closePiScopedRuntime, - getPiScopedRuntime, streamCortexKitAnthropic, } from '../stream.ts' +import { + fixtureAccountIdentity, + readNativePiFixture, + saveNativePiFixture, + setNativePiFixtureMode, +} from './native-fixture.ts' +import { trackPiTestBody } from './setup.ts' const originalFetch = globalThis.fetch let dir: string | undefined let path: string | undefined afterEach(async () => { - if (path) closePiScopedRuntime(path) + if (path) closePiNativeRuntime(path) globalThis.fetch = originalFetch delete process.env.PI_ANTHROPIC_AUTH_FILE delete process.env.PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE @@ -103,7 +105,7 @@ async function fixture( JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), { mode: 0o600 }, ) - await saveAccounts( + await saveNativePiFixture( { version: 1, accounts: [ @@ -128,9 +130,9 @@ async function fixture( : {}), }, path, + { primary: false }, ) - await setClaustrumModePersistent('claustrum', path) - const rows = [row('oauth:anthropic', 'main-provider')] + const rows = [row('oauth:anthropic', fixtureAccountIdentity('main'))] const gets: Array[0]> = [] const reports: Array< Parameters[0] @@ -153,7 +155,19 @@ async function fixture( }, close: () => {}, } - getPiScopedRuntime(path, { connect: async () => client, pollIntervalMs: 0 }) + const native = getPiNativeRuntime(path, { connect: async () => client }) + await setNativePiFixtureMode(path, 'claustrum') + const roster = await (await native.service()).vault.refresh() + const primary = roster?.rows.find( + (account) => account.credentialId === 'oauth:anthropic', + ) + if (!primary?.accountIdentity) + throw new Error('Fixture vault primary identity is missing') + await setNativePiFixtureMode(path, 'claustrum', { + routeId: primary.routeId, + credentialId: primary.credentialId, + accountIdentity: primary.accountIdentity, + }) return { rows, gets, reports, client, path } } @@ -178,13 +192,11 @@ test('Pi sends using scoped credentials without host auth, and never bootstraps }, { preconnect: originalFetch.preconnect }, ) - expect( - ( - await streamCortexKitAnthropic(model, context, { - sessionId: 'scoped-first', - }).result() - ).stopReason, - ).toBe('stop') + const first = await streamCortexKitAnthropic(model, context, { + sessionId: 'scoped-first', + }).result() + expect(first.errorMessage).toBeUndefined() + expect(first.stopReason).toBe('stop') expect( ( await streamCortexKitAnthropic(model, context, { @@ -200,9 +212,9 @@ test('Pi sends using scoped credentials without host auth, and never bootstraps expect( sent.every((request) => request.url.endsWith('/v1/messages?beta=true')), ).toBe(true) - expect(sent[0]?.body).toContain('main-provider') + expect(sent[0]?.body).toContain(fixtureAccountIdentity('main')) expect(f.gets).toHaveLength(2) - expect(JSON.stringify(await loadAccounts(f.path))).not.toContain( + expect(JSON.stringify(await readNativePiFixture(f.path))).not.toContain( 'vault-test-access', ) }) @@ -231,7 +243,7 @@ test('a revoked scoped credential never falls back to local auth or a paid API r apiKey: 'must-not-use-local-key', }).result() expect(result.stopReason).toBe('error') - expect(result.errorMessage).toContain('enrollment_revoked') + expect(result.errorMessage).toContain('Native custody refused: unavailable') expect(sends).toBe(1) }) @@ -243,15 +255,16 @@ test('fallback-first discovers a new account on the next turn and honors a live expect( (await streamCortexKitAnthropic(model, context).result()).stopReason, ).toBe('stop') - f.rows.push(row('oauth:anthropic:new', 'new-provider')) + f.rows.push(row('oauth:anthropic:new', fixtureAccountIdentity('new'))) expect( (await streamCortexKitAnthropic(model, context).result()).stopReason, ).toBe('stop') - const added = (await loadAccounts(f.path))?.accounts.find( - (account) => account.type === 'oauth', + const native = getPiNativeRuntime(f.path) + const added = (await native.view()).accounts.find( + (account) => account.credentialId === 'oauth:anthropic:new', ) if (!added) throw new Error('new account was not persisted') - await setAccountEnabledPersistent(added.id, false, f.path) + await (await native.service()).setEnabled(added.id, false) expect( (await streamCortexKitAnthropic(model, context).result()).stopReason, ).toBe('stop') @@ -260,7 +273,9 @@ test('fallback-first discovers a new account on the next turn and honors a live 'oauth:anthropic:new', 'oauth:anthropic', ]) - expect(await readFile(f.path, 'utf8')).not.toContain('vault-test-access') + await expect(readFile(f.path, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }) }) test.each([500, 401])( @@ -274,7 +289,7 @@ test.each([500, 401])( const version = Math.min(f.gets.length, 2) return { credentialId: input.credentialId, - accountId: 'main-provider', + accountId: fixtureAccountIdentity('main'), material: `vault-test-access-${version}`, recordVersion: version, expiresAtMs: Date.now() + 600_000, @@ -321,7 +336,7 @@ test.each([500, 401])( test('scoped model switches reuse the existing sticky allocator and its Opus reserve preference', async () => { const f = await fixture('sticky-balanced') - f.rows.push(row('oauth:anthropic:work', 'work-provider')) + f.rows.push(row('oauth:anthropic:work', fixtureAccountIdentity('work'))) const served: string[] = [] globalThis.fetch = Object.assign( async ( @@ -492,9 +507,8 @@ test.each(hostCredentials)( try { await cortexKitPiAnthropicAuth(pi, { connectScoped: async () => ({ ...f.client }), - pollIntervalMs: 0, }) - await getPiScopedRuntime(f.path).refresh() + await getPiNativeRuntime(f.path).view() expect(provider?.auth.oauth).toBeUndefined() if (credential?.type === 'oauth') { expect(await host.getAuth('anthropic')).toBeUndefined() @@ -503,7 +517,7 @@ test.each(hostCredentials)( } else { expect(await host.getAuth('anthropic')).toEqual({ auth: {}, - source: 'Claustrum', + source: 'CortexKit Native', }) const registered = host.getModel('anthropic', model.id) if (!registered) @@ -512,16 +526,16 @@ test.each(hostCredentials)( (await host.completeSimple(registered, context)).stopReason, ).toBe('stop') expect(sends).toBe(1) - const accountCommand = commands.get('claude-account') + const accountCommand = commands.get('claude') if (!accountCommand) - throw new Error('Account command was not registered') + throw new Error('Claude menu command was not registered') const ctx = { ui: { notify: () => {} }, } as unknown as ExtensionCommandContext await accountCommand.handler('local', ctx) - expect(host.getProvider('anthropic')?.auth.oauth).toBeDefined() + expect(host.getProvider('anthropic')?.auth.oauth).toBeUndefined() await accountCommand.handler('claustrum', ctx) - await getPiScopedRuntime(f.path).refresh() + await getPiNativeRuntime(f.path).view() expect(host.getProvider('anthropic')?.auth.oauth).toBeUndefined() expect( (await host.completeSimple(registered, context)).stopReason, @@ -548,7 +562,7 @@ test.each([200, 401])( gets.push(version) return { credentialId, - accountId: 'main-provider', + accountId: fixtureAccountIdentity('main'), material: `rotated-pi-${version}`, recordVersion: version, expiresAtMs: Date.now() + 600_000, @@ -594,16 +608,17 @@ test.each([200, 401])( 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] : [], + finalStatus === 401 ? [1, 2] : [1], ) - // Pi's direct-model retry decision reaches the shared diagnostics line. + // Retry diagnostics must not include access or refresh tokens, or the + // provider's raw error text. expect( records - .filter((record) => record.message === 'scoped 401 re-authorized') + .filter((record) => record.message === 'Pi 401 retry decision') .map((record) => record.payload), ).toContainEqual( expect.objectContaining({ - site: 'pi-model', + source: 'vault', servedVersion: 1, currentVersion: 2, retry: true, @@ -622,7 +637,7 @@ test.each([200, 401])( relayTokens: string[] = [] f.client.getScoped = async ({ credentialId }) => ({ credentialId, - accountId: 'main-provider', + accountId: fixtureAccountIdentity('main'), material: `relay-pi-${version}`, recordVersion: version, expiresAtMs: Date.now() + 600_000, @@ -665,7 +680,7 @@ test.each([200, 401])( }).result() expect(result.stopReason).toBe(finalStatus === 401 ? 'error' : 'stop') expect(relayTokens).toEqual(['Bearer relay-pi-1', 'Bearer relay-pi-2']) - expect(reports).toEqual(finalStatus === 401 ? [2] : []) + expect(reports).toEqual(finalStatus === 401 ? [1, 2] : [1]) }, ) @@ -677,7 +692,7 @@ test.each([200, 401])( const sent: string[] = [] f.client.getScoped = async ({ credentialId }) => ({ credentialId, - accountId: 'main-provider', + accountId: fixtureAccountIdentity('main'), material: `pi-cachekeep-v${version}`, recordVersion: version, expiresAtMs: Date.now() + 600_000, @@ -724,13 +739,397 @@ test.each([200, 401])( messages: [{ role: 'user', content: 'warm' }], }), oauthAccountId: 'main', - oauthAccountIdentity: 'main-provider', + oauthAccountIdentity: fixtureAccountIdentity('main'), accountStoragePath: f.path, }) expect(result.ok).toBe(finalStatus === 200) expect(sent).toEqual(['Bearer pi-cachekeep-v1', 'Bearer pi-cachekeep-v2']) expect(f.reports.map((report) => report.recordVersion)).toEqual( - finalStatus === 401 ? [2] : [], + finalStatus === 401 ? [1, 2] : [1], ) }, ) + +test.each([200, 401])( + 'Pi WebSocket relay retries a strict-newer native receipt once and reports every rejected version; final status %i', + async (finalStatus) => + trackPiTestBody( + (async () => { + const f = await fixture() + let version = 1 + const sent: string[] = [] + f.client.getScoped = async ({ credentialId }) => ({ + credentialId, + accountId: fixtureAccountIdentity('main'), + material: `pi-ws-v${version}`, + recordVersion: version, + expiresAtMs: Date.now() + 600_000, + }) + const sse = await success().text() + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch: (request, server) => + server.upgrade(request) + ? undefined + : new Response('unexpected HTTP', { status: 500 }), + websocket: { + open: (socket) => { + socket.send( + JSON.stringify({ type: 'ready', protocol: 2, state: null }), + ) + }, + message: (socket, data) => { + const payload = JSON.parse(String(data)) as { + id: string + upstream: { headers: Record } + } + const token = payload.upstream.headers.authorization ?? '' + sent.push(token) + const status = token === 'Bearer pi-ws-v1' ? 401 : finalStatus + version = 2 + socket.send( + JSON.stringify({ + type: 'response_start', + id: payload.id, + status, + headers: { 'content-type': 'text/event-stream' }, + }), + ) + if (status === 200) + socket.send( + JSON.stringify({ + type: 'chunk', + id: payload.id, + base64: Buffer.from(sse).toString('base64'), + }), + ) + socket.send(JSON.stringify({ type: 'done', id: payload.id })) + }, + }, + }) + const abort = new AbortController() + const deadline = setTimeout(() => abort.abort(), 4_000) + try { + await (await getPiNativeRuntime(f.path).service()).updateRelay({ + enabled: true, + url: server.url.href, + token: 'synthetic-ws-relay-secret', + transport: 'websocket', + fallbackToDirect: false, + }) + const result = await streamCortexKitAnthropic(model, context, { + sessionId: `pi-ws-${finalStatus}`, + signal: abort.signal, + }).result() + expect(result.stopReason).toBe(finalStatus === 200 ? 'stop' : 'error') + expect(sent).toEqual(['Bearer pi-ws-v1', 'Bearer pi-ws-v2']) + expect( + f.reports.map((report) => [ + report.recordVersion, + report.reporterSource, + ]), + ).toEqual( + finalStatus === 200 + ? [[1, 'relay_status_field']] + : [ + [1, 'relay_status_field'], + [2, 'relay_status_field'], + ], + ) + } finally { + clearTimeout(deadline) + abort.abort() + server.stop(true) + } + })(), + ), +) + +test('Pi WebSocket reconnect authorizes the replacement physical attempt without reporting a transport failure', () => + trackPiTestBody( + (async () => { + const f = await fixture() + const sent: string[] = [] + const sse = await success().text() + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch: (request, server) => + server.upgrade(request) + ? undefined + : new Response('unexpected HTTP', { status: 500 }), + websocket: { + open: (socket) => { + socket.send( + JSON.stringify({ type: 'ready', protocol: 2, state: null }), + ) + }, + message: (socket, data) => { + const payload = JSON.parse(String(data)) as { + id: string + upstream: { headers: Record } + } + sent.push(payload.upstream.headers.authorization ?? '') + if (sent.length === 1) { + socket.close(1011, 'interrupted before upstream') + return + } + socket.send( + JSON.stringify({ + type: 'response_start', + id: payload.id, + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }), + ) + socket.send( + JSON.stringify({ + type: 'chunk', + id: payload.id, + base64: Buffer.from(sse).toString('base64'), + }), + ) + socket.send(JSON.stringify({ type: 'done', id: payload.id })) + }, + }, + }) + const abort = new AbortController() + const deadline = setTimeout(() => abort.abort(), 4_000) + try { + await (await getPiNativeRuntime(f.path).service()).updateRelay({ + enabled: true, + url: server.url.href, + token: 'synthetic-ws-reconnect-secret', + transport: 'websocket', + fallbackToDirect: false, + }) + expect( + ( + await streamCortexKitAnthropic(model, context, { + sessionId: 'pi-ws-reconnect', + signal: abort.signal, + }).result() + ).stopReason, + ).toBe('stop') + expect(sent).toEqual([ + 'Bearer vault-test-access-1', + 'Bearer vault-test-access-2', + ]) + expect(f.gets).toHaveLength(2) + expect(f.reports).toEqual([]) + } finally { + clearTimeout(deadline) + abort.abort() + server.stop(true) + } + })(), + )) + +test('Pi refuses an unchanged native vault version after 401 without local or paid API rescue', async () => { + const f = await fixture() + f.client.getScoped = async ({ credentialId }) => ({ + credentialId, + accountId: fixtureAccountIdentity('main'), + material: 'synthetic-unchanged-vault-access', + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + }) + let sends = 0 + globalThis.fetch = Object.assign( + async () => { + sends++ + return new Response('same version rejected', { status: 401 }) + }, + { preconnect: originalFetch.preconnect }, + ) + expect( + ( + await streamCortexKitAnthropic(model, context, { + apiKey: 'synthetic-host-rescue', + }).result() + ).stopReason, + ).toBe('error') + expect(sends).toBe(1) + expect(f.reports.map((report) => report.recordVersion)).toEqual([1]) +}) + +test.each([ + 'https://unapproved.invalid/v1', + 'http://api.anthropic.com', + 'https://user:password@api.anthropic.com/v1', + 'https://api.anthropic.com:8443/v1', + 'blob:https://api.anthropic.com/opaque', + 'not a URL', +])( + 'Pi rejects native OAuth recipient %s before authorization or dispatch', + async (baseUrl) => { + const f = await fixture() + let sends = 0 + globalThis.fetch = Object.assign( + async () => { + sends++ + return success() + }, + { preconnect: originalFetch.preconnect }, + ) + const result = await streamCortexKitAnthropic( + { ...model, baseUrl }, + context, + ).result() + expect(result.stopReason).toBe('error') + expect(result.errorMessage).toContain('official Anthropic HTTPS origin') + expect(f.gets).toEqual([]) + expect(sends).toBe(0) + }, +) + +test('Pi accepts the official versioned OAuth endpoint with redirects disabled', async () => { + const f = await fixture() + const requests: Array<{ + url: string + redirect: RequestInit['redirect'] + }> = [] + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requests.push({ url: String(input), redirect: init?.redirect }) + return success() + }, + { preconnect: originalFetch.preconnect }, + ) + expect( + ( + await streamCortexKitAnthropic( + { ...model, baseUrl: 'https://api.anthropic.com/v1' }, + context, + ).result() + ).stopReason, + ).toBe('stop') + expect(requests).toEqual([ + { + url: 'https://api.anthropic.com/v1/messages?beta=true', + redirect: 'error', + }, + ]) + expect(f.gets).toHaveLength(1) +}) + +test('Pi refuses a foreign CacheKeep target before authorizing a native receipt', async () => { + const f = await fixture() + let sends = 0 + globalThis.fetch = Object.assign( + async () => { + sends++ + return success() + }, + { preconnect: originalFetch.preconnect }, + ) + const result = await __prewarmPiCacheKeepForTest({ + sessionId: 'pi-foreign-prewarm', + url: 'https://unapproved.invalid/v1/messages', + headers: new Headers(), + bodyText: JSON.stringify({ model: model.id, max_tokens: 0, messages: [] }), + oauthAccountId: 'main', + oauthAccountIdentity: fixtureAccountIdentity('main'), + accountStoragePath: f.path, + }) + expect(result.ok).toBe(false) + expect(f.gets).toEqual([]) + expect(sends).toBe(0) +}) + +test.each([ + { name: 'Fable', id: 'claude-fable-5-1', allowed: false }, + { name: 'Sonnet', id: 'claude-sonnet-5-5', allowed: true }, +])( + 'Pi final native vault admission rechecks the pending request model: $name', + (requested) => + trackPiTestBody( + (async () => { + const f = await fixture() + const runtime = await getPiNativeRuntime(f.path).service() + const receipt = await runtime.authorizeVault('main') + const checkedAt = Date.now() + const quota = (remaining: number, at: number) => ({ + accountIdentity: fixtureAccountIdentity('main'), + checkedAt: at, + source: 'poll' as const, + five_hour: { usedPercent: 0, remainingPercent: 100, checkedAt: at }, + seven_day: { usedPercent: 0, remainingPercent: 100, checkedAt: at }, + scoped: [ + { + id: 'fable-weekly', + title: 'Fable weekly', + modelName: 'Fable', + usedPercent: 100 - remaining, + remainingPercent: remaining, + checkedAt: at, + }, + ], + }) + expect( + await runtime.vault.publish(receipt, { + quota: quota(100, checkedAt), + }), + ).toBe(true) + await runtime.updateSettings((settings) => ({ + ...settings, + killswitch: { + enabled: true, + main: { five_hour: 5, seven_day: 10, scoped: 0 }, + }, + })) + let entered: () => void = () => {} + let release: () => void = () => {} + const pending = new Promise((resolve) => { + entered = resolve + }) + const resume = new Promise((resolve) => { + release = resolve + }) + const get = f.client.getScoped + f.client.getScoped = async (input) => { + entered() + await resume + return get(input) + } + const sent: string[] = [] + globalThis.fetch = Object.assign( + async (input: Parameters[0]) => { + sent.push(String(input)) + if (!String(input).includes('/v1/messages')) + throw new Error( + 'Unexpected quota dispatch in final admission control', + ) + return success() + }, + { preconnect: originalFetch.preconnect }, + ) + const response = streamCortexKitAnthropic( + { ...model, id: requested.id, name: requested.name }, + context, + ).result() + try { + await pending + expect( + await runtime.vault.publish(receipt, { + quota: quota(0, checkedAt + 1), + }), + ).toBe(true) + } finally { + release() + } + const result = await response + expect(result.stopReason).toBe(requested.allowed ? 'stop' : 'error') + expect(sent).toEqual( + requested.allowed + ? ['https://api.anthropic.com/v1/messages?beta=true'] + : [], + ) + expect(f.reports).toEqual([]) + })(), + ), +) diff --git a/packages/pi/src/tests/stream.test.ts b/packages/pi/src/tests/stream.test.ts index bddf06186..523e549b5 100644 --- a/packages/pi/src/tests/stream.test.ts +++ b/packages/pi/src/tests/stream.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, mock, test } from 'bun:test' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { loadAccounts, saveAccounts } from '@cortexkit/anthropic-auth-core' import { buildExplicitBaseMessagesUrl, configureApiRouteHeaders, @@ -11,6 +10,7 @@ import { streamCortexKitAnthropic, } from '../stream.ts' import { normalizeContext } from '../transcript.ts' +import { readNativePiFixture, saveNativePiFixture } from './native-fixture.ts' let tempDir: string | undefined const originalFetch = globalThis.fetch @@ -41,11 +41,11 @@ afterEach(async () => { }) describe('Pi API fallback routing helpers', () => { - test('mints the main account identity when Pi storage omits it', async () => { + test('keeps the admitted native main identity without recreating legacy storage', async () => { tempDir = await mkdtemp(join(tmpdir(), 'pi-main-identity-')) const storagePath = join(tempDir, 'anthropic-auth.json') process.env.PI_ANTHROPIC_AUTH_FILE = storagePath - await saveAccounts( + await saveNativePiFixture( { version: 1, main: { type: 'opencode', provider: 'anthropic' }, @@ -53,7 +53,8 @@ describe('Pi API fallback routing helpers', () => { }, storagePath, ) - expect((await loadAccounts(storagePath))?.mainAccountId).toBeUndefined() + const before = (await readNativePiFixture(storagePath)).mainAccountId + expect(before).toEqual(expect.any(String)) globalThis.fetch = mock(async (input: string | URL | Request) => { const url = input.toString() @@ -82,16 +83,18 @@ describe('Pi API fallback routing helpers', () => { // Drain the provider stream. } - expect((await loadAccounts(storagePath))?.mainAccountId).toEqual( - expect.any(String), - ) + expect((await stream.result()).stopReason).toBe('stop') + expect((await readNativePiFixture(storagePath)).mainAccountId).toBe(before) + await expect(readFile(storagePath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }) }) test('sends Fable 5.1 thinking binding controls after compacted signed history', async () => { tempDir = await mkdtemp(join(tmpdir(), 'pi-fable-51-binding-')) const storagePath = join(tempDir, 'anthropic-auth.json') process.env.PI_ANTHROPIC_AUTH_FILE = storagePath - await saveAccounts( + await saveNativePiFixture( { version: 1, main: { type: 'opencode', provider: 'anthropic' }, @@ -271,7 +274,7 @@ describe('Pi API fallback routing helpers', () => { tempDir, 'sticky-routes.json', ) - await saveAccounts( + await saveNativePiFixture( { version: 1, mainAccountId: 'main-account', @@ -326,7 +329,7 @@ describe('Pi API fallback routing helpers', () => { const storagePath = join(tempDir, 'anthropic-auth.json') process.env.PI_ANTHROPIC_AUTH_FILE = storagePath const expires = Date.now() + 5 * 60 * 60_000 - await saveAccounts( + await saveNativePiFixture( { version: 1, mainAccountId: 'main-account', @@ -388,7 +391,7 @@ describe('Pi API fallback routing helpers', () => { tempDir = await mkdtemp(join(tmpdir(), 'pi-killswitch-unknown-quota-')) const storagePath = join(tempDir, 'anthropic-auth.json') process.env.PI_ANTHROPIC_AUTH_FILE = storagePath - await saveAccounts( + await saveNativePiFixture( { version: 1, mainAccountId: 'main-account', @@ -534,7 +537,7 @@ describe('Pi API fallback routing helpers', () => { }, ], }) - await saveAccounts( + await saveNativePiFixture( { version: 1, mainAccountId: 'main-account', @@ -596,6 +599,13 @@ describe('Pi API fallback routing helpers', () => { ), ) } + if (url.includes('/oauth/token')) { + return Promise.resolve( + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 400, + }), + ) + } if (!url.includes('/v1/messages')) { return Promise.resolve(new Response('{}', { status: 200 })) } @@ -655,13 +665,16 @@ describe('Pi API fallback routing helpers', () => { for await (const _event of directOpus) { // Drain the provider stream. } + expect((await directOpus.result()).errorMessage).toBeUndefined() + // The main account’s invalid_grant prevents its reuse. The next Opus + // session must select the usable fallback instead of sending the rejected + // main credential again. expect(authorizations).toEqual([ 'Bearer abundant-access', 'Bearer abundant-access', 'Bearer main-access', 'Bearer abundant-access', - 'Bearer main-access', 'Bearer abundant-access', ]) }) @@ -700,7 +713,7 @@ describe('Pi API fallback routing helpers', () => { }, ], }) - await saveAccounts( + await saveNativePiFixture( { version: 1, mainAccountId: 'main-account', @@ -807,7 +820,7 @@ describe('Pi API fallback routing helpers', () => { }, ], }) - await saveAccounts( + await saveNativePiFixture( { version: 1, mainAccountId: 'main-account', @@ -931,6 +944,10 @@ describe('Pi Anthropic stream content blocks', () => { test('preserves redacted thinking for same-model replay', async () => { tempDir = await mkdtemp(join(tmpdir(), 'pi-redacted-thinking-')) process.env.PI_ANTHROPIC_AUTH_FILE = join(tempDir, 'anthropic-auth.json') + await saveNativePiFixture( + { version: 1, accounts: [] }, + process.env.PI_ANTHROPIC_AUTH_FILE, + ) globalThis.fetch = mock((input: string | URL | Request) => { const url = @@ -985,7 +1002,7 @@ test('Pi 0.86 normalized transcript maps a returned Claude Code tool name back t tempDir = await mkdtemp(join(tmpdir(), 'pi-transcript-tool-roundtrip-')) const storagePath = join(tempDir, 'anthropic-auth.json') process.env.PI_ANTHROPIC_AUTH_FILE = storagePath - await saveAccounts( + await saveNativePiFixture( { version: 1, accounts: [], quota: { enabled: false } }, storagePath, ) diff --git a/packages/pi/src/tests/transport.test.ts b/packages/pi/src/tests/transport.test.ts new file mode 100644 index 000000000..8f4c5a8da --- /dev/null +++ b/packages/pi/src/tests/transport.test.ts @@ -0,0 +1,56 @@ +import { afterEach, expect, test } from 'bun:test' +import { + buildPiOAuthMessagesUrl, + fetchPiOAuth, + requirePiOAuthOrigin, +} from '../transport.ts' + +const originalFetch = globalThis.fetch +afterEach(() => { + globalThis.fetch = originalFetch +}) + +test.each([ + 'https://unapproved.invalid', + 'http://api.anthropic.com', + 'https://api.anthropic.com:8443', + 'https://user:secret@api.anthropic.com', + 'blob:https://api.anthropic.com/opaque', + 'not a URL', +])('native OAuth URL parser refuses recipient %s', (url) => { + expect(() => requirePiOAuthOrigin(url)).toThrow( + 'official Anthropic HTTPS origin', + ) +}) + +test('official versioned and default-port OAuth URLs keep the fixed Messages path', () => { + expect(buildPiOAuthMessagesUrl('https://api.anthropic.com/v1').href).toBe( + 'https://api.anthropic.com/v1/messages?beta=true', + ) + expect(buildPiOAuthMessagesUrl('HTTPS://API.ANTHROPIC.COM:443').href).toBe( + 'https://api.anthropic.com/v1/messages?beta=true', + ) +}) + +test('guarded OAuth fetch overrides caller redirect-follow and refuses foreign recipients without dispatch', async () => { + const sent: Array<{ url: string; redirect: RequestInit['redirect'] }> = [] + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + sent.push({ url: String(input), redirect: init?.redirect }) + return new Response('{}') + }, + { preconnect: originalFetch.preconnect }, + ) + await fetchPiOAuth('https://api.anthropic.com/api/oauth/profile', { + redirect: 'follow', + }) + await expect( + fetchPiOAuth('https://unapproved.invalid/profile'), + ).rejects.toThrow('official Anthropic HTTPS origin') + expect(sent).toEqual([ + { url: 'https://api.anthropic.com/api/oauth/profile', redirect: 'error' }, + ]) +}) diff --git a/packages/pi/src/transport.ts b/packages/pi/src/transport.ts new file mode 100644 index 000000000..b9a668441 --- /dev/null +++ b/packages/pi/src/transport.ts @@ -0,0 +1,42 @@ +const ANTHROPIC_ORIGIN = 'https://api.anthropic.com' + +/** Model base URLs and remembered cache-warm URLs must send OAuth tokens only to Anthropic’s HTTPS server. */ +export function requirePiOAuthOrigin(value: string): URL { + let url: URL + try { + url = new URL(value) + } catch { + throw new Error('Native OAuth requires the official Anthropic HTTPS origin') + } + if ( + url.protocol !== 'https:' || + url.origin !== ANTHROPIC_ORIGIN || + url.username || + url.password + ) + throw new Error( + 'Native OAuth requires the official Anthropic HTTPS origin without URL userinfo', + ) + return url +} + +export function buildPiOAuthMessagesUrl(baseUrl: string): URL { + return new URL('/v1/messages?beta=true', requirePiOAuthOrigin(baseUrl)) +} + +/** Reject redirects rather than letting an approved OAuth destination forward the request. */ +export const fetchPiOAuth: typeof fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requirePiOAuthOrigin(input instanceof Request ? input.url : String(input)) + return fetch(input, { ...init, redirect: 'error' }) + }, + { + preconnect: (...args: Parameters) => { + requirePiOAuthOrigin(String(args[0])) + return fetch.preconnect(...args) + }, + }, +) From e82907b23a50208036cbbfc692dfea780bdac804 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:57:08 +0200 Subject: [PATCH 069/169] mason: unify checked Claude menu UI and host IPC Preserve the reviewed host-only UI and IPC slice; shared Core presentation, credentials and settings are supplied through the accepted public Core facade. Structured menu requests retain all native parameters, host capability checks, confirmations and session fences, with fixed redacted RPC failures. The dual TUI default remains an id/tui object and now also exposes setup. Legacy compiled/raw loading is deferred until tui invocation, so default-object identity with the eagerly imported legacy module is intentionally no longer the contract; its ID, arguments and loader behavior remain preserved while SDK2 uses its real project RPC setup without eager legacy imports. Parent reviewed all twelve staged files and passed 52 owning tests (137 assertions) on Bun 1.3.14. Actual packed legacy invocations and fresh-prefix SDK2 default.setup passed. Strict declaration diagnostics matched six baseline vendor errors with zero owned-source diagnostics; whole-app acceptance remains integrated CI work. --- packages/opencode/src/native-command.ts | 38 ++ packages/opencode/src/rpc/notifications.ts | 11 +- packages/opencode/src/rpc/protocol.ts | 15 +- packages/opencode/src/rpc/rpc-client.ts | 17 + packages/opencode/src/rpc/rpc-server.ts | 23 +- .../opencode/src/tests/native-command.test.ts | 55 +++ packages/opencode/src/tests/native-ui.test.ts | 370 ++++++++++++++++++ .../src/tests/rpc-notifications.test.ts | 28 ++ .../opencode/src/tests/rpc-server.test.ts | 173 ++++++++ packages/opencode/src/tui.tsx | 46 ++- packages/opencode/src/tui/command-dialogs.tsx | 264 +++++++++++++ packages/opencode/src/tui/entry.mjs | 62 ++- 12 files changed, 1063 insertions(+), 39 deletions(-) create mode 100644 packages/opencode/src/native-command.ts create mode 100644 packages/opencode/src/tests/native-command.test.ts create mode 100644 packages/opencode/src/tests/native-ui.test.ts diff --git a/packages/opencode/src/native-command.ts b/packages/opencode/src/native-command.ts new file mode 100644 index 000000000..cc9efa40e --- /dev/null +++ b/packages/opencode/src/native-command.ts @@ -0,0 +1,38 @@ +import { + type CommandApplyRequest, + type CommandApplyResult, + type CommandDialogPayload, + type CommandMenu, + createNativeUi, + type NativeUiOptions, +} from '@cortexkit/anthropic-auth-core' + +export interface NativeCommand { + readonly menu: CommandMenu + readonly open: (sessionId: string) => Promise + readonly apply: (request: CommandApplyRequest) => Promise +} + +function session(sessionId: string | undefined): string { + if (!sessionId?.trim()) throw new TypeError('sessionId is required') + return sessionId +} + +/** Build and execute the Claude menu using account, status and session callbacks supplied by the host. */ +export function createNativeCommand(options: NativeUiOptions): NativeCommand { + if (options.host !== 'opencode') + throw new TypeError('OpenCode host is required') + const menu = createNativeUi(options) + return { + menu, + open(sessionId) { + return menu.open({ sessionId: session(sessionId), notify() {} }) + }, + apply(request) { + return menu.apply(request, { + sessionId: session(request.sessionId), + notify() {}, + }) + }, + } +} diff --git a/packages/opencode/src/rpc/notifications.ts b/packages/opencode/src/rpc/notifications.ts index 74a85ee69..1a7a1f750 100644 --- a/packages/opencode/src/rpc/notifications.ts +++ b/packages/opencode/src/rpc/notifications.ts @@ -1,3 +1,4 @@ +import type { CommandDialogPayload } from '@cortexkit/anthropic-auth-core' import type { OpenDialogPayload, RpcNotification } from './protocol' const QUEUE_CAP = 100 @@ -18,11 +19,17 @@ function assertSessionId(sessionId: string): void { } export function pushNotification( - payload: OpenDialogPayload, + payload: OpenDialogPayload | CommandDialogPayload, sessionId: string, ): void { assertSessionId(sessionId) - queue.push({ id: nextId++, type: 'open-dialog', payload, sessionId }) + if ('menu' in payload) { + if (payload.command !== 'claude' || payload.menu.command !== 'claude') + throw new TypeError('Only the claude menu is supported') + queue.push({ id: nextId++, type: 'open-menu', payload, sessionId }) + } else { + queue.push({ id: nextId++, type: 'open-dialog', payload, sessionId }) + } if (queue.length > QUEUE_CAP) queue = queue.slice(queue.length - QUEUE_CAP) } diff --git a/packages/opencode/src/rpc/protocol.ts b/packages/opencode/src/rpc/protocol.ts index f31896eff..af326eb50 100644 --- a/packages/opencode/src/rpc/protocol.ts +++ b/packages/opencode/src/rpc/protocol.ts @@ -1,4 +1,7 @@ -import type { ClaustrumEnrollmentStatus } from '@cortexkit/anthropic-auth-core' +import type { + ClaustrumEnrollmentStatus, + CommandDialogPayload, +} from '@cortexkit/anthropic-auth-core' export const COMMAND_MODAL_NAMES = [ 'claude-account', @@ -53,12 +56,10 @@ export interface OpenDialogPayload { knobs: Record } -export interface RpcNotification { - id: number - type: 'open-dialog' - payload: OpenDialogPayload - sessionId: string -} +export type RpcNotification = { id: number; sessionId: string } & ( + | { type: 'open-dialog'; payload: OpenDialogPayload } + | { type: 'open-menu'; payload: CommandDialogPayload } +) export interface ApplyRequest { command: CommandModalName diff --git a/packages/opencode/src/rpc/rpc-client.ts b/packages/opencode/src/rpc/rpc-client.ts index ec1ae96e9..51df75bf6 100644 --- a/packages/opencode/src/rpc/rpc-client.ts +++ b/packages/opencode/src/rpc/rpc-client.ts @@ -1,3 +1,7 @@ +import type { + CommandApplyRequest, + CommandApplyResult, +} from '@cortexkit/anthropic-auth-core' import { discoverPortFile } from './port-file' import type { ApplyRequest, ApplyResult, RpcNotification } from './protocol' @@ -7,6 +11,7 @@ export interface RpcClient { sessionId: string, ) => Promise apply: (request: ApplyRequest) => Promise + applyMenu: (request: CommandApplyRequest) => Promise } async function call( @@ -58,5 +63,17 @@ export function createRpcClient(dir: string, expectedPid?: number): RpcClient { ) return out ?? { text: 'apply failed', knobs: {} } }, + async applyMenu(request) { + if (request.command !== 'claude' || !request.sessionId?.trim()) + throw new TypeError('A claude menu sessionId is required') + const out = await call( + dir, + 'apply-menu', + { ...request }, + expectedPid, + ) + if (!out) throw new Error('Menu apply unavailable; outcome unknown') + return out + }, } } diff --git a/packages/opencode/src/rpc/rpc-server.ts b/packages/opencode/src/rpc/rpc-server.ts index 9bee53c1b..fc40d2edc 100644 --- a/packages/opencode/src/rpc/rpc-server.ts +++ b/packages/opencode/src/rpc/rpc-server.ts @@ -6,6 +6,11 @@ import { type ServerResponse, } from 'node:http' import { join } from 'node:path' +import { + type CommandApplyRequest, + type CommandApplyResult, + parseApplyRequest, +} from '@cortexkit/anthropic-auth-core' import type { drainNotifications } from './notifications' import { writePortFile } from './port-file' import type { ApplyRequest, ApplyResult } from './protocol' @@ -19,7 +24,10 @@ export interface RpcServerHandle { export interface RpcServerOptions { dir: string drain: typeof drainNotifications - apply: (request: ApplyRequest) => Promise + /** Structured-menu hosts need no text parser; internal text consumers may still supply one. */ + apply?: (request: ApplyRequest) => Promise + /** Run menu actions through the host's credential service, not legacy account-file writes. */ + applyMenu?: (request: CommandApplyRequest) => Promise } function readBody(req: IncomingMessage): Promise { @@ -87,14 +95,23 @@ export async function startRpcServer( return json(200, { messages }) } if (method === 'apply') { + if (!options.apply) + return json(501, { error: 'text apply unavailable' }) const result = await options.apply(params as unknown as ApplyRequest) return json(200, result) } + if (method === 'apply-menu') { + const request = parseApplyRequest(params) + if (request?.command !== 'claude' || !request.sessionId?.trim()) + return json(400, { error: 'invalid menu request' }) + if (!options.applyMenu) return json(501, { error: 'menu unavailable' }) + return json(200, await options.applyMenu(request)) + } return json(404, { error: 'unknown method' }) - } catch (error) { + } catch { if (!res.headersSent && !res.writableEnded && !res.destroyed) { json(500, { - error: error instanceof Error ? error.message : String(error), + error: 'RPC request failed', }) } } diff --git a/packages/opencode/src/tests/native-command.test.ts b/packages/opencode/src/tests/native-command.test.ts new file mode 100644 index 000000000..85341e3d5 --- /dev/null +++ b/packages/opencode/src/tests/native-command.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from 'bun:test' +import type { NativeMenuDispatchRequest } from '@cortexkit/anthropic-auth-core' +import { createNativeCommand } from '../native-command.ts' + +test('native command requires a session and dispatches to the injected backend', async () => { + const calls: NativeMenuDispatchRequest[] = [] + const command = createNativeCommand({ + host: 'opencode', + interactive: true, + dispatch: async (request) => { + calls.push(request) + return { ok: true, text: 'reset' } + }, + readStatus: async (_command, invocation) => + `session ${invocation.sessionId}`, + }) + expect((await command.open('session-a')).menu.command).toBe('claude') + const result = await command.apply({ + command: 'claude', + sectionId: 'Routing', + actionId: 'routing-reset', + sessionId: 'session-a', + }) + expect(result.ok).toBe(true) + expect(calls).toEqual([ + { action: 'routing-reset', values: {}, sessionId: 'session-a' }, + ]) + expect(() => command.open('')).toThrow('sessionId is required') + expect(() => + command.apply({ + command: 'claude', + sectionId: 'Routing', + actionId: 'routing-reset', + }), + ).toThrow('sessionId is required') +}) + +test('native command never substitutes success for a failed backend', async () => { + const command = createNativeCommand({ + host: 'opencode', + interactive: true, + dispatch: async () => ({ ok: false, text: 'Backend refused' }), + readStatus: async () => 'public', + }) + expect( + ( + await command.apply({ + command: 'claude', + sectionId: 'Cache', + actionId: 'cache-off', + sessionId: 'a', + }) + ).ok, + ).toBe(false) +}) diff --git a/packages/opencode/src/tests/native-ui.test.ts b/packages/opencode/src/tests/native-ui.test.ts new file mode 100644 index 000000000..d7cf85a1a --- /dev/null +++ b/packages/opencode/src/tests/native-ui.test.ts @@ -0,0 +1,370 @@ +import { expect, test } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import type { + CommandApplyRequest, + CommandApplyResult, + CommandDialogPayload, +} from '@cortexkit/common-auth/commands' +import type { + TuiDialogConfirmProps, + TuiDialogPromptProps, + TuiDialogSelectProps, +} from '@opencode-ai/plugin/tui' +import { testRender } from '@opentui/solid' +import type { JSX } from 'solid-js' +import { createNativeUi } from '../../../core/src/native-ui.ts' +import { openNativeMenuDialog } from '../tui/command-dialogs.tsx' + +test('dual TUI entry exposes both default host methods without eagerly loading OC1 TSX', () => { + const entry = fileURLToPath(new URL('../tui/entry.mjs', import.meta.url)) + // Node cannot load the legacy TSX entry. Importing this actual module under + // Node therefore proves that the SDK2 import path does not eagerly load it. + const probe = spawnSync( + 'node', + [ + '--input-type=module', + '-e', + ` + const entry = await import(process.argv[1]); + if (entry.default.tui !== entry.tui || entry.default.setup !== entry.setup) + throw new Error('Default and named host methods must agree'); + console.log(JSON.stringify({ id: entry.default.id, tui: typeof entry.default.tui, setup: typeof entry.default.setup })); + `, + entry, + ], + { encoding: 'utf8' }, + ) + expect(probe.status).toBe(0) + const exported: unknown = JSON.parse(probe.stdout) + expect(exported).toEqual({ + id: 'cortexkit.anthropic-auth', + tui: 'function', + setup: 'function', + }) +}) + +async function harness() { + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + dispatch: async () => ({ ok: true, text: 'Applied' }), + readStatus: async (command) => `${command}: honest status`, + }) + const payload = await menu.open({ sessionId: 'a', notify() {} }) + const requests: CommandApplyRequest[] = [] + const toasts: string[] = [] + let select: + | { + title: string + options: { title: string; value: string }[] + onSelect: (option: { title: string; value: string }) => void + } + | undefined + let prompt: TuiDialogPromptProps | undefined + let confirm: TuiDialogConfirmProps | undefined + let render: (() => JSX.Element) | undefined + let currentSession = true + let cleared = 0 + const api = { + ui: { + dialog: { + size: 'xlarge' as const, + depth: 0, + open: true, + setSize() {}, + replace(fn: () => JSX.Element) { + render = fn + }, + clear() { + cleared++ + }, + }, + toast(options: { message: string }) { + toasts.push(options.message) + }, + DialogSelect(props: TuiDialogSelectProps) { + select = { + title: props.title, + options: props.options.map((option) => ({ + title: option.title, + value: String(option.value), + })), + onSelect(option) { + const original = props.options.find( + (entry) => String(entry.value) === option.value, + ) + if (original) props.onSelect?.(original) + }, + } + return null + }, + DialogPrompt(props: TuiDialogPromptProps) { + prompt = props + return props.description?.() ?? null + }, + DialogConfirm(props: TuiDialogConfirmProps) { + confirm = props + return null + }, + }, + } + let apply: (request: CommandApplyRequest) => Promise = + async (request) => { + requests.push(request) + return menu.apply(request, { sessionId: 'a', notify() {} }) + } + let pending: Promise | undefined + const invoke = (request: CommandApplyRequest) => { + pending = apply(request) + return pending + } + const draw = async () => { + select = undefined + prompt = undefined + confirm = undefined + if (!render) throw new Error('No dialog render') + const frame = await testRender(render) + await frame.flush() + frame.renderOnce() + const text = frame + .captureSpans() + .lines.map((line) => line.spans.map((span) => span.text).join('')) + .join('\n') + frame.renderer.destroy() + return text + } + const choose = (value: string) => { + const option = select?.options.find((entry) => entry.value === value) + if (!option) throw new Error(`Missing option ${value}`) + select?.onSelect?.(option) + } + openNativeMenuDialog(api, payload, invoke, () => currentSession) + return { + payload, + requests, + toasts, + draw, + choose, + async settle() { + try { + await pending + } catch { + // The renderer already reports IPC failure to the user. Suppress only + // the rejection from the test harness observing that handled promise. + } + await Promise.resolve() + }, + get select() { + return select + }, + get prompt() { + return prompt + }, + get confirm() { + return confirm + }, + get cleared() { + return cleared + }, + leave() { + currentSession = false + }, + setApply(callback: typeof apply) { + apply = callback + }, + reopen(next: CommandDialogPayload) { + openNativeMenuDialog(api, next, invoke, () => currentSession) + }, + } +} + +test('single Claude dialog displays all sections, honest status and every action', async () => { + const h = await harness() + await h.draw() + expect(h.select?.title).toBe('Claude') + expect(h.select?.options.map((option) => option.title)).toEqual([ + 'Accounts', + 'Quota', + 'Routing', + 'Limits', + 'Cache', + 'Diagnostics', + 'Extras', + 'Close', + ]) + h.choose('Cache') + expect(await h.draw()).toContain('cachekeep: honest status') + expect(h.select?.options.map((option) => option.value)).toEqual([ + 'action:cache-on', + 'action:cache-off', + 'action:cache-mode', + 'action:cachekeep-always', + 'action:cachekeep-off', + 'action:cachekeep-window', + 'action:cachekeep-subagents', + '__back', + ]) + h.choose('__back') + await h.draw() + h.choose('__close') + expect(h.cleared).toBe(1) +}) + +test('renderer collects API key, label, baseURL and authHeader without seeding secrets', async () => { + const h = await harness() + await h.draw() + h.choose('Accounts') + await h.draw() + h.choose('action:add-apikey') + expect(await h.draw()).toContain('This host prompt displays input') + expect(h.prompt?.value).toBe('') + h.prompt?.onConfirm?.('synthetic-ui-key') + await h.draw() + h.prompt?.onConfirm?.('two word label') + await h.draw() + h.prompt?.onConfirm?.('https://example.invalid/api') + await h.draw() + h.choose('x-api-key') + await h.settle() + await h.draw() + expect(h.requests).toEqual([ + { + command: 'claude', + sectionId: 'Accounts', + actionId: 'add-apikey', + values: { + apiKey: 'synthetic-ui-key', + label: 'two word label', + baseURL: 'https://example.invalid/api', + authHeader: 'x-api-key', + }, + }, + ]) + expect(h.toasts).toEqual(['Applied']) + expect(h.select?.title).toBe('Accounts') +}) + +test('renderer confirmation cancellation prevents removal and clears collected input', async () => { + const h = await harness() + await h.draw() + h.choose('Accounts') + await h.draw() + h.choose('action:remove') + await h.draw() + h.prompt?.onConfirm?.('vault:oauth:team') + await h.draw() + expect(h.confirm?.message).toContain('Remove') + h.confirm?.onCancel?.() + await h.draw() + expect(h.requests).toEqual([]) + h.choose('action:remove') + await h.draw() + h.prompt?.onConfirm?.('vault:oauth:team') + await h.draw() + h.confirm?.onConfirm?.() + await h.settle() + await h.draw() + expect(h.requests).toEqual([ + { + command: 'claude', + sectionId: 'Accounts', + actionId: 'remove', + values: { id: 'vault:oauth:team' }, + confirmed: true, + }, + ]) +}) + +test('renderer keeps structured scoped thresholds and full opaque account IDs', async () => { + const h = await harness() + await h.draw() + h.choose('Limits') + await h.draw() + h.choose('action:killswitch-set') + await h.draw() + const rows = + '[{"account":"vault:oauth:anthropic:team","fh":5,"sd":10,"scoped":27}]' + h.prompt?.onConfirm?.(rows) + await h.settle() + await h.draw() + expect(h.requests[0]?.values).toEqual({ entries: rows }) +}) + +test('renderer validates hours and preserves overnight windows', async () => { + const h = await harness() + await h.draw() + h.choose('Cache') + await h.draw() + h.choose('action:cachekeep-window') + await h.draw() + h.prompt?.onConfirm?.('24') + expect(h.requests).toEqual([]) + expect(h.toasts[0]).toContain('number in range') + h.prompt?.onConfirm?.('23') + await h.draw() + h.prompt?.onConfirm?.('7') + await h.settle() + await h.draw() + expect(h.requests[0]?.values).toEqual({ startHour: 23, endHour: 7 }) +}) + +test('stale session callbacks cannot submit an action', async () => { + const h = await harness() + await h.draw() + h.choose('Accounts') + await h.draw() + h.choose('action:remove') + await h.draw() + h.prompt?.onConfirm?.('opaque-id') + await h.draw() + h.leave() + h.confirm?.onConfirm?.() + expect(h.requests).toEqual([]) +}) + +test('late results after a route change cannot toast or replace another session dialog', async () => { + const h = await harness() + let resolve: ((result: CommandApplyResult) => void) | undefined + h.setApply((request) => { + h.requests.push(request) + return new Promise((done) => { + resolve = done + }) + }) + await h.draw() + h.choose('Cache') + await h.draw() + h.choose('action:cache-on') + expect(h.requests).toHaveLength(1) + h.leave() + resolve?.({ + command: 'claude', + ok: true, + text: 'late secret-free result', + menu: h.payload.menu, + }) + await h.settle() + expect(h.toasts).toEqual([]) +}) + +test('failed IPC is generic and duplicate clicks do not repeat an effect', async () => { + const h = await harness() + let reject: ((reason: Error) => void) | undefined + h.setApply((request) => { + h.requests.push(request) + return new Promise((_resolve, fail) => { + reject = fail + }) + }) + await h.draw() + h.choose('Cache') + await h.draw() + h.choose('action:cache-on') + h.choose('action:cache-on') + expect(h.requests).toHaveLength(1) + reject?.(new Error('synthetic-secret-error')) + await h.settle() + await h.draw() + expect(h.toasts).toEqual(['Menu apply unavailable; outcome unknown.']) +}) diff --git a/packages/opencode/src/tests/rpc-notifications.test.ts b/packages/opencode/src/tests/rpc-notifications.test.ts index 950b1e7ca..01965815e 100644 --- a/packages/opencode/src/tests/rpc-notifications.test.ts +++ b/packages/opencode/src/tests/rpc-notifications.test.ts @@ -16,6 +16,34 @@ const payload = (command: OpenDialogPayload['command']): OpenDialogPayload => ({ describe('notifications', () => { beforeEach(() => resetNotificationsForTest()) + test('single claude menus retain their discriminator and per-session drain fence', () => { + const menu = { command: 'claude', title: 'Claude', sections: [] } + pushNotification({ command: 'claude', menu }, 'session-a') + pushNotification({ command: 'claude', menu }, 'session-b') + const notices = drainNotifications(0, 'session-a') + expect(notices).toHaveLength(1) + expect(notices[0]).toMatchObject({ + type: 'open-menu', + sessionId: 'session-a', + payload: { command: 'claude', menu }, + }) + expect(drainNotifications(notices[0]?.id ?? 0, 'session-a')).toEqual([]) + expect(drainNotifications(0, 'session-b')).toHaveLength(1) + }) + + test('menu producers cannot publish a legacy command alias', () => { + expect(() => + pushNotification( + { + command: 'claude-account', + menu: { command: 'claude-account', title: 'Legacy', sections: [] }, + }, + 's1', + ), + ).toThrow('Only the claude menu') + expect(drainNotifications(0, 's1')).toEqual([]) + }) + test('a session-scoped drain prunes only its own acknowledged notices', () => { pushNotification(payload('claude-quota'), 's1') pushNotification(payload('claude-dump'), 's2') diff --git a/packages/opencode/src/tests/rpc-server.test.ts b/packages/opencode/src/tests/rpc-server.test.ts index 36db1a86d..770277747 100644 --- a/packages/opencode/src/tests/rpc-server.test.ts +++ b/packages/opencode/src/tests/rpc-server.test.ts @@ -2,12 +2,16 @@ import { afterEach, describe, expect, test } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { NativeMenuDispatchRequest } from '@cortexkit/anthropic-auth-core' +import type { CommandApplyRequest } from '@cortexkit/common-auth/commands' +import { createNativeUi } from '../../../core/src/native-ui.ts' import { drainNotifications, pushNotification, resetNotificationsForTest, } from '../rpc/notifications' import { discoverPortFile } from '../rpc/port-file' +import { createRpcClient } from '../rpc/rpc-client' import { startRpcServer } from '../rpc/rpc-server' let stop: (() => Promise) | null = null @@ -21,6 +25,175 @@ afterEach(async () => { }) describe('rpc-server', () => { + test('native menu client/server dispatch preserves structured limits and opaque IDs', async () => { + dir = await mkdtemp(join(tmpdir(), 'aa-native-rpc-')) + const calls: NativeMenuDispatchRequest[] = [] + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + dispatch: async (request) => { + calls.push(request) + return { ok: true, text: 'Applied limits' } + }, + readStatus: async () => 'public', + }) + const server = await startRpcServer({ + dir, + drain: drainNotifications, + apply: async () => ({ text: 'legacy', knobs: {} }), + applyMenu: (request) => + menu.apply(request, { sessionId: request.sessionId, notify() {} }), + }) + stop = server.stop + const client = createRpcClient(dir, process.pid) + pushNotification( + await menu.open({ sessionId: 'session-a', notify() {} }), + 'session-a', + ) + expect((await client.pending(0, 'session-a'))[0]?.type).toBe('open-menu') + expect(await client.pending(0, 'session-b')).toEqual([]) + const entries = [ + { account: 'vault:oauth:anthropic:team', fh: 5, sd: 10, scoped: 33 }, + ] + const result = await client.applyMenu({ + command: 'claude', + sectionId: 'Limits', + actionId: 'killswitch-set', + values: { entries: JSON.stringify(entries) }, + sessionId: 'session-a', + }) + expect(result.ok).toBe(true) + expect(result.text).toBe('Applied limits') + expect(calls).toEqual([{ action: 'killswitch-set', values: { entries } }]) + }) + + test('native menu IPC requires authentication and a session before calling the host', async () => { + dir = await mkdtemp(join(tmpdir(), 'aa-native-rpc-')) + let calls = 0 + const server = await startRpcServer({ + dir, + drain: drainNotifications, + apply: async () => ({ text: 'legacy', knobs: {} }), + applyMenu: async () => { + calls++ + throw new Error('not expected') + }, + }) + stop = server.stop + const base = `http://127.0.0.1:${server.port}/rpc/apply-menu` + const request = { + command: 'claude', + sectionId: 'Cache', + actionId: 'cache-on', + sessionId: 'a', + } + expect( + (await fetch(base, { method: 'POST', body: JSON.stringify(request) })) + .status, + ).toBe(401) + for (const bad of [ + { ...request, sessionId: '' }, + { ...request, sessionId: ' ' }, + { ...request, sessionId: undefined }, + { ...request, command: 'claude-cache' }, + { ...request, values: { entries: [] } }, + ]) { + expect( + ( + await fetch(base, { + method: 'POST', + headers: { authorization: `Bearer ${server.token}` }, + body: JSON.stringify(bad), + }) + ).status, + ).toBe(400) + } + expect(calls).toBe(0) + }) + + test('native IPC confirmation gate executes no backend effect until confirmed', async () => { + dir = await mkdtemp(join(tmpdir(), 'aa-native-rpc-')) + let calls = 0 + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + dispatch: async () => { + calls++ + return { ok: true, text: 'removed' } + }, + readStatus: async () => 'public', + }) + const server = await startRpcServer({ + dir, + drain: drainNotifications, + apply: async () => ({ text: 'legacy', knobs: {} }), + applyMenu: (request) => + menu.apply(request, { sessionId: request.sessionId, notify() {} }), + }) + stop = server.stop + const client = createRpcClient(dir, process.pid) + const request: CommandApplyRequest = { + command: 'claude', + sectionId: 'Accounts', + actionId: 'remove', + values: { id: 'opaque:id' }, + sessionId: 'a', + } + expect((await client.applyMenu(request)).code).toBe('confirmation-required') + expect(calls).toBe(0) + expect((await client.applyMenu({ ...request, confirmed: true })).ok).toBe( + true, + ) + expect(calls).toBe(1) + }) + + test('native IPC redacts arbitrary callback errors and refuses an absent capability', async () => { + dir = await mkdtemp(join(tmpdir(), 'aa-native-rpc-')) + const first = await startRpcServer({ + dir, + drain: drainNotifications, + apply: async () => ({ text: 'legacy', knobs: {} }), + }) + const request = { + command: 'claude', + sectionId: 'Cache', + actionId: 'cache-on', + sessionId: 'a', + } + const response = await fetch( + `http://127.0.0.1:${first.port}/rpc/apply-menu`, + { + method: 'POST', + headers: { authorization: `Bearer ${first.token}` }, + body: JSON.stringify(request), + }, + ) + expect(response.status).toBe(501) + await first.stop() + const second = await startRpcServer({ + dir, + drain: drainNotifications, + apply: async () => ({ text: 'legacy', knobs: {} }), + applyMenu: async () => { + throw new Error('secret-failure-value') + }, + }) + stop = second.stop + const failed = await fetch( + `http://127.0.0.1:${second.port}/rpc/apply-menu`, + { + method: 'POST', + headers: { authorization: `Bearer ${second.token}` }, + body: JSON.stringify(request), + }, + ) + expect(failed.status).toBe(500) + expect(await failed.json()).toEqual({ error: 'RPC request failed' }) + await expect( + createRpcClient(dir, process.pid).applyMenu(request), + ).rejects.toThrow('outcome unknown') + }) + test('health is open; pending-notifications requires bearer and drains', async () => { resetNotificationsForTest() dir = await mkdtemp(join(tmpdir(), 'aa-rpcsrv-')) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index e5f77c0d1..775360966 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -34,7 +34,10 @@ import { SEVEN_DAY_MS, type SidebarState, } from './sidebar-state.js' -import { openCommandDialog } from './tui/command-dialogs.js' +import { + openCommandDialog, + openNativeMenuDialog, +} from './tui/command-dialogs.js' import { type AnthropicAuthTuiPrefs, type AppearancePrefs, @@ -920,23 +923,48 @@ const tui: TuiPlugin = async (api) => { getRpcDir(api.state.path.directory ?? ''), process.pid, ) - let lastNotificationId = 0 + const lastNotificationIdBySession = new Map() let rpcInFlight = false - setInterval(() => { - if (rpcInFlight) return + const currentSessionId = () => { const current = (api.route as { current?: unknown }).current const resolved = typeof current === 'function' ? (current as () => unknown)() : current - const sessionId = ( - resolved as { params?: { sessionID?: string } } | undefined - )?.params?.sessionID + return (resolved as { params?: { sessionID?: string } } | undefined) + ?.params?.sessionID + } + setInterval(() => { + if (rpcInFlight) return + const sessionId = currentSessionId() if (!sessionId) return rpcInFlight = true void rpcClient - .pending(lastNotificationId, sessionId) + .pending(lastNotificationIdBySession.get(sessionId) ?? 0, sessionId) .then((messages) => { + // A route change while draining must not open a different session's + // dialog or acknowledge notices that were never displayed. + if (currentSessionId() !== sessionId) return for (const message of [...messages].sort((a, b) => a.id - b.id)) { - lastNotificationId = Math.max(lastNotificationId, message.id) + if (message.sessionId !== sessionId) continue + lastNotificationIdBySession.set( + sessionId, + Math.max( + lastNotificationIdBySession.get(sessionId) ?? 0, + message.id, + ), + ) + if (message.type === 'open-menu') { + openNativeMenuDialog( + api, + message.payload, + (request) => { + if (currentSessionId() !== sessionId) + return Promise.reject(new Error('Menu session changed')) + return rpcClient.applyMenu({ ...request, sessionId }) + }, + () => currentSessionId() === sessionId, + ) + continue + } if (message.payload.command === 'claude-quota') { api.ui.dialog.setSize('xlarge') api.ui.dialog.replace(() => ( diff --git a/packages/opencode/src/tui/command-dialogs.tsx b/packages/opencode/src/tui/command-dialogs.tsx index d2ba03667..e5ab44712 100644 --- a/packages/opencode/src/tui/command-dialogs.tsx +++ b/packages/opencode/src/tui/command-dialogs.tsx @@ -1,4 +1,15 @@ /** @jsxImportSource @opentui/solid */ + +import type { + CommandApplyRequest, + CommandApplyResult, + CommandDialogPayload, + CommandMenuModel, + KnobValue, + MenuAction, + MenuItem, + MenuSection, +} from '@cortexkit/anthropic-auth-core' import { custodyStatusLabel, type PrimeAccountStatus, @@ -8,6 +19,259 @@ import type { AccountDialogAccount } from '../rpc/protocol' import type { OpenDialogPayload } from '../rpc/protocol.js' import { formatPrimeCost, formatPrimeTime } from '../sidebar-state.js' +/** Render menu sections, accounts and action inputs; send changes to the host instead of writing credentials here. */ +export function openNativeMenuDialog( + api: { + readonly ui: Pick< + TuiPluginApi['ui'], + 'dialog' | 'DialogSelect' | 'DialogPrompt' | 'DialogConfirm' | 'toast' + > + }, + payload: CommandDialogPayload, + apply: (request: CommandApplyRequest) => Promise, + isCurrentSession: () => boolean, +): void { + let menu: CommandMenuModel = payload.menu + let inFlight = false + const DialogSelect = api.ui.DialogSelect + const DialogPrompt = api.ui.DialogPrompt + const DialogConfirm = api.ui.DialogConfirm + const active = () => isCurrentSession() && !inFlight + + const root = () => { + if (!active()) return + api.ui.dialog.setSize('xlarge') + api.ui.dialog.replace(() => ( + ({ + title: section.title, + value: section.id, + })), + { title: 'Close', value: '__close' }, + ]} + onSelect={(option) => { + if (!active()) return + const section = menu.sections.find( + (entry) => entry.id === option.value, + ) + if (section) showSection(section) + else if (option.value === '__close') api.ui.dialog.clear() + }} + /> + )) + } + + const showSection = (section: MenuSection) => { + if (!active()) return + api.ui.dialog.replace(() => ( + + {section.lines.join('\n')} + ({ + title: item.label, + value: `item:${item.id}`, + description: item.detail, + })), + ...section.actions.map((action) => ({ + title: action.label, + value: `action:${action.id}`, + description: action.description, + })), + { title: 'Back', value: '__back' }, + ]} + onSelect={(option) => { + if (!active()) return + if (option.value === '__back') return root() + const action = section.actions.find( + (entry) => `action:${entry.id}` === option.value, + ) + if (action) return collect(section, action) + const item = section.items.find( + (entry) => `item:${entry.id}` === option.value, + ) + if (item) showItem(section, item) + }} + /> + + )) + } + + const showItem = (section: MenuSection, item: MenuItem) => { + if (!active()) return + api.ui.dialog.replace(() => ( + + {item.detail ?? ''} + ({ + title: action.label, + value: action.id, + description: action.description, + })), + { title: 'Back', value: '__back' }, + ]} + onSelect={(option) => { + if (!active()) return + const action = item.actions.find( + (entry) => entry.id === option.value, + ) + if (action) collect(section, action, item) + else if (option.value === '__back') showSection(section) + }} + /> + + )) + } + + const collect = ( + section: MenuSection, + action: MenuAction, + item?: MenuItem, + ) => { + const values: Record = {} + const back = () => { + for (const key of Object.keys(values)) delete values[key] + if (item) showItem(section, item) + else showSection(section) + } + const submit = async () => { + if (!active()) return + inFlight = true + try { + const result = await apply({ + command: menu.command, + sectionId: section.id, + actionId: action.id, + ...(item ? { itemId: item.id } : {}), + values: { ...values }, + ...(action.confirm ? { confirmed: true } : {}), + }) + if (!isCurrentSession()) return + menu = result.menu + api.ui.toast({ message: result.text }) + } catch { + if (isCurrentSession()) + api.ui.toast({ message: 'Menu apply unavailable; outcome unknown.' }) + } finally { + for (const key of Object.keys(values)) delete values[key] + inFlight = false + if (isCurrentSession()) { + const refreshed = menu.sections.find( + (entry) => entry.id === section.id, + ) + if (refreshed) showSection(refreshed) + else root() + } + } + } + const next = (index: number) => { + if (!active()) return + const knob = action.knobs[index] + if (!knob) { + if (!action.confirm) return void submit() + api.ui.dialog.replace(() => ( + void submit()} + onCancel={back} + /> + )) + return + } + if (knob.kind === 'choice' || knob.kind === 'toggle') { + const choices = + knob.kind === 'choice' + ? knob.choices + : [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + ] + api.ui.dialog.replace(() => ( + ({ + title: choice.label, + value: choice.value, + })), + { title: 'Cancel', value: '__cancel' }, + ]} + onSelect={(option) => { + if (!active()) return + if (option.value === '__cancel') return back() + values[knob.id] = + knob.kind === 'toggle' ? option.value === 'on' : option.value + next(index + 1) + }} + /> + )) + return + } + // DialogPrompt has no masking capability in the host API. Never seed a + // protected input or claim it is hidden; warn before collecting it. + api.ui.dialog.replace(() => ( + ( + + {knob.kind === 'text' && knob.masked + ? 'This host prompt displays input. Avoid screen sharing; the value is not echoed in menu results.' + : knob.required + ? 'Required input' + : 'Optional; leave empty for the default'} + + )} + value={ + knob.kind === 'text' && knob.masked + ? '' + : knob.value === undefined + ? '' + : String(knob.value) + } + placeholder={ + knob.kind === 'text' + ? knob.placeholder + : `${knob.min ?? ''}-${knob.max ?? ''}` + } + onCancel={back} + onConfirm={(typed) => { + if (!active()) return + const text = typed.trim() + if (!text && knob.required) { + api.ui.toast({ message: `${knob.label} is required.` }) + return + } + const value = + text === '' ? null : knob.kind === 'number' ? Number(text) : text + if ( + knob.kind === 'number' && + typeof value === 'number' && + (!Number.isFinite(value) || + (knob.min !== undefined && value < knob.min) || + (knob.max !== undefined && value > knob.max)) + ) { + api.ui.toast({ + message: `${knob.label} needs a number in range.`, + }) + return + } + values[knob.id] = value + next(index + 1) + }} + /> + )) + } + next(0) + } + root() +} + type ApplyFn = ( command: OpenDialogPayload['command'], args: string, diff --git a/packages/opencode/src/tui/entry.mjs b/packages/opencode/src/tui/entry.mjs index 40b6ff861..f78eec70d 100644 --- a/packages/opencode/src/tui/entry.mjs +++ b/packages/opencode/src/tui/entry.mjs @@ -12,26 +12,52 @@ function isMissingRuntimeRegistry(error) { ) } -let mod -try { - await import(runtimeProbe) -} catch (error) { - if (!isMissingRuntimeRegistry(error)) { - console.error('Anthropic Auth TUI runtime registry probe failed', error) - throw error - } - // Older hosts and bare Bun do not provide the virtual registry. Their source - // loader still applies the Solid transform, so retain the raw TSX fallback. - mod = await import('../tui.tsx') -} - -if (!mod) { +async function loadLegacyTui() { + let mod try { - mod = await import('../tui-compiled/tui.tsx') + await import(runtimeProbe) } catch (error) { - console.error('Anthropic Auth compiled TUI failed to load', error) - throw error + if (!isMissingRuntimeRegistry(error)) { + console.error('Anthropic Auth TUI runtime registry probe failed', error) + throw error + } + // Older hosts and bare Bun do not provide the virtual registry. Their source + // loader still applies the Solid transform, so retain the raw TSX fallback. + mod = await import('../tui.tsx') } + + if (!mod) { + try { + mod = await import('../tui-compiled/tui.tsx') + } catch (error) { + console.error('Anthropic Auth compiled TUI failed to load', error) + throw error + } + } + return mod.default +} + +let legacyModule + +// Both hosts load the same subpath. Keep OC1's id/tui object shape and put +// OC2's setup on that default object too; named exports alone are insufficient +// for consumers that select the default definition after importing the module. +export const id = 'cortexkit.anthropic-auth' + +/** @type {import('@opencode-ai/plugin/tui').TuiPlugin} */ +export const tui = async (...args) => { + legacyModule ??= loadLegacyTui() + const plugin = await legacyModule + return plugin.tui(...args) +} + +/** @type {import('@opencode/plugin/tui').Plugin.Definition['setup']} */ +export const setup = async (context) => { + // OpenCode 2 already routes RPC through the active project. Importing its + // TUI must not load the OpenCode 1 renderer or start that renderer's separate + // loopback notification poller. + const { setupAnthropicTui } = await import('../../dist/v2/tui.js') + return setupAnthropicTui(context) } -export default mod.default +export default { id, tui, setup } From f27e80fb6a00cdb6365e0cb6faa7ea407f849fd9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:57:13 +0200 Subject: [PATCH 070/169] chore(core): adopt verified common-auth 0.11.7 deletion fence --- bun.lock | 4 ++-- packages/core/package.json | 2 +- packages/core/scripts/check-native-type-closure.test.ts | 4 ++-- packages/core/scripts/check-native-type-closure.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index a61e96b94..f63cd28ce 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "0.11.5", + "@cortexkit/common-auth": "0.11.7", }, }, "packages/e2e-tests": { @@ -237,7 +237,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.5", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-9ez7nmPsMxhiUQOBFwU8+91UHQ2ka7nwrn5OGLaj/61V89Wce/ZtICnIR47yXWT/ojA7zYSvGIr6tzGAf/UpFA=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.7","",{"dependencies":{"jsonc-parser":"^3.3.1"},"peerDependencies":{"solid-js":"1.9.12","@opentui/core":">=0.5.11","@opentui/solid":">=0.5.11","@opencode/plugin":">=2.0.21","@cortexkit/claustrum-client":">=0.6.2"},"optionalPeers":["solid-js","@opentui/core","@opentui/solid","@opencode/plugin","@cortexkit/claustrum-client"]},"sha512-dUvexlN0Jx/Q1dZWhVBI+cDJPLIg/nEmBlh6P4tqjTqSR4H2GxwmWKuJJ4PdClQLeFMitUWLAllSEcEi/iL8rg=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], diff --git a/packages/core/package.json b/packages/core/package.json index 1510eb17c..2b1ef1fa5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "0.11.5" + "@cortexkit/common-auth": "0.11.7" }, "dependencies": { "@cortexkit/claustrum-client": "0.6.2", diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index 2482487fa..b8575d5a9 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -98,7 +98,7 @@ test('rejects unsupported published producer versions before copying declaration ) await expect(closeNativeTypeDeclarations(dist, producer)).rejects.toThrow( - 'Expected published common-auth 0.11.5, got 0.9.0', + 'Expected published common-auth 0.11.7, got 0.9.0', ) expect(await readdir(dist)).not.toContain('internal-types') }) @@ -111,7 +111,7 @@ test('copies the complete published relative declaration graph including cycles producer, 'package.json', JSON.stringify({ - version: '0.11.5', + version: '0.11.7', exports: { './store': { types: './dist/store/index.d.ts' } }, }), ) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts index fe51f2fc8..9d51f322d 100644 --- a/packages/core/scripts/check-native-type-closure.ts +++ b/packages/core/scripts/check-native-type-closure.ts @@ -185,9 +185,9 @@ export async function closeNativeTypeDeclarations( version: string exports: Record } - if (manifest.version !== '0.11.5') + if (manifest.version !== '0.11.7') throw new Error( - `Expected published common-auth 0.11.5, got ${manifest.version}`, + `Expected published common-auth 0.11.7, got ${manifest.version}`, ) const producerDist = join(producerRoot, 'dist') const assets = join(distRoot, TYPE_ASSETS) From 1a3b8cc7ed3a53206d51e1bf8d59b36bb9ee7ef3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:43:04 +0200 Subject: [PATCH 071/169] mason: preserve OC1 native serving and single claude menu Approved eight-path preservation snapshot ab950d098bee6204a3b5d9fe14d79593c9509f47f0c6340ff5876f2dd95b1693. Credentials and physical attempts use the native backend; SDK OAuth returns inert activation. Command registration assertions intentionally change from legacy claude-* aliases to the single checked claude contract. New fixture suites use the real version-3 offline migration controller and body-owned test lifetimes. Parent's exact Biome hook correction is included. Borrowed Core/UI dependencies are excluded and retained untouched. Preservation only: remote pinned verification and final source integration remain outstanding. --- packages/opencode/src/cli.ts | 249 +- packages/opencode/src/custody-mode.ts | 23 +- packages/opencode/src/index.ts | 3596 ++++++++--------- .../src/tests/account-command.test.ts | 23 +- .../opencode/src/tests/custody-mode.test.ts | 22 +- packages/opencode/src/tests/index.test.ts | 81 +- .../opencode/src/tests/native-cli.test.ts | 188 + .../opencode/src/tests/native-serving.test.ts | 1190 ++++++ 8 files changed, 3352 insertions(+), 2020 deletions(-) create mode 100644 packages/opencode/src/tests/native-cli.test.ts create mode 100644 packages/opencode/src/tests/native-serving.test.ts diff --git a/packages/opencode/src/cli.ts b/packages/opencode/src/cli.ts index c59f95058..2e356a92a 100644 --- a/packages/opencode/src/cli.ts +++ b/packages/opencode/src/cli.ts @@ -3,40 +3,31 @@ import { stdin as input, stdout as output } from 'node:process' import { createInterface } from 'node:readline/promises' import { - type AccountStorage, - addAccountPersistent, authorize, + createNativeAccountRuntime, exchange, generateRelayToken, getAccountStoragePath, - getClaustrumMode, - isOAuthAccount, isValidApiBaseURL, - loadAccounts, - saveAccounts, + type NativeAccountRuntime, + resolveNativePoolPaths, WORKER_SCRIPT, } from '@cortexkit/anthropic-auth-core' -function defaultStorage(): AccountStorage { - return { - version: 1, - main: { type: 'opencode', provider: 'anthropic' }, - fallbackOn: [401, 403, 429], - refresh: { - enabled: true, - intervalMinutes: 10, - refreshBeforeExpiryMinutes: 240, - }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { - five_hour: 10, - seven_day: 20, - }, - failClosedOnUnknownQuota: true, - }, - accounts: [], +async function withNativeAccounts( + run: (runtime: NativeAccountRuntime) => Promise, +): Promise { + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) + throw new Error( + 'Native account changes require offline setup without OPENCODE_AUTH_CONTENT', + ) + const paths = await resolveNativePoolPaths() + const runtime = createNativeAccountRuntime({ paths, host: 'opencode' }) + try { + await runtime.read() + return await run(runtime) + } finally { + runtime.close() } } @@ -48,7 +39,8 @@ function usage() { opencode-anthropic-auth list opencode-anthropic-auth relay setup -Fallback accounts are stored in: +Native credentials are managed by the offline setup migration. +Legacy import source: ${getAccountStoragePath()}`) } @@ -207,6 +199,7 @@ export interface RelaySetupDeps { } export async function relaySetup(deps: RelaySetupDeps = {}) { + await withNativeAccounts((runtime) => runtime.read()) const fetchImpl = deps.fetchImpl ?? fetch const ask = deps.prompt ?? prompt const token = requireText( @@ -256,20 +249,20 @@ export async function relaySetup(deps: RelaySetupDeps = {}) { defaultUrl || requireText(await prompt('Relay Worker URL: '), 'Relay Worker URL') - // Provisioning can take minutes. Reload immediately before commit so the - // relay setup cannot overwrite fallback accounts changed while it was open. - const storage = (await loadAccounts()) ?? defaultStorage() - storage.relay = { - enabled: true, - url, - token: relayToken, - fallbackToDirect: true, - transport: 'http', - } - await saveAccounts(storage) + // Provisioning can take minutes. The relay transaction updates private + // connection authorization and settings without replacing account metadata. + await withNativeAccounts((runtime) => + runtime.updateRelay({ + enabled: true, + url, + token: relayToken, + fallbackToDirect: true, + transport: 'http', + }), + ) console.log(`Relay enabled at ${url}`) - console.log(`Config saved to ${getAccountStoragePath()}.`) + console.log('Relay settings saved to the native account store.') } let promptInterface: ReturnType | null = null @@ -285,11 +278,9 @@ function closePromptInterface() { } /** - * Dependencies the `login` command talks to the outside world through. All - * default to the real implementations (the readline-backed prompt, and the - * core authorize/exchange helpers) so the production `login` path is - * unchanged; tests inject deterministic stubs to exercise the full login flow - * in-process without a subprocess, real network, or stdin. + * Tests can supply prompts and OAuth responses without opening a browser. + * Login stores exchanged tokens in the shared account pool after validating + * the account identity. It never writes OpenCode's auth.json or old sidecars. */ export interface LoginDeps { prompt?: (message: string) => Promise @@ -298,62 +289,70 @@ export interface LoginDeps { } export async function login(labelArg?: string, deps: LoginDeps = {}) { + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) { + throw new Error( + 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set', + ) + } const ask = deps.prompt ?? prompt const authorizeImpl = deps.authorize ?? authorize const exchangeImpl = deps.exchange ?? exchange - if (getClaustrumMode(await loadAccounts()) === 'claustrum') { - throw new Error('Exit Claustrum mode first: /claude-account local') - } - const label = - labelArg?.trim() || (await ask('Fallback account label (optional): ')) - const authorization = await authorizeImpl('max') - - console.log('\nOpen this URL in your browser and complete Claude sign-in:\n') - console.log(`${authorization.url}\n`) - const code = await ask( - 'Paste the full callback URL or authorization code here: ', - ) - const result = await exchangeImpl( - code, - authorization.verifier, - authorization.redirectUri, - authorization.state, - ) - - if (result.type === 'failed') { - throw new Error('Authentication failed') - } - - const now = Date.now() - const account = { - id: label || crypto.randomUUID(), - label: label || undefined, - type: 'oauth', - authLineageId: crypto.randomUUID(), - access: result.access, - refresh: result.refresh, - expires: result.expires, - enabled: true, - addedAt: now, - lastUsed: now, - lastRefreshedAt: now, - } as const - await addAccountPersistent(account) - - console.log(`\nSaved fallback account${label ? ` "${label}"` : ''}.`) + await withNativeAccounts(async (runtime) => { + const snapshot = await runtime.read() + if (snapshot.mode !== 'local') + throw new Error( + 'Exit vault custody with offline setup before local login', + ) + const label = + labelArg?.trim() || (await ask('Fallback account label (optional): ')) + const authorization = await authorizeImpl('max') + console.log( + '\nOpen this URL in your browser and complete Claude sign-in:\n', + ) + console.log(`${authorization.url}\n`) + const code = await ask( + 'Paste the full callback URL or authorization code here: ', + ) + const result = await exchangeImpl( + code, + authorization.verifier, + authorization.redirectUri, + authorization.state, + ) + if (result.type === 'failed') throw new Error('Authentication failed') + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) + throw new Error( + 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set', + ) + const routeId = label && label !== 'main' ? label : crypto.randomUUID() + const replace = snapshot.accounts.some( + (account) => account.id === routeId && account.source === 'local', + ) + await runtime.loginOAuth({ + routeId, + replace, + label: label || undefined, + credential: { + access: result.access, + refresh: result.refresh, + expires: result.expires, + }, + }) + console.log(`\nSaved native fallback account${label ? ` "${label}"` : ''}.`) + }) } /** - * Dependencies the `api add` command talks to the outside world through. The - * prompt defaults to the real readline-backed prompt so the production - * `api add` path is unchanged; tests inject canned answers to exercise the - * full route-add flow in-process without a subprocess or stdin. + * Tests can supply prompts to check API keys and proxy route settings. + * New keys go to the shared account pool after migration has committed; + * OpenCode's auth.json and the old sidecar files remain unchanged. */ export interface ApiAddDeps { prompt?: (message: string) => Promise } export async function addApiRoute(labelArg?: string, deps: ApiAddDeps = {}) { + await withNativeAccounts((runtime) => runtime.read()) const ask = deps.prompt ?? prompt const label = labelArg?.trim() || (await ask('API fallback label (optional): ')) @@ -382,18 +381,21 @@ export async function addApiRoute(labelArg?: string, deps: ApiAddDeps = {}) { .toLowerCase() const authHeader = authHeaderInput === 'x-api-key' ? 'x-api-key' : 'authorization-bearer' - const now = Date.now() - - await addAccountPersistent({ - id: label || crypto.randomUUID(), - label: label || undefined, - type: 'api', - apiKey: apiKey.trim(), - baseURL, - authHeader, - enabled: true, - addedAt: now, - lastUsed: now, + + await withNativeAccounts(async (runtime) => { + const snapshot = await runtime.read() + const routeId = label && label !== 'main' ? label : crypto.randomUUID() + const existing = snapshot.accounts.find((account) => account.id === routeId) + if (existing?.source === 'vault') + throw new Error('Vault routes cannot be replaced with a local API key') + await runtime.addApi({ + routeId, + replace: Boolean(existing), + label: label || undefined, + apiKey: apiKey.trim(), + baseURL, + authHeader, + }) }) console.log( @@ -402,29 +404,32 @@ export async function addApiRoute(labelArg?: string, deps: ApiAddDeps = {}) { } async function listAccounts() { - const storage = await loadAccounts() - if (!storage?.accounts.length) { - console.log(`No fallback accounts found at ${getAccountStoragePath()}.`) - return - } - - for (const [index, account] of storage.accounts.entries()) { - const label = account.label || account.id - const status = account.enabled === false ? 'disabled' : 'enabled' - if (!isOAuthAccount(account)) { - console.log( - `${index + 1}. ${label} (${status}) — API route ${account.baseURL}`, - ) - continue + await withNativeAccounts(async (runtime) => { + const accounts = (await runtime.read()).accounts.filter( + (account) => account.id !== 'main', + ) + if (!accounts.length) { + console.log('No native fallback accounts found.') + return } - const fiveHour = account.quota?.five_hour?.remainingPercent - const sevenDay = account.quota?.seven_day?.remainingPercent - const quota = - fiveHour === undefined && sevenDay === undefined - ? 'quota unknown' - : `5h ${fiveHour ?? '?'}%, 1w ${sevenDay ?? '?'}% remaining` - console.log(`${index + 1}. ${label} (${status}) — ${quota}`) - } + for (const [index, account] of accounts.entries()) { + const label = account.label || account.id + const status = account.enabled ? 'enabled' : 'disabled' + if (account.type === 'api') { + console.log( + `${index + 1}. ${label} (${status}) — API route ${account.baseURL}`, + ) + continue + } + const fiveHour = account.quota?.five_hour?.remainingPercent + const sevenDay = account.quota?.seven_day?.remainingPercent + const quota = + fiveHour === undefined && sevenDay === undefined + ? 'quota unknown' + : `5h ${fiveHour ?? '?'}%, 1w ${sevenDay ?? '?'}% remaining` + console.log(`${index + 1}. ${label} (${status}) — ${quota}`) + } + }) } import { runSetupCommand } from './setup/command.ts' diff --git a/packages/opencode/src/custody-mode.ts b/packages/opencode/src/custody-mode.ts index 9f79620bd..b118cf930 100644 --- a/packages/opencode/src/custody-mode.ts +++ b/packages/opencode/src/custody-mode.ts @@ -1,5 +1,3 @@ -export const OPENCODE_MAIN_OAUTH_REFRESH_LOCK = 'opencode-main-oauth-refresh' - export class CustodyStateMismatchError extends Error { readonly code = 'custody_state_mismatch' constructor( @@ -22,14 +20,31 @@ export class CustodyStateMismatchError extends Error { } } -/** Only a scoped roster and a non-secret host tombstone can activate custody. */ +/** + * L/C select local/Claustrum custody. Main R/T/X means stored tokens, an empty + * activation marker, or neither; fallback R/T/M means local accounts, tokenless + * vault accounts, or missing roster metadata. V/N marks verified/missing evidence. + * Only migrated OAuth uses these checks; ordinary OpenCode API keys are untouched. + * The activation marker enables the provider but cannot authenticate a request. + * Before each send, validate the local account and current tokens or obtain + * fresh authorization for that account from the vault. + */ export function reconcileCustodyStartup(input: { mode: 'L' | 'C' main: 'R' | 'T' | 'X' fallbacks: 'R' | 'T' | 'M' evidence: 'V' | 'N' + authority?: 'committed' | 'retired' }): { verdict: 'LOCAL_SERVE' | 'CLAUSTRUM_SERVE' } { - if (input.mode === 'L' && input.main === 'R' && input.fallbacks === 'R') { + if (input.authority !== 'committed' && input.authority !== 'retired') { + throw new CustodyStateMismatchError('MIGRATION_REQUIRED', input) + } + if ( + input.mode === 'L' && + input.main === 'T' && + input.fallbacks === 'R' && + input.evidence === 'V' + ) { return { verdict: 'LOCAL_SERVE' } } if ( diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 69fb5666d..fd1e5dcbd 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -3,8 +3,6 @@ import { type AccountCommandStatusProjection, type AccountStorage, type ApiKeyAccount, - acquireRefreshFileLock, - addAccountPersistent, applyCustomHeaders, assertNotCustodyTombstone, authorize, @@ -12,20 +10,14 @@ import { buildClaudeQuotaSummary, buildFallbackQuotaSummaries, buildPrimeRequestBody, - buildRefreshOperationError, - CACHE_1H_COMMAND_NAME, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, CacheKeepSessionRegistry, - CLAUDE_ACCOUNT_COMMAND_NAME, - CLAUDE_CACHE_KEEP_COMMAND_NAME, - CLAUDE_DUMP_COMMAND_NAME, CLAUDE_FABLE_MYTHOS_5_1_PRICING, CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW, CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS, CLAUDE_FABLE_MYTHOS_5_MODEL_SPECS, CLAUDE_FABLE_MYTHOS_5_PRICING, - CLAUDE_FAST_COMMAND_NAME, CLAUDE_HAIKU_4_5_MODEL_ID, CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING, @@ -34,35 +26,25 @@ import { CLAUDE_HAIKU_5_5_MODEL_ID, CLAUDE_HAIKU_5_5_PRICING, CLAUDE_HAIKU_5_5_RELEASE_DATE, - CLAUDE_LOGGING_COMMAND_NAME, CLAUDE_OPUS_5_5_CONTEXT_WINDOW, CLAUDE_OPUS_5_5_MAX_OUTPUT_TOKENS, CLAUDE_OPUS_5_5_MODEL_ID, CLAUDE_OPUS_5_5_PRICING, CLAUDE_OPUS_5_5_RELEASE_DATE, - CLAUDE_PRIME_COMMAND_NAME, - CLAUDE_QUOTAS_COMMAND_NAME, - CLAUDE_ROUTING_COMMAND_NAME, CLAUDE_SONNET_5_5_CONTEXT_WINDOW, CLAUDE_SONNET_5_5_MAX_OUTPUT_TOKENS, CLAUDE_SONNET_5_5_MODEL_ID, CLAUDE_SONNET_5_5_PRICING, CLAUDE_SONNET_5_5_RELEASE_DATE, - CLAUDE_START_COMMAND_NAME, CLAUSTRUM_OPENCODE_ENROLLMENT_NAME, - type ClaustrumScopedAttempt, - type ClaustrumScopedClient, - ClaustrumScopedRuntime, - type ClaustrumScopedRuntimeOptions, type CustodyStatusState, - CustodyTombstoneLoginError, computeXxhash64Hex, configuredAnthropicOAuthAccountCount, - connectClaustrumScopedClient, createEmptyStorage, + createNativeAccountRuntime, createStickyNoRouteResponse, + custodyTombstoneOAuth, type DumpHandle, - decideScopedRetryAfter401, decideStickyQuotaFailure, detectClaustrumConnection, dumpDirectRequest, @@ -78,10 +60,9 @@ import { executeLoggingCommand, executePrimeCommand, executeRoutingCommand, - FallbackAccountManager, + FALLBACK_BACKGROUND_TICK_MS, fallbackAccountUuidForLineage, - fetchOAuthAccountProfile, - fetchOAuthQuotaSnapshot, + type fetchOAuthAccountProfile, formatEnrollmentStatus, formatOAuthAccountTier, formatQuotaBackoffMessage, @@ -90,24 +71,19 @@ import { getCache1hMode, getCache1hPersistentMode, getCacheKeepWindow, + getClaudeCodeIdentityForVerifiedAccount, getClaudeFableMythos5ReleaseDate, getClaustrumMode, getDefaultCacheKeepRegistryDirectory, getFallbackReauthLabels, getHostClaustrumEnrollmentPaths, getKillswitchConfig, - getOrCreateMainAccountId, - getOrCreatePrimeAuthLineageId, getPersistedLogLevel, - getPersistedMainQuota, getQuotaNextRefreshAt, - getRelayConfig, getRoutingMode, getStickyRoutingStatePath, getThinkingPrefixMismatchBehavior, - hashRefreshToken, type IdentityState, - incrementPrimeUsagePersistent, isApiKeyAccount, isCache1hEnabled, isCache1hPersistentlyEnabled, @@ -134,21 +110,23 @@ import { isPrimePersistentlyEnabled, isQuotaBearingHeaderFrame, isValidApiBaseURL, - KILLSWITCH_COMMAND_NAME, killswitchPassesPolicy, killswitchRetryAfterSeconds, - loadAccounts, log, logger, mergeAnthropicBetas, mergeHeaderQuotaForPersistence, - mergeMainQuotaErrorClearedAt, - mergeMainRefreshErrorClearedAt, + type NativeAccountRuntimeOptions, + type NativeAccountView, + type NativeCustodyClient, + type NativeCustodyReceipt, + type NativeLocalCredentialValidation as NativeKnownCredentialSubject, + type NativeMenuDispatch, + type NativeUiOptions, normalizeQuotaHeaders, type OAuthAccount, type OAuthQuotaSnapshot, oauthProfileIsFresh, - oauthProfileMatchesIdentity, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, PrimeManager, type PrimeManagerOptions, @@ -164,8 +142,6 @@ import { parseLoggingCommandAction, parsePrimeCommandAction, parseRoutingCommandAction, - persistFallbackQuotaHeaderPersistent, - primeStorageFingerprint, QUOTA_HEADER_FEED_SCHEMA_VERSION, type QuotaAccountSummary, type QuotaEntry, @@ -179,36 +155,19 @@ import { quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, readClaustrumEnrollmentStatus, + readNativeMigrationJournal, refreshBackoffActive, - refreshClaudeOAuthToken, remapRequestBodyModel, - removeAccountPersistent, - reorderAccountsPersistent, resetClaustrumEnrollmentState, - resolveClaudeCodeIdentity, + resolveNativePoolPaths, STICKY_ROUTING_MAIN_ACCOUNT_ID, type StickyRouteCandidate, StickySessionRouter, - saveAccountState, - saveOAuthProfileState, sendViaRelay, - setAccountEnabledPersistent, - setCache1hPersistentEnabled, - setCache1hPersistentMode, setCache1hState, - setCacheKeepPersistentAlways, - setCacheKeepPersistentEnabled, - setCacheKeepPersistentWindow, - setCacheKeepSubagentsEnabled, setDumpEnabled, - setDumpPersistentEnabled, setFastModeEnabled, - setFastModePersistentEnabled, - setKillswitchPersistent, setLogLevel, - setLogLevelPersistent, - setPrimePersistentEnabled, - setRoutingMode, shouldFallbackStatus, stickyQuotaSnapshotIsFresh, stickyRouteFamilyForModel, @@ -243,7 +202,6 @@ import { } from './custody-dimensions.ts' import { CustodyStateMismatchError, - OPENCODE_MAIN_OAUTH_REFRESH_LOCK, reconcileCustodyStartup, } from './custody-mode.ts' import { @@ -264,6 +222,7 @@ import { LANE_START_REQUEST_HEADER, LaneStartTracker, } from './lane-start.ts' +import { createNativeCommand } from './native-command.js' import { adoptPrimeManager } from './prime-manager-registry.ts' import { resolvePromptContext } from './prompt-context.ts' import { @@ -375,15 +334,10 @@ function trailingAssistantHistoryFailureResponse( } const MAIN_AUTH_REFRESH_TICK_MS = 60_000 const MAIN_AUTH_REFRESH_TICK_JITTER_MS = 60_000 -const CONCURRENT_MAIN_REFRESH_WAIT_MS = 5_000 -const CONCURRENT_MAIN_REFRESH_POLL_BASE_MS = 200 const MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240 const DEFAULT_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES const SIDEBAR_ROUTING_FRESH_MS = 10 * 60 * 1000 -// OpenCode creates one plugin instance per project, but those instances share -// the same host OAuth credential and account sidecar within a server process. -const mainAccessRefreshesByStoragePath = new Map>() function hasEnabledOAuthAccount( storage: AccountStorage | null, @@ -469,7 +423,7 @@ async function resolveFreshSidebarRouting( } async function resolveInitialSidebarRouting( - accountStoragePath: string, + loadStorage: () => Promise, ): Promise<{ activeId: string | undefined route: string @@ -478,7 +432,7 @@ async function resolveInitialSidebarRouting( await getInitialSidebarRoutingTestHooks()?.beforeStorageLoad?.() let freshStorage: AccountStorage | null try { - freshStorage = await loadAccounts(accountStoragePath) + freshStorage = await loadStorage() } catch { return { activeId: 'main', route: 'main', freshStorage: null } } finally { @@ -824,6 +778,8 @@ type ServedQuotaHeaders = { authLineageId?: string anthropicAccountUuid?: ProviderAccountUuid | null mainQuotaIdentity?: MainQuotaIdentityBinding + localSubject?: NativeKnownCredentialSubject + scopedAttempt?: NativeCustodyReceipt } function asProviderAccountUuid( @@ -1125,7 +1081,8 @@ type PluginRuntimeOverrides = Partial<{ setInterval: typeof globalThis.setInterval clearInterval: typeof globalThis.clearInterval scopedRosterPollIntervalMs: number - claustrumScopedConnect: () => Promise + claustrumScopedConnect: () => Promise + nativeLocal: NativeAccountRuntimeOptions['local'] cacheKeepAggregateRefreshIntervalMs: number }> @@ -1206,14 +1163,137 @@ const anthropicAuthPlugin = async ( return entry } - let initialStorage: AccountStorage | null + const nativePaths = await resolveNativePoolPaths(accountStoragePath) + const nativeAccounts = createNativeAccountRuntime({ + paths: nativePaths, + host: 'opencode', + local: runtimeOverrides.nativeLocal, + vault: runtimeOverrides.claustrumScopedConnect + ? { connect: runtimeOverrides.claustrumScopedConnect } + : undefined, + }) + // The AccountStorage-shaped projection supplies quota, settings, and account + // metadata to routing and display code. It contains no OAuth access/refresh + // tokens or API keys; credential material comes only from native authorization. + async function loadAccounts( + path = accountStoragePath, + ): Promise { + if (path !== accountStoragePath) + throw new Error('Native account storage mismatch') + const snapshot = await nativeAccounts.read() + if ( + snapshot.mode !== 'claustrum' || + !snapshot.accounts.some((account) => account.source === 'vault') + ) + return snapshot.policyStorage + const primary = snapshot.accounts.find( + (account) => account.id === 'main' && account.source === 'vault', + ) + const primaryUuid = asProviderAccountUuid(primary?.accountIdentity) + // Existing routing predicates use the scopedRoster flag. Derive that + // metadata only from native vault rows, not legacy config; every physical + // request still obtains its own receipt from the shared vault runtime. + return { + ...snapshot.policyStorage, + claustrum: { + ...snapshot.policyStorage.claustrum, + mode: 'claustrum', + scopedRoster: true, + primaryAccount: + primary?.credentialId && primaryUuid + ? { + credentialId: primary.credentialId, + accountId: primaryUuid, + state: primary.state ?? 'cold', + } + : undefined, + }, + } + } + async function updateNativeSection( + section: + | 'claudeCache' + | 'cacheKeep' + | 'dump' + | 'claudeFast' + | 'logging' + | 'prime' + | 'routing' + | 'killswitch', + patch: Record, + ) { + await nativeAccounts.updateSettings((settings) => { + const previous = settings[section] + return { + ...settings, + [section]: { + ...(previous && + typeof previous === 'object' && + !Array.isArray(previous) + ? previous + : {}), + ...patch, + }, + } + }) + return loadAccounts() + } + const setCache1hPersistentEnabled = (enabled: boolean) => + updateNativeSection('claudeCache', { enabled }) + const setCache1hPersistentMode = ( + mode: NonNullable['mode'], + ) => updateNativeSection('claudeCache', { mode }) + const setCacheKeepPersistentEnabled = (enabled: boolean) => + updateNativeSection('cacheKeep', { enabled }) + const setCacheKeepPersistentAlways = () => + updateNativeSection('cacheKeep', { enabled: true, always: true }) + const setCacheKeepPersistentWindow = (startHour: number, endHour: number) => + updateNativeSection('cacheKeep', { + enabled: true, + always: false, + startHour, + endHour, + }) + const setCacheKeepSubagentsEnabled = (subagents: boolean) => + updateNativeSection('cacheKeep', { subagents }) + const setDumpPersistentEnabled = (enabled: boolean) => + updateNativeSection('dump', { enabled }) + const setFastModePersistentEnabled = (enabled: boolean) => + updateNativeSection('claudeFast', { enabled }) + const setLogLevelPersistent = ( + level: NonNullable['level'], + ) => updateNativeSection('logging', { level }) + const setPrimePersistentEnabled = ( + enabled: boolean, + _path = accountStoragePath, + ) => updateNativeSection('prime', { enabled }) + const setRoutingMode = ( + mode: NonNullable['mode'], + _path = accountStoragePath, + ) => updateNativeSection('routing', { mode }) + const setKillswitchPersistent = ( + killswitch: NonNullable, + ) => updateNativeSection('killswitch', killswitch) + const setAccountEnabledPersistent = ( + routeId: string, + enabled: boolean, + _path = accountStoragePath, + ) => nativeAccounts.setEnabled(routeId, enabled) + const removeAccountPersistent = ( + routeId: string, + _path = accountStoragePath, + ) => nativeAccounts.remove(routeId) + const reorderAccountsPersistent = ( + routeIds: string[], + _path = accountStoragePath, + ) => nativeAccounts.reorder(routeIds) + + let initialStorage: AccountStorage | null = null + let nativeStartupError: unknown try { - initialStorage = await loadAccounts(accountStoragePath) + initialStorage = await loadAccounts() } catch (error) { - const message = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to load account store: ${message}`, { - cause: error instanceof Error ? error : undefined, - }) + nativeStartupError = error } function isScopedCustodyActive( storage: AccountStorage | null | undefined, @@ -1224,53 +1304,178 @@ const anthropicAuthPlugin = async ( storage.claustrum?.scopedRoster === true, ) } - - const openCodeScopedRuntimes = new Map() - function getOpenCodeScopedRuntime( storagePath = accountStoragePath, - ctxDirectory = ctx.directory, - overrides: Partial = {}, - ): ClaustrumScopedRuntime { - let runtime = openCodeScopedRuntimes.get(storagePath) - if (!runtime) { - const identity = { - project_root: ctxDirectory ?? process.cwd(), - harness: 'opencode', - session: `store-${primeStorageFingerprint(storagePath)}`, + _ctxDirectory = ctx.directory, + ) { + if (storagePath !== accountStoragePath) + throw new Error('Native vault storage mismatch') + return nativeAccounts.vault + } + function closeOpenCodeScopedRuntime(_storagePath = accountStoragePath): void { + nativeAccounts.close() + } + + function assertNativeEnvironment() { + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) + throw new Error( + 'Native OAuth cannot serve while OPENCODE_AUTH_CONTENT is set', + ) + } + type NativeOAuthAuthorization = { + accessToken: string + expires?: number + accountIdentity?: string + localSubject?: NativeKnownCredentialSubject + localSource?: 'current' | 'validated' | 'rotated' | 'adopted' + scopedAttempt?: NativeCustodyReceipt + } + function nativeWireIdentity(accountIdentity: string | undefined) { + const accountUuid = asProviderAccountUuid(accountIdentity) + if (!accountUuid) + throw new Error( + 'Native wire identity requires an admitted provider account', + ) + // Credential admission already established this account UUID. Signing uses + // stable client identity metadata, never another profile lookup with an + // earlier token or vault receipt. + return getClaudeCodeIdentityForVerifiedAccount(accountUuid, accountUuid) + } + class NativeModelPolicyError extends Error { + constructor(readonly account: NativeAccountView) { + super( + 'OAuth account cannot serve the requested model under current quota policy', + ) + } + } + async function authorizeOAuth( + routeId: string, + signal?: AbortSignal, + rejectedAccessToken?: string, + modelId?: string, + intent: 'serve' | 'refresh' = 'serve', + ): Promise { + assertNativeEnvironment() + signal?.throwIfAborted() + const snapshot = await nativeAccounts.read() + const account = snapshot.accounts.find( + (candidate) => candidate.id === routeId, + ) + if (account?.type !== 'oauth' || !account.enabled) { + throw new Error('Native OAuth account is unavailable') + } + const checkModelPolicy = ( + view: NativeAccountView, + storage: AccountStorage, + ) => { + if ( + modelId && + ((isKillswitchEnabled(storage) && + !killswitchPassesPolicy( + view.quota, + storage, + routeId === 'main' ? undefined : routeId, + modelId, + )) || + !quotaSnapshotPassesModelScope(view.quota, modelId)) + ) + throw new NativeModelPolicyError(view) + } + checkModelPolicy(account, snapshot.policyStorage) + let result: NativeOAuthAuthorization + if (account.source === 'vault') { + const receipt = await nativeAccounts.authorizeVault(routeId, signal) + result = { + accessToken: receipt.accessToken, + expires: receipt.expiresAtMs ?? undefined, + accountIdentity: receipt.accountIdentity, + scopedAttempt: receipt, } - runtime = new ClaustrumScopedRuntime({ - storagePath, - tokenPath: getHostClaustrumEnrollmentPaths('opencode').tokenPath, - connect: () => - runtimeOverrides.claustrumScopedConnect - ? runtimeOverrides.claustrumScopedConnect() - : connectClaustrumScopedClient({ - storagePath, - identity, - ...(getConfiguredClaustrumConnectionFile() && { - connectionFile: getConfiguredClaustrumConnectionFile(), - }), - }), - pollIntervalMs: runtimeOverrides.scopedRosterPollIntervalMs ?? 2000, - onRoster: async () => { - await fallbackManager.refreshQuotaForDueAccounts().catch(() => {}) - await refreshSidebarQuota().catch(() => {}) - }, - ...overrides, + } else { + const authorization = await nativeAccounts.authorizeLocal(routeId, { + signal, + rejectedAccessToken, + intent: rejectedAccessToken ? 'refresh' : intent, }) - openCodeScopedRuntimes.set(storagePath, runtime) + if (authorization.status !== 'usable') + throw new Error( + `Native OAuth authorization refused: ${authorization.status}`, + ) + result = { + accessToken: authorization.access, + expires: authorization.expires, + accountIdentity: authorization.binding.identity, + localSubject: authorization.subject, + localSource: authorization.source, + } } - return runtime + // Concurrent requests can share token refresh while using different models. + // After authorization waits, recheck that the account is still enabled and + // that this request's model quota and killswitch rules still permit sending. + const current = await nativeAccounts.read() + const selected = current.accounts.find( + (candidate) => candidate.id === routeId, + ) + if (signal?.aborted) throw signal.reason ?? new Error('Request aborted') + if ( + !selected?.enabled || + selected.source !== account.source || + selected.accountIdentity !== result.accountIdentity + ) + throw new Error('Native OAuth account changed before dispatch') + if ( + result.localSubject && + (!selected.binding || + selected.binding.storageId !== result.localSubject.binding.storageId || + selected.binding.rowId !== result.localSubject.binding.rowId || + selected.binding.credentialEpoch !== + result.localSubject.binding.credentialEpoch || + selected.binding.identity !== result.localSubject.binding.identity) + ) + throw new Error('Native local account binding changed before dispatch') + if ( + result.scopedAttempt && + selected.credentialId !== result.scopedAttempt.credentialId + ) + throw new Error('Native vault credential binding changed before dispatch') + checkModelPolicy(selected, current.policyStorage) + return result } - function closeOpenCodeScopedRuntime(storagePath = accountStoragePath): void { - openCodeScopedRuntimes.get(storagePath)?.close() - openCodeScopedRuntimes.delete(storagePath) + async function resetNativeBackoff(routeId: string, signal?: AbortSignal) { + const snapshot = await nativeAccounts.read() + const account = snapshot.accounts.find( + (candidate) => candidate.id === routeId, + ) + if (account?.type !== 'oauth') + throw new Error('Native OAuth account is unavailable') + const fence = + account.source === 'vault' + ? await nativeAccounts.authorizeVault(routeId, signal) + : await nativeAccounts.captureLocalSubject(routeId) + if (!(await nativeAccounts.resetBackoff(routeId, fence))) + throw new Error('Native reset fence changed') } - - if (isScopedCustodyActive(initialStorage)) { - getOpenCodeScopedRuntime(accountStoragePath, ctx.directory).start() + const nativeLocalRefreshFailures = new WeakMap() + const reportedVault401 = new WeakSet() + async function reportVault401( + receipt: NativeCustodyReceipt, + source: 'direct' | 'relay_status_field', + ) { + if (reportedVault401.has(receipt)) return + reportedVault401.add(receipt) + await nativeAccounts.vault.reportFailure(receipt, 401, source).catch(() => { + logger.warn('claustrum', 'native auth-failure report unavailable') + }) + } + async function prepareVaultRetry( + receipt: NativeCustodyReceipt, + signal?: AbortSignal, + source: 'direct' | 'relay_status_field' = 'direct', + ) { + await reportVault401(receipt, source) + const result = await nativeAccounts.vault.prepareRetry(receipt, signal) + return result.retry ? result.receipt : undefined } const fallbackMode = resolveContentFilterFallbackMode( @@ -1294,152 +1499,19 @@ const anthropicAuthPlugin = async ( process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE || getStickyRoutingStatePath(accountStoragePath), }) - const persistFallbackQuotaError = async ( - accountId: string, - error: NonNullable, - ) => { - try { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && isOAuthAccount(candidate), - ) - if (!storage || !account) return - if ( - account.lastQuotaRefreshError?.checkedAt !== undefined && - account.lastQuotaRefreshError.checkedAt > error.checkedAt - ) { - return - } - if (quotaSnapshotCheckedAt(account.quota) > error.checkedAt) return - account.lastQuotaRefreshError = error - await saveAccountState(storage, accountStoragePath, { - accounts: [accountId], - }) - } catch (caught) { - logger.warn('quota', 'failed to persist fallback backoff state', { - accountId, - error: caught instanceof Error ? caught.message : String(caught), - }) - } - } + // The runtime stores quota readings for the account and tokens used to fetch + // them. QuotaManager caches those readings and when they were checked. + // A failed poll supplies no exhaustion evidence and cannot prove that a + // rejected refresh token became usable, so it must not clear refresh errors. const quotaManager = new QuotaManager({ storage: initialStorage, - fetchQuotaSnapshot: async (request) => { - const storage = await loadAccounts(accountStoragePath) - if (getClaustrumMode(storage) === 'claustrum') { - if (!isScopedCustodyActive(storage)) - throw new Error('Claustrum scoped custody setup is incomplete') - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - const routeId = request.kind === 'main' ? 'main' : request.accountId - if (!routeId) - throw new Error('Claustrum quota request has no account identity') - return runtime.fetchQuota(routeId) - } - return fetchOAuthQuotaSnapshot({ - accessToken: request.accessToken, - }) - }, - onMainQuotaFetched: async ( - quota, - checkedAt, - tokenFingerprint, - fetchStartedAt, - quotaErrorGeneration, - quotaErrorClearedAt, - ) => { - try { - const storage = - (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() - const persisted = getPersistedMainQuota(storage) - if ( - persisted && - persisted.checkedAt >= fetchStartedAt && - getQuotaNextRefreshAt(persisted.quota, storage, persisted.checkedAt) > - Date.now() - ) { - quotaManager.seedMainFromStorage(storage, mainQuotaAccountId) - return - } - storage.quota = storage.quota ?? {} - storage.quota.mainQuota = quota - storage.quota.mainQuotaCheckedAt = checkedAt - storage.quota.mainQuotaToken = tokenFingerprint - storage.quota.mainLastQuotaApiError = undefined - storage.quota.mainQuotaErrorGeneration = quotaErrorGeneration - storage.quota.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt( - storage.quota.mainQuotaErrorClearedAt, - quotaErrorClearedAt, - ) - await saveAccountState(storage, accountStoragePath, { mainQuota: true }) - } catch (error) { - logger.warn('quota', 'failed to persist main quota', { - error: error instanceof Error ? error.message : String(error), - }) - } + fetchQuotaSnapshot: (request) => { + assertNativeEnvironment() + const routeId = request.kind === 'main' ? 'main' : request.accountId + if (!routeId) throw new Error('Quota request has no account identity') + return nativeAccounts.fetchQuota(routeId) }, - onApiError: async (error, quotaErrorGeneration) => { - try { - const storage = - (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() - storage.quota = storage.quota ?? {} - storage.quota.mainLastQuotaApiError = error - storage.quota.mainQuotaErrorGeneration = quotaErrorGeneration - await saveAccountState(storage, accountStoragePath, { mainQuota: true }) - } catch (e) { - logger.warn('quota', 'failed to persist backoff state', { - error: e instanceof Error ? e.message : String(e), - }) - } - }, - onFallbackQuotaFetched: async ( - accountId, - quota, - _checkedAt, - fetchStartedAt, - ) => { - try { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && isOAuthAccount(candidate), - ) - if (!storage || !account) return - const persistedCheckedAt = quotaSnapshotCheckedAt(account.quota) - if ( - persistedCheckedAt >= fetchStartedAt && - getQuotaNextRefreshAt(account.quota, storage, persistedCheckedAt) > - Date.now() - ) { - quotaManager.seedFallbacksFromAccounts( - storage.accounts.filter(isOAuthAccount), - ) - return - } - if ( - account.lastQuotaRefreshError?.checkedAt !== undefined && - account.lastQuotaRefreshError.checkedAt > fetchStartedAt - ) { - return - } - account.quota = quota - account.lastQuotaRefreshError = undefined - await saveAccountState(storage, accountStoragePath, { - accounts: [accountId], - }) - } catch (error) { - logger.warn('quota', 'failed to persist fallback quota', { - accountId, - error: error instanceof Error ? error.message : String(error), - }) - } - }, - onFallbackApiError: persistFallbackQuotaError, }) - async function reconcileMainQuotaAccountIdentity( accessToken: string, quotaKey: string | undefined, @@ -1448,72 +1520,24 @@ const anthropicAuthPlugin = async ( if ( mainQuotaIdentityAccessToken === accessToken && mainQuotaAccountId === quotaKey - ) { + ) return - } - const providerIdentityChanged = mainProviderAccountUuid !== undefined && providerAccountUuid !== undefined && mainProviderAccountUuid !== providerAccountUuid mainQuotaIdentityAccessToken = accessToken mainQuotaAccountId = quotaKey - const clearedGeneration = quotaManager.setMainQuotaAccountIdentity( + quotaManager.setMainQuotaAccountIdentity( quotaKey, - accessToken.startsWith('sk-ant-oat'), + true, providerIdentityChanged, ) - if (quotaKey === undefined) return - - const storage = await loadAccounts(accountStoragePath) - const persistedError = storage?.quota?.mainLastQuotaApiError - if ( - !storage || - !persistedError || - persistedError.accountIdentity === quotaKey || - (!accessToken.startsWith('sk-ant-oat') && - persistedError.accountIdentity === undefined) - ) { - return - } - - storage.quota = storage.quota ?? {} - storage.quota.mainLastQuotaApiError = undefined - storage.quota.mainQuotaErrorGeneration = Math.max( - storage.quota.mainQuotaErrorGeneration ?? 0, - clearedGeneration ?? quotaManager.getMainQuotaErrorGeneration(), - ) - storage.quota.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt( - storage.quota.mainQuotaErrorClearedAt, - quotaManager.getMainQuotaErrorClearedAt() ?? Date.now(), - ) - await saveAccountState(storage, accountStoragePath, { mainQuota: true }) - } - - async function persistFallbackAnthropicAccountUuid( - accountId: string, - anthropicAccountUuid: ProviderAccountUuid, - ): Promise { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && isOAuthAccount(candidate), - ) - if ( - !storage || - !account || - account.anthropicAccountUuid === anthropicAccountUuid - ) - return - account.anthropicAccountUuid = anthropicAccountUuid - await saveAccountState(storage, accountStoragePath, { - accounts: [accountId], - }) } async function resolveMainQuotaAccountIdentity( accessToken: string, - model?: string, + _model?: string, credentialAccountUuid?: string, credentialRecordVersion?: number, ): Promise { @@ -1529,12 +1553,15 @@ const anthropicAuthPlugin = async ( const resolution = (async () => { const resolutionGeneration = ++mainQuotaIdentityResolutionGeneration const mainSlotQuotaKey = mainAccountId - const identity = await resolveClaudeCodeIdentity( - accessToken, - model, - credentialAccountUuid ?? mainSlotQuotaKey, + + const currentSnapshot = await nativeAccounts.read() + const currentStorage = currentSnapshot.policyStorage + const nativePrimaryIdentity = currentSnapshot.accounts.find( + (account) => account.id === 'main', + )?.accountIdentity + const identity = nativeWireIdentity( + credentialAccountUuid ?? nativePrimaryIdentity, ) - const currentStorage = await loadAccounts(accountStoragePath) const scopedCustodyActive = isScopedCustodyActive(currentStorage) const rosterPrimaryAccountId = scopedCustodyActive ? currentStorage?.claustrum?.primaryAccount?.accountId @@ -1544,10 +1571,9 @@ const anthropicAuthPlugin = async ( // 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) + (credentialAccountUuid !== undefined && + credentialAccountUuid !== nativePrimaryIdentity) || + (scopedCustodyActive && credentialAccountUuid === undefined) ) { return { quotaKey: undefined, @@ -1562,7 +1588,8 @@ const anthropicAuthPlugin = async ( // 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 = rosterPrimaryAccountId ?? mainSlotQuotaKey + const quotaKey = + nativePrimaryIdentity ?? rosterPrimaryAccountId ?? mainSlotQuotaKey const persistedProviderAccountUuid = currentStorage?.main?.profile?.providerAccountUuid ?? (currentStorage?.main?.profile?.accountIdentity as @@ -1650,22 +1677,20 @@ const anthropicAuthPlugin = async ( async function hydrateProfileOnce( accountId: string, accountIdentity: string | undefined, - accessToken: string, - providerAccountUuid?: ProviderAccountUuid, + _accessToken: string, + _providerAccountUuid?: ProviderAccountUuid, signal?: AbortSignal, ) { if (signal?.aborted) return undefined + assertNativeEnvironment() const attemptKey = `${accountIdentity ?? accountId}` let attempt = profileHydrationAttempts.get(attemptKey) if (!attempt) { - const fetchAttempt = fetchOAuthAccountProfile({ - accessToken, - accountIdentity, - providerAccountUuid, - fetchImpl: profileFetch, - signal, - }) - .catch((error) => { + const fetchAttempt: Promise< + Awaited> | undefined + > = nativeAccounts + .fetchProfile(accountId, profileFetch, signal) + .catch((error: unknown) => { logger.debug('quota', 'failed to hydrate account profile', { account: accountId, error: error instanceof Error ? error.message : String(error), @@ -1696,226 +1721,69 @@ const anthropicAuthPlugin = async ( }) } - async function persistProfileStateBestEffort(input: { - accountId: 'main' | string - accountIdentity?: string - providerAccountUuid?: ProviderAccountUuid - accessToken: string - profile: OAuthAccount['profile'] - }): Promise { - try { - return await saveOAuthProfileState( - { - accountId: input.accountId, - profile: input.profile, - accountIdentity: input.accountIdentity, - }, - accountStoragePath, - ) - } catch (error) { - logger.debug('quota', 'failed to persist account profile', { - account: input.accountId, - error: error instanceof Error ? error.message : String(error), - }) - return undefined - } - } - async function ensureProfilesForQuotaDisplay( storage: AccountStorage, - mainAccessToken?: string, + _mainAccessToken?: string, signal?: AbortSignal, - mainProviderAccountUuid?: ProviderAccountUuid, + _mainProviderAccountUuid?: ProviderAccountUuid, ): Promise { - if (process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION === '1') { + if (process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION === '1') return storage - } - const now = Date.now() - const mainIdentity = mainQuotaAccountId - if ( - mainAccessToken && - storage.main?.profile && - mainProviderAccountUuid && - storage.main.profile.providerAccountUuid !== mainProviderAccountUuid - ) { - storage.main.profile = undefined - void persistProfileStateBestEffort({ - accountId: 'main', - accountIdentity: mainIdentity, - providerAccountUuid: mainProviderAccountUuid, - accessToken: mainAccessToken, - profile: undefined, - }).catch(() => {}) - } - if ( - mainAccessToken && - storage.main?.profile && - mainIdentity !== undefined && - storage.main.profile.accountIdentity === undefined - ) { - storage.main.profile = { - ...storage.main.profile, - accountIdentity: mainIdentity, - } - void persistProfileStateBestEffort({ - accountId: 'main', - accountIdentity: mainIdentity, - providerAccountUuid: mainProviderAccountUuid, - accessToken: mainAccessToken, - profile: storage.main.profile, - }).catch(() => {}) - } - if (mainAccessToken && !oauthProfileIsFresh(storage.main?.profile, now)) { - const profile = await hydrateProfileOnce( - 'main', - undefined, - mainAccessToken, - mainProviderAccountUuid, - signal, - ) - if (profile) { - storage.main = { - type: 'opencode', - provider: 'anthropic', - profile, - } - void persistProfileStateBestEffort({ - accountId: 'main', - accountIdentity: mainIdentity, - providerAccountUuid: mainProviderAccountUuid, - accessToken: mainAccessToken, - profile, - }).catch(() => {}) - } - } - - for (const account of storage.accounts) { + const snapshot = await nativeAccounts.read() + for (const account of snapshot.accounts) { if (signal?.aborted) break - if (!isOAuthAccount(account)) continue - const vaultEnabled = isScopedCustodyActive(storage) - if (vaultEnabled && !isFallbackAccountVaultServed(account.id, storage)) - continue - const accessToken = vaultEnabled - ? ( - await getOpenCodeScopedRuntime() - .authorize(account.id, signal) - .catch(() => undefined) - )?.accessToken - : account.access - if (!accessToken) continue if ( - account.profile && - !oauthProfileMatchesIdentity(account.profile, account.id) - ) { - account.profile = undefined - void persistProfileStateBestEffort({ - accountId: account.id, - accountIdentity: account.id, - accessToken, - profile: undefined, - }).catch(() => {}) - } - if (account.profile && account.profile.accountIdentity === undefined) { - account.profile = { ...account.profile, accountIdentity: account.id } - void persistProfileStateBestEffort({ - accountId: account.id, - accountIdentity: account.id, - accessToken, - profile: account.profile, - }).catch(() => {}) - } - if (oauthProfileIsFresh(account.profile, now)) continue - const profile = await hydrateProfileOnce( - account.id, + account.type !== 'oauth' || + !account.enabled || + oauthProfileIsFresh(account.profile, Date.now()) + ) + continue + await hydrateProfileOnce( account.id, - accessToken, - undefined, + account.accountIdentity, + '', + asProviderAccountUuid(account.accountIdentity), signal, ) - if (profile) { - account.profile = profile - void persistProfileStateBestEffort({ - accountId: account.id, - accountIdentity: account.id, - accessToken, - profile, - }).catch(() => {}) - } } - return storage + // Profile polling saved the tier and organization for the account it read. + // Reload current account metadata so replacing this route cannot display + // the profile fetched for its previous credentials. + return (await nativeAccounts.read()).policyStorage } const warnedQuotaNormalizeErrors = new Set() - const warnedStaleFallbackQuotaPersists = new Set() async function persistPushedQuota( served: ServedQuotaHeaders, entry: QuotaEntry, ): Promise { - const storage = - (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() - if (served.accountId === 'main') { - const quotaKey = served.mainQuotaIdentity?.quotaKey - if (!quotaKey || entry.quota.accountIdentity !== quotaKey) { - logger.trace( - 'quota', - 'skipped quota persistence without verified main account identity', - ) - return null - } - storage.quota = storage.quota ?? {} - storage.quota.mainQuota = { - ...entry.quota, - accountIdentity: quotaKey, - } - storage.quota.mainQuotaCheckedAt = entry.checkedAt - await saveAccountState(storage, accountStoragePath, { mainQuota: true }) - const reloaded = await loadAccounts(accountStoragePath) - const persistedQuota = reloaded?.quota?.mainQuota - const persistedQuotaBelongsToRequest = Boolean( - persistedQuota && persistedQuota.accountIdentity === quotaKey, - ) - const mergedQuota = persistedQuotaBelongsToRequest - ? mergeHeaderQuotaForPersistence(persistedQuota, entry.quota) - : entry.quota - return persistedQuota - ? { - ...entry, - quota: mergedQuota, - checkedAt: entry.checkedAt, - } - : entry - } - const persisted = await persistFallbackQuotaHeaderPersistent( - { - accountId: served.accountId, - authLineageId: served.authLineageId, - quota: entry.quota, - anthropicAccountUuid: served.anthropicAccountUuid ?? undefined, - }, - accountStoragePath, + const snapshot = await nativeAccounts.read() + const account = snapshot.accounts.find( + (candidate) => candidate.id === served.accountId, + ) + if ( + !account || + !served.anthropicAccountUuid || + account.accountIdentity !== served.anthropicAccountUuid ) + return null + const quota = { + ...mergeHeaderQuotaForPersistence(account.quota, entry.quota), + accountIdentity: account.accountIdentity, + } + const persisted = served.scopedAttempt + ? await nativeAccounts.vault.publish(served.scopedAttempt, { quota }) + : served.localSubject + ? await nativeAccounts.publishLocal(served.localSubject, { quota }) + : false if (!persisted) { - const key = `${served.accountId}:${served.authLineageId ?? ''}` - if (!warnedStaleFallbackQuotaPersists.has(key)) { - warnedStaleFallbackQuotaPersists.add(key) - logger.debug( - 'quota', - 'skipped stale fallback quota persistence after lineage change', - { - accountId: served.accountId, - servedLineage: served.authLineageId, - }, - ) - } - // The persistence fence is authoritative; discard the optimistic cache - // write unless a newer replacement observation already won the race. - if (quotaManager.getAllFallbacks().get(served.accountId) === entry) { + if (served.accountId === 'main') quotaManager.clearMain() + else if (quotaManager.getAllFallbacks().get(served.accountId) === entry) quotaManager.clearFallback(served.accountId) - } return null } - return entry + return { ...entry, quota } } function logPersistFailure(error: unknown) { @@ -2103,46 +1971,197 @@ const anthropicAuthPlugin = async ( } } - // Local OAuth and scoped custody share quota/routing machinery. The latter - // never exposes a bearer from sidecar storage or a capability-handle cache. - const fallbackManager = new FallbackAccountManager({ - quotaManager, - isFallbackAccountVaultServed, - isFallbackAccountVaultEnabled: (_accountId, storage) => - isScopedCustodyActive(storage), - resolveFallbackAccessToken: (account, storage) => - getClaustrumMode(storage) === 'local' && account.access - ? { token: account.access, source: 'sidecar' as const } - : undefined, - setIntervalImpl: runtimeTimers.setInterval, - clearIntervalImpl: runtimeTimers.clearInterval, - onFallbackStorageChanged: () => { - void refreshSidebarQuota().catch(() => {}) - }, - }) - const fallbackRefreshReady = fallbackManager.startBackgroundRefresh() - const fallbackCustodyStateFor = ( - accountId: string, - storage: AccountStorage | null, - ): Exclude => { - const state = custodyStateFor({ id: accountId, role: 'fallback' }, storage) - return state === 'na' ? 'off' : state + async function refreshFallbackQuotas(force = false) { + const snapshot = await nativeAccounts.read() + if (!force && snapshot.policyStorage.quota?.enabled === false) return + for (const account of snapshot.accounts) { + if (account.id === 'main' || account.type !== 'oauth' || !account.enabled) + continue + if ( + !force && + getQuotaNextRefreshAt( + account.quota, + snapshot.policyStorage, + quotaSnapshotCheckedAt(account.quota), + ) > Date.now() + ) + continue + try { + await quotaManager.refreshFallback( + account.id, + '', + snapshot.policyStorage.accounts.find( + (candidate) => + candidate.id === account.id && isOAuthAccount(candidate), + ) as OAuthAccount | undefined, + ) + } catch { + // The runtime already recorded the quota-fetch failure for its account. + // Keep any separate refresh-token rejection; a usage poll cannot fix it. + } + } } - const cacheDiagnosticsTracker = new CacheDiagnosticsTracker() - const cacheDiagnosticsBetaTracker = new CacheDiagnosticsBetaTracker() - type CacheDiagnosticsResponse = { - request?: CacheDiagnosticsRequestContext - trackSessionId?: string - source: CacheDiagnosticsSource - accountId: string - synthetic: boolean - betasHash: string - betas: string[] - requestedModel?: string - dump: DumpHandle | null - status: number - streaming: boolean - dumpWrite: Promise + const fallbackManager = { + refreshQuotaForDueAccounts: () => refreshFallbackQuotas(), + refreshQuotaForAllAccounts: (_options?: { force?: boolean }) => + refreshFallbackQuotas(true), + refreshAccount: async ( + account: OAuthAccount, + _storage: AccountStorage, + options?: { force?: boolean }, + ) => { + const credential = await authorizeOAuth( + account.id, + undefined, + options?.force ? account.access : undefined, + ) + return { + ...account, + access: credential.accessToken, + expires: credential.expires, + } + }, + refreshAccountQuota: async ( + account: OAuthAccount, + _storage: AccountStorage, + ) => { + assertNativeEnvironment() + const quota = await nativeAccounts.fetchQuota(account.id) + return { account: { ...account, quota }, fetched: true } + }, + getUsableFallbackAccounts: async ( + storage: AccountStorage | null | undefined, + options?: { modelId?: string }, + ) => { + storage = storage ?? null + const usable: OAuthAccount[] = [] + for (const account of storage?.accounts ?? []) { + if (!isOAuthAccount(account) || account.enabled === false) continue + const quota = + quotaManager.getAllFallbacks().get(account.id)?.quota ?? account.quota + if ( + !quotaSnapshotPassesPolicy(quota, storage) || + !quotaSnapshotPassesModelScope(quota, options?.modelId) || + (isKillswitchEnabled(storage) && + !killswitchPassesPolicy( + quota, + storage, + account.id, + options?.modelId, + )) + ) + continue + try { + const credential = await authorizeOAuth( + account.id, + undefined, + undefined, + options?.modelId, + ) + usable.push({ + ...account, + access: credential.accessToken, + expires: credential.expires, + }) + } catch { + // Do not use credentials that failed authorization. A failed or + // missing quota reading also cannot prove the account's general + // limits are exhausted and therefore cannot permit paid fallback. + } + } + return usable + }, + } + async function refreshNativeFallbacks() { + assertNativeEnvironment() + const snapshot = await nativeAccounts.read() + if ( + snapshot.mode === 'local' && + snapshot.policyStorage.refresh?.enabled !== false + ) { + for (const account of snapshot.accounts) { + if ( + account.id === 'main' || + account.type !== 'oauth' || + !account.enabled + ) + continue + try { + const credential = await authorizeOAuth(account.id) + if ( + credential.localSource !== 'rotated' && + credential.localSource !== 'adopted' && + (!credential.expires || + credential.expires - Date.now() <= + mainRefreshBeforeExpiryMs(snapshot.policyStorage)) + ) { + await authorizeOAuth( + account.id, + undefined, + undefined, + undefined, + 'refresh', + ) + } + } catch { + // The runtime records whether refresh failed temporarily or the + // provider rejected the refresh token with invalid_grant. A subsequent + // quota poll cannot repair that rejected token or clear its error. + } + } + } + await refreshFallbackQuotas() + } + const canStartNativeServices = + initialStorage !== null && process.env.OPENCODE_AUTH_CONTENT === undefined + const fallbackRefreshReady = canStartNativeServices + ? (isScopedCustodyActive(initialStorage) + ? nativeAccounts.vault.refresh().then(() => refreshNativeFallbacks()) + : refreshNativeFallbacks() + ).catch(() => {}) + : Promise.resolve() + const fallbackRefreshTimer = canStartNativeServices + ? runtimeTimers.setInterval(() => { + void refreshNativeFallbacks().catch(() => {}) + }, FALLBACK_BACKGROUND_TICK_MS + + jitterMs(MAIN_AUTH_REFRESH_TICK_JITTER_MS)) + : null + fallbackRefreshTimer?.unref?.() + const rosterRefreshTimer = + canStartNativeServices && + isScopedCustodyActive(initialStorage) && + runtimeOverrides.scopedRosterPollIntervalMs !== 0 + ? runtimeTimers.setInterval(() => { + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) return + void nativeAccounts.vault + .refresh() + .then(() => refreshSidebarQuota()) + .catch(() => {}) + }, runtimeOverrides.scopedRosterPollIntervalMs ?? 2000) + : null + rosterRefreshTimer?.unref?.() + const fallbackCustodyStateFor = ( + accountId: string, + storage: AccountStorage | null, + ): Exclude => { + const state = custodyStateFor({ id: accountId, role: 'fallback' }, storage) + return state === 'na' ? 'off' : state + } + const cacheDiagnosticsTracker = new CacheDiagnosticsTracker() + const cacheDiagnosticsBetaTracker = new CacheDiagnosticsBetaTracker() + type CacheDiagnosticsResponse = { + request?: CacheDiagnosticsRequestContext + trackSessionId?: string + source: CacheDiagnosticsSource + accountId: string + synthetic: boolean + betasHash: string + betas: string[] + requestedModel?: string + dump: DumpHandle | null + status: number + streaming: boolean + dumpWrite: Promise messageStart?: Record } const cacheDiagnosticsResponses = new WeakMap< @@ -2314,7 +2333,9 @@ const anthropicAuthPlugin = async ( cacheDiagnosticsResponses.set(response, { ...input, dumpWrite }) } - let latestRefreshMainAccessToken: (() => Promise) | null = null + let latestGetActivation: + | (() => Promise<{ type: string; active: boolean }>) + | null = null const cacheKeepRegistry = new CacheKeepSessionRegistry({ directory: process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR || @@ -2323,7 +2344,7 @@ const anthropicAuthPlugin = async ( let aggregateCacheKeepSessions: ReturnType< CacheKeepManager['trackedSessions'] > = [] - const cacheKeepScopedAttempts = new Map() + const cacheKeepScopedAttempts = new Map() const cacheKeepManager = new CacheKeepManager({ loadStorage: () => loadAccounts(accountStoragePath), setIntervalImpl: runtimeTimers.setInterval, @@ -2373,13 +2394,9 @@ const anthropicAuthPlugin = async ( receivedAt, attempt, }) => { - const receipt = cacheKeepScopedAttempts.get(attempt.id) - if (status === 401 && receipt) { - await getOpenCodeScopedRuntime().reportFailure( - receipt, - status, - 'direct', - ) + const authorization = cacheKeepScopedAttempts.get(attempt.id) + if (status === 401 && authorization?.scopedAttempt) { + await reportVault401(authorization.scopedAttempt, 'direct') } const prepared = cacheKeepDiagnosticsRequests.get(target.id) if (!prepared?.betasHash || !prepared.betas) { @@ -2429,94 +2446,64 @@ const anthropicAuthPlugin = async ( retryOnUnauthorized: async ({ target, headers, attempt }) => { const served = cacheKeepScopedAttempts.get(attempt.id) if (!served) return undefined - let current: ClaustrumScopedAttempt | undefined + let modelId: string | undefined try { - current = await getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ).authorize(target.oauthAccountId ?? 'main', attempt.signal) - } catch { - // No verified replacement: fall through so the decision records - // reauthorize-failed, then keep the original 401. - } - if (!decideScopedRetryAfter401('cachekeep', served, current)) + const body = JSON.parse(target.bodyText) + if (typeof body.model === 'string') modelId = body.model + } catch {} + if ( + served.scopedAttempt && + !(await prepareVaultRetry(served.scopedAttempt, attempt.signal)) + ) return undefined - logger.info( - 'claustrum', - 'retrying CacheKeep after scoped credential rotation', - { - accountId: target.oauthAccountId ?? 'main', - previousVersion: served.recordVersion, - newVersion: current.recordVersion, - }, + const current = await authorizeOAuth( + target.oauthAccountId ?? 'main', + attempt.signal, + served.scopedAttempt ? undefined : served.accessToken, + modelId, + ) + if ( + current.accountIdentity !== served.accountIdentity || + Boolean(current.scopedAttempt) !== Boolean(served.scopedAttempt) ) + return undefined + if ( + served.scopedAttempt && + (!current.scopedAttempt || + current.scopedAttempt.credentialId !== + served.scopedAttempt.credentialId || + current.scopedAttempt.recordVersion <= + served.scopedAttempt.recordVersion) + ) + return undefined + if (!served.scopedAttempt && current.accessToken === served.accessToken) + return undefined const rotatedHeaders = new Headers(headers) rotatedHeaders.set('authorization', `Bearer ${current.accessToken}`) cacheKeepScopedAttempts.set(attempt.id, current) return rotatedHeaders }, prepareHeaders: async (headers, target, attempt) => { - let accessToken: string | undefined - let scopedAttempt: ClaustrumScopedAttempt | undefined - const accountId = target.oauthAccountId - if (accountId && accountId !== 'main') { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && - candidate.enabled !== false && - isOAuthAccount(candidate), - ) - if (!account || !storage) { - throw new Error( - `OAuth account ${accountId} is unavailable for cache prewarm`, - ) - } - if ( - isScopedCustodyActive(storage) && - account.claustrumScopedCredentialId - ) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - scopedAttempt = await runtime.authorize(accountId, attempt.signal) - accessToken = scopedAttempt.accessToken - } else if (getClaustrumMode(storage) === 'local') { - let current = account - try { - current = await fallbackManager.refreshAccount(account, storage) - } catch (error) { - logger.warn('cachekeep', 'fallback token refresh failed', { - accountId, - error: error instanceof Error ? error.message : String(error), - }) - } - accessToken = current.access - } else { - throw new Error('CacheKeep requires a scoped Claustrum account') - } - if (!accessToken) { - throw new Error( - `OAuth account ${accountId} has no access token for cache prewarm`, - ) - } - } else { - const credential = await getCurrentMainCredential(attempt.signal) - accessToken = credential.accessToken - scopedAttempt = credential.scopedAttempt - } - if (!accessToken) return headers + const accountId = target.oauthAccountId ?? 'main' + let modelId: string | undefined + try { + const body = JSON.parse(target.bodyText) + if (typeof body.model === 'string') modelId = body.model + } catch {} + const credential = await authorizeOAuth( + accountId, + attempt.signal, + undefined, + modelId, + ) + const accessToken = credential.accessToken + cacheKeepScopedAttempts.set(attempt.id, credential) try { const parsedBody = JSON.parse(target.bodyText) as Record< string, unknown > - const identity = await resolveClaudeCodeIdentity( - accessToken, - typeof parsedBody.model === 'string' ? parsedBody.model : undefined, - accountId === 'main' ? mainAccountId : accountId, - ) + const identity = nativeWireIdentity(credential.accountIdentity) headers.delete('anthropic-beta') setOAuthHeaders(headers, accessToken, { body: parsedBody, @@ -2538,59 +2525,48 @@ const anthropicAuthPlugin = async ( } catch { setOAuthHeaders(headers, accessToken) } - if (scopedAttempt) cacheKeepScopedAttempts.set(attempt.id, scopedAttempt) + + const finalCredential = await authorizeOAuth( + accountId, + attempt.signal, + undefined, + modelId, + ) + if ( + finalCredential.accountIdentity !== credential.accountIdentity || + Boolean(finalCredential.scopedAttempt) !== + Boolean(credential.scopedAttempt) + ) + throw new Error( + 'CacheKeep credential authority changed before dispatch', + ) + headers.set('authorization', `Bearer ${finalCredential.accessToken}`) + cacheKeepScopedAttempts.set(attempt.id, finalCredential) return headers }, }) const recoveryWarmChains = new Map>() - // -- /claude-prime manager: direct OAuth request to start each account's - // five-hour quota window ~60s after reset, exactly once across concurrent - // OpenCode processes via atomic marker claim. In-process manager mirrors the - // cacheKeep singleton shape; cross-process exclusivity is the marker's job. + // Prime sends a minimal Haiku request after the account's quota reset to + // start its next five-hour usage window. One shared manager and an atomic + // claim file prevent different projects or processes firing for the same reset. - // Resolve one current main credential for Prime. In Claustrum mode the - // OpenCode slot is an inert tombstone, so both quota checks and fires must - // use the resident vault credential and must never enter local refresh. + // OpenCode stores an empty OAuth marker to enable this provider, not a token + // that can authenticate requests. Prime and CacheKeep must still validate + // the current local credentials or freshly authorize them through the vault. async function getCurrentMainCredential(signal?: AbortSignal): Promise<{ accessToken: string credentialAccountId?: ProviderAccountUuid - scopedAttempt?: ClaustrumScopedAttempt + scopedAttempt?: NativeCustodyReceipt + localSubject?: NativeKnownCredentialSubject }> { - if (!latestGetAuth) { - throw new Error('prime: main auth loader is not available') - } - const auth = await latestGetAuth() - if (auth.type !== 'oauth') { - throw new Error('prime: main account is not an OAuth account') - } - const storage = await loadAccounts(accountStoragePath) - if (getClaustrumMode(storage) === 'claustrum') { - if (!isScopedCustodyActive(storage)) - throw new Error('Scoped Claustrum custody is not configured') - const attempt = await getOpenCodeScopedRuntime().authorize('main', signal) - return { - accessToken: attempt.accessToken, - credentialAccountId: asProviderAccountUuid(attempt.accountId), - scopedAttempt: attempt, - } - } - if (auth.access && (!auth.expires || auth.expires > Date.now())) { - return { accessToken: auth.access } - } - if (!latestRefreshMainAccessToken) { - throw new Error('prime: main token refresh unavailable') - } - try { - return { accessToken: await latestRefreshMainAccessToken() } - } catch (error) { - if (error instanceof Error) { - ;( - error as Error & { isPrimeTokenRefresh?: boolean } - ).isPrimeTokenRefresh = true - } - throw error + if (!latestGetActivation || !(await latestGetActivation()).active) + throw new Error('Native main OAuth activation is not available') + const credential = await authorizeOAuth('main', signal) + return { + ...credential, + credentialAccountId: asProviderAccountUuid(credential.accountIdentity), } } @@ -2604,9 +2580,9 @@ const anthropicAuthPlugin = async ( 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. + // The usage poll independently validates local credentials or obtains vault + // authorization. Report a 401 against the tokens used for that actual poll, + // not the tokens obtained above to resolve the main account's identity. const result = await quotaManager.refreshMainWithMetadata( mainQuotaAccountId, credential.accessToken, @@ -2617,6 +2593,7 @@ const anthropicAuthPlugin = async ( async function refreshPrimeFallbackQuota( accountId: string, ): Promise { + assertNativeEnvironment() const storage = await loadAccounts(accountStoragePath) const account = storage?.accounts.find( (candidate): candidate is OAuthAccount => @@ -2627,101 +2604,53 @@ const anthropicAuthPlugin = async ( if (!account || !storage) { throw new Error(`prime: OAuth account ${accountId} is unavailable`) } - // Reuse the same fallback-manager refresh path used by the - // background quota refresh — it persists the refreshed quota to the - // state file (M1 persist requirement) and is the SINGLE usage-API - // call per tick for fallback fresh-checks. The redundant - // `quotaManager.refreshFallback` call was collapsed (R2). + // This poll stores current quota for the account and tokens used to fetch it. + // Prime uses that reading to decide whether to start the next window; + // sendPrime sends the minimal Haiku request without repeating this poll. const refreshed = await fallbackManager.refreshAccountQuota( account, storage, ) - await fallbackManager.save(storage, [accountId]) + return { quota: refreshed.account.quota ?? {}, fresh: refreshed.fetched, } } + const primePhysicalAttempts = new Map() async function sendPrime( accountId: 'main' | string, ): Promise { const start = performance.now() let accessToken: string | undefined let resolvedModel: string | undefined - let scopedAttempt: ClaustrumScopedAttempt | undefined + let scopedAttempt: NativeCustodyReceipt | undefined try { - if (accountId === 'main') { - // Use the same refresh path the fresh-check uses, so a missing or - // expired access token is refreshed BEFORE the request — not used - // as-is. The previous early-return-on-!auth.access made the - // refresh arm unreachable (M2). - try { - const credential = await getCurrentMainCredential() - accessToken = credential.accessToken - scopedAttempt = credential.scopedAttempt - } catch (error) { - const isTokenRefresh = - error instanceof Error && - (error as Error & { isPrimeTokenRefresh?: boolean }) - .isPrimeTokenRefresh === true - return { - ok: false, - ...(isTokenRefresh && { reason: 'token-refresh' as const }), - error: error instanceof Error ? error.message : String(error), - } - } - resolvedModel = CLAUDE_HAIKU_4_5_MODEL_ID - } else { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && - candidate.enabled !== false && - isOAuthAccount(candidate), - ) - if (!account || !storage) { - return { - ok: false, - error: `prime: OAuth account ${accountId} is unavailable`, - } - } - if (isScopedCustodyActive(storage)) { - scopedAttempt = await getOpenCodeScopedRuntime().authorize(account.id) - accessToken = scopedAttempt.accessToken - } else if (getClaustrumMode(storage) === 'local') { - let current = account - try { - // R2: the fire path refreshes ONLY the token, not the quota. - // The fresh-check already performed the single usage-API - // call and persisted the result; the fire path reuses the - // in-memory quota via the headers / URL contract. This keeps - // the cycle at exactly one quota API call per account. - current = await fallbackManager.refreshAccount(account, storage) - } catch (error) { - return { - ok: false, - reason: 'token-refresh', - error: error instanceof Error ? error.message : String(error), - } - } - accessToken = current.access - } else { - throw new Error('Scoped Claustrum custody is not configured') - } - resolvedModel = CLAUDE_HAIKU_4_5_MODEL_ID - } + // The manager already checked current quota. Validate the account and + // tokens for this Haiku send without another quota poll. An upstream 401 + // may retry once with newer credentials for the same account. + if ( + accountId === 'main' && + (!latestGetActivation || !(await latestGetActivation()).active) + ) + throw new Error('Native main OAuth activation is not available') + const initialAuthorization = await authorizeOAuth( + accountId, + undefined, + undefined, + CLAUDE_HAIKU_4_5_MODEL_ID, + ) + accessToken = initialAuthorization.accessToken + scopedAttempt = initialAuthorization.scopedAttempt + resolvedModel = CLAUDE_HAIKU_4_5_MODEL_ID if (!accessToken) { return { ok: false, error: 'prime: no access token available' } } const primeBody = buildPrimeRequestBody() - const identity = await resolveClaudeCodeIdentity( - accessToken, - resolvedModel, - accountId === 'main' ? mainAccountId : accountId, - ) + const identity = nativeWireIdentity(initialAuthorization.accountIdentity) const body = await rewriteRequestBody(JSON.stringify(primeBody), { identity, }) @@ -2744,19 +2673,34 @@ const anthropicAuthPlugin = async ( primeRequest.url?.toString() ?? primeRequest.input.toString() const signal = AbortSignal.timeout(30_000) const primeInit = { method: 'POST', headers, body, signal } + let physicalAuthorization = await authorizeOAuth( + accountId, + signal, + undefined, + resolvedModel, + ) + if ( + physicalAuthorization.accountIdentity !== + initialAuthorization.accountIdentity || + Boolean(physicalAuthorization.scopedAttempt) !== + Boolean(initialAuthorization.scopedAttempt) + ) + throw new Error('Prime credential authority changed before dispatch') + scopedAttempt = physicalAuthorization.scopedAttempt + headers.set( + 'authorization', + `Bearer ${physicalAuthorization.accessToken}`, + ) let response = await fetch(primeUrl, primeInit) if (response.status === 401 && scopedAttempt && !signal.aborted) { - let current: ClaustrumScopedAttempt | undefined + let current: NativeCustodyReceipt | undefined try { - current = await getOpenCodeScopedRuntime().authorize( - accountId, - signal, - ) + current = await prepareVaultRetry(scopedAttempt, signal) } catch { // The first 401 belongs to the original physical attempt unless // the vault confirms a newer version of this same account. } - if (decideScopedRetryAfter401('prime', scopedAttempt, current)) { + if (current) { await response.body?.cancel().catch(() => {}) logger.info( 'claustrum', @@ -2767,8 +2711,61 @@ const anthropicAuthPlugin = async ( newVersion: current.recordVersion, }, ) - scopedAttempt = current - headers.set('authorization', `Bearer ${current.accessToken}`) + physicalAuthorization = await authorizeOAuth( + accountId, + signal, + undefined, + resolvedModel, + ) + const successor = physicalAuthorization.scopedAttempt + if ( + !successor || + successor.credentialId !== scopedAttempt.credentialId || + successor.accountIdentity !== scopedAttempt.accountIdentity || + successor.recordVersion <= scopedAttempt.recordVersion + ) + throw new Error('Prime vault retry lost its strict newer receipt') + scopedAttempt = successor + headers.set('authorization', `Bearer ${successor.accessToken}`) + response = await fetch(primeUrl, primeInit) + } + } + if ( + response.status === 401 && + !physicalAuthorization.scopedAttempt && + !signal.aborted + ) { + const rejectedAccess = physicalAuthorization.accessToken + const replacement = await authorizeOAuth( + accountId, + signal, + rejectedAccess, + resolvedModel, + ).catch(() => undefined) + if ( + replacement && + replacement.accessToken !== rejectedAccess && + replacement.accountIdentity === + initialAuthorization.accountIdentity && + !replacement.scopedAttempt + ) { + await response.body?.cancel().catch(() => {}) + physicalAuthorization = await authorizeOAuth( + accountId, + signal, + undefined, + resolvedModel, + ) + if ( + physicalAuthorization.scopedAttempt || + physicalAuthorization.accountIdentity !== + initialAuthorization.accountIdentity + ) + throw new Error('Prime local authority changed before retry') + headers.set( + 'authorization', + `Bearer ${physicalAuthorization.accessToken}`, + ) response = await fetch(primeUrl, primeInit) } } @@ -2777,11 +2774,7 @@ const anthropicAuthPlugin = async ( const reason = (await response.text().catch(() => '')) || `HTTP ${response.status}` if (response.status === 401 && scopedAttempt) { - await getOpenCodeScopedRuntime().reportFailure( - scopedAttempt, - 401, - 'direct', - ) + await reportVault401(scopedAttempt, 'direct') } return { ok: false, status: response.status, ms, error: reason } } @@ -2805,10 +2798,12 @@ const anthropicAuthPlugin = async ( : undefined, } : undefined + primePhysicalAttempts.set(accountId, physicalAuthorization) return { ok: true, status: response.status, ms, ...(usage && { usage }) } } catch (error) { return { ok: false, + ...(!accessToken && { reason: 'token-refresh' as const }), ms: Math.round(performance.now() - start), error: error instanceof Error ? error.message : String(error), } @@ -2819,9 +2814,17 @@ const anthropicAuthPlugin = async ( storagePath: accountStoragePath, loadStorage: () => loadAccounts(accountStoragePath), getAccountFingerprint: async (accountId) => { - const authLineageId = await getOrCreatePrimeAuthLineageId( + if ( + accountId === 'main' && + (!latestGetActivation || !(await latestGetActivation()).active) + ) + return undefined + const credential = await authorizeOAuth(accountId) + const fence = credential.scopedAttempt ?? credential.localSubject + if (!fence) return undefined + const authLineageId = await nativeAccounts.getOrCreateAuthLineage( accountId, - accountStoragePath, + fence, ) return authLineageId ? tokenFingerprint(authLineageId) : undefined }, @@ -2830,8 +2833,27 @@ const anthropicAuthPlugin = async ( return refreshPrimeFallbackQuota(accountId) }, sendPrime, - recordSuccess: (accountId, usage) => - incrementPrimeUsagePersistent(accountId, usage, accountStoragePath), + recordSuccess: async (accountId, usage) => { + const credential = primePhysicalAttempts.get(accountId) + primePhysicalAttempts.delete(accountId) + const fence = credential?.scopedAttempt ?? credential?.localSubject + if ( + !fence || + !(await nativeAccounts.incrementPrimeUsage(accountId, fence, usage)) + ) { + throw new Error( + 'Prime usage publication lost its native credential fence', + ) + } + const current = (await nativeAccounts.read()).accounts.find( + (account) => account.id === accountId, + )?.prime + if (!current) + throw new Error( + 'Prime usage counters are unavailable after publication', + ) + return current + }, setIntervalImpl: runtimeTimers.setInterval, clearIntervalImpl: runtimeTimers.clearInterval, setTimeoutImpl: runtimeTimers.setTimeout, @@ -2952,22 +2974,6 @@ const anthropicAuthPlugin = async ( } let rpcServerAdoption: RpcServerAdoption | null = null - if (ctx.directory) { - const rpcDir = getRpcDir(ctx.directory) - try { - rpcServerAdoption = await adoptRpcServer(rpcDir, () => - startRpcServer({ - dir: rpcDir, - drain: drainNotifications, - apply: applyCommand, - }), - ) - } catch (error) { - logger.warn('rpc', 'failed to start', { - error: error instanceof Error ? error.message : String(error), - }) - } - } const dispose: NonNullable = async () => { try { closeOpenCodeScopedRuntime(accountStoragePath) @@ -3001,7 +3007,9 @@ const anthropicAuthPlugin = async ( }) } try { - fallbackManager.stopBackgroundRefresh() + if (fallbackRefreshTimer) + runtimeTimers.clearInterval(fallbackRefreshTimer) + if (rosterRefreshTimer) runtimeTimers.clearInterval(rosterRefreshTimer) } catch (error) { logger.warn('fallback-background', 'failed to stop', { error: error instanceof Error ? error.message : String(error), @@ -3058,50 +3066,9 @@ const anthropicAuthPlugin = async ( mainAccessToken?: string mainRefreshToken?: string routingAuthoritative?: boolean - useHydratedProfiles?: boolean skipFallbackQuotaSeed?: boolean } - function mergeHydratedProfilesForSidebar( - latest: Awaited>, - hydrated: Awaited>, - mainAccessToken?: string, - ) { - if (!latest || !hydrated) return latest - const merged: AccountStorage = { - ...latest, - accounts: latest.accounts.map((account) => { - const hydratedAccount = hydrated.accounts.find( - (candidate) => candidate.id === account.id, - ) - if ( - !isOAuthAccount(account) || - !hydratedAccount || - !isOAuthAccount(hydratedAccount) || - !account.access || - hydratedAccount.access !== account.access - ) { - return account - } - return { ...account, profile: hydratedAccount.profile } - }), - } - const latestMainProfile = latest.main?.profile - const mainState = latest.main ?? hydrated.main - if ( - mainAccessToken && - mainState && - (!latestMainProfile || - oauthProfileMatchesIdentity(latestMainProfile, mainAccountId)) - ) { - merged.main = { - ...mainState, - profile: hydrated.main?.profile, - } - } - return merged - } - function buildSidebarState( storage: Awaited>, options: SidebarStateInput, @@ -3178,15 +3145,10 @@ const anthropicAuthPlugin = async ( }), activeId: options.activeId, route: options.route, - relay: (() => { - const currentRelayConfig = getRelayConfig(storage) - return currentRelayConfig - ? { - enabled: true, - transport: currentRelayConfig.transport ?? 'http', - } - : null - })(), + relay: + storage?.relay?.enabled && storage.relay.url + ? { enabled: true, transport: storage.relay.transport ?? 'http' } + : null, fastMode: isFastModeEnabled(), cacheKeep: { enabled: isCacheKeepHybridActive(storage), @@ -3235,13 +3197,10 @@ const anthropicAuthPlugin = async ( () => loadAccounts(accountStoragePath), { activeId: state.activeId, route: state.route }, ) - const displayStorage = options.useHydratedProfiles - ? mergeHydratedProfilesForSidebar( - preservedRouting.freshStorage, - storage, - options.mainAccessToken, - ) - : preservedRouting.freshStorage + // Display the profile from current storage. A route may have been + // replaced while polling, so the earlier fetched profile could + // describe a different account. + const displayStorage = preservedRouting.freshStorage return { activeId: preservedRouting.activeId, route: preservedRouting.route, @@ -3361,159 +3320,59 @@ const anthropicAuthPlugin = async ( return storage?.refresh?.enabled !== false } - async function clearStaleMainRefreshError( - accountIdentity?: string, - currentRefreshTokenFingerprint?: string, - ) { - if (!accountIdentity) return - const storage = await loadAccounts(accountStoragePath) - const error = storage?.refresh?.mainLastRefreshError - if (!storage?.refresh || !error) return - const identityChanged = - Boolean(error.accountIdentity) && - error.accountIdentity !== accountIdentity - const refreshTokenChanged = - Boolean(error.refreshTokenFingerprint) && - Boolean(currentRefreshTokenFingerprint) && - error.refreshTokenFingerprint !== currentRefreshTokenFingerprint - if (!identityChanged && !refreshTokenChanged) return - storage.refresh.mainLastRefreshError = undefined - storage.refresh.mainRefreshErrorClearedAt = mergeMainRefreshErrorClearedAt( - storage.refresh.mainRefreshErrorClearedAt, - Date.now(), - ) - if (!mainQuotaIdentityAccessToken?.startsWith('sk-ant-oat')) { - const quotaError = storage.quota?.mainLastQuotaApiError - if (quotaError && quotaError.accountIdentity !== mainQuotaAccountId) { - const clearedGeneration = quotaManager.clearMainBackoff() - storage.quota = storage.quota ?? {} - storage.quota.mainLastQuotaApiError = undefined - storage.quota.mainQuotaErrorGeneration = Math.max( - storage.quota.mainQuotaErrorGeneration ?? 0, - clearedGeneration ?? quotaManager.getMainQuotaErrorGeneration(), - ) - storage.quota.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt( - storage.quota.mainQuotaErrorClearedAt, - quotaManager.getMainQuotaErrorClearedAt() ?? Date.now(), - ) + async function buildQuotaCommandSummary(refresh = false) { + const errors = new Map() + if (refresh) { + // Only the explicit refresh action polls usage. Opening the menu reads + // saved account metadata and cannot start credential recovery or a poll. + if (latestGetActivation && (await latestGetActivation()).active) { + try { + await nativeAccounts.fetchQuota('main') + } catch { + errors.set('main', 'Native primary usage refresh unavailable') + } } + await refreshFallbackQuotas(true) } - await saveAccountState(storage, accountStoragePath, { - mainRefresh: true, - ...(!mainQuotaIdentityAccessToken?.startsWith('sk-ant-oat') && { - mainQuota: true, - }), - }) - log( - '[refresh] opencode main oauth cleared stale backoff after token rotation', - { - previousCheckedAt: error.checkedAt, - previousNextRetryAt: error.nextRetryAt, - previousRetryCount: error.retryCount, - }, + const snapshot = await nativeAccounts.read() + quotaManager.updateStorage(snapshot.policyStorage) + quotaManager.seedMainFromStorage(snapshot.policyStorage, mainQuotaAccountId) + quotaManager.seedFallbacksFromAccounts( + snapshot.policyStorage.accounts.filter(isOAuthAccount), + ) + const accounts: QuotaAccountSummary[] = [] + const main = snapshot.accounts.find( + (account) => account.id === 'main' && account.type === 'oauth', ) + if (main) + accounts.push({ + name: main.label ?? 'OpenCode anthropic', + role: 'main', + quota: main.quota, + tierLabel: formatOAuthAccountTier(main.profile), + error: errors.get('main') ?? main.lastQuotaRefreshError?.message, + }) + accounts.push( + ...buildFallbackQuotaSummaries(snapshot.policyStorage, errors), + ) + const checkedAt = Math.max( + 0, + ...snapshot.accounts.map((account) => + quotaSnapshotCheckedAt(account.quota), + ), + ) + return buildClaudeQuotaSummary({ + accounts, + refreshedAt: checkedAt || undefined, + }) } - async function buildQuotaCommandSummary() { - const accounts: QuotaAccountSummary[] = [] - let mainAccessToken: string | undefined + async function refreshSidebarAfterMutation( + updatedStorage: AccountStorage | null, + ) { if (latestGetAuth) { try { - const auth = await latestGetAuth() - // 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 - 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. - const quota = await quotaManager.refreshMain( - mainQuotaAccountId, - commandAccessToken, - ) - accounts.push({ - name: 'OpenCode anthropic', - role: 'main', - quota, - }) - } else if (auth.type === 'oauth') { - accounts.push({ - name: 'OpenCode anthropic', - role: 'main', - error: - 'missing access token; send a request first or reconnect auth', - }) - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - accounts.push({ - name: 'OpenCode anthropic', - role: 'main', - error: msg.includes('429') - ? 'Usage API rate limited — try again in a moment' - : msg, - }) - } - } - - // Force a real fallback refresh and PERSIST it to anthropic-auth.json. - // refreshQuotaForAllAccounts({ force: true }) bypasses the staleness skip, - // routes through the unified QuotaManager path (429 backoff respected - // per-account), saves refreshed snapshots, and clears stale errors. - const { storage, errors } = - await fallbackManager.refreshQuotaForAllAccounts({ force: true }) - const displayStorage = await ensureProfilesForQuotaDisplay( - storage ?? createEmptyStorage(), - mainAccessToken, - AbortSignal.timeout(3_000), - mainProviderAccountUuid, - ) - const mainSummary = accounts.find((account) => account.role === 'main') - if (mainSummary) { - mainSummary.tierLabel = formatOAuthAccountTier( - displayStorage.main?.profile, - ) - } - const errorMap = new Map(errors.map((e) => [e.accountId, e.message])) - accounts.push(...buildFallbackQuotaSummaries(displayStorage, errorMap)) - - if (!latestGetAuth) { - accounts.unshift({ - name: 'OpenCode anthropic', - role: 'main', - error: 'auth loader has not run yet; send a request first', - }) - } - - return buildClaudeQuotaSummary({ accounts, refreshedAt: Date.now() }) - } - - async function refreshSidebarAfterMutation( - updatedStorage: AccountStorage | null, - ) { - if (latestGetAuth) { - try { - await getAllTrackedCacheKeepSessions().catch(() => {}) + await getAllTrackedCacheKeepSessions().catch(() => {}) const auth = await latestGetAuth() await writeSidebarState(updatedStorage, { activeId: lastSidebarRouting.activeId, @@ -4047,8 +3906,9 @@ const anthropicAuthPlugin = async ( async function executePersistentAccountCommand( argumentsText: string, sessionId?: string, + checkedAction?: ReturnType, ) { - const action = parseAccountCommandAction(argumentsText) + const action = checkedAction ?? parseAccountCommandAction(argumentsText) // -- add-apikey -------------------------------------------------------- if (action.type === 'add-apikey') { @@ -4084,7 +3944,13 @@ const anthropicAuthPlugin = async ( addedAt: now, lastUsed: now, } - await addAccountPersistent(account, accountStoragePath) + await nativeAccounts.addApi({ + routeId: account.id, + apiKey: action.apiKey, + label: account.label, + baseURL: resolvedBaseURL, + authHeader: resolvedAuthHeader, + }) logger.info('commands', 'account added', { id: account.id, label: account.label, @@ -4176,7 +4042,16 @@ const anthropicAuthPlugin = async ( lastUsed: now, lastRefreshedAt: now, } - await addAccountPersistent(account, accountStoragePath) + assertNativeEnvironment() + await nativeAccounts.loginOAuth({ + routeId: account.id, + label: account.label, + credential: { + access: result.access, + refresh: result.refresh, + expires: result.expires, + }, + }) logger.info('commands', 'account added', { id: account.id, label: account.label, @@ -4311,44 +4186,9 @@ const anthropicAuthPlugin = async ( label: account?.label, }) } else if (result.updated.action === 'reset-backoff') { - const mainIdentity = mainAccountId ?? storage?.mainAccountId - storage = storage ?? createEmptyStorage() - storage.refresh = storage.refresh ?? {} - storage.refresh.mainLastRefreshError = undefined - storage.refresh.mainRefreshErrorClearedAt = - mergeMainRefreshErrorClearedAt( - storage.refresh.mainRefreshErrorClearedAt, - Date.now(), - ) - const quotaError = storage.quota?.mainLastQuotaApiError - if ( - !quotaError?.accountIdentity || - !mainIdentity || - quotaError.accountIdentity === mainIdentity - ) { - const clearedGeneration = quotaManager.clearMainBackoff() - storage.quota = storage.quota ?? {} - storage.quota.mainLastQuotaApiError = undefined - storage.quota.mainQuotaErrorGeneration = Math.max( - storage.quota.mainQuotaErrorGeneration ?? 0, - clearedGeneration ?? quotaManager.getMainQuotaErrorGeneration(), - ) - storage.quota.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt( - storage.quota.mainQuotaErrorClearedAt, - quotaManager.getMainQuotaErrorClearedAt() ?? Date.now(), - ) - } - await saveAccountState(storage, accountStoragePath, { - mainRefresh: true, - mainQuota: true, - }) - logger.info( - 'commands', - 'main OAuth refresh and quota backoff cleared', - { - mainIdentity, - }, - ) + await resetNativeBackoff('main') + quotaManager.clearMainBackoff() + logger.info('commands', 'native main refresh and quota backoff cleared') } const updatedStorage = await loadAccounts(accountStoragePath) @@ -4572,13 +4412,275 @@ const anthropicAuthPlugin = async ( } } - async function applyCommand(request: ApplyRequest): Promise { + async function applyCommand(_request: ApplyRequest): Promise { + // Old dialog requests return guidance instead of changing accounts or + // settings. The /claude menu requires confirmation and checks which + // actions are allowed without an interactive UI before dispatching them. + return { text: 'Open /claude to manage native Claude settings.', knobs: {} } + } + + const dispatchNative: NativeMenuDispatch = async (request, options) => { + if (options.signal?.aborted) + throw options.signal.reason ?? new Error('Menu action aborted') + let text: string + switch (request.action) { + case 'enable': + case 'disable': + await nativeAccounts.setEnabled( + request.values.id, + request.action === 'enable', + ) + text = `Account ${request.values.id} ${request.action}d.` + break + case 'remove': + await nativeAccounts.remove(request.values.id) + text = `Account ${request.values.id} removed.` + break + case 'move-up': + case 'move-down': { + const order = (await nativeAccounts.read()).accounts + .filter((account) => account.id !== 'main') + .map((account) => account.id) + const index = order.indexOf(request.values.id) + if (index < 0) throw new Error('Native account not found') + const target = index + (request.action === 'move-up' ? -1 : 1) + if (target >= 0 && target < order.length) { + const neighbor = order[target] + if (neighbor === undefined) + throw new Error('Native account order changed') + order[target] = request.values.id + order[index] = neighbor + await nativeAccounts.reorder(order) + } + text = 'Native account order updated.' + break + } + case 'add-apikey': + await nativeAccounts.addApi({ ...request.values }) + text = 'Native API fallback route added.' + break + case 'add-oauth-start': + text = ( + await executePersistentAccountCommand('', request.sessionId, { + type: 'add-oauth-start', + }) + ).text + break + case 'add-oauth-finish': + text = ( + await executePersistentAccountCommand('', request.sessionId, { + type: 'add-oauth-finish', + ...request.values, + }) + ).text + break + case 'reset-backoff': { + await resetNativeBackoff('main', options.signal) + quotaManager.clearMainBackoff() + text = + 'Native main refresh and quota backoff cleared; credential validation and quarantine are unchanged.' + break + } + case 'enrollment-reset': + text = (await executePersistentAccountCommand('enrollment-reset')).text + break + case 'quota-refresh': + text = await buildQuotaCommandSummary(true) + break + case 'routing-mode': + text = await executePersistentRoutingCommand(request.values.mode) + break + case 'routing-reset': + text = await executePersistentRoutingCommand('reset', request.sessionId) + break + case 'cache-on': + case 'cache-off': + text = await executePersistentCache1hCommand( + request.action === 'cache-on' ? 'on' : 'off', + ) + break + case 'cache-mode': + text = await executePersistentCache1hCommand( + `mode ${request.values.mode}`, + ) + break + case 'cachekeep-always': + text = await executePersistentCacheKeepCommand('always') + break + case 'cachekeep-off': + text = await executePersistentCacheKeepCommand('off') + break + case 'cachekeep-window': + text = await executePersistentCacheKeepCommand( + `${request.values.startHour}-${request.values.endHour}`, + ) + break + case 'cachekeep-subagents': + text = await executePersistentCacheKeepCommand( + `subagents ${request.values.enabled}`, + ) + break + case 'dump-on': + case 'dump-off': + text = await executePersistentDumpCommand( + request.action === 'dump-on' ? 'on' : 'off', + ) + break + case 'fast-on': + case 'fast-off': + text = await executePersistentFastModeCommand( + request.action === 'fast-on' ? 'on' : 'off', + ) + break + case 'prime-on': + case 'prime-off': + text = await executePersistentPrimeCommand( + request.action === 'prime-on' ? 'on' : 'off', + ) + break + case 'logging-level': + text = await executePersistentLoggingCommand(request.values.level) + break + case 'killswitch-on': + case 'killswitch-off': + case 'killswitch-set': { + const snapshot = await nativeAccounts.read() + const accountIds = snapshot.accounts + .filter((account) => account.id !== 'main') + .map((account) => account.id) + await nativeAccounts.updateSettings((settings) => { + const config = getKillswitchConfig({ + ...settings, + version: 1, + accounts: [], + }) + if (request.action !== 'killswitch-set') { + const result = executeKillswitchCommand({ + argumentsText: request.action === 'killswitch-on' ? 'on' : 'off', + config, + accountIds, + }) + return { ...settings, killswitch: result.updatedConfig ?? config } + } + const accounts = { ...config.accounts } + const updated: NonNullable = { + ...config, + enabled: true, + accounts, + } + for (const entry of request.values.entries) { + const thresholds = { + five_hour: entry.fh, + seven_day: entry.sd, + ...(entry.scoped === undefined ? {} : { scoped: entry.scoped }), + } + if (entry.account === 'main' || entry.account === 'all') + updated.main = thresholds + if (entry.account === 'all') { + for (const id of accountIds) accounts[id] = thresholds + } else if (entry.account !== 'main') + accounts[entry.account] = thresholds + } + return { ...settings, killswitch: updated } + }) + text = 'Native killswitch policy updated.' + break + } + case 'start-fire': + text = await executePersistentStartCommand('', request.sessionId) + break + default: { + const unsupported: never = request + throw new Error(`Unsupported native menu action: ${unsupported}`) + } + } + await refreshSidebarAfterMutation(await loadAccounts()) + return { + ok: true, + text: text + .split('\n') + .filter((line) => !line.includes('/claude-')) + .join('\n'), + } + } + + const readNativeStatus: NativeUiOptions['readStatus'] = async ( + command, + invocation, + ) => { + if (command === 'account') { + const snapshot = await nativeAccounts.read() + const enrollment = + snapshot.mode === 'claustrum' + ? await readClaustrumEnrollmentStatus( + getHostClaustrumEnrollmentPaths('opencode'), + CLAUSTRUM_OPENCODE_ENROLLMENT_NAME, + ) + : undefined + return [ + `Custody mode: ${snapshot.mode}`, + ...(enrollment + ? formatEnrollmentStatus( + enrollment, + snapshot.accounts.some( + (account) => + account.id === 'main' && + account.source === 'vault' && + account.enabled, + ), + ) + : []), + ...snapshot.accounts.map( + (account) => + `${account.label ?? account.id} [${account.id === 'main' ? 'main' : 'fallback'}] ${account.enabled ? 'enabled' : 'disabled'} — ${account.source}${account.state ? ` (${account.state})` : ''}${formatOAuthAccountTier(account.profile) ? ` — ${formatOAuthAccountTier(account.profile)}` : ''}`, + ), + ].join('\n') + } + const modalNames = { + account: 'claude-account', + quota: 'claude-quota', + routing: 'claude-routing', + killswitch: 'claude-killswitch', + cache: 'claude-cache', + cachekeep: 'claude-cachekeep', + dump: 'claude-dump', + logging: 'claude-logging', + fast: 'claude-fast', + prime: 'claude-prime', + } satisfies Record< + Parameters[0], + CommandModalName + > const payload = await buildDialogPayload( - request.command, - request.arguments, - request.sessionId, + modalNames[command], + '', + invocation.sessionId, ) - return { text: payload.text, knobs: payload.knobs } + return payload.text + .split('\n') + .filter((line) => !line.includes('/claude-')) + .join('\n') + } + const nativeCommands = new Map< + string, + { interactive: boolean; command: ReturnType } + >() + function getNativeCommand(sessionId: string) { + const interactive = isTuiConnected(sessionId) + let entry = nativeCommands.get(sessionId) + if (!entry || entry.interactive !== interactive) { + entry = { + interactive, + command: createNativeCommand({ + host: 'opencode', + dispatch: dispatchNative, + readStatus: readNativeStatus, + interactive, + }), + } + nativeCommands.set(sessionId, entry) + } + return entry.command } function quotaBar(pct: number, width = 10): string { @@ -4684,6 +4786,23 @@ const anthropicAuthPlugin = async ( ?.catch?.(() => {}) } + if (ctx.directory) { + const rpcDir = getRpcDir(ctx.directory) + try { + rpcServerAdoption = await adoptRpcServer(rpcDir, () => + startRpcServer({ + dir: rpcDir, + drain: drainNotifications, + apply: applyCommand, + applyMenu: (request) => + getNativeCommand(request.sessionId ?? '').apply(request), + }), + ) + } catch { + logger.warn('rpc', 'native menu server unavailable') + } + } + return { 'experimental.chat.messages.transform': async ( _input: Record, @@ -4825,6 +4944,8 @@ const anthropicAuthPlugin = async ( } if (value.type === 'session.deleted') { + nativeCommands.delete(sessionId) + oauthPending.delete(sessionId) laneStartTracker.clearSession(sessionId) fableRecoveryNotices.delete(sessionId) pendingDesktopNotices.delete(sessionId) @@ -4841,63 +4962,15 @@ const anthropicAuthPlugin = async ( } }, config: async (config: { command?: Record }) => { + const commands = { ...(config.command ?? {}) } + // Retire only commands owned by this plugin; unrelated user commands stay. + for (const command of COMMAND_MODAL_NAMES) delete commands[command] config.command = { - ...(config.command ?? {}), - [CACHE_1H_COMMAND_NAME]: { - template: CACHE_1H_COMMAND_NAME, - description: - 'Show or toggle 1-hour Anthropic ephemeral prompt cache TTL.', - }, - [CLAUDE_ACCOUNT_COMMAND_NAME]: { - template: CLAUDE_ACCOUNT_COMMAND_NAME, - description: - 'Manage fallback accounts — list, enable/disable, reorder, remove, or add (API key or OAuth).', - }, - [CLAUDE_CACHE_KEEP_COMMAND_NAME]: { - template: CLAUDE_CACHE_KEEP_COMMAND_NAME, - description: - 'Keep hybrid Claude cache warm always or during a local time window.', - }, - [CLAUDE_PRIME_COMMAND_NAME]: { - template: CLAUDE_PRIME_COMMAND_NAME, + ...commands, + claude: { + template: 'claude', description: - "Start each OAuth account's five-hour quota window after reset.", - }, - [CLAUDE_START_COMMAND_NAME]: { - template: CLAUDE_START_COMMAND_NAME, - description: - 'Send a billed one-token synthetic turn to warm and renew the current Claude session cache.', - }, - - [CLAUDE_QUOTAS_COMMAND_NAME]: { - template: CLAUDE_QUOTAS_COMMAND_NAME, - description: - 'Show current Claude OAuth quota usage for all accounts.', - }, - [CLAUDE_DUMP_COMMAND_NAME]: { - template: CLAUDE_DUMP_COMMAND_NAME, - description: - 'Show or toggle Anthropic request dump capture for debugging.', - }, - [CLAUDE_FAST_COMMAND_NAME]: { - template: CLAUDE_FAST_COMMAND_NAME, - description: - 'Show or toggle Anthropic fast mode for supported Opus models.', - }, - [CLAUDE_LOGGING_COMMAND_NAME]: { - template: CLAUDE_LOGGING_COMMAND_NAME, - description: - 'Show or set the plugin log level (error, warn, info, debug, trace).', - }, - [CLAUDE_ROUTING_COMMAND_NAME]: { - template: CLAUDE_ROUTING_COMMAND_NAME, - description: - 'Show or change Claude account routing: main-first, fallback-first, or sticky-balanced.', - }, - [KILLSWITCH_COMMAND_NAME]: { - template: KILLSWITCH_COMMAND_NAME, - description: - 'Manage killswitch — hard-block requests when quota drops below per-account thresholds.', + 'Manage Claude accounts, quotas, routing, and cache settings.', }, } }, @@ -4936,37 +5009,32 @@ const anthropicAuthPlugin = async ( arguments: string sessionID: string }) => { - if (!COMMAND_MODAL_NAMES.includes(input.command as CommandModalName)) - return - const command = input.command as CommandModalName - const payload = await buildDialogPayload( - command, - input.arguments, + if (input.command !== 'claude') return + const payload = await getNativeCommand(input.sessionID).open( input.sessionID, ) - if (command === 'claude-quota') { - const cmdStorage = await loadAccounts() - const cmdAuth = latestGetAuth - ? await latestGetAuth().catch(() => undefined) - : undefined - writeSidebarState(cmdStorage, { - activeId: lastSidebarRouting.activeId, - route: lastSidebarRouting.route, - mainAccessToken: cmdAuth?.access, - routingAuthoritative: false, - }) - } if (isTuiConnected(input.sessionID)) { pushNotification(payload, input.sessionID) } else { - await sendIgnoredMessage(ctx, input.sessionID, payload.text) + const text = [ + payload.menu.title, + ...payload.menu.sections.flatMap((section) => [ + `\n## ${section.title}`, + ...section.lines, + ...section.items.map( + (item) => + `${item.label}${item.detail ? ` — ${item.detail}` : ''}`, + ), + ]), + ].join('\n') + await sendIgnoredMessage(ctx, input.sessionID, text) } cleanAbort() }, auth: { provider: 'anthropic', async loader( - getAuth: () => Promise<{ + hostGetAuth: () => Promise<{ type: string access?: string refresh?: string @@ -4974,8 +5042,109 @@ const anthropicAuthPlugin = async ( }>, _provider: { models: Record }, ) { + latestGetActivation = async () => { + const current = await hostGetAuth() + return { + type: current.type, + active: isCustodyTombstoneOAuth(current, 'anthropic'), + } + } + const activation = await hostGetAuth() + // Ordinary OpenCode API-key authentication remains unchanged. After + // migration, the OAuth marker only enables our provider. Each send must + // validate current local credentials or obtain new vault authorization. + if (activation.type !== 'oauth') return {} + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) { + return { + apiKey: '', + fetch: async () => { + throw new Error( + 'Native OAuth cannot serve while OPENCODE_AUTH_CONTENT is set', + ) + }, + } + } + if (nativeStartupError) { + return { + apiKey: '', + fetch: async () => { + throw nativeStartupError + }, + } + } + const journal = await readNativeMigrationJournal(nativePaths) + const getAuth = async (modelId?: string, signal?: AbortSignal) => { + const currentActivation = await hostGetAuth() + if (currentActivation.type !== 'oauth') + return { + type: currentActivation.type, + access: '', + expires: 0, + refresh: undefined, + nativeAccountIdentity: undefined, + nativeLocalSource: undefined, + nativeScopedAttempt: undefined, + modelDenied: false, + } + if (!isCustodyTombstoneOAuth(currentActivation, 'anthropic')) { + throw new Error( + 'Native OAuth requires inert OpenCode activation; run offline setup', + ) + } + let credential: NativeOAuthAuthorization | undefined + let denied: NativeAccountView | undefined + try { + credential = await authorizeOAuth( + 'main', + signal, + undefined, + modelId, + ) + } catch (error) { + if (error instanceof NativeModelPolicyError) { + denied = error.account + // A missing usage snapshot may itself trigger the killswitch. + // Its neutral usage poll is not permission to send this model. + if ( + !quotaSnapshotHasStandardWindows(denied.quota) && + !quotaSnapshotModelScopeIsExhausted(denied.quota, modelId) + ) { + try { + await nativeAccounts.fetchQuota('main', undefined, signal) + credential = await authorizeOAuth( + 'main', + signal, + undefined, + modelId, + ) + denied = undefined + } catch (retryError) { + if (retryError instanceof NativeModelPolicyError) + denied = retryError.account + } + } + } + } + signal?.throwIfAborted() + return { + type: 'oauth', + access: credential?.accessToken ?? '', + expires: credential?.expires ?? 0, + refresh: undefined, + nativeAccountIdentity: + credential?.accountIdentity ?? denied?.accountIdentity, + modelDenied: Boolean(denied), + nativeLocalSource: credential?.localSource, + nativeScopedAttempt: credential?.scopedAttempt, + } + } latestGetAuth = getAuth - const auth = await getAuth() + const auth: { + type: string + access: string + refresh?: string + expires: number + } = { type: 'oauth', access: '', expires: 0 } const custodyStorage = await loadAccounts(accountStoragePath) if ( getClaustrumMode(custodyStorage) === 'claustrum' && @@ -4998,494 +5167,115 @@ const anthropicAuthPlugin = async ( headers: { 'content-type': 'application/json' }, }, ), - } - } - // Provider initialization also runs for sessions using other models. - // Never make that shared path wait on account discovery: dispatch-time - // scoped authorization verifies the chosen route before Anthropic I/O. - const custodyMode = getClaustrumMode(custodyStorage) - const main = mainCustodyDimension(auth) - const fallbackDimensions = fallbackCustodyDimensions(custodyStorage) - const mainEvidence = - custodyMode !== 'claustrum' || - (main === 'T' && - custodyStorage?.claustrum?.primaryAccount?.state === 'active') - ? 'V' - : 'N' - const scopedMainRoute = - custodyMode === 'claustrum' && - main === 'T' && - isScopedCustodyActive(custodyStorage) - if (auth.type === 'oauth') { - try { - reconcileCustodyStartup({ - mode: custodyMode === 'claustrum' ? 'C' : 'L', - main, - fallbacks: fallbackDimensions.fallbacks, - evidence: mainEvidence, - }) - } catch (error) { - if (!(error instanceof CustodyStateMismatchError)) throw error - custodyStartupMismatchVerdict = error.verdict - return { - apiKey: '', - fetch: async () => { - throw error - }, - } - } - } - if (auth.type === 'oauth') { - if ( - isCustodyTombstoneOAuth(auth, 'anthropic') && - custodyMode !== 'claustrum' - ) { - throw new CustodyTombstoneLoginError('anthropic') - } - mainAccountId = await getOrCreateMainAccountId(accountStoragePath) - mainQuotaAccountId ??= - custodyMode === 'claustrum' - ? custodyStorage?.claustrum?.primaryAccount?.accountId - : mainAccountId - if (auth.access) { - await resolveMainQuotaAccountIdentity(auth.access) - } - async function refreshMainAccessToken(rejectedAccess?: string) { - if (rejectedAccess) { - const currentAuth = await getAuth() - if ( - currentAuth.type === 'oauth' && - currentAuth.access && - currentAuth.access !== rejectedAccess && - (!currentAuth.expires || currentAuth.expires > Date.now()) - ) { - log( - '[refresh] opencode main oauth reused concurrently rotated access token', - { - expiresInMs: currentAuth.expires - ? currentAuth.expires - Date.now() - : undefined, - }, - ) - return currentAuth.access - } - } - - let refreshPromise = - mainAccessRefreshesByStoragePath.get(accountStoragePath) - if (!refreshPromise) { - refreshPromise = (async () => { - const maxRetries = 2 - const baseDelayMs = 500 - let leaseId: string | null = null - let leaseTokenHash: string | null = null - let releaseFileLock: (() => Promise) | null = null - - async function updateMainRefreshState( - update: (storage: AccountStorage) => void, - ) { - const storage: AccountStorage = - (await loadAccounts(accountStoragePath)) ?? - createEmptyStorage() - storage.refresh = storage.refresh ?? {} - update(storage) - await saveAccountState(storage, accountStoragePath, { - mainRefresh: true, - }) - } - - async function waitForConcurrentMainRefresh(previous: { - access?: string - refresh?: string - expires?: number - }) { - const deadline = Date.now() + CONCURRENT_MAIN_REFRESH_WAIT_MS - while (Date.now() < deadline) { - await new Promise((resolve) => - runtimeTimers.setTimeout( - resolve, - CONCURRENT_MAIN_REFRESH_POLL_BASE_MS + - jitterMs(CONCURRENT_MAIN_REFRESH_POLL_BASE_MS), - ), - ) - const latest = await getAuth() - if (latest.type !== 'oauth' || !latest.access) continue - const changed = - latest.access !== previous.access || - latest.refresh !== previous.refresh || - (latest.expires ?? 0) > (previous.expires ?? 0) + 60_000 - if ( - changed && - (!latest.expires || latest.expires > Date.now()) - ) { - log( - '[refresh] opencode main oauth joined concurrent refresh', - { - expiresInMs: latest.expires - ? latest.expires - Date.now() - : undefined, - }, - ) - return latest.access - } - } - return null - } - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - let freshAuth: Awaited> | null = - null - try { - if (attempt > 0) { - const delay = baseDelayMs * 2 ** (attempt - 1) - await new Promise((resolve) => - runtimeTimers.setTimeout(resolve, delay), - ) - } - - // Re-read auth to get the latest refresh token. - // The outer `auth` snapshot may be stale if tokens - // were rotated since the fetch() call was made. - freshAuth = await getAuth() - - if (!freshAuth.refresh) { - throw new Error( - 'Token refresh failed: missing refresh token', - ) - } - - const storage = await loadAccounts(accountStoragePath) - const refreshTokenHash = hashRefreshToken(freshAuth.refresh) - const mainError = storage?.refresh?.mainLastRefreshError - log('[refresh] opencode main oauth refresh check', { - attempt, - expiresInMs: freshAuth.expires - ? freshAuth.expires - Date.now() - : undefined, - hasBackoff: Boolean(mainError), - backoffActive: mainError - ? refreshBackoffActive( - mainError, - mainAccountId ?? storage?.mainAccountId, - Date.now(), - tokenFingerprint(freshAuth.refresh), - ) - : false, - retryCount: mainError?.retryCount, - nextRetryAt: mainError?.nextRetryAt, - }) - if ( - mainError && - refreshBackoffActive( - mainError, - mainAccountId ?? storage?.mainAccountId, - Date.now(), - tokenFingerprint(freshAuth.refresh), - ) - ) { - log( - '[refresh] opencode main oauth refresh skipped backoff', - { - nextRetryAt: mainError.nextRetryAt, - retryCount: mainError.retryCount, - }, - ) - throw new Error( - formatRefreshBackoffMessage(mainError, Date.now()), - ) - } - if ( - storage?.refresh?.mainRefreshLeaseUntil && - storage.refresh.mainRefreshLeaseUntil > Date.now() && - storage.refresh.mainRefreshLeaseTokenHash === - refreshTokenHash - ) { - log( - '[refresh] opencode main oauth refresh skipped lease', - { - leaseUntil: storage.refresh.mainRefreshLeaseUntil, - }, - ) - const concurrentAccess = - await waitForConcurrentMainRefresh(freshAuth) - if (concurrentAccess) return concurrentAccess - throw new Error( - 'Claude OAuth refresh is already in progress', - ) - } - - const fileLock = await acquireRefreshFileLock({ - name: OPENCODE_MAIN_OAUTH_REFRESH_LOCK, - ttlMs: 2 * 60_000, - renew: true, - }) - if (!fileLock) { - log( - '[refresh] opencode main oauth refresh skipped file lock', - ) - const concurrentAccess = - await waitForConcurrentMainRefresh(freshAuth) - if (concurrentAccess) return concurrentAccess - throw new Error( - 'Claude OAuth refresh is already in progress', - ) - } - releaseFileLock = fileLock.release - - leaseId = randomUUID() - leaseTokenHash = refreshTokenHash - await updateMainRefreshState((nextStorage) => { - nextStorage.refresh = nextStorage.refresh ?? {} - nextStorage.refresh.mainRefreshLeaseId = - leaseId ?? undefined - nextStorage.refresh.mainRefreshLeaseUntil = - Date.now() + 2 * 60_000 - nextStorage.refresh.mainRefreshLeaseTokenHash = - refreshTokenHash - }) - const latestLease = await loadAccounts(accountStoragePath) - log( - '[refresh] opencode main oauth refresh lease acquired', - { - attempt, - leaseUntil: Date.now() + 2 * 60_000, - }, - ) - if ( - latestLease?.refresh?.mainRefreshLeaseId !== leaseId || - latestLease.refresh.mainRefreshLeaseTokenHash !== - refreshTokenHash - ) { - throw new Error( - 'Claude OAuth refresh is already in progress', - ) - } - - log('[refresh] opencode main oauth refresh request start', { - attempt, - }) - const refreshed = await refreshClaudeOAuthToken({ - refreshToken: freshAuth.refresh, - // Main OpenCode OAuth already has request-path retry, - // persisted backoff, and cross-process serialization here. - // Keep the shared helper single-shot in this path so the - // two retry layers cannot multiply endpoint pressure. - maxRetries: 0, - }) - - // biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set - await (client as any).auth.set({ - path: { - id: 'anthropic', - }, - body: { - type: 'oauth', - refresh: refreshed.refresh, - access: refreshed.access, - expires: refreshed.expires, - }, - }) - - await updateMainRefreshState((storage) => { - if (!storage?.refresh) return - storage.refresh.mainLastRefreshError = undefined - if (storage.refresh.mainRefreshLeaseId === leaseId) { - storage.refresh.mainRefreshLeaseId = undefined - storage.refresh.mainRefreshLeaseUntil = undefined - storage.refresh.mainRefreshLeaseTokenHash = undefined - } - }) - - log('[refresh] opencode main oauth refresh succeeded', { - attempt, - expiresInMs: refreshed.expires - Date.now(), - }) - return refreshed.access - } catch (error) { - const isNetworkError = - error instanceof Error && - (error.message.includes('fetch failed') || - ('code' in error && - (error.code === 'ECONNRESET' || - error.code === 'ECONNREFUSED' || - error.code === 'ETIMEDOUT' || - error.code === 'UND_ERR_CONNECT_TIMEOUT'))) - - if ( - attempt < maxRetries && - (isNetworkError || - (() => { - const s = (error as { status?: number }).status - return typeof s === 'number' && s >= 500 - })()) - ) { - continue - } - - log( - '[refresh] opencode main oauth refresh attempt failed', - { - attempt, - error: - error instanceof Error - ? error.message - : String(error), - transient: isNetworkError, - }, - ) - - const failedRefreshToken = freshAuth?.refresh - if ( - failedRefreshToken && - (error as { isRefreshError?: boolean }).isRefreshError - ) { - await updateMainRefreshState((storage) => { - storage.refresh = storage.refresh ?? {} - storage.refresh.mainLastRefreshError = - buildRefreshOperationError({ - error, - now: Date.now(), - accountIdentity: - mainAccountId ?? storage.mainAccountId, - refreshTokenFingerprint: - tokenFingerprint(failedRefreshToken), - previous: storage.refresh.mainLastRefreshError, - }) - }) - } - - throw error - } finally { - if (leaseId) { - await updateMainRefreshState((storage) => { - if (!storage?.refresh) return - if ( - storage.refresh.mainRefreshLeaseId === leaseId && - storage.refresh.mainRefreshLeaseTokenHash === - leaseTokenHash - ) { - storage.refresh.mainRefreshLeaseId = undefined - storage.refresh.mainRefreshLeaseUntil = undefined - storage.refresh.mainRefreshLeaseTokenHash = undefined - } - }).catch(() => {}) - } - await releaseFileLock?.().catch(() => {}) - } - } - // Unreachable — each iteration either returns or throws. - // Kept as a TypeScript exhaustiveness guard. - throw new Error('Token refresh exhausted all retries') - })() - mainAccessRefreshesByStoragePath.set( - accountStoragePath, - refreshPromise, - ) - } - try { - return await refreshPromise - } finally { - if ( - mainAccessRefreshesByStoragePath.get(accountStoragePath) === - refreshPromise - ) { - mainAccessRefreshesByStoragePath.delete(accountStoragePath) - } + } + } + // Other providers share initialization, so do not block them while + // the vault lists Claude accounts. Each Anthropic send separately + // checks that its selected vault account can supply credentials. + const custodyMode = getClaustrumMode(custodyStorage) + const main = mainCustodyDimension(activation) + const fallbackDimensions = fallbackCustodyDimensions(custodyStorage) + const mainEvidence = + custodyMode !== 'claustrum' || + (main === 'T' && + custodyStorage?.claustrum?.primaryAccount?.state === 'active') + ? 'V' + : 'N' + const scopedMainRoute = + custodyMode === 'claustrum' && + main === 'T' && + isScopedCustodyActive(custodyStorage) + if (auth.type === 'oauth') { + try { + reconcileCustodyStartup({ + mode: custodyMode === 'claustrum' ? 'C' : 'L', + main, + fallbacks: fallbackDimensions.fallbacks, + evidence: mainEvidence, + authority: + journal?.phase === 'committed' || journal?.phase === 'retired' + ? journal.phase + : undefined, + }) + } catch (error) { + if (!(error instanceof CustodyStateMismatchError)) throw error + custodyStartupMismatchVerdict = error.verdict + return { + apiKey: '', + fetch: async () => { + throw error + }, } } - - latestRefreshMainAccessToken = refreshMainAccessToken + } + if (auth.type === 'oauth') { + const initialSnapshot = await nativeAccounts.read() + const initialMain = initialSnapshot.accounts.find( + (account) => account.id === 'main', + ) + mainAccountId = + initialMain?.binding?.rowId ?? initialMain?.accountIdentity + mainQuotaAccountId ??= initialMain?.accountIdentity ?? mainAccountId + if (auth.access) { + await resolveMainQuotaAccountIdentity(auth.access) + } + async function refreshMainAccessToken( + rejectedAccess?: string, + modelId?: string, + signal?: AbortSignal, + ) { + const credential = await authorizeOAuth( + 'main', + signal, + rejectedAccess, + modelId, + 'refresh', + ) + return credential.accessToken + } function startMainBackgroundRefresh() { - if (mainBackgroundRefreshTimer) { + if (mainBackgroundRefreshTimer) runtimeTimers.clearInterval(mainBackgroundRefreshTimer) - mainBackgroundRefreshTimer = null - } - const run = async () => { try { const storage = await loadAccounts(accountStoragePath) if (!mainRefreshEnabled(storage)) return const latestAuth = await getAuth() if (latestAuth.type !== 'oauth') return - await clearStaleMainRefreshError( - mainAccountId ?? storage?.mainAccountId, - latestAuth.refresh - ? tokenFingerprint(latestAuth.refresh) - : undefined, - ) - if (!latestAuth.expires) return - const expiresInMs = latestAuth.expires - Date.now() - const refreshBeforeMs = mainRefreshBeforeExpiryMs(storage) - if (expiresInMs > refreshBeforeMs) { - return - } - log('[refresh] opencode main oauth background due', { - expiresInMs, - refreshBeforeMs, - }) if ( - refreshBackoffActive( - storage?.refresh?.mainLastRefreshError, - mainAccountId ?? storage?.mainAccountId, - Date.now(), - latestAuth.refresh - ? tokenFingerprint(latestAuth.refresh) - : undefined, - ) - ) { - log( - '[refresh] opencode main oauth background skipped backoff', - { - nextRetryAt: - storage?.refresh?.mainLastRefreshError?.nextRetryAt, - retryCount: - storage?.refresh?.mainLastRefreshError?.retryCount, - expiresInMs, - }, - ) + latestAuth.nativeLocalSource === 'rotated' || + latestAuth.nativeLocalSource === 'adopted' + ) return - } if ( - latestAuth.refresh && - storage?.refresh?.mainRefreshLeaseUntil && - storage.refresh.mainRefreshLeaseUntil > Date.now() && - storage.refresh.mainRefreshLeaseTokenHash === - hashRefreshToken(latestAuth.refresh) - ) { + latestAuth.expires && + latestAuth.expires - Date.now() > + mainRefreshBeforeExpiryMs(storage) + ) return - } - await refreshMainAccessToken() - const refreshedAuth = await getAuth() - log('[refresh] opencode main oauth refreshed in background', { - newExpiresInMs: refreshedAuth.expires - ? refreshedAuth.expires - Date.now() - : undefined, - }) - } catch (error) { - logger.warn('refresh', 'opencode main oauth refresh failed', { - message: - error instanceof Error ? error.message : String(error), - }) + } catch { + logger.warn( + 'refresh', + 'native main OAuth background refresh unavailable', + ) } } - mainBackgroundRefreshTimer = runtimeTimers.setInterval(() => { void run() }, MAIN_AUTH_REFRESH_TICK_MS + jitterMs(MAIN_AUTH_REFRESH_TICK_JITTER_MS)) - if ('unref' in mainBackgroundRefreshTimer) { - mainBackgroundRefreshTimer.unref() - } + mainBackgroundRefreshTimer.unref?.() } if (!scopedMainRoute) startMainBackgroundRefresh() quotaManager.seedFallbacksFromAccounts( (initialStorage?.accounts ?? []).filter(isOAuthAccount), ) - const initialSidebarRouting = - await resolveInitialSidebarRouting(accountStoragePath) + const initialSidebarRouting = await resolveInitialSidebarRouting(() => + loadAccounts(accountStoragePath), + ) await writeSidebarState(initialSidebarRouting.freshStorage, { activeId: initialSidebarRouting.activeId, route: initialSidebarRouting.route, @@ -5506,7 +5296,6 @@ const anthropicAuthPlugin = async ( route: 'main', mainAccessToken: auth.access, routingAuthoritative: false, - useHydratedProfiles: true, }) }) .catch(() => {}) @@ -5668,6 +5457,48 @@ const anthropicAuthPlugin = async ( applyCustomHeaders(headers) } + async function authorizePaidFallback( + routeId: string, + accessToken: string | undefined, + identity: MainQuotaIdentityBinding | undefined, + signal?: AbortSignal, + ) { + assertNativeEnvironment() + if (!accessToken) { + const general = identity?.quotaKey + ? quotaManager.getMain(identity.quotaKey) + : null + if ( + !identity?.providerAccountUuid || + !general || + general.refreshAfter <= Date.now() || + !quotaSnapshotIsExhausted(general.quota) + ) { + throw new Error( + 'API fallback requires fresh general primary OAuth exhaustion', + ) + } + // This primary authorization validates the existing general-quota + // gate for an API route. It does not permit the denied Claude model. + const primary = await authorizeOAuth('main', signal) + if (primary.accountIdentity !== identity.providerAccountUuid) + throw new Error('Primary identity changed before API fallback') + accessToken = primary.accessToken + } + const credential = await nativeAccounts.authorizeApi( + routeId, + signal, + ) + // Recheck the primary account's fresh quota after obtaining the + // API credential. Only account-wide OAuth exhaustion permits API + // fallback; model-only limits, auth errors or an unknown account + // do not justify using a paid API route. + if (!mainQuotaEntryIsFreshExhausted(accessToken, identity)) + throw new Error( + 'API fallback requires fresh general primary OAuth exhaustion', + ) + return credential + } async function sendWithApiAccount( input: string | URL | Request, init: RequestInit | undefined, @@ -5676,6 +5507,8 @@ const anthropicAuthPlugin = async ( route = 'api_fallback', currentStorage?: Awaited>, fableRequest?: FableRequestContext, + mainAccessToken?: string, + mainQuotaIdentity?: MainQuotaIdentityBinding, ) { void currentStorage if (fableRequest?.plan.downgraded) { @@ -5760,6 +5593,14 @@ const anthropicAuthPlugin = async ( const cacheDiagnosticsBetas = await getCacheDiagnosticsBetas(requestHeaders) + const apiCredential = await authorizePaidFallback( + account.id, + mainAccessToken, + mainQuotaIdentity, + init?.signal ?? undefined, + ) + account = { ...account, ...apiCredential } + configureApiRouteHeaders(requestHeaders, account) const rewritten = rewriteUrl(input, { baseURL: account.baseURL }) const sendStart = nowMs() let response: Response @@ -5815,6 +5656,10 @@ const anthropicAuthPlugin = async ( totalSendWithAccessMs: roundMs(nowMs() - start), baseURL: account.baseURL, }) + if (response.ok) + await nativeAccounts.publishApi(apiCredential.subject, { + lastUsed: Date.now(), + }) return response } @@ -5830,7 +5675,7 @@ const anthropicAuthPlugin = async ( fableRequest?: FableRequestContext, laneStartRequest = false, mainQuotaIdentity?: MainQuotaIdentityResolution, - scopedAttempt?: ClaustrumScopedAttempt, + scopedAttempt?: NativeCustodyReceipt, ) { assertNotCustodyTombstone(accessToken, 'anthropic') const start = nowMs() @@ -5888,34 +5733,28 @@ const anthropicAuthPlugin = async ( bodyBytes: body.length, }) } - const identityStart = nowMs() - const identity = await resolveClaudeCodeIdentity( - accessToken, + let nativeAuthorization = await authorizeOAuth( + oauthAccountId, + init?.signal ?? undefined, + undefined, modelForIdentity, - oauthAccountId === 'main' - ? (mainQuotaIdentity?.quotaKey ?? - asProviderAccountUuid(scopedAttempt?.accountId) ?? - (!accessToken.startsWith('sk-ant-oat') - ? mainAccountId - : undefined)) - : oauthAccountId, ) - if (oauthAccountId !== 'main' && identity.accountUuid) { - void persistFallbackAnthropicAccountUuid( - oauthAccountId, - identity.accountUuid, - ).catch((error) => { - logger.warn( - 'claustrum', - 'failed to persist fallback identity', - { - accountId: oauthAccountId, - error: - error instanceof Error ? error.message : String(error), - }, - ) - }) + accessToken = nativeAuthorization.accessToken + if ( + oauthAccountId === 'main' && + mainQuotaIdentity?.providerAccountUuid && + nativeAuthorization.accountIdentity !== + mainQuotaIdentity.providerAccountUuid + ) { + throw new Error( + 'Main account identity changed after request routing', + ) } + scopedAttempt = nativeAuthorization.scopedAttempt + const identityStart = nowMs() + const identity = nativeWireIdentity( + nativeAuthorization.accountIdentity, + ) trace?.mark('resolve_claude_code_identity', { route, ms: roundMs(nowMs() - identityStart), @@ -6105,39 +5944,62 @@ const anthropicAuthPlugin = async ( } } + const requestAuthority = nativeAuthorization + let retryFromVault: NativeCustodyReceipt | undefined + const authorizePhysical = async (rejectedAccessToken?: string) => { + const current = await authorizeOAuth( + oauthAccountId, + init?.signal ?? undefined, + rejectedAccessToken, + modelForIdentity, + ) + if ( + current.accountIdentity !== requestAuthority.accountIdentity || + Boolean(current.scopedAttempt) !== + Boolean(requestAuthority.scopedAttempt) + ) { + throw new Error( + 'Native credential authority changed during the request', + ) + } + if ( + retryFromVault && + (!current.scopedAttempt || + current.scopedAttempt.credentialId !== + retryFromVault.credentialId || + current.scopedAttempt.accountIdentity !== + retryFromVault.accountIdentity || + current.scopedAttempt.recordVersion <= + retryFromVault.recordVersion) + ) { + throw new Error( + 'Native vault retry has no strict newer same-account receipt', + ) + } + return current + } let usedDirectFetch = false let directAttempt = scopedAttempt - let retryDirectAttempt: ClaustrumScopedAttempt | undefined + const directFetch = async () => { usedDirectFetch = true - if (scopedAttempt) { - // A relay-to-direct fallback is another physical attempt. Its - // original receipt may have rotated while the relay was failing. - directAttempt = - retryDirectAttempt ?? - (relayConfig?.enabled - ? await getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ).authorize( - oauthAccountId, - init?.signal instanceof AbortSignal - ? init.signal - : undefined, - ) - : scopedAttempt) - retryDirectAttempt = undefined - requestHeaders.set( - 'authorization', - `Bearer ${directAttempt.accessToken}`, - ) - served = { - ...served, - accessToken: directAttempt.accessToken, - anthropicAccountUuid: asProviderAccountUuid( - directAttempt.accountId, - ), - } + // Validate current local credentials or obtain new vault + // authorization before every direct send, including relay rescue. + // Tokens checked earlier may have expired or been revoked. + nativeAuthorization = await authorizePhysical() + directAttempt = nativeAuthorization.scopedAttempt + requestHeaders.set( + 'authorization', + `Bearer ${nativeAuthorization.accessToken}`, + ) + served = { + ...served, + accessToken: nativeAuthorization.accessToken, + anthropicAccountUuid: asProviderAccountUuid( + nativeAuthorization.accountIdentity, + ), + localSubject: nativeAuthorization.localSubject, + scopedAttempt: nativeAuthorization.scopedAttempt, } try { const response = await fetch(rewritten.input, { @@ -6181,7 +6043,7 @@ const anthropicAuthPlugin = async ( } const requestStorageForIdentity = await getRequestStorage() - const relayConfig = getRelayConfig(requestStorageForIdentity) + const relayConfig = await nativeAccounts.getRelayConfig() const persistedFallbackAccount = oauthAccountId === 'main' ? undefined @@ -6198,30 +6060,20 @@ const anthropicAuthPlugin = async ( accessToken, authLineageId: fallbackAuthLineageId, anthropicAccountUuid: - asProviderAccountUuid(scopedAttempt?.accountId) ?? + asProviderAccountUuid(scopedAttempt?.accountIdentity) ?? (oauthAccountId === 'main' ? mainQuotaIdentity?.providerAccountUuid : (asProviderAccountUuid(identity.accountUuid) ?? asProviderAccountUuid(persistedFallbackAccountUuid))), + localSubject: nativeAuthorization.localSubject, + scopedAttempt: nativeAuthorization.scopedAttempt, ...(oauthAccountId === 'main' && mainQuotaIdentity ? { mainQuotaIdentity } : {}), } const sendStart = nowMs() - let relay401Attempt: ClaustrumScopedAttempt | undefined - let relayReturned = false - const reportScoped401 = ( - attempt: ClaustrumScopedAttempt, - source: 'direct' | 'relay_status_field', - ) => - getOpenCodeScopedRuntime(accountStoragePath, ctx.directory) - .reportFailure(attempt, 401, source) - .catch(() => { - logger.warn( - 'claustrum', - 'OpenCode scoped auth-failure report unavailable', - ) - }) + let relay401Attempt: NativeCustodyReceipt | undefined + const reportScoped401 = reportVault401 const sendOnce = () => sendViaRelay({ config: relayConfig, @@ -6232,46 +6084,37 @@ const anthropicAuthPlugin = async ( fallback: directFetch, affinity: relayAffinity, optimisticResponse: relayConfig?.transport === 'websocket', - authorizeAttempt: scopedAttempt - ? async () => { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - const nextAttempt = await runtime.authorize( - oauthAccountId, - init?.signal instanceof AbortSignal - ? init.signal - : undefined, - ) - served = { - ...served, - accessToken: nextAttempt.accessToken, - anthropicAccountUuid: asProviderAccountUuid( - nextAttempt.accountId, - ), - } - const relayHeaders = new Headers(requestHeaders) - relayHeaders.set( - 'authorization', - `Bearer ${nextAttempt.accessToken}`, - ) - return { - headers: relayHeaders, - onUpstreamStatus: (status: number) => { - if (status !== 401) return - relay401Attempt = nextAttempt - // A WebSocket can deliver a real upstream 401 after - // its optimistic response has been handed to the host. - if (relayReturned) - void reportScoped401( - nextAttempt, - 'relay_status_field', - ) - }, - } - } - : undefined, + authorizeAttempt: async () => { + const nextAuthorization = await authorizePhysical() + const nextAttempt = nextAuthorization.scopedAttempt + nativeAuthorization = nextAuthorization + served = { + ...served, + accessToken: nextAuthorization.accessToken, + anthropicAccountUuid: asProviderAccountUuid( + nextAuthorization.accountIdentity, + ), + localSubject: nextAuthorization.localSubject, + scopedAttempt: nextAttempt, + } + const relayHeaders = new Headers(requestHeaders) + relayHeaders.set( + 'authorization', + `Bearer ${nextAuthorization.accessToken}`, + ) + return { + headers: relayHeaders, + onUpstreamStatus: (status: number) => { + if (status !== 401 || !nextAttempt) return + relay401Attempt = nextAttempt + // The relay can return an optimistic 200 before Anthropic + // answers. If Anthropic later rejects the tokens, report + // the vault credential and version used for this send, + // not a newer version obtained after the rejection. + void reportScoped401(nextAttempt, 'relay_status_field') + }, + } + }, onResponseHeaders: (headers) => { harvestQuotaHeaders(headers, served) billingLineageTracker.commit( @@ -6293,25 +6136,17 @@ const anthropicAuthPlugin = async ( (fetchMethod(input, init) ?? '').toUpperCase() === 'POST' && !init?.signal?.aborted ) { - let rotated: ClaustrumScopedAttempt | undefined + let rotated: NativeCustodyReceipt | undefined try { - rotated = await getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ).authorize( - oauthAccountId, - init?.signal instanceof AbortSignal ? init.signal : undefined, + rotated = await prepareVaultRetry( + relay401Attempt, + init?.signal ?? undefined, + 'relay_status_field', ) } catch { // Keep the 401 and report the exact relay-served record below. } - if ( - decideScopedRetryAfter401( - 'model-relay', - relay401Attempt, - rotated, - ) - ) { + if (rotated) { await response.body?.cancel().catch(() => {}) logger.info( 'claustrum', @@ -6323,6 +6158,7 @@ const anthropicAuthPlugin = async ( transport: 'relay', }, ) + retryFromVault = relay401Attempt relay401Attempt = undefined // sendOnce obtains its own fresh receipt for this HTTP retry. response = await sendOnce() @@ -6336,19 +6172,16 @@ const anthropicAuthPlugin = async ( (fetchMethod(input, init) ?? '').toUpperCase() === 'POST' && !init?.signal?.aborted ) { - let rotated: ClaustrumScopedAttempt | undefined + let rotated: NativeCustodyReceipt | undefined try { - rotated = await getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ).authorize( - oauthAccountId, - init?.signal instanceof AbortSignal ? init.signal : undefined, + rotated = await prepareVaultRetry( + directAttempt, + init?.signal ?? undefined, ) } catch { // Preserve the genuine 401 if no replacement can be verified. } - if (decideScopedRetryAfter401('model', directAttempt, rotated)) { + if (rotated) { await response.body?.cancel().catch(() => {}) logger.info( 'claustrum', @@ -6360,12 +6193,34 @@ const anthropicAuthPlugin = async ( transport: 'direct', }, ) - retryDirectAttempt = rotated + + retryFromVault = directAttempt // Transport failures on the new attempt must propagate. They // are not evidence that the new record's token was rejected. response = await directFetch() } } + if ( + response.status === 401 && + !nativeAuthorization.scopedAttempt && + typeof body === 'string' && + (fetchMethod(input, init) ?? '').toUpperCase() === 'POST' && + !init?.signal?.aborted + ) { + const rejectedAccess = nativeAuthorization.accessToken + let replacement: NativeOAuthAuthorization | undefined + try { + replacement = await authorizePhysical(rejectedAccess) + } catch (error) { + nativeLocalRefreshFailures.set(response, error) + } + if (replacement && replacement.accessToken !== rejectedAccess) { + await response.body?.cancel().catch(() => {}) + response = usedDirectFetch + ? await directFetch() + : await sendOnce() + } + } trace?.mark('send_headers_received', { route, ms: roundMs(nowMs() - sendStart), @@ -6396,10 +6251,19 @@ const anthropicAuthPlugin = async ( if (usedDirectFetch && directAttempt && response.status === 401) { await reportScoped401(directAttempt, 'direct') } - relayReturned = true if (!usedDirectFetch && relay401Attempt) { await reportScoped401(relay401Attempt, 'relay_status_field') } + if (response.ok) { + if (served.scopedAttempt) + await nativeAccounts.vault.publish(served.scopedAttempt, { + lastUsed: Date.now(), + }) + else if (served.localSubject) + await nativeAccounts.publishLocal(served.localSubject, { + lastUsed: Date.now(), + }) + } return response } @@ -6519,7 +6383,6 @@ const anthropicAuthPlugin = async ( options.includeApiRoutes === true && isApiKeyAccount(account) && account.enabled !== false && - account.apiKey && isValidApiBaseURL(account.baseURL) ) { usable.push(account) @@ -6542,6 +6405,7 @@ const anthropicAuthPlugin = async ( storage: AccountStorage | null mainAccessToken: string mainRefreshToken?: string + mainPolicyDenied?: boolean requestedModelId?: string mainQuotaIdentity?: MainQuotaIdentityBinding }) { @@ -6592,6 +6456,7 @@ const anthropicAuthPlugin = async ( }) } } else if ( + (input.mainAccessToken || input.mainPolicyDenied) && !isPermanentRefreshError( latestStorage?.refresh?.mainLastRefreshError, ) @@ -6654,11 +6519,11 @@ const anthropicAuthPlugin = async ( if (stored.enabled === false || !isOAuthAccount(stored)) continue const account = usableFallbacksById.get(stored.id) ?? stored - if ( - !account.access || - isPermanentRefreshError(account.lastRefreshError) - ) - continue + // Retain temporarily unavailable account metadata so sticky + // sessions can keep their account assignment. Only IDs with + // separately authorized credentials can serve requests; + // these empty access fields cannot supply a bearer token. + if (isPermanentRefreshError(account.lastRefreshError)) continue let accountQuota = getFallbackQuota(account) if ( !stickyQuotaSnapshotIsFresh( @@ -6671,14 +6536,14 @@ const anthropicAuthPlugin = async ( try { accountQuota = await quotaManager.refreshFallback( account.id, - account.access, + account.access ?? '', account, ) } catch {} } allRoutes.push({ id: account.id, - access: account.access, + access: account.access ?? '', quota: accountQuota, identity: { kind: 'known', accountId: account.id }, order: index + 1, @@ -6847,6 +6712,8 @@ const anthropicAuthPlugin = async ( trace?: PerfTrace, options?: { returnLastOnExhausted?: boolean + mainAccessToken?: string + mainQuotaIdentity?: MainQuotaIdentityBinding onSuccess?: (account: { id: string access?: string @@ -6869,7 +6736,6 @@ const anthropicAuthPlugin = async ( for (const [index, account] of accounts.entries()) { let response: Response if (isApiKeyAccount(account)) { - if (!account.apiKey) continue await cancelCurrentResponse() response = await sendWithApiAccount( input, @@ -6879,19 +6745,24 @@ const anthropicAuthPlugin = async ( `api_fallback_${index}`, storage, options?.fableRequest, + options?.mainAccessToken, + options?.mainQuotaIdentity, ) } else if ( isScopedCustodyActive(storage) && account.claustrumScopedCredentialId ) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - const scopedAttempt = await runtime.authorize( + const credential = await authorizeOAuth( account.id, - init?.signal instanceof AbortSignal ? init.signal : undefined, + init?.signal ?? undefined, + undefined, + parseRequestModel(await fetchBody(input, init)), ) + const scopedAttempt = credential.scopedAttempt + if (!scopedAttempt) + throw new Error( + 'Native vault fallback credential is unavailable', + ) await cancelCurrentResponse() response = await sendWithAccessToken( input, @@ -6941,7 +6812,7 @@ const anthropicAuthPlugin = async ( response, isApiKeyAccount(account) ? 'api' : 'oauth', ) - await fallbackManager.markUsed(account) + await options?.onSuccess?.(account) // Active-route every-N refresh: this fallback just served the // request, so keep its quota fresh on the same cadence as main. @@ -7005,7 +6876,6 @@ const anthropicAuthPlugin = async ( account.enabled !== false && (isOAuthAccount(account) || (isApiKeyAccount(account) && - Boolean(account.apiKey) && isValidApiBaseURL(account.baseURL))), ) if (!hasPotentialFallbackRoute) return mainResponse @@ -7088,6 +6958,8 @@ const anthropicAuthPlugin = async ( trace, { onSuccess: onFallbackSuccess, + mainAccessToken, + mainQuotaIdentity, fableRequest, laneStartRequest, }, @@ -7098,6 +6970,8 @@ const anthropicAuthPlugin = async ( return { apiKey: '', async fetch(input: string | URL | Request, init?: RequestInit) { + if (input instanceof Request && init?.signal === undefined) + init = { ...init, signal: input.signal } const incomingHeaders = mergeHeaders(input, init) const laneStartRequest = incomingHeaders.get(LANE_START_REQUEST_HEADER) === '1' @@ -7312,7 +7186,13 @@ const anthropicAuthPlugin = async ( }) } const authStart = nowMs() - const auth = await getAuth() + const credentialModelId = + fablePlan?.effectiveModel ?? + parseRequestModel(await fetchBody(input, init)) + const auth = await getAuth( + credentialModelId, + init?.signal ?? undefined, + ) trace.mark('get_auth', { ms: roundMs(nowMs() - authStart), authType: auth.type, @@ -7340,32 +7220,9 @@ const anthropicAuthPlugin = async ( return response } const storage = await loadAccounts() - let requestMainProviderUuid: ProviderAccountUuid | undefined - let requestMainScopedAttempt: ClaustrumScopedAttempt | undefined - if (isScopedCustodyActive(storage)) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - try { - const attempt = await runtime.authorize( - 'main', - init?.signal instanceof AbortSignal - ? init.signal - : undefined, - ) - auth.access = attempt.accessToken - auth.expires = attempt.expiresAtMs - mainServedAccessToken = attempt.accessToken - requestMainScopedAttempt = attempt - requestMainProviderUuid = asProviderAccountUuid( - attempt.accountId, - ) - } catch { - auth.access = '' - auth.expires = 0 - } - } + const requestMainProviderUuid: ProviderAccountUuid | undefined = + asProviderAccountUuid(auth.nativeAccountIdentity) + const requestMainScopedAttempt = auth.nativeScopedAttempt let requestMainQuotaIdentity: | MainQuotaIdentityResolution | undefined @@ -7386,11 +7243,25 @@ const anthropicAuthPlugin = async ( mainServedAccessToken = auth.access mainProviderAccountUuid = resolution.providerAccountUuid } + } else if (auth.modelDenied && requestMainProviderUuid) { + // The saved account UUID identifies which quota to inspect; + // it does not authorize credentials for this denied model. + // An API fallback still requires its separate primary-account + // credential check and fresh account-wide exhaustion proof. + await reconcileMainQuotaAccountIdentity( + '', + requestMainProviderUuid, + requestMainProviderUuid, + ) + requestMainQuotaIdentity = { + quotaKey: requestMainProviderUuid, + providerAccountUuid: requestMainProviderUuid, + generation: quotaManager.getMainQuotaIdentityGeneration(), + stale: false, + state: isScopedCustodyActive(storage) ? 'on-cold' : 'na', + } } - await clearStaleMainRefreshError( - mainAccountId, - auth.refresh ? tokenFingerprint(auth.refresh) : undefined, - ) + const loadStart = nowMs() trace.mark('load_storage', { ms: roundMs(nowMs() - loadStart) }) quotaManager.updateStorage(storage) @@ -7411,7 +7282,34 @@ const anthropicAuthPlugin = async ( clearFableRecoveryNotice(sessionId, storage, auth) } const replayableRequest = isReplayableRequest(input, init?.body) - const requestModelId = parseRequestModel(init?.body) + const requestModelId = + parseRequestModel(init?.body) ?? credentialModelId + const modelDeniedResponse = () => { + const retryAfter = killswitchRetryAfterSeconds( + quotaManager.getMain(requestMainQuotaIdentity?.quotaKey) + ?.quota, + (storage?.accounts ?? []).filter(isOAuthAccount), + Date.now(), + credentialModelId, + ) + return new Response( + JSON.stringify({ + type: 'error', + error: { + type: 'rate_limit_error', + message: + 'No enabled OAuth route can serve the requested model under current quota policy.', + }, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': String(retryAfter), + }, + }, + ) + } // Count every replayable request up front — before the // fallback-first early return — so the every-N refresh cadence // (quota.refreshEveryNRequests) advances for main and the active @@ -7465,8 +7363,7 @@ const anthropicAuthPlugin = async ( if ( replayableRequest && sessionId && - getRoutingMode(storage) === 'sticky-balanced' && - auth.access + getRoutingMode(storage) === 'sticky-balanced' ) { const routingModelId = fablePlan?.effectiveModel ?? requestModelId @@ -7485,6 +7382,7 @@ const anthropicAuthPlugin = async ( storage, mainAccessToken: auth.access, mainRefreshToken: auth.refresh, + mainPolicyDenied: auth.modelDenied, requestedModelId: routingModelId, mainQuotaIdentity: requestMainQuotaIdentity, }) @@ -7522,11 +7420,13 @@ const anthropicAuthPlugin = async ( ) let resolution = await resolveRoute() if (!resolution && incompleteQuotaPool) { + if (auth.modelDenied) return modelDeniedResponse() throw new Error( 'Sticky-balanced routing is waiting for current OAuth quota snapshots', ) } if (!resolution) { + if (auth.modelDenied) return modelDeniedResponse() const response = createStickyNoRouteResponse({ mainRefreshError: stickyRoutes.storage?.refresh?.mainLastRefreshError, @@ -7563,16 +7463,17 @@ const anthropicAuthPlugin = async ( } const sendRoute = async (selected: StickyOAuthRoute) => { if (selected.scoped) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - const scopedAttempt = await runtime.authorize( + const credential = await authorizeOAuth( selected.id, - init?.signal instanceof AbortSignal - ? init.signal - : undefined, + init?.signal ?? undefined, + undefined, + routingModelId, ) + const scopedAttempt = credential.scopedAttempt + if (!scopedAttempt) + throw new Error( + 'Native vault sticky credential is unavailable', + ) return sendWithAccessToken( input, init, @@ -7609,11 +7510,8 @@ const anthropicAuthPlugin = async ( const completeRoute = async ( selected: StickyOAuthRoute, response: Response, - markUsed = true, + _markUsed = true, ) => { - if (markUsed && selected.account) { - await fallbackManager.markUsed(selected.account) - } await writeCurrentSidebarState( selected.id, 'sticky-balanced', @@ -7687,7 +7585,7 @@ const anthropicAuthPlugin = async ( ) } - let inspected = await inspectResponse( + const inspected = await inspectResponse( await sendRoute(route), ) let permanentAuthFailure = false @@ -7696,58 +7594,28 @@ const anthropicAuthPlugin = async ( } if (inspected.response.status === 401) { - const authRouteId = route.id - try { - if (route.scoped) { - permanentAuthFailure = true - } else if ( - authRouteId === STICKY_ROUTING_MAIN_ACCOUNT_ID - ) { - auth.access = await refreshMainAccessToken( - route.access ?? '', - ) - route = { ...route, access: auth.access } - } else if (route.account && stickyRoutes.storage) { - const refreshed = - await fallbackManager.refreshAccount( - route.account, - stickyRoutes.storage, - { force: true }, - ) - if (refreshed.access) { - route = { - ...route, - access: refreshed.access, - account: refreshed, - } - } - } - if (!permanentAuthFailure) { - await inspected.response.body - ?.cancel() - .catch(() => {}) - inspected = await inspectResponse( - await sendRoute(route), - ) - if (!inspected.routeFailure) { - return completeRoute(route, inspected.response) - } - permanentAuthFailure = - inspected.response.status === 401 - } - } catch (error) { + const failure = nativeLocalRefreshFailures.get( + inspected.response, + ) + if (failure) { const latest = await loadAccounts(accountStoragePath) + const failedRouteId = route.id const refreshError = - authRouteId === STICKY_ROUTING_MAIN_ACCOUNT_ID + route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? latest?.refresh?.mainLastRefreshError : latest?.accounts.find( (account): account is OAuthAccount => - account.id === authRouteId && + account.id === failedRouteId && isOAuthAccount(account), )?.lastRefreshError - if (!isPermanentRefreshError(refreshError)) throw error - permanentAuthFailure = true + // This request already retried after token rejection; + // do not rotate again. Temporary refresh or network + // failure keeps the current account. Switch only after + // the runtime confirms its credentials are permanently invalid. + if (!isPermanentRefreshError(refreshError)) + throw failure } + permanentAuthFailure = true } let migrate = @@ -7805,6 +7673,7 @@ const anthropicAuthPlugin = async ( storage: stickyRoutes.storage, mainAccessToken: auth.access, mainRefreshToken: auth.refresh, + mainPolicyDenied: auth.modelDenied, requestedModelId: routingModelId, mainQuotaIdentity: requestMainQuotaIdentity, }) @@ -7860,6 +7729,8 @@ const anthropicAuthPlugin = async ( account.id, 'sticky-balanced', ), + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, fableRequest, laneStartRequest, }, @@ -7918,6 +7789,8 @@ const anthropicAuthPlugin = async ( trace, { returnLastOnExhausted: false, + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback-first'), fableRequest, @@ -7939,19 +7812,23 @@ const anthropicAuthPlugin = async ( } } - if (!auth.access || !auth.expires || auth.expires < Date.now()) { + if ( + !auth.modelDenied && + (!auth.access || !auth.expires || auth.expires < Date.now()) + ) { if (isScopedCustodyActive(storage)) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) try { - const scopedAttempt = await runtime.authorize( + const credential = await authorizeOAuth( 'main', - init?.signal instanceof AbortSignal - ? init.signal - : undefined, + init?.signal ?? undefined, + undefined, + credentialModelId, ) + const scopedAttempt = credential.scopedAttempt + if (!scopedAttempt) + throw new Error( + 'Native vault main credential is unavailable', + ) mainServedAccessToken = scopedAttempt.accessToken const response = await sendWithAccessToken( input, @@ -7984,6 +7861,8 @@ const anthropicAuthPlugin = async ( { onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, fableRequest, laneStartRequest, }, @@ -8031,13 +7910,17 @@ const anthropicAuthPlugin = async ( }, ) const refreshStart = nowMs() - auth.access = await refreshMainAccessToken() + auth.access = await refreshMainAccessToken( + undefined, + credentialModelId, + init?.signal ?? undefined, + ) trace.mark('refresh_main_access', { ms: roundMs(nowMs() - refreshStart), }) } - if (!auth.access) { + if (!auth.access && !auth.modelDenied) { trace.done('missing_access_error') throw new Error('OAuth access token is missing after refresh') } @@ -8187,6 +8070,8 @@ const anthropicAuthPlugin = async ( { onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, fableRequest, laneStartRequest, }, @@ -8345,6 +8230,8 @@ const anthropicAuthPlugin = async ( // is wrong once the killswitch hands off to a fallback. onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, laneStartRequest, }, ) @@ -8406,6 +8293,31 @@ const anthropicAuthPlugin = async ( ) } + if (auth.modelDenied) { + const deniedResponse = modelDeniedResponse() + const fallbackAccounts = replayableRequest + ? await getRoutableFallbackAccounts(storage, { + modelId: credentialModelId, + }) + : [] + const response = await tryUsableFallbackAccounts( + input, + init, + fallbackAccounts, + storage, + deniedResponse, + trace, + { + onSuccess: (account) => + writeCurrentSidebarState(account.id, 'fallback'), + fableRequest, + laneStartRequest, + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, + }, + ) + return wrapResponse(response ?? deniedResponse) + } const mainResponse = await sendWithAccessToken( input, init, @@ -8479,7 +8391,24 @@ const anthropicAuthPlugin = async ( result.redirectUri, result.state, ) - return exchanged + if (exchanged.type === 'failed') return exchanged + assertNativeEnvironment() + await nativeAccounts.loginOAuth({ + routeId: 'main', + replace: true, + credential: { + access: exchanged.access, + refresh: exchanged.refresh, + expires: exchanged.expires, + }, + }) + const activation = custodyTombstoneOAuth('anthropic') + return { + type: 'success' as const, + access: activation.access, + refresh: activation.refresh, + expires: activation.expires, + } }, } }, @@ -8530,7 +8459,6 @@ const anthropicAuthPlugin = async ( __resolveMainQuotaIdentityForTest: resolveMainQuotaAccountIdentity, __resolveSidebarQuotaAccessForTest: resolveSidebarQuotaAccess, __mainProviderAccountUuidForTest: () => mainProviderAccountUuid, - __persistFallbackQuotaErrorForTest: persistFallbackQuotaError, __fallbackRefreshReady: fallbackRefreshReady, get __scopedRuntime() { return getOpenCodeScopedRuntime(accountStoragePath, ctx.directory) diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index f1e3c9bf6..9bdf5cd96 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -1004,9 +1004,16 @@ describe('account command INFO logs (via plugin)', () => { await executeCommand(plugin, 'claude-account', 'custody fallback-1 on') - const payload = drainNotifications(0, 'ses_test').at(-1)?.payload - expect(payload?.text).toContain('/claude-account claustrum') - expect(payload?.text).toContain('/claude-account local') + const notice = drainNotifications(0, 'ses_test').at(-1) + expect(notice).toBeDefined() + if ( + notice?.type !== 'open-dialog' || + notice.payload.command !== 'claude-account' + ) + throw new Error('Expected legacy account guidance payload') + const payload = notice.payload + expect(payload.text).toContain('/claude-account claustrum') + expect(payload.text).toContain('/claude-account local') expect(await readFile(accountPath, 'utf8')).toBe(before) expect((await fs.stat(accountPath)).mtimeMs).toBe(beforeMtime) }) @@ -1026,9 +1033,15 @@ describe('account command INFO logs (via plugin)', () => { const before = await readFile(accountPath, 'utf8') drainNotifications(0, 'ses_test') await executeCommand(plugin, 'claude-account', 'claustrum') - expect(drainNotifications(0, 'ses_test').at(-1)?.payload.text).toContain( - 'setup', + const notice = drainNotifications(0, 'ses_test').at(-1) + expect(notice).toBeDefined() + if ( + notice?.type !== 'open-dialog' || + notice.payload.command !== 'claude-account' ) + throw new Error('Expected legacy account guidance payload') + const payload = notice.payload + expect(payload.text).toContain('setup') expect(await readFile(accountPath, 'utf8')).toBe(before) }) diff --git a/packages/opencode/src/tests/custody-mode.test.ts b/packages/opencode/src/tests/custody-mode.test.ts index cbe04528f..d9af2792d 100644 --- a/packages/opencode/src/tests/custody-mode.test.ts +++ b/packages/opencode/src/tests/custody-mode.test.ts @@ -4,13 +4,14 @@ import { reconcileCustodyStartup, } from '../custody-mode.ts' -test('local OAuth and enrolled scoped custody are the only serving states', () => { +test('committed native local and enrolled vault custody require inert activation', () => { expect( reconcileCustodyStartup({ mode: 'L', - main: 'R', + main: 'T', fallbacks: 'R', evidence: 'V', + authority: 'committed', }).verdict, ).toBe('LOCAL_SERVE') expect( @@ -19,16 +20,27 @@ test('local OAuth and enrolled scoped custody are the only serving states', () = main: 'T', fallbacks: 'T', evidence: 'V', + authority: 'retired', }).verdict, ).toBe('CLAUSTRUM_SERVE') for (const input of [ { mode: 'C', main: 'T', fallbacks: 'M', evidence: 'V' }, { mode: 'C', main: 'R', fallbacks: 'T', evidence: 'V' }, { mode: 'C', main: 'T', fallbacks: 'T', evidence: 'N' }, + { mode: 'L', main: 'R', fallbacks: 'R', evidence: 'V' }, + { mode: 'L', main: 'T', fallbacks: 'R', evidence: 'N' }, + ] as const) { + expect(() => + reconcileCustodyStartup({ ...input, authority: 'committed' }), + ).toThrow(CustodyStateMismatchError) + } +}) + +test('activation and roster cannot bypass the committed migration journal', () => { + for (const input of [ { mode: 'L', main: 'T', fallbacks: 'R', evidence: 'V' }, + { mode: 'C', main: 'T', fallbacks: 'T', evidence: 'V' }, ] as const) { - expect(() => reconcileCustodyStartup(input)).toThrow( - CustodyStateMismatchError, - ) + expect(() => reconcileCustodyStartup(input)).toThrow('MIGRATION_REQUIRED') } }) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index e1d7b4a5f..51221d34a 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -749,7 +749,10 @@ describe('scoped enrollment is explicit', () => { ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') const notices = drainNotifications(0, sessionId) notificationId = notices.at(-1)?.id ?? 0 - const accountDialog = notices.at(-1)?.payload + const notice = notices.at(-1) + if (notice?.type !== 'open-dialog') + throw new Error('Expected a legacy account dialog notification') + const accountDialog = notice.payload expect(accountDialog?.command).toBe('claude-account') expect(accountDialog?.knobs?.accounts).toContainEqual( expect.objectContaining({ @@ -7639,7 +7642,7 @@ describe('auth.loader', () => { expect(state.relay).toEqual({ enabled: true, transport: 'websocket' }) }) - test('registers and handles /claude-cache slash command with ignored status replies', async () => { + test('registers /claude and handles cache settings with ignored status replies', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const mockClient = createMockClient() const plugin = await getPlugin(mockClient) @@ -7647,30 +7650,13 @@ describe('auth.loader', () => { await plugin.config(config) - expect(config.command?.['claude-cache']).toMatchObject({ - template: 'claude-cache', - description: expect.stringContaining('1-hour'), - }) - expect(config.command?.['claude-quota']).toMatchObject({ - template: 'claude-quota', - description: expect.stringContaining('Claude OAuth quota'), - }) - expect(config.command?.['claude-dump']).toMatchObject({ - template: 'claude-dump', - description: expect.stringContaining('dump'), - }) - expect(config.command?.['claude-fast']).toMatchObject({ - template: 'claude-fast', - description: expect.stringContaining('fast mode'), - }) - expect(config.command?.['claude-cachekeep']).toMatchObject({ - template: 'claude-cachekeep', - description: expect.stringContaining('cache warm'), - }) - expect(config.command?.['claude-routing']).toMatchObject({ - template: 'claude-routing', - description: expect.stringContaining('account routing'), + expect(config.command?.claude).toMatchObject({ + template: 'claude', + description: expect.stringContaining('Claude accounts'), }) + for (const command of COMMAND_MODAL_NAMES) { + expect(config.command?.[command]).toBeUndefined() + } await expectHandledCommandResponse( plugin['command.execute.before']({ @@ -7716,41 +7702,36 @@ describe('auth.loader', () => { expect(saved.claudeCache).toEqual({ enabled: true, mode: 'explicit' }) }) - test('config hook registers every modal command so they appear in the command palette', async () => { + test('config hook registers only /claude and retires owned aliases without clobbering foreign commands', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const plugin = await getPlugin() - - // Seed a pre-existing command from another plugin / opencode itself — the - // config hook must MERGE into config.command, never clobber it. const preExisting = { template: 'other-plugin-cmd', description: 'foreign' } const result: { command?: Record } = { - command: { 'other-plugin-cmd': preExisting }, + command: { + 'other-plugin-cmd': preExisting, + ...Object.fromEntries( + COMMAND_MODAL_NAMES.map((name) => [ + name, + { + template: name, + description: 'previous plugin registration', + }, + ]), + ), + }, } await plugin.config(result) - const registered = Object.keys(result.command ?? {}) - - // Passthrough-survival: the foreign command must still be present (the hook - // spreads ...(config.command ?? {}) — dropping that spread would silently - // wipe every other plugin's commands). expect(result.command?.['other-plugin-cmd']).toEqual(preExisting) - - // Every modal command must be registered — if one is missing it won't appear - // in the slash-command palette and users will get "No matching items". + expect(result.command?.claude).toMatchObject({ template: 'claude' }) for (const name of COMMAND_MODAL_NAMES) { - expect(registered).toContain(name) + expect(result.command?.[name]).toBeUndefined() } - - // The config hook must not register extra claude-* commands beyond the - // shared modal-command set (drift in either direction is a bug). The foreign - // 'other-plugin-cmd' is excluded from this count via the claude- prefix. - const claudeRegistered = registered.filter((name) => - name.startsWith('claude-'), - ) - expect(claudeRegistered).toHaveLength(COMMAND_MODAL_NAMES.length) - expect([...claudeRegistered].sort()).toEqual( - [...COMMAND_MODAL_NAMES].sort(), - ) + expect(Object.keys(result.command ?? {}).sort()).toEqual([ + 'claude', + 'other-plugin-cmd', + ]) + await plugin.dispose?.() }) test('handles /claude-start by injecting one visible synthetic prompt', async () => { diff --git a/packages/opencode/src/tests/native-cli.test.ts b/packages/opencode/src/tests/native-cli.test.ts new file mode 100644 index 000000000..e7285770c --- /dev/null +++ b/packages/opencode/src/tests/native-cli.test.ts @@ -0,0 +1,188 @@ +import { afterEach, expect, mock } from 'bun:test' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { authorize, exchange } from '@cortexkit/anthropic-auth-core' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { addApiRoute, login, relaySetup } from '../cli.ts' + +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_AUTH_CONTENT', +] as const +const cancellations = new Set() +let teardownRequested = false +afterEach(() => { + teardownRequested = true + const closing = [...cancellations] + for (const controller of closing) controller.abort() +}) +const lifetimes = createTestLifetimeSuite() +function test(name: string, body: () => unknown, timeout?: number) { + lifetimes.test( + name, + async () => { + teardownRequested = false + await setupBody() + return body() + }, + timeout, + ) +} +const originalFetch = globalThis.fetch +let directory: string +let path: string +let originalBytes: string +const prompt = mock(async (_question: string) => 'synthetic-answer') +const authorizeImpl = mock( + async (): Promise>> => ({ + url: 'https://example.test/sign-in', + verifier: 'synthetic-verifier', + state: 'synthetic-state', + redirectUri: 'https://example.test/callback', + }), +) +const exchangeImpl = mock( + async (): Promise>> => ({ + type: 'failed', + }), +) +const network = mock(async (): Promise => { + throw new Error('Unexpected external request in native CLI refusal test') +}) + +async function setupBody() { + const savedFetch = globalThis.fetch + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const controller = new AbortController() + cancellations.add(controller) + if (teardownRequested) controller.abort() + const ownedDirectory = await mkdtemp(join(tmpdir(), 'oc1-native-cli-')) + directory = ownedDirectory + lifetimes.deferCleanup(async () => { + // TestLifetime waits for the test and its registered background reads + // before deleting that test's directory or restoring its environment. + controller.abort() + try { + await rm(ownedDirectory, { recursive: true, force: true }) + } finally { + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + cancellations.delete(controller) + } + }) + path = join(directory, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = path + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = join( + directory, + 'anthropic-auth-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + directory, + 'enrollment.json', + ) + delete process.env.OPENCODE_AUTH_CONTENT + originalBytes = JSON.stringify({ + version: 1, + accounts: [ + { + id: 'legacy', + type: 'oauth', + access: 'synthetic-legacy-access-canary', + refresh: 'synthetic-legacy-refresh-canary', + expires: Date.now() + 60_000, + }, + ], + }) + await writeFile(path, originalBytes, { mode: 0o600 }) + prompt.mockClear() + network.mockClear() + authorizeImpl.mockClear() + exchangeImpl.mockClear() + globalThis.fetch = Object.assign( + async () => { + controller.signal.throwIfAborted() + return network() + }, + { preconnect: originalFetch.preconnect }, + ) +} + +async function expectNoMutationOrExternalWork() { + expect(await readFile(path, 'utf8')).toBe(originalBytes) + expect(await readdir(directory)).toEqual(['anthropic-auth.json']) + expect(prompt).not.toHaveBeenCalled() + expect(authorizeImpl).not.toHaveBeenCalled() + expect(exchangeImpl).not.toHaveBeenCalled() + expect(network).not.toHaveBeenCalled() +} + +test('CLI OAuth login refuses an unmigrated store before prompting or creating the sign-in URL', async () => { + await expect( + login('synthetic-label', { + prompt, + authorize: authorizeImpl, + exchange: exchangeImpl, + }), + ).rejects.toThrow('migration') + await expectNoMutationOrExternalWork() +}) + +test('CLI API route add refuses an unmigrated store before collecting any credential', async () => { + await expect(addApiRoute('synthetic-label', { prompt })).rejects.toThrow( + 'migration', + ) + await expectNoMutationOrExternalWork() +}) + +test('CLI relay setup refuses an unmigrated store before Cloudflare provisioning', async () => { + await expect(relaySetup({ prompt })).rejects.toThrow('migration') + await expectNoMutationOrExternalWork() +}) + +test('supervised auth content refuses CLI OAuth login without replacing host or legacy auth', async () => { + process.env.OPENCODE_AUTH_CONTENT = '{}' + await expect( + login('synthetic-label', { + prompt, + authorize: authorizeImpl, + exchange: exchangeImpl, + }), + ).rejects.toThrow('OPENCODE_AUTH_CONTENT') + await expectNoMutationOrExternalWork() +}) + +test('supervised auth content refuses native API route and relay mutations', async () => { + process.env.OPENCODE_AUTH_CONTENT = '{}' + await expect(addApiRoute('synthetic-label', { prompt })).rejects.toThrow( + 'OPENCODE_AUTH_CONTENT', + ) + await expect(relaySetup({ prompt })).rejects.toThrow('OPENCODE_AUTH_CONTENT') + await expectNoMutationOrExternalWork() +}) + +test('native CLI suite lifetime joins final fixture reads before restoring body environment', async () => { + const ownedPath = path + const expectedBytes = originalBytes + const savedAccountPath = process.env.OPENCODE_ANTHROPIC_AUTH_FILE + const readGate = lifetimes.gate() + let completed = false + lifetimes.trackDetached( + (async () => { + await readGate.wait + expect(process.env.OPENCODE_ANTHROPIC_AUTH_FILE).toBe(savedAccountPath) + expect(await readFile(ownedPath, 'utf8')).toBe(expectedBytes) + completed = true + })(), + ) + lifetimes.deferCleanup(() => { + expect(completed).toBe(true) + }) + readGate.open() +}) diff --git a/packages/opencode/src/tests/native-serving.test.ts b/packages/opencode/src/tests/native-serving.test.ts new file mode 100644 index 000000000..8c40455f2 --- /dev/null +++ b/packages/opencode/src/tests/native-serving.test.ts @@ -0,0 +1,1190 @@ +import { afterEach, expect, mock } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type authorize, + CacheKeepManager, + createNativeAccountRuntime, + custodyTombstoneOAuth, + discoverNativeVaultInventory, + type NativeCustodyClient, + PrimeManager, + publishNativeVaultRosterSeed, + publishNativeVaultRuntimeSeed, + resetClaudeCodeIdentityCachesForTest, + resolveNativePoolPaths, + runNativeMigration, +} from '@cortexkit/anthropic-auth-core' +import { createOpencodeClient, type Provider } from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { MockRelayServer } from '../../../e2e-tests/src/mock-relay.ts' +import { AnthropicAuthPlugin } from '../index.ts' +import { COMMAND_MODAL_NAMES } from '../rpc/protocol.ts' + +type PluginHooks = Awaited> +type PluginFetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise + +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_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_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', + 'CLAUDE_CONFIG_DIR', +] as const +const originalFetch = globalThis.fetch +const provider: Provider = { + id: 'anthropic', + name: 'Anthropic', + source: 'custom', + env: [], + options: {}, + models: {}, +} +type BodyOwner = { + directory: string + controller: AbortController + plugins: PluginHooks[] + relays: Set +} +const owners = new Set() +let currentOwner: BodyOwner | undefined +let teardownRequested = false +// Register cancellation before createTestLifetimeSuite's afterEach hook: +// stop test I/O before that hook waits for timed-out tests to finish. +afterEach(async () => { + teardownRequested = true + const closing = [...owners] + const pluginsToClose = closing.flatMap((owner) => [...owner.plugins]) + const relaysToClose = closing.flatMap((owner) => [...owner.relays]) + for (const owner of closing) owner.controller.abort() + const siblings = [ + ...pluginsToClose.map((plugin) => + Promise.resolve().then(() => plugin.dispose?.()), + ), + ...relaysToClose.map((relay) => Promise.resolve().then(() => relay.stop())), + ] + const results = await Promise.allSettled(siblings) + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Native serving cancellation failed', + ) +}) +const lifetimes = createTestLifetimeSuite() +function test(name: string, body: () => unknown, timeout?: number) { + lifetimes.test( + name, + async () => { + teardownRequested = false + await setupBody() + return body() + }, + timeout, + ) +} +let relays: Set +let directory: string +let configPath: string +let legacyBytes: string +const network = mock( + async ( + _input: Parameters[0], + _init?: Parameters[1], + ): Promise => { + throw new Error( + 'Unexpected network request in native admission refusal test', + ) + }, +) + +async function setupBody() { + const savedFetch = globalThis.fetch + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const ownedDirectory = await mkdtemp(join(tmpdir(), 'oc1-native-refusal-')) + directory = ownedDirectory + const owner: BodyOwner = { + directory: ownedDirectory, + controller: new AbortController(), + plugins: [], + relays: new Set(), + } + currentOwner = owner + if (teardownRequested) owner.controller.abort() + owners.add(owner) + relays = owner.relays + lifetimes.deferCleanup(async () => { + const remainingPlugins = owner.plugins.splice(0) + const remainingRelays = [...owner.relays] + owner.relays.clear() + owner.controller.abort() + const siblings = [ + ...remainingPlugins.map((plugin) => + Promise.resolve().then(() => plugin.dispose?.()), + ), + ...remainingRelays.map((relay) => + Promise.resolve().then(() => relay.stop()), + ), + ] + const results = await Promise.allSettled(siblings) + try { + await rm(ownedDirectory, { recursive: true, force: true }) + } finally { + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + owners.delete(owner) + if (currentOwner === owner) currentOwner = undefined + } + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Native serving cleanup failed', + ) + }) + configPath = join(directory, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = configPath + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = join( + directory, + 'anthropic-auth-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( + directory, + 'sidebar.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( + directory, + 'cachekeep', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( + directory, + 'quota-feed', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = join(directory, 'rpc') + process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE = join( + directory, + 'routing.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + directory, + 'enrollment.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = join( + directory, + 'synthetic-connection.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + delete process.env.OPENCODE_AUTH_CONTENT + legacyBytes = JSON.stringify({ + version: 1, + refresh: { enabled: false }, + quota: { enabled: false }, + claudeCache: { enabled: false }, + accounts: [ + { + id: 'legacy-route', + type: 'oauth', + access: 'synthetic-legacy-access-canary', + refresh: 'synthetic-legacy-refresh-canary', + expires: Date.now() + 60_000, + }, + ], + }) + await writeFile(configPath, legacyBytes, { mode: 0o600 }) + process.env.CLAUDE_CONFIG_DIR = join(directory, 'synthetic-claude') + resetClaudeCodeIdentityCachesForTest() + network.mockClear() + globalThis.fetch = Object.assign( + ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const signal = AbortSignal.any([ + owner.controller.signal, + ...(init?.signal ? [init.signal] : []), + ]) + signal.throwIfAborted() + return network(input, { ...init, signal }) + }, + { preconnect: originalFetch.preconnect }, + ) +} + +async function createPlugin( + options: Parameters[1] = {}, +) { + const owner = currentOwner + if (!owner) throw new Error('Native serving fixture has no body owner') + const creation = AnthropicAuthPlugin( + { + client: createOpencodeClient({ + baseUrl: 'http://127.0.0.1:9', + fetch: globalThis.fetch, + }), + project: { + id: 'native-refusal-fixture', + worktree: directory, + time: { created: Date.now() }, + }, + directory, + worktree: directory, + serverUrl: new URL('http://127.0.0.1:9'), + experimental_workspace: { register() {} }, + $, + }, + { + scopedRosterPollIntervalMs: 0, + cacheKeepAggregateRefreshIntervalMs: 0, + ...options, + }, + ) + lifetimes.trackDetached(creation) + const plugin = await creation + owner.plugins.push(plugin) + if (owner.controller.signal.aborted) await plugin.dispose?.() + return plugin +} + +function requestFetch(options: Record): PluginFetch { + const handler = options.fetch + if (typeof handler !== 'function') + throw new Error('Plugin did not install its request handler') + return async (input, init) => { + const owner = currentOwner + if (!owner) throw new Error('Native request has no body owner') + const signal = AbortSignal.any([ + owner.controller.signal, + ...(init?.signal ? [init.signal] : []), + ]) + const response: unknown = await handler(input, { ...init, signal }) + if (!(response instanceof Response)) + throw new Error('Plugin request handler did not return a Response') + return response + } +} + +async function load( + plugin: PluginHooks, + auth: Parameters['loader']>>[0], +) { + const loader = plugin.auth?.loader + if (!loader) throw new Error('Plugin did not install its auth loader') + return loader(auth, provider) +} + +test('unmigrated local OAuth refuses foreground and background network without adopting legacy secrets', async () => { + const plugin = await createPlugin() + const options = await load(plugin, async () => ({ + type: 'oauth', + access: 'synthetic-host-access-canary', + refresh: 'synthetic-host-refresh-canary', + expires: Date.now() + 60_000, + })) + await expect( + requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] }), + }), + ).rejects.toThrow('migration') + expect(network).not.toHaveBeenCalled() + expect(await readFile(configPath, 'utf8')).toBe(legacyBytes) +}) + +test('inert activation alone cannot serve before offline migration commits', async () => { + const plugin = await createPlugin() + const options = await load(plugin, async () => + custodyTombstoneOAuth('anthropic'), + ) + await expect( + requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: '{}', + }), + ).rejects.toThrow('migration') + expect(network).not.toHaveBeenCalled() + expect(await readFile(configPath, 'utf8')).toBe(legacyBytes) +}) + +test('supervised auth content refuses native OAuth before any provider request', async () => { + const plugin = await createPlugin() + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + anthropic: custodyTombstoneOAuth('anthropic'), + }) + const options = await load(plugin, async () => + custodyTombstoneOAuth('anthropic'), + ) + await expect( + requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: '{}', + }), + ).rejects.toThrow('OPENCODE_AUTH_CONTENT') + expect(network).not.toHaveBeenCalled() +}) + +test('ordinary OpenCode API auth stays passthrough even without native migration', async () => { + const plugin = await createPlugin() + expect( + await load(plugin, async () => ({ + type: 'api', + key: 'synthetic-stock-api-key', + })), + ).toEqual({}) + expect(network).not.toHaveBeenCalled() + expect(await readFile(configPath, 'utf8')).toBe(legacyBytes) +}) + +test('single claude registration removes owned aliases and preserves foreign commands', async () => { + const plugin = await createPlugin() + const config = { + command: { + foreign: { template: 'foreign' }, + ...Object.fromEntries( + COMMAND_MODAL_NAMES.map((name) => [name, { template: name }]), + ), + }, + } + await plugin.config?.(config) + expect(Object.keys(config.command).sort()).toEqual(['claude', 'foreign']) + expect(config.command.foreign).toEqual({ template: 'foreign' }) + expect(network).not.toHaveBeenCalled() +}) + +test('retired slash aliases cannot mutate settings or dispatch a native menu action', async () => { + const plugin = await createPlugin() + for (const command of COMMAND_MODAL_NAMES) { + await plugin['command.execute.before']?.( + { command, arguments: 'on', sessionID: 'synthetic-session' }, + { parts: [] }, + ) + } + expect(await readFile(configPath, 'utf8')).toBe(legacyBytes) + expect(network).not.toHaveBeenCalled() +}) + +test('local OAuth sign-in cannot begin before native authority migration', async () => { + const authorizeImpl: typeof authorize = mock(async () => ({ + url: 'https://example.test/oauth', + redirectUri: 'https://example.test/callback', + verifier: 'synthetic-verifier', + state: 'synthetic-state', + })) + const plugin = await createPlugin({ authorize: authorizeImpl }) + const method = plugin.auth?.methods[0] + if (method?.type !== 'oauth') + throw new Error('Plugin did not install native OAuth sign-in') + await expect(method.authorize()).rejects.toThrow('migration') + expect(authorizeImpl).not.toHaveBeenCalled() + expect(network).not.toHaveBeenCalled() +}) + +test('entrypoint retains only the plugin factory runtime export', async () => { + expect(Object.keys(await import('../index.ts'))).toEqual([ + 'AnthropicAuthPlugin', + ]) +}) + +const mainIdentity = '11111111-1111-4111-8111-111111111111' +const fallbackIdentity = '22222222-2222-4222-8222-222222222222' + +async function migrateServingFixture( + mode: 'local' | 'claustrum', + fallback = false, +) { + const paths = await resolveNativePoolPaths( + configPath, + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE, + ) + const hostAuthPath = join(directory, 'host-auth.json') + const scopedReports: Array<{ + credentialId: string + recordVersion: number + providerStatus: number + }> = [] + const authorizedGets: string[] = [] + let vaultVersion = 1 + const scopedClient: NativeCustodyClient = { + async listScoped() { + return { + view: 'synthetic-native-view', + rows: [ + { + id: 'oauth:anthropic', + accountId: mainIdentity, + credentialType: 'oauth', + state: 'active', + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + refreshAdapter: 'anthropic', + recordVersion: vaultVersion, + operations: ['read', 'invalidate'], + createdAtMs: 1, + }, + ...(fallback + ? [ + { + id: 'oauth:secondary', + accountId: fallbackIdentity, + credentialType: 'oauth', + state: 'active', + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + refreshAdapter: 'anthropic', + recordVersion: vaultVersion, + operations: ['read', 'invalidate'], + createdAtMs: 1, + }, + ] + : []), + ], + } + }, + async getScoped({ credentialId }) { + authorizedGets.push(credentialId) + return { + credentialId, + material: `sk-ant-oat01-vault-${credentialId === 'oauth:anthropic' ? 'main' : 'fallback'}-v${vaultVersion}`, + accountId: + credentialId === 'oauth:anthropic' ? mainIdentity : fallbackIdentity, + recordVersion: vaultVersion, + expiresAtMs: Date.now() + 8 * 60 * 60_000, + } + }, + async reportAuthFailureScoped(report) { + scopedReports.push(report) + }, + close() {}, + } + const config = { + version: 1, + mainAccountId: 'primary-route', + refresh: { enabled: false }, + quota: { enabled: false }, + routing: { mode: 'main-first' }, + ...(mode === 'claustrum' + ? { + claustrum: { + mode, + scopedRoster: true, + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: mainIdentity, + state: 'active', + }, + }, + } + : {}), + accounts: fallback + ? [ + { + id: 'fallback-route', + type: 'oauth', + enabled: true, + ...(mode === 'claustrum' + ? { + refresh: '', + claustrumScopedCredentialId: 'oauth:secondary', + anthropicAccountUuid: fallbackIdentity, + } + : { + access: 'sk-ant-oat01-local-fallback', + refresh: 'synthetic-fallback-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }), + }, + ] + : [], + } + await writeFile(paths.legacyConfig, JSON.stringify(config), { mode: 0o600 }) + await writeFile( + hostAuthPath, + JSON.stringify({ + anthropic: + mode === 'claustrum' + ? custodyTombstoneOAuth('anthropic') + : { + type: 'oauth', + access: 'sk-ant-oat01-local-main', + refresh: 'synthetic-primary-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + other: { type: 'api', key: 'synthetic-unrelated-provider' }, + }), + { mode: 0o600 }, + ) + await writeFile( + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE!, + JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const journal = await runNativeMigration({ + paths, + host: 'opencode', + hostAuthPath, + routingSourcePath: join(directory, 'legacy-routing.json'), + routingDestinationPath: join(directory, 'native-routing.json'), + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: false, + ...(mode === 'claustrum' + ? { + custody: { + discover: () => + discoverNativeVaultInventory({ + paths, + host: 'opencode', + connect: async () => scopedClient, + }), + publishSeed: publishNativeVaultRosterSeed, + publishRuntimeSeed: publishNativeVaultRuntimeSeed, + }, + } + : {}), + }) + expect(journal.phase).toBe('retired') + expect(journal.version).toBe(3) + expect(JSON.parse(await readFile(hostAuthPath, 'utf8')).other).toEqual({ + type: 'api', + key: 'synthetic-unrelated-provider', + }) + const records: Array<{ url: string; authorization: string; body: string }> = + [] + const fetchProvider = async ( + input: Parameters[0], + init?: Parameters[1], + ): Promise => { + const url = input instanceof Request ? input.url : input.toString() + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ) + const body = typeof init?.body === 'string' ? init.body : '' + records.push({ + url, + authorization: headers.get('authorization') ?? '', + body, + }) + if (url.includes('/oauth/token')) { + const payload = JSON.parse(body) + const isFallback = String(payload.refresh_token).includes('fallback') + return Response.json({ + access_token: `sk-ant-oat01-local-${isFallback ? 'fallback' : 'main'}-rotated`, + refresh_token: `synthetic-${isFallback ? 'fallback' : 'primary'}-successor`, + expires_in: 28800, + }) + } + if (url.includes('/api/claude_cli/bootstrap')) { + const uuid = headers.get('authorization')?.includes('fallback') + ? fallbackIdentity + : mainIdentity + return Response.json({ oauth_account: { account_uuid: uuid } }) + } + if (url.includes('/api/oauth/profile')) { + const uuid = headers.get('authorization')?.includes('fallback') + ? fallbackIdentity + : mainIdentity + return Response.json({ + account: { uuid }, + organization: { + uuid: 'synthetic-org', + billing_type: 'stripe', + rate_limit_tier: 'default_claude_max_5x', + }, + }) + } + if (url.includes('/api/oauth/usage')) + return Response.json({ + five_hour: { + utilization: 5, + resets_at: new Date(Date.now() + 5 * 60 * 60_000).toISOString(), + }, + seven_day: { + utilization: 10, + resets_at: new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString(), + }, + }) + if (url.includes('/v1/messages')) + return Response.json({ + id: 'synthetic-message', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'native-serving-ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }) + throw new Error(`Unexpected synthetic provider path: ${url}`) + } + network.mockImplementation(fetchProvider) + return { + paths, + records, + scopedReports, + authorizedGets, + scopedClient, + fetchProvider, + rotateVault: () => { + vaultVersion++ + }, + } +} + +async function sendNative(plugin: PluginHooks, model = 'claude-sonnet-4-6') { + const options = await load(plugin, async () => + custodyTombstoneOAuth('anthropic'), + ) + return requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-affinity': `synthetic-session-${directory}`, + }, + body: JSON.stringify({ + model, + max_tokens: 16, + messages: [{ role: 'user', content: 'Synthetic native serving probe' }], + }), + }) +} + +test('real offline local migration serves only positively admitted pool OAuth, never SDK or legacy secrets', async () => { + const fixture = await migrateServingFixture('local') + const plugin = await createPlugin() + const response = await sendNative(plugin) + expect(response.status).toBe(200) + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-local-main-rotated') + expect(sent[0]?.body).not.toContain('synthetic-primary-refresh') + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + const snapshot = await runtime.read() + expect( + snapshot.accounts.find((account) => account.id === 'main') + ?.accountIdentity, + ).toBe(mainIdentity) + expect(JSON.stringify(snapshot.policyStorage)).not.toContain('sk-ant-oat01') + } finally { + runtime.close() + } +}) + +test('real offline vault migration freshly authorizes direct model attempts without local token rescue', async () => { + const fixture = await migrateServingFixture('claustrum') + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + expect((await sendNative(plugin)).status).toBe(200) + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-vault-main-v1') + expect( + fixture.records.some((record) => record.url.includes('/oauth/token')), + ).toBe(false) + expect(fixture.scopedReports).toHaveLength(0) +}) + +test('supervised content after real migration refuses foreground and background dispatch', async () => { + const fixture = await migrateServingFixture('local') + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + anthropic: custodyTombstoneOAuth('anthropic'), + }) + const plugin = await createPlugin() + await expect(sendNative(plugin)).rejects.toThrow('OPENCODE_AUTH_CONTENT') + expect(fixture.records).toHaveLength(0) +}) + +for (const transport of ['http', 'websocket'] as const) { + test(`migrated vault ${transport} relay uses native private relay config and fresh vault receipt`, async () => { + const fixture = await migrateServingFixture('claustrum') + const relay = new MockRelayServer() + relays.add(relay) + const address = await relay.start({ token: 'synthetic-native-relay-token' }) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await runtime.updateRelay({ + enabled: true, + url: address.url, + token: 'synthetic-native-relay-token', + transport, + fallbackToDirect: false, + }) + expect(JSON.stringify(await runtime.read())).not.toContain( + 'synthetic-native-relay-token', + ) + } finally { + runtime.close() + } + network.mockImplementation((input, init) => { + const url = input instanceof Request ? input.url : input.toString() + return url.startsWith(address.url) + ? originalFetch(input, init) + : fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + const response = await sendNative(plugin) + expect(response.status).toBe(200) + expect(await response.text()).toContain('native-serving-ok') + expect(relay.acceptedRequests()).toBe(1) + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-vault-main-v1') + }) +} + +test('vault direct 401 retry reports each rejected physical credential version exactly', async () => { + const fixture = await migrateServingFixture('claustrum') + let attempts = 0 + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.includes('/v1/messages')) { + attempts++ + fixture.records.push({ + url, + authorization: new Headers(init?.headers).get('authorization') ?? '', + body: String(init?.body ?? ''), + }) + if (attempts === 1) fixture.rotateVault() + return Response.json( + { + type: 'error', + error: { + type: 'authentication_error', + message: 'Synthetic physical rejection', + }, + }, + { status: 401 }, + ) + } + return fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + expect((await sendNative(plugin)).status).toBe(401) + expect(attempts).toBe(2) + expect( + fixture.scopedReports.map((report) => ({ + credentialId: report.credentialId, + recordVersion: report.recordVersion, + })), + ).toEqual([ + { credentialId: 'oauth:anthropic', recordVersion: 1 }, + { credentialId: 'oauth:anthropic', recordVersion: 2 }, + ]) + expect( + fixture.records + .filter((record) => record.url.includes('/v1/messages')) + .map((record) => record.authorization), + ).toEqual([ + 'Bearer sk-ant-oat01-vault-main-v1', + 'Bearer sk-ant-oat01-vault-main-v2', + ]) +}) + +test('native CacheKeep background prewarm cannot reuse a stored legacy bearer', async () => { + const fixture = await migrateServingFixture('claustrum') + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + let storage: Awaited>['policyStorage'] + try { + await runtime.updateSettings((settings) => ({ + ...settings, + claudeCache: { enabled: true, mode: 'hybrid' }, + cacheKeep: { enabled: true, always: true }, + })) + storage = (await runtime.read()).policyStorage + } finally { + runtime.close() + } + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + await load(plugin, async () => custodyTombstoneOAuth('anthropic')) + const cacheKeep: unknown = Reflect.get(plugin, '__cacheKeepManager') + if (!(cacheKeep instanceof CacheKeepManager)) + throw new Error('Native host CacheKeep manager is unavailable') + cacheKeep.track({ + sessionId: 'synthetic-background-session', + url: 'https://api.anthropic.com/v1/messages', + headers: new Headers({ + authorization: 'Bearer synthetic-legacy-prewarm-canary', + 'content-type': 'application/json', + }), + bodyText: JSON.stringify({ + model: 'claude-sonnet-4-6', + max_tokens: 1, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Synthetic warm', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], + }), + storage, + cacheMode: 'hybrid', + oauthAccountId: 'main', + }) + await cacheKeep.prewarmNow({ + sessionId: 'synthetic-background-session', + url: 'https://api.anthropic.com/v1/messages', + headers: new Headers({ + authorization: 'Bearer synthetic-legacy-prewarm-canary', + 'content-type': 'application/json', + }), + bodyText: JSON.stringify({ + model: 'claude-sonnet-4-6', + max_tokens: 1, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Synthetic warm', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], + }), + oauthAccountId: 'main', + isSubagent: false, + }) + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-vault-main-v1') + expect(JSON.stringify(fixture.records)).not.toContain( + 'synthetic-legacy-prewarm-canary', + ) +}) + +test('vault relay transport failure reauthorizes direct fallback without false credential rejection', async () => { + const fixture = await migrateServingFixture('claustrum') + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + const relayUrl = 'https://synthetic-native-relay.invalid' + try { + await runtime.updateRelay({ + enabled: true, + url: relayUrl, + token: 'synthetic-native-relay-token', + transport: 'http', + fallbackToDirect: true, + }) + } finally { + runtime.close() + } + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.startsWith(relayUrl)) { + fixture.rotateVault() + throw new Error('Synthetic relay connection failure') + } + return fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + expect((await sendNative(plugin)).status).toBe(200) + expect( + fixture.records + .filter((record) => record.url.includes('/v1/messages')) + .map((record) => record.authorization), + ).toEqual(['Bearer sk-ant-oat01-vault-main-v2']) + expect(fixture.scopedReports).toHaveLength(0) +}) + +test('native Prime performs one fresh usage poll before one minimal Haiku send', async () => { + const fixture = await migrateServingFixture('claustrum') + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await runtime.updateSettings((settings) => ({ + ...settings, + prime: { enabled: true }, + })) + } finally { + runtime.close() + } + let usagePolls = 0 + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.includes('/api/oauth/usage')) { + usagePolls++ + fixture.records.push({ + url, + authorization: new Headers(init?.headers).get('authorization') ?? '', + body: '', + }) + return Response.json({ + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() - 120_000).toISOString(), + }, + seven_day: { + utilization: 0, + resets_at: new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString(), + }, + }) + } + return fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + await load(plugin, async () => custodyTombstoneOAuth('anthropic')) + const prime: unknown = Reflect.get(plugin, '__primeManager') + if (!(prime instanceof PrimeManager)) + throw new Error('Native host Prime manager is unavailable') + await prime.tick() + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(usagePolls).toBe(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-vault-main-v1') + expect(JSON.parse(sent[0]?.body ?? '{}')).toMatchObject({ + max_tokens: 1, + model: expect.stringContaining('haiku'), + }) + prime.stop() +}) + +for (const fallback of [false, true]) { + test(`model-only primary denial ${fallback ? 'preserves eligible OAuth fallback' : 'returns local 429'} without primary token authorization`, async () => { + const fixture = await migrateServingFixture('claustrum', fallback) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + vault: { connect: async () => fixture.scopedClient }, + }) + try { + const receipt = await runtime.authorizeVault('main') + expect( + await runtime.vault.publish(receipt, { + quota: { + accountIdentity: mainIdentity, + checkedAt: Date.now(), + five_hour: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), + }, + seven_day: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + remainingPercent: 0, + usedPercent: 100, + checkedAt: Date.now(), + resetsAt: new Date(Date.now() + 24 * 60 * 60_000).toISOString(), + }, + ], + }, + }), + ).toBe(true) + } finally { + runtime.close() + } + fixture.authorizedGets.length = 0 + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + const response = await sendNative(plugin, 'claude-fable-5-1') + expect(response.status).toBe(fallback ? 200 : 429) + expect(fixture.authorizedGets).not.toContain('oauth:anthropic') + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(fallback ? 1 : 0) + if (fallback) + expect(sent[0]?.authorization).toBe( + 'Bearer sk-ant-oat01-vault-fallback-v1', + ) + expect( + fixture.records.some((record) => record.url.includes('/oauth/token')), + ).toBe(false) + }) +} + +test('OAuth SDK callback commits native credentials and returns only inert activation', async () => { + const fixture = await migrateServingFixture('local') + const authorization: typeof authorize = mock(async () => ({ + url: 'https://example.test/oauth', + verifier: 'synthetic-verifier', + state: 'synthetic-state', + redirectUri: 'https://example.test/callback', + })) + const plugin = await createPlugin({ authorize: authorization }) + const method = plugin.auth?.methods[0] + if (method?.type !== 'oauth') + throw new Error('Native OAuth method is unavailable') + const flow = await method.authorize() + if (flow.method !== 'code') + throw new Error('Expected code-based OAuth sign-in') + const result = await flow.callback('synthetic-code#synthetic-state') + const activation = custodyTombstoneOAuth('anthropic') + expect(result).toMatchObject({ + type: 'success', + access: activation.access, + refresh: activation.refresh, + expires: activation.expires, + }) + expect(JSON.stringify(result)).not.toContain( + 'sk-ant-oat01-local-main-rotated', + ) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + expect( + (await runtime.read()).accounts.some( + (account) => account.id === 'main' && account.type === 'oauth', + ), + ).toBe(true) + } finally { + runtime.close() + } +}) + +test('optimistic websocket late 401 reports the exact served receipt, not a newer vault credential', async () => { + const fixture = await migrateServingFixture('claustrum') + const relay = new MockRelayServer() + relays.add(relay) + const address = await relay.start({ + token: 'synthetic-native-relay-token', + responseStartDelayMs: 5, + }) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await runtime.updateRelay({ + enabled: true, + url: address.url, + token: 'synthetic-native-relay-token', + transport: 'websocket', + fallbackToDirect: false, + }) + } finally { + runtime.close() + } + const reportGate = lifetimes.gate() + const reported = reportGate.wait + const reportObserved = reportGate.open + fixture.scopedClient.reportAuthFailureScoped = async (report) => { + fixture.scopedReports.push(report) + reportObserved() + } + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.includes('/v1/messages')) { + fixture.records.push({ + url, + authorization: new Headers(init?.headers).get('authorization') ?? '', + body: String(init?.body ?? ''), + }) + fixture.rotateVault() + return Response.json( + { + type: 'error', + error: { + type: 'authentication_error', + message: 'Synthetic late websocket rejection', + }, + }, + { status: 401 }, + ) + } + return fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + const response = await sendNative(plugin) + expect(response.status).toBe(200) + expect(await response.text()).toContain('Relay upstream returned HTTP 401') + await reported + expect( + fixture.scopedReports.map((report) => ({ + credentialId: report.credentialId, + recordVersion: report.recordVersion, + })), + ).toEqual([{ credentialId: 'oauth:anthropic', recordVersion: 1 }]) + expect( + fixture.records + .filter((record) => record.url.includes('/v1/messages')) + .map((record) => record.authorization), + ).toEqual(['Bearer sk-ant-oat01-vault-main-v1']) +}) + +test('native serving suite lifetime joins detached fixture reads before body-owned cleanup', async () => { + const ownedPath = configPath + const expectedBytes = legacyBytes + const readGate = lifetimes.gate() + let completed = false + lifetimes.trackDetached( + (async () => { + await readGate.wait + expect(await readFile(ownedPath, 'utf8')).toBe(expectedBytes) + completed = true + })(), + ) + lifetimes.deferCleanup(() => { + expect(completed).toBe(true) + }) + readGate.open() +}) From c038b8b43f3123fa243b4174d957c55e246aaacf Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:50:23 +0200 Subject: [PATCH 072/169] mason: document offline setup integration checks --- ...offline-setup-integrated-check-20261009.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/perf/offline-setup-integrated-check-20261009.md diff --git a/docs/perf/offline-setup-integrated-check-20261009.md b/docs/perf/offline-setup-integrated-check-20261009.md new file mode 100644 index 000000000..eb728529d --- /dev/null +++ b/docs/perf/offline-setup-integrated-check-20261009.md @@ -0,0 +1,34 @@ +# Offline setup integration check — 2026-10-09 + +## Result + +**Partial verification.** The reviewed six-file source merge is preserved. With the pinned runtime supplied by the remote build service, the offline frozen install, Core build, and all three bounded setup test files passed. The requested OpenCode typecheck exited 1 on five TS2339 property-access diagnostics in two existing test files outside the six-file merge. No unrelated files were changed to address them; the report records the failure and exact next action for full verification. + +## Source integration + +A no-commit, no-fast-forward merge of `d3220b8dee7745b10a508c720a2d30879e593b17` into the `f27e80fb6a00cdb6365e0cb6faa7ea407f849fd9` worktree staged exactly the six reviewed setup helper and test paths. The staged diff was 590 insertions and 12 deletions. Each working-tree file's Git blob hash matched the same path in `d3220b8dee7745b10a508c720a2d30879e593b17`; no helper contents were changed. The destination already contains the source commit's parent; merging adds only the six helper changes, rather than replacing the destination with the older source branch. The source merge was committed locally as `abe365778c3c2a2d9681d8b5849ba3514b1d9a6e`. + +## Remote runtime check + +The requested remote command was: + +```sh +set +e; printf 'pwd: '; pwd; printf 'bun --version: '; bun --version; rc=$?; printf 'exit_status=%s\n' "$rc"; exit "$rc" +``` + +The first `bun --version` check on `ck-motor` reported **1.4.2** (exit 0), the runner's default version. The runner's maintainer confirmed that automatic dependency installation before the job still uses Bun 1.4.2. The maintainer supplied `/usr/local/bin/bun-1.3.14` and reported verifying it against the official release `SHASUMS256`. This worker did not download or install that asset. The remote job verified `/usr/local/bin/bun-1.3.14 --version` as **1.3.14** (exit 0), then used a temporary private `PATH` symlink for plain `bun` commands. It explicitly verified 1.3.14 through both the binary and `PATH` before each build/test/typecheck entry. The initial direct registry attempt was stopped after `curl: (6) Could not resolve host: registry.npmjs.org`; no further outbound network request was made. + +The remote job ran `bun-1.3.14 install --frozen-lockfile --offline` successfully: 517 installs across 570 packages, no changes. The Core build passed, copying 32 declaration assets and reporting a 99-module closure. The only test command was `bun test packages/opencode/src/setup/native-enrollment.test.ts packages/opencode/src/setup/native-paths.test.ts packages/opencode/src/setup/process-fence.test.ts`: 8 passed, 0 failed, 35 expectations across 3 files. + +`bun run --cwd packages/opencode typecheck` failed (exit 1) under TypeScript 7.0.2 with five TS2339 diagnostics: missing `text` at `src/tests/account-command.test.ts:1008,1009,1029` and missing `knobs` at `src/tests/index.test.ts:754,760` on `CommandDialogPayload | OpenDialogPayload`. Both diagnostic files are unchanged relative to base `f27e80f`; neither is among the six merged paths. Scoped AFT diagnostics for `packages/opencode/src/setup` reported 0 errors and 0 warnings, with four `await`-has-no-effect hints in setup tests. + +Raw output and statuses for the preflight and network attempts are in `tmp/offline-setup-integrated-check-20261009/remote-runs.txt`; the pinned job's raw output is in `tmp/offline-setup-integrated-check-20261009/remote-pinned-gates.txt`. Exact inline job bodies are preserved in `remote-command.sh` and `remote-pinned-command.sh`. Source-identity hashes and merge scope are recorded at `source-comparison.txt`. All evidence paths are relative to this verification worktree. They are under the repository's ignored `tmp/` directory and are not included in the report commit; the parent retains a separate copy. + +## Gates and outcome + +- `bun-1.3.14 install --frozen-lockfile --offline` — passed; Bun 1.3.14 reported `Checked 517 installs across 570 packages (no changes)`. +- `bun run --cwd packages/core build` — passed under verified Bun 1.3.14; output reported 32 published declaration assets and a 99-module, 287-edge closure. +- `bun test packages/opencode/src/setup/native-enrollment.test.ts packages/opencode/src/setup/native-paths.test.ts packages/opencode/src/setup/process-fence.test.ts` — passed under verified Bun 1.3.14; 8 tests across 3 files, 0 failures, 35 expectations. +- `bun run --cwd packages/opencode typecheck` — failed (exit 1) under TypeScript 7.0.2 with five TS2339 property errors in `src/tests/account-command.test.ts` and `src/tests/index.test.ts`. These files are unchanged from base and outside the six-file merge; the diagnostics do not name any changed setup path. No broader workaround was attempted. + +The worktree preparation reported a successful `bun run build` before the reviewed setup helpers were integrated; that result is not used as evidence for this report. Before rerunning the OpenCode typecheck, `account-command.test.ts` must distinguish the legacy dialog payload before reading its `text` field; `index.test.ts` must do the same before reading `knobs`. The union also permits the new native menu payload, which has neither field. Both files are unchanged from the verification base; no pre-merge typecheck result was captured, so this run alone does not establish when the errors first appeared. No full test suites, application starts, live credentials, global toolchain installation, or runtime bump were used. From d1963e5da49b590d2eedb4382f085c135c9c2d74 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:16:21 +0200 Subject: [PATCH 073/169] mason: add OpenCode native positive test fixture foundation Test-only helper migrateNativeOpencodeFixture (packages/opencode/src/tests/ native-fixture.ts) imports caller-supplied synthetic legacy OpenCode config, state, routing and host auth into an explicit, empty, owner-only root through the real runNativeMigration (host 'opencode'), optionally with caller-supplied synthetic custody discovery published by the real publishNativeVaultRosterSeed / publishNativeVaultRuntimeSeed. It returns canonical NativePoolPaths, the retired journal, and a path env record that it never applies to process.env. The returned promise is registered with the caller's lifetime trackDetached before any I/O, so teardown drains it and reports its rejection after deferred root cleanup. No production code or existing suites change. Biome pre-commit hook run manually on the remote runner (biome 2.5.14, 2 files, exit 0). --- .../opencode/src/tests/native-fixture.test.ts | 583 ++++++++++++++++++ packages/opencode/src/tests/native-fixture.ts | 258 ++++++++ 2 files changed, 841 insertions(+) create mode 100644 packages/opencode/src/tests/native-fixture.test.ts create mode 100644 packages/opencode/src/tests/native-fixture.ts diff --git a/packages/opencode/src/tests/native-fixture.test.ts b/packages/opencode/src/tests/native-fixture.test.ts new file mode 100644 index 000000000..a0f1bf795 --- /dev/null +++ b/packages/opencode/src/tests/native-fixture.test.ts @@ -0,0 +1,583 @@ +import { expect } from 'bun:test' +import { + chmod, + lstat, + mkdtemp, + readdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createNativeAccountRuntime, + custodyTombstoneOAuth, + type NativeCustodyClient, + readNativeMigrationJournal, + resolveNativePoolPaths, +} from '@cortexkit/anthropic-auth-core' +import { + createTestLifetimeSuite, + TestLifetime, +} from '../../../core/src/tests/test-lifetime.ts' +import { + migrateNativeOpencodeFixture, + type NativeOpencodeFixtureLifetime, +} from './native-fixture.ts' + +const lifetimes = createTestLifetimeSuite() +const test = lifetimes.test + +const mainIdentity = '11111111-1111-4111-8111-111111111111' +const fallbackIdentity = '22222222-2222-4222-8222-222222222222' +const unrelatedProvider = { type: 'api', key: 'synthetic-unrelated-provider' } +const enrollment = { token: 'ab'.repeat(32), token_generation: 1 } + +/** A fresh owner-only directory, removed by the test body's lifetime even if the fixture refuses it. */ +async function disposableRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oc-native-fixture-')) + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + return root +} + +async function exists(path: string): Promise { + return lstat(path).then( + () => true, + () => false, + ) +} + +/** + * Replace fetch for this body only, restoring it before the body returns. The + * preload's own guard only blocks non-loopback hosts; this records any attempt. + */ +async function withoutNetwork( + body: () => Promise, +): Promise<{ result: PromiseSettledResult; requests: string[] }> { + const saved = globalThis.fetch + const requests: string[] = [] + globalThis.fetch = Object.assign( + async (input: Parameters[0]) => { + requests.push(input instanceof Request ? input.url : String(input)) + throw new Error('Native fixture attempted a network request') + }, + { preconnect: saved.preconnect }, + ) + try { + const [result] = await Promise.allSettled([body()]) + return { result: result as PromiseSettledResult, requests } + } finally { + globalThis.fetch = saved + } +} + +function fulfilled(result: PromiseSettledResult): T { + if (result.status === 'rejected') throw result.reason + return result.value +} + +function rejection(result: PromiseSettledResult): unknown { + if (result.status === 'fulfilled') + throw new Error('Expected the fixture to be refused') + return result.reason +} + +/** + * Finish a lifetime whose fixture was expected to fail. Teardown must still + * reject, and with exactly that one failure, so an unexpected setup failure in + * a positive test can never pass silently. + */ +async function expectTeardownFailure( + lifetime: TestLifetime, + reason: unknown, +): Promise { + const failure = await lifetime.finish().then( + () => undefined, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toHaveLength(1) + expect((failure as AggregateError).errors[0]).toBe(reason) +} + +/** Forwards to the given lifetime and records what the fixture registered. */ +function recordingLifetime(target: NativeOpencodeFixtureLifetime) { + const calls: string[] = [] + const lifetime: NativeOpencodeFixtureLifetime = { + deferCleanup: (cleanup) => { + calls.push('deferCleanup') + target.deferCleanup(cleanup) + }, + trackDetached: (work) => { + calls.push('trackDetached') + target.trackDetached(work) + }, + } + return { lifetime, calls } +} + +function localLegacyConfig() { + return { + version: 1, + refresh: { enabled: false }, + quota: { enabled: false }, + accounts: [ + { + id: 'fallback-route', + type: 'oauth', + enabled: true, + access: 'synthetic-local-fallback-access', + refresh: 'synthetic-local-fallback-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + { + id: 'api-route', + type: 'api', + apiKey: 'synthetic-api-route-key', + baseURL: 'https://synthetic.invalid', + }, + ], + } +} + +function localHostAuth() { + return { + anthropic: { + type: 'oauth', + access: 'synthetic-local-main-access', + refresh: 'synthetic-local-main-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + other: unrelatedProvider, + } +} + +function vaultLegacyConfig() { + return { + version: 1, + mainAccountId: 'primary-route', + refresh: { enabled: false }, + quota: { enabled: false }, + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: mainIdentity, + state: 'active', + }, + }, + accounts: [ + { + id: 'fallback-route', + type: 'oauth', + enabled: true, + refresh: '', + claustrumScopedCredentialId: 'oauth:secondary', + anthropicAccountUuid: fallbackIdentity, + }, + ], + } +} + +/** Lists two synthetic vault rows and records every call; reading credential material is a failure. */ +function syntheticVault() { + const calls = { + connect: 0, + listTokens: [] as Array, + getScoped: 0, + reports: 0, + close: 0, + } + const row = (id: string, accountId: string) => ({ + id, + accountId, + credentialType: 'oauth', + state: 'active', + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + refreshAdapter: 'anthropic', + recordVersion: 1, + operations: ['read', 'invalidate'], + createdAtMs: 1, + }) + const client: NativeCustodyClient = { + async listScoped(token) { + calls.listTokens.push(token) + return { + view: 'synthetic-native-view', + rows: [ + row('oauth:anthropic', mainIdentity), + row('oauth:secondary', fallbackIdentity), + ], + } + }, + async getScoped() { + calls.getScoped++ + throw new Error('Discovery must not read vault credential material') + }, + async reportAuthFailureScoped() { + calls.reports++ + throw new Error('Discovery must not report credential failures') + }, + close() { + calls.close++ + }, + } + return { + calls, + connect: async () => { + calls.connect++ + return client + }, + } +} + +async function readAccounts( + paths: Awaited>, +) { + const runtime = createNativeAccountRuntime({ paths, host: 'opencode' }) + try { + return await runtime.read() + } finally { + runtime.close() + } +} + +test('local legacy accounts commit through the real migration and keep unrelated host providers', async () => { + const root = await disposableRoot() + const { lifetime, calls } = recordingLifetime(lifetimes) + const { result, requests } = await withoutNetwork(() => + migrateNativeOpencodeFixture({ + root, + lifetime, + legacyConfig: localLegacyConfig(), + hostAuth: localHostAuth(), + }), + ) + const fixture = fulfilled(result) + expect(requests).toEqual([]) + expect(calls).toEqual(['trackDetached', 'deferCleanup']) + + expect(fixture.journal).toMatchObject({ phase: 'retired', version: 3 }) + expect(await readNativeMigrationJournal(fixture.paths)).toEqual( + fixture.journal, + ) + // Every pool path is the canonical one the production resolver derives + // from the returned env, and lives in this test's own root. + expect(fixture.paths).toEqual( + await resolveNativePoolPaths( + fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE, + fixture.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE, + ), + ) + for (const path of [ + ...Object.entries(fixture.paths) + .filter(([key]) => key !== 'storageId') + .map(([, value]) => value), + fixture.hostAuthPath, + fixture.routingSourcePath, + fixture.routingDestinationPath, + fixture.enrollmentPath, + ...Object.values(fixture.env), + ]) + expect(path.startsWith(`${fixture.root}/`)).toBe(true) + + const snapshot = await readAccounts(fixture.paths) + expect(snapshot.mode).toBe('local') + expect( + snapshot.accounts.map(({ id, type, source, enabled }) => ({ + id, + type, + source, + enabled, + })), + ).toEqual([ + { id: 'main', type: 'oauth', source: 'local', enabled: true }, + { id: 'fallback-route', type: 'oauth', source: 'local', enabled: true }, + { id: 'api-route', type: 'api', source: 'local', enabled: true }, + ]) + // The OAuth credentials from host-auth.json move into pool state beside + // the existing fallback OAuth credentials and API-route key. + const poolState = await readFile(fixture.paths.state, 'utf8') + for (const secret of [ + 'synthetic-local-main-access', + 'synthetic-local-fallback-access', + 'synthetic-api-route-key', + ]) + expect(poolState).toContain(secret) + + const hostAuth = await readFile(fixture.hostAuthPath, 'utf8') + expect(JSON.parse(hostAuth).other).toEqual(unrelatedProvider) + expect(hostAuth).not.toContain('synthetic-local-main-access') + expect(hostAuth).not.toContain('synthetic-local-main-refresh') + // Without a custody input, the fixture creates no enrollment token and + // no connection file that could reach the real vault daemon. + expect(await exists(fixture.enrollmentPath)).toBe(false) + expect( + await exists(fixture.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE), + ).toBe(false) + expect(await exists(fixture.paths.roster)).toBe(false) +}) + +test('vault custody discovery publishes the roster and runtime seed through the real publishers', async () => { + const root = await disposableRoot() + const vault = syntheticVault() + const { result, requests } = await withoutNetwork(() => + migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: vaultLegacyConfig(), + legacyState: { + version: 1, + accounts: { + 'fallback-route': { + claustrumScopedCredentialId: 'oauth:secondary', + anthropicAccountUuid: fallbackIdentity, + lastUsed: 100, + }, + }, + }, + hostAuth: { + anthropic: custodyTombstoneOAuth('anthropic'), + other: unrelatedProvider, + }, + custody: { connect: vault.connect, enrollment }, + }), + ) + const fixture = fulfilled(result) + expect(requests).toEqual([]) + expect(fixture.journal.phase).toBe('retired') + + // The real token reader found the enrollment at the fixture's own path. + expect(vault.calls).toMatchObject({ + connect: 1, + listTokens: [enrollment.token], + getScoped: 0, + reports: 0, + }) + expect(vault.calls.close).toBeGreaterThan(0) + + const snapshot = await readAccounts(fixture.paths) + expect(snapshot.mode).toBe('claustrum') + const fallback = snapshot.accounts.find( + (account) => account.id === 'fallback-route', + ) + expect(fallback).toMatchObject({ + source: 'vault', + credentialId: 'oauth:secondary', + accountIdentity: fallbackIdentity, + lastUsed: 100, + }) + for (const path of [fixture.paths.roster, fixture.paths.runtime]) { + const text = await readFile(path, 'utf8') + expect(text).not.toContain(enrollment.token) + } + expect( + JSON.parse(await readFile(fixture.hostAuthPath, 'utf8')).other, + ).toEqual(unrelatedProvider) +}) + +test('malformed legacy config fails closed before anything is published', async () => { + const root = await disposableRoot() + const hostAuth = JSON.stringify(localHostAuth()) + const inner = new TestLifetime() + const { result, requests } = await withoutNetwork(() => + migrateNativeOpencodeFixture({ + root, + lifetime: inner, + legacyConfig: '{"version":1,"accounts":[', + hostAuth, + }), + ) + expect(requests).toEqual([]) + expect(result).toMatchObject({ + status: 'rejected', + reason: { name: 'NativeMigrationError' }, + }) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + expect(await readNativeMigrationJournal(paths)).toBeUndefined() + for (const path of [paths.config, paths.state, paths.runtime, paths.roster]) + expect(await exists(path)).toBe(false) + expect(await readFile(join(root, 'host-auth.json'), 'utf8')).toBe(hostAuth) + await expectTeardownFailure(inner, rejection(result)) + expect(await exists(root)).toBe(false) +}) + +test('vault config without caller-supplied custody is refused, not guessed', async () => { + const root = await disposableRoot() + const inner = new TestLifetime() + const { result, requests } = await withoutNetwork(() => + migrateNativeOpencodeFixture({ + root, + lifetime: inner, + legacyConfig: vaultLegacyConfig(), + hostAuth: { + anthropic: custodyTombstoneOAuth('anthropic'), + other: unrelatedProvider, + }, + }), + ) + expect(requests).toEqual([]) + expect(result).toMatchObject({ + status: 'rejected', + reason: { name: 'NativeMigrationError', code: 'invalid-source' }, + }) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + expect(await readNativeMigrationJournal(paths)).toBeUndefined() + expect(await exists(paths.roster)).toBe(false) + expect(await exists(join(root, 'opencode-enrollment.json'))).toBe(false) + await expectTeardownFailure(inner, rejection(result)) +}) + +test('unsafe or non-empty roots are refused and left untouched', async () => { + const occupied = await disposableRoot() + const existing = join(occupied, 'anthropic-auth.json') + await writeFile(existing, 'caller-owned-bytes', { mode: 0o600 }) + const shared = await disposableRoot() + await chmod(shared, 0o750) + const target = await disposableRoot() + const linkParent = await disposableRoot() + const link = join(linkParent, 'link') + await symlink(target, link) + + const cases: Array<[string, RegExp]> = [ + [occupied, /not empty/], + [shared, /not owner-only/], + [link, /symbolic link/], + ['relative/root', /not an absolute path/], + [join(target, 'missing'), /does not exist/], + ] + for (const [root, reason] of cases) { + const inner = new TestLifetime() + const { lifetime, calls } = recordingLifetime(inner) + const [result] = await Promise.allSettled([ + migrateNativeOpencodeFixture({ + root, + lifetime, + legacyConfig: localLegacyConfig(), + }), + ]) + expect(String(rejection(result))).toMatch(reason) + // A refused root is never claimed, so its removal is never scheduled. + expect(calls).toEqual(['trackDetached']) + await expectTeardownFailure(inner, rejection(result)) + } + expect(await readFile(existing, 'utf8')).toBe('caller-owned-bytes') + expect((await lstat(link)).isSymbolicLink()).toBe(true) + expect(await exists(join(target, 'anthropic-auth.json'))).toBe(false) +}) + +test('root removal waits for the whole body and its late tracked work', async () => { + const root = await disposableRoot() + const inner = new TestLifetime() + const gate = inner.gate() + const observed: string[] = [] + const fixture = await inner.runBody(async () => { + const created = await migrateNativeOpencodeFixture({ + root, + lifetime: inner, + legacyConfig: localLegacyConfig(), + }) + // Late work that outlives the body must still see the fixture files. + inner.trackDetached( + (async () => { + await gate.wait + observed.push( + (await exists(created.paths.journal)) ? 'present' : 'removed', + ) + })(), + ) + return created + }) + expect(await exists(fixture.paths.journal)).toBe(true) + await inner.finish() + expect(observed).toEqual(['present']) + expect(await exists(fixture.root)).toBe(false) +}) + +test('an unawaited fixture call is drained before its root is removed', async () => { + const root = await disposableRoot() + const inner = new TestLifetime() + let pending: ReturnType | undefined + await inner.runBody(() => { + pending = migrateNativeOpencodeFixture({ + root, + lifetime: inner, + legacyConfig: localLegacyConfig(), + }) + }) + if (!pending) throw new Error('Fixture call did not start') + await inner.finish() + // The migration finished before teardown removed the directory under it. + expect((await pending).journal.phase).toBe('retired') + expect(await exists(root)).toBe(false) +}) + +test('a forgotten await on a failing fixture fails teardown after its cleanup', async () => { + // A migration that fails after the root was claimed, and a root refused + // before anything was claimed. Neither call is awaited by its body. + const claimed = await disposableRoot() + const cases = [ + { root: claimed, legacyConfig: '{"version":1,"accounts":[' }, + { root: 'not-an-absolute-path', legacyConfig: localLegacyConfig() }, + ] + const reasons: unknown[] = [] + for (const { root, legacyConfig } of cases) { + const inner = new TestLifetime() + const { result, requests } = await withoutNetwork(async () => { + let pending: ReturnType | undefined + await inner.runBody(() => { + pending = migrateNativeOpencodeFixture({ + root, + lifetime: inner, + legacyConfig, + }) + }) + if (!pending) throw new Error('Fixture call did not start') + // Teardown, not the body, is the first to wait for the fixture. + const teardown = await inner.finish().then( + () => undefined, + (error: unknown) => error, + ) + const [outcome] = await Promise.allSettled([pending]) + return { teardown, reason: rejection(outcome) } + }) + expect(requests).toEqual([]) + const { teardown, reason } = fulfilled(result) + expect(teardown).toBeInstanceOf(AggregateError) + expect((teardown as AggregateError).errors).toHaveLength(1) + expect((teardown as AggregateError).errors[0]).toBe(reason) + reasons.push(reason) + } + expect(reasons[0]).toMatchObject({ name: 'NativeMigrationError' }) + expect(String(reasons[1])).toMatch(/not an absolute path/) + // The claimed root's cleanup still ran before teardown reported the failure. + expect(await exists(claimed)).toBe(false) +}) + +test('a closed lifetime refuses the fixture before any file is touched', async () => { + const root = await disposableRoot() + const inner = new TestLifetime() + await inner.finish() + const { result, requests } = await withoutNetwork(async () => + migrateNativeOpencodeFixture({ + root, + lifetime: inner, + legacyConfig: localLegacyConfig(), + hostAuth: localHostAuth(), + }), + ) + expect(requests).toEqual([]) + expect(String(rejection(result))).toContain('Test fixture lifetime is closed') + expect(await readdir(root)).toEqual([]) +}) diff --git a/packages/opencode/src/tests/native-fixture.ts b/packages/opencode/src/tests/native-fixture.ts new file mode 100644 index 000000000..84f77a1b3 --- /dev/null +++ b/packages/opencode/src/tests/native-fixture.ts @@ -0,0 +1,258 @@ +import { lstat, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join } from 'node:path' +import { + discoverNativeVaultInventory, + getStickyRoutingStatePath, + type NativeCustodyClient, + type NativeMigrationJournal, + type NativePoolPaths, + publishNativeVaultRosterSeed, + publishNativeVaultRuntimeSeed, + resolveNativePoolPaths, + runNativeMigration, +} from '@cortexkit/anthropic-auth-core' + +/** + * deferCleanup registers fixture removal; trackDetached registers work that + * may outlive the test callback. TestLifetime waits for the callback and all + * registered work before cleanup, then reports any registered rejection. + */ +export interface NativeOpencodeFixtureLifetime { + deferCleanup(cleanup: () => void | Promise): void + trackDetached(work: Promise): void +} + +/** File content: a string is written byte-for-byte (for malformed input); anything else is JSON-encoded. */ +export type NativeOpencodeFixtureSource = string | Record + +/** Synthetic vault discovery. The fixture never opens a daemon connection file. */ +export interface NativeOpencodeFixtureCustody { + connect: () => Promise + /** Written owner-only to this fixture's enrollment path for the real token reader. */ + enrollment: { token: string; token_generation: number } +} + +export interface NativeOpencodeFixtureInput { + /** + * An existing, empty, owner-only directory created by the caller, such as a + * fresh mkdtemp result. The fixture removes it through lifetime.deferCleanup + * only after accepting it; a refused directory is left untouched. + */ + root: string + lifetime: NativeOpencodeFixtureLifetime + /** Legacy OpenCode account config (anthropic-auth.json). */ + legacyConfig: NativeOpencodeFixtureSource + /** Legacy account state; omitted means no state file exists. */ + legacyState?: NativeOpencodeFixtureSource + /** Legacy sticky-routing state; omitted means no routing file exists. */ + legacyRouting?: NativeOpencodeFixtureSource + /** OpenCode auth.json contents, including unrelated providers; omitted means no file. */ + hostAuth?: NativeOpencodeFixtureSource + /** Required only when legacyConfig selects vault custody; the migration refuses otherwise. */ + custody?: NativeOpencodeFixtureCustody +} + +/** Path variables a test may apply to process.env itself, with its own save and restore, before loading the plugin. */ +export type NativeOpencodeFixtureEnv = Readonly<{ + OPENCODE_ANTHROPIC_AUTH_FILE: string + OPENCODE_ANTHROPIC_AUTH_STATE_FILE: string + OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE: string + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: string + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: string + OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE: string + OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR: string + OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR: string + OPENCODE_ANTHROPIC_AUTH_RPC_DIR: string + CLAUDE_CONFIG_DIR: string +}> + +export interface NativeOpencodeFixture { + /** Canonical (realpath) form of the caller's root. */ + root: string + /** Canonical pool paths, as resolveNativePoolPaths returns them. */ + paths: NativePoolPaths + /** Journal returned by runNativeMigration; always phase 'retired'. */ + journal: NativeMigrationJournal + hostAuthPath: string + routingSourcePath: string + routingDestinationPath: string + enrollmentPath: string + env: NativeOpencodeFixtureEnv +} + +class NativeOpencodeFixtureRefusal extends Error { + constructor(reason: string) { + super(`Native OpenCode fixture refused root: ${reason}`) + this.name = 'NativeOpencodeFixtureRefusal' + } +} + +async function claimRoot(root: string): Promise { + if (typeof root !== 'string' || !isAbsolute(root) || /\p{Cc}/u.test(root)) + throw new NativeOpencodeFixtureRefusal('not an absolute path') + const info = await lstat(root).catch(() => undefined) + if (!info) throw new NativeOpencodeFixtureRefusal('does not exist') + if (info.isSymbolicLink()) + throw new NativeOpencodeFixtureRefusal('is a symbolic link') + if (!info.isDirectory()) + throw new NativeOpencodeFixtureRefusal('not a directory') + if (process.getuid && info.uid !== process.getuid()) + throw new NativeOpencodeFixtureRefusal('owned by another user') + if ((info.mode & 0o077) !== 0) + throw new NativeOpencodeFixtureRefusal('not owner-only') + if ((await readdir(root)).length !== 0) + throw new NativeOpencodeFixtureRefusal('not empty') + // The root itself is not a link, so realpath only resolves parent aliases + // such as macOS /var -> /private/var; the pool resolver canonicalizes the same way. + return realpath(root) +} + +async function writeSource( + path: string, + source: NativeOpencodeFixtureSource, +): Promise { + // Exclusive create: never replace a file that appeared after the empty check. + await writeFile( + path, + typeof source === 'string' ? source : JSON.stringify(source), + { mode: 0o600, flag: 'wx' }, + ) +} + +async function migrate( + input: NativeOpencodeFixtureInput, +): Promise { + const root = await claimRoot(input.root) + input.lifetime.deferCleanup(() => rm(root, { recursive: true, force: true })) + const legacyConfigPath = join(root, 'anthropic-auth.json') + const legacyStatePath = join(root, 'anthropic-auth-state.json') + const paths = await resolveNativePoolPaths(legacyConfigPath, legacyStatePath) + for (const path of [ + paths.legacyConfig, + paths.legacyState, + paths.config, + paths.state, + paths.runtime, + paths.journal, + paths.roster, + ]) + if (dirname(path) !== root) + throw new Error('Native OpenCode fixture path escaped its root') + const hostAuthPath = join(root, 'host-auth.json') + // The legacy plugin keeps routing beside its account config; the migrated + // routing goes beside the pool config, and env points the plugin there. + const routingSourcePath = getStickyRoutingStatePath(paths.legacyConfig) + const routingDestinationPath = getStickyRoutingStatePath(paths.config) + const enrollmentPath = join(root, 'opencode-enrollment.json') + const env: NativeOpencodeFixtureEnv = Object.freeze({ + OPENCODE_ANTHROPIC_AUTH_FILE: paths.legacyConfig, + OPENCODE_ANTHROPIC_AUTH_STATE_FILE: paths.legacyState, + OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE: routingDestinationPath, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: enrollmentPath, + // Points at a file the fixture never creates, so a plugin that applies + // this env finds no Claustrum daemon connection details. + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + root, + 'claustrum-connection.json', + ), + OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE: join( + root, + 'sidebar-state.json', + ), + OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR: join( + root, + 'cachekeep-registry', + ), + OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR: join(root, 'quota-header-feed'), + OPENCODE_ANTHROPIC_AUTH_RPC_DIR: join(root, 'rpc'), + CLAUDE_CONFIG_DIR: join(root, 'claude'), + }) + + await writeSource(paths.legacyConfig, input.legacyConfig) + if (input.legacyState !== undefined) + await writeSource(paths.legacyState, input.legacyState) + if (input.legacyRouting !== undefined) + await writeSource(routingSourcePath, input.legacyRouting) + if (input.hostAuth !== undefined) + await writeSource(hostAuthPath, input.hostAuth) + const custody = input.custody + if (custody) await writeSource(enrollmentPath, custody.enrollment) + + // This fixture does not start or inspect host processes. Its processFence + // callback counts the migration's requests to check that hosts are stopped; + // zero calls fails the fixture instead of bypassing the check silently. + let processFenceChecks = 0 + const journal = await runNativeMigration({ + paths, + legacyConfigPath: paths.legacyConfig, + legacyStatePath: paths.legacyState, + host: 'opencode', + hostAuthPath, + routingSourcePath, + routingDestinationPath, + // Pass the fixture env, not process.env, so an inherited + // OPENCODE_AUTH_CONTENT or another test's path variables cannot reach the + // migration. + env: { ...env }, + removePiAnthropicAuth: false, + processFence: async () => { + processFenceChecks++ + }, + ...(custody && { + custody: { + discover: () => + discoverNativeVaultInventory({ + paths, + host: 'opencode', + connect: custody.connect, + env: { ...env }, + }), + publishSeed: publishNativeVaultRosterSeed, + publishRuntimeSeed: publishNativeVaultRuntimeSeed, + }, + }), + }) + if (processFenceChecks === 0) + throw new Error('Fixture migration did not check its process fence') + if (journal.phase !== 'retired') + throw new Error(`Fixture migration ended in phase ${journal.phase}`) + return { + root, + paths, + journal, + hostAuthPath, + routingSourcePath, + routingDestinationPath, + enrollmentPath, + env, + } +} + +/** + * Import caller-supplied synthetic legacy OpenCode files into a native pool + * through the real offline migration controller, and return its retired journal. + * + * It does not modify process.env, start a host, or validate credentials. Vault + * discovery runs only through the caller's custody.connect callback; the + * fixture never falls back to the default Claustrum connector. A test that + * needs a served credential must still admit it through the public runtime. + * The returned promise is also registered with lifetime.trackDetached, so + * teardown waits for it even if the caller never awaits it, and any rejection, + * including a refused root or a migration refusal, fails teardown after the + * deferred cleanups have run. A test that expects a refusal should pass its + * own TestLifetime and assert that its finish() rejects. + */ +export function migrateNativeOpencodeFixture( + input: NativeOpencodeFixtureInput, +): Promise { + let start!: () => void + const started = new Promise((resolve) => { + start = resolve + }) + const work = started.then(() => migrate(input)) + // Register before migrate() runs. On a closed lifetime trackDetached throws + // here, start() is never called, and no file is read or written. + input.lifetime.trackDetached(work) + start() + return work +} From ff18e229b0a3e038793087c911c9d6e35fab1ca0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:17:33 +0200 Subject: [PATCH 074/169] mason: recheck pool authority before local credentials leave the service createNativeLocalCredentialService.authorize checked migration authority once, then returned coordinator.authorize(request). The coordinator awaits runtime reads and the host admission policy, so a journal invalidated or rolled back during that await still let a usable access token out. Keep the initial check and add a final requireNativePoolAuthority admission before any usable result is returned. A failed final check returns the same closed refusal (NativeAuthorityError code or authority-unavailable) with no credential; persisted is true only for source 'rotated', the only usable source the coordinator produces from a job that already saved its successor. The saved successor and its proof are not rolled back. Non-usable results and the coordinator are unchanged. Tests: real-migrated fixture option; authority lost inside the admission policy (invalid and pre-commit journal) refuses after a healthy validated serve; authority lost during a consumed refresh keeps the durable successor but serves nothing; guardBreaks entry proves removing the final check fails 'delayed authority loss blocks credential exit'. Existing tests unchanged. --- .../src/native-local-credential-service.ts | 49 ++++-- .../native-local-credential-service.test.ts | 161 +++++++++++++++++- 2 files changed, 193 insertions(+), 17 deletions(-) diff --git a/packages/core/src/native-local-credential-service.ts b/packages/core/src/native-local-credential-service.ts index 075c47423..b07e45eeb 100644 --- a/packages/core/src/native-local-credential-service.ts +++ b/packages/core/src/native-local-credential-service.ts @@ -27,7 +27,7 @@ import { } from './pool-authority.ts' import type { NativePoolPaths } from './pool-paths.ts' -/** A failure that the runtime failure publisher can record for one credential. */ +/** A failure that can be recorded for a single credential. */ export type NativeLocalAttributedFailure = Exclude< NativeLocalFailureAttribution, { kind: 'none' } @@ -204,8 +204,10 @@ function capturePolicy(value: unknown): NativeLocalFailurePolicy { /** * Authorize local OAuth only after offline migration commits the pool as the * credential authority. Creating this service neither imports credentials nor - * completes migration. The coordinator owns exchange, account lookup, bounded - * retries and the final validation check; it refuses API-key rows. + * completes migration. Authority is checked before the coordinator starts and + * again before any access token is returned. The coordinator owns exchange, + * account lookup, bounded retries and the final validation check; it refuses + * API-key rows. * * Successful account checks record validation for the exact credential version. * An existing runtime entry with no account UUID loses its unproven metadata, @@ -239,9 +241,10 @@ export function createNativeLocalCredentialService( >(descriptors, 'resolveIdentity') async function reconcile(observation: NativeRefreshObservation) { - // Select from the captured provider observation, never a later runtime read. - // Writers recheck its credential version, binding and error-clear timestamps - // under locks; changing the subject would misattribute the provider result. + // This result identifies the account and credential version to update. + // A later storage read may already refer to a replacement credential. + // Writers verify the captured version, account binding and error-clear + // timestamps while holding the account and runtime locks. const event = frozenCopy(observation) if ( (event.status === 'persisted' || @@ -285,22 +288,38 @@ export function createNativeLocalCredentialService( reconcile, }) + function authorityRefusal( + error: unknown, + persisted: boolean, + ): NativeRefreshResult { + return { + status: 'refused', + reason: + error instanceof NativeAuthorityError + ? error.code + : 'authority-unavailable', + persisted, + } + } + async function authorize( request: NativeRefreshRequest, ): Promise { try { await requireNativePoolAuthority(paths) } catch (error) { - return { - status: 'refused', - reason: - error instanceof NativeAuthorityError - ? error.code - : 'authority-unavailable', - persisted: false, - } + return authorityRefusal(error, false) } - return coordinator.authorize(request) + const result = await coordinator.authorize(request) + if (result.status !== 'usable') return result + // Authority can change while the coordinator awaits storage or admission + // policy. Recheck before returning an access token. A 'rotated' result + // means the successor is already durable; refusal must report that fact + // without returning the credential or undoing its saved state. + return requireNativePoolAuthority(paths).then( + () => result, + (error: unknown) => authorityRefusal(error, result.source === 'rotated'), + ) } return Object.freeze({ authorize }) diff --git a/packages/core/src/tests/native-local-credential-service.test.ts b/packages/core/src/tests/native-local-credential-service.test.ts index a93871cf0..a18189295 100644 --- a/packages/core/src/tests/native-local-credential-service.test.ts +++ b/packages/core/src/tests/native-local-credential-service.test.ts @@ -1,4 +1,5 @@ import { expect } from 'bun:test' +import { readFileSync, writeFileSync } from 'node:fs' import { chmod, mkdir, @@ -8,7 +9,7 @@ import { rm, writeFile, } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { dirname, join, relative } from 'node:path' import { pathToFileURL } from 'node:url' import { fingerprintOf, type PoolRow } from '@cortexkit/common-auth/store' @@ -42,6 +43,7 @@ import { import { type NativePoolPaths, resolveNativePoolPaths } from '../pool-paths.ts' import { createNativePoolStore } from '../pool-store.ts' import { tokenFingerprint } from '../token-fingerprint.ts' +import { initializeNativeTestAuthority } from './native-authority-fixture.ts' import { createTestLifetimeSuite } from './test-lifetime.ts' const { test, deferCleanup, gate } = createTestLifetimeSuite() @@ -74,6 +76,8 @@ interface FixtureOptions { | Partial | ((binding: NativeLocalPoolBinding) => Partial) authority?: Authority + /** Commit the empty pool through the real offline migration instead of writing a journal. */ + migrated?: boolean expires?: number } @@ -171,6 +175,7 @@ async function fixture(options: FixtureOptions = {}) { const read = await readNativeRuntime(paths.runtime, paths.storageId) return read.status === 'ready' ? read.state.accounts.row : undefined } + if (options.migrated) await initializeNativeTestAuthority(paths) await store.initialize() await store.add({ id: 'row', @@ -197,7 +202,8 @@ async function fixture(options: FixtureOptions = {}) { }, })) } - await writeJournal(paths, options.authority ?? 'committed') + if (!options.migrated) + await writeJournal(paths, options.authority ?? 'committed') const f = { root, paths, @@ -378,6 +384,150 @@ async function authorityScenario(module: ServiceModule) { }) } +type AuthorityLoss = 'invalid' | 'incomplete' + +/** + * Take authority away from a pool that the real offline migration committed. + * 'invalid' truncates the migrated journal; 'incomplete' keeps its contents but + * moves it back to the pre-commit 'verified' phase. Neither variant writes a + * journal that grants authority. The write is synchronous so it finishes + * inside the callback that calls it, while the service request is in flight. + */ +function loseAuthority(paths: NativePoolPaths, loss: AuthorityLoss) { + const journal = readFileSync(paths.journal, 'utf8') + writeFileSync( + paths.journal, + loss === 'invalid' + ? journal.slice(0, Math.floor(journal.length / 2)) + : JSON.stringify({ ...JSON.parse(journal), phase: 'verified' }), + ) +} + +async function lockFiles(f: Fixture) { + return (await readdir(f.root)).filter((name) => name.endsWith('.lock')) +} + +/** + * The service checks authority before the coordinator starts, but the + * coordinator then awaits runtime reads and the host's admission policy. Lose + * authority from inside that policy, after the first check has passed, and + * require that the already-admitted access token is not returned. + */ +async function finalAuthorityScenario( + module: ServiceModule, + loss: AuthorityLoss = 'invalid', +) { + const f = await fixture({ migrated: true }) + let duringAdmission: (() => void) | undefined + const s = service(f, module, { + externalPolicy: () => { + const change = duringAdmission + duringAdmission = undefined + change?.() + return { status: 'allowed' } + }, + }) + const validated = await s.authorize(f.request()) + const proof = await f.proof() + await named('healthy authorization serves validated access', () => { + expect(validated).toEqual({ + status: 'usable', + source: 'validated', + binding: proof.binding, + access: original.access, + expires: original.expires, + subject: proof, + validation: proof, + }) + expect([f.exchanges, f.lookups]).toEqual([0, 1]) + }) + const journal = relative(f.root, f.paths.journal) + const before = await snapshot(f.root) + duringAdmission = () => loseAuthority(f.paths, loss) + // The stored proof now matches, so this request is admitted as 'current' + // without an exchange or lookup; the policy callback is its only await window. + const result = await s.authorize(f.request()) + await named('delayed authority loss blocks credential exit', () => { + expect(duringAdmission).toBeUndefined() + expect(result).toEqual({ + status: 'refused', + reason: authorityCodes[loss], + persisted: false, + }) + }) + // Only the journal changed. The validation proof, pool row and runtime entry + // stay as they were, and no lease or staging file is left behind. + const after = await snapshot(f.root) + expect(after[journal]).not.toBe(before[journal]) + expect(after).toEqual({ ...before, [journal]: expect.any(String) }) + expect(await f.entry()).toEqual({ + binding: proof.binding, + credentialValidation: proof, + }) + expect([f.exchanges, f.lookups]).toEqual([0, 1]) + expect(await lockFiles(f)).toEqual([]) + leaksNoSecret(result) +} + +for (const loss of ['invalid', 'incomplete'] as const) + test(`${loss} journal written during admission refuses the already admitted credential`, async () => { + await finalAuthorityScenario(production, loss) + }) + +test('authority lost during a consumed refresh keeps the committed successor but serves nothing', async () => { + const f = await fixture({ migrated: true }) + const entered = gate() + const release = gate() + f.exchange = async () => { + entered.open() + await release.wait + return successor + } + const s = service(f) + const pending = s.authorize( + f.request({ rejectedAccessToken: original.access }), + ) + await entered.wait + // The paused exchange holds production refresh/provider leases, so the empty + // lease check at the end proves they were released. + expect((await lockFiles(f)).length).toBeGreaterThan(0) + loseAuthority(f.paths, 'invalid') + release.open() + const result = await pending + // The provider already consumed the old refresh token, so the result reports + // the saved successor (persisted: true) but carries no credential. + expect(result).toEqual({ + status: 'refused', + reason: 'invalid-journal', + persisted: true, + }) + leaksNoSecret(result) + expect(JSON.stringify(result)).not.toContain(successor.access) + // The successor commit and its account proof are durable, not rolled back. + const proof = await f.proof() + expect(proof.credentialFingerprint).toBe( + fingerprintOf({ type: 'oauth', refresh: successor.refresh }), + ) + expect((await f.row()).credential).toMatchObject({ + access: successor.access, + refresh: successor.refresh, + }) + expect(await f.entry()).toEqual({ + binding: proof.binding, + credentialValidation: proof, + }) + expect([f.exchanges, f.lookups, f.failures.length]).toEqual([1, 1, 0]) + expect(await stages(f)).toEqual([]) + expect(await lockFiles(f)).toEqual([]) + // Later requests are refused by the first check, before any provider work. + expect(await s.authorize(f.request())).toEqual({ + status: 'refused', + reason: 'invalid-journal', + persisted: false, + }) + expect([f.exchanges, f.lookups]).toEqual([1, 1]) +}) + test('exact positive proof is published before current access is served', async () => { const f = await fixture() const s = service(f) @@ -1150,6 +1300,13 @@ const guardBreaks: Array<{ to: ' true\n', assertion: 'unresolved lookup records validation retry, not proof', }, + { + guard: 'final authority admission', + scenario: finalAuthorityScenario, + from: 'return requireNativePoolAuthority(paths).then(', + to: 'return Promise.resolve().then(', + assertion: 'delayed authority loss blocks credential exit', + }, ] for (const { guard, scenario, from, to, assertion } of guardBreaks) { From 1bf2896ce8a49c44d3c2b3462fc7713ff1469da5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:17:47 +0200 Subject: [PATCH 075/169] mason: normalize verified OC1 native serving slice on accepted base Byte-identical eight-path source from approved preservation 1a3b8cc7ed3a53206d51e1bf8d59b36bb9ee7ef3, now based on f27e80fb6a00cdb6365e0cb6faa7ea407f849fd9. Explicit parent authorization permits skipping repeated local hook/gates; the same bytes passed preservation Biome and parent delegated remote source verification. Single checked claude registration intentionally replaces legacy slash aliases and mutating legacy dialogs. OAuth SDK state is inert activation; native version-bound credential admission owns local/vault foreground and background serving. Synthetic positive fixtures use real version-3 offline migration and body-owned lifetimes. Normalization proof: retained manifest 31 dependency copies matched expected hashes; additional 34 clean old-base/absent additions were verified against preserved tree, archived under ignored tmp/oc1-base-materialization-evidence-20261009, and materialized only from accepted base. No new source content, dependency installs, application starts, or local gates. --- packages/opencode/src/cli.ts | 249 +- packages/opencode/src/custody-mode.ts | 23 +- packages/opencode/src/index.ts | 3596 ++++++++--------- .../src/tests/account-command.test.ts | 23 +- .../opencode/src/tests/custody-mode.test.ts | 22 +- packages/opencode/src/tests/index.test.ts | 81 +- .../opencode/src/tests/native-cli.test.ts | 188 + .../opencode/src/tests/native-serving.test.ts | 1190 ++++++ 8 files changed, 3352 insertions(+), 2020 deletions(-) create mode 100644 packages/opencode/src/tests/native-cli.test.ts create mode 100644 packages/opencode/src/tests/native-serving.test.ts diff --git a/packages/opencode/src/cli.ts b/packages/opencode/src/cli.ts index c59f95058..2e356a92a 100644 --- a/packages/opencode/src/cli.ts +++ b/packages/opencode/src/cli.ts @@ -3,40 +3,31 @@ import { stdin as input, stdout as output } from 'node:process' import { createInterface } from 'node:readline/promises' import { - type AccountStorage, - addAccountPersistent, authorize, + createNativeAccountRuntime, exchange, generateRelayToken, getAccountStoragePath, - getClaustrumMode, - isOAuthAccount, isValidApiBaseURL, - loadAccounts, - saveAccounts, + type NativeAccountRuntime, + resolveNativePoolPaths, WORKER_SCRIPT, } from '@cortexkit/anthropic-auth-core' -function defaultStorage(): AccountStorage { - return { - version: 1, - main: { type: 'opencode', provider: 'anthropic' }, - fallbackOn: [401, 403, 429], - refresh: { - enabled: true, - intervalMinutes: 10, - refreshBeforeExpiryMinutes: 240, - }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { - five_hour: 10, - seven_day: 20, - }, - failClosedOnUnknownQuota: true, - }, - accounts: [], +async function withNativeAccounts( + run: (runtime: NativeAccountRuntime) => Promise, +): Promise { + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) + throw new Error( + 'Native account changes require offline setup without OPENCODE_AUTH_CONTENT', + ) + const paths = await resolveNativePoolPaths() + const runtime = createNativeAccountRuntime({ paths, host: 'opencode' }) + try { + await runtime.read() + return await run(runtime) + } finally { + runtime.close() } } @@ -48,7 +39,8 @@ function usage() { opencode-anthropic-auth list opencode-anthropic-auth relay setup -Fallback accounts are stored in: +Native credentials are managed by the offline setup migration. +Legacy import source: ${getAccountStoragePath()}`) } @@ -207,6 +199,7 @@ export interface RelaySetupDeps { } export async function relaySetup(deps: RelaySetupDeps = {}) { + await withNativeAccounts((runtime) => runtime.read()) const fetchImpl = deps.fetchImpl ?? fetch const ask = deps.prompt ?? prompt const token = requireText( @@ -256,20 +249,20 @@ export async function relaySetup(deps: RelaySetupDeps = {}) { defaultUrl || requireText(await prompt('Relay Worker URL: '), 'Relay Worker URL') - // Provisioning can take minutes. Reload immediately before commit so the - // relay setup cannot overwrite fallback accounts changed while it was open. - const storage = (await loadAccounts()) ?? defaultStorage() - storage.relay = { - enabled: true, - url, - token: relayToken, - fallbackToDirect: true, - transport: 'http', - } - await saveAccounts(storage) + // Provisioning can take minutes. The relay transaction updates private + // connection authorization and settings without replacing account metadata. + await withNativeAccounts((runtime) => + runtime.updateRelay({ + enabled: true, + url, + token: relayToken, + fallbackToDirect: true, + transport: 'http', + }), + ) console.log(`Relay enabled at ${url}`) - console.log(`Config saved to ${getAccountStoragePath()}.`) + console.log('Relay settings saved to the native account store.') } let promptInterface: ReturnType | null = null @@ -285,11 +278,9 @@ function closePromptInterface() { } /** - * Dependencies the `login` command talks to the outside world through. All - * default to the real implementations (the readline-backed prompt, and the - * core authorize/exchange helpers) so the production `login` path is - * unchanged; tests inject deterministic stubs to exercise the full login flow - * in-process without a subprocess, real network, or stdin. + * Tests can supply prompts and OAuth responses without opening a browser. + * Login stores exchanged tokens in the shared account pool after validating + * the account identity. It never writes OpenCode's auth.json or old sidecars. */ export interface LoginDeps { prompt?: (message: string) => Promise @@ -298,62 +289,70 @@ export interface LoginDeps { } export async function login(labelArg?: string, deps: LoginDeps = {}) { + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) { + throw new Error( + 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set', + ) + } const ask = deps.prompt ?? prompt const authorizeImpl = deps.authorize ?? authorize const exchangeImpl = deps.exchange ?? exchange - if (getClaustrumMode(await loadAccounts()) === 'claustrum') { - throw new Error('Exit Claustrum mode first: /claude-account local') - } - const label = - labelArg?.trim() || (await ask('Fallback account label (optional): ')) - const authorization = await authorizeImpl('max') - - console.log('\nOpen this URL in your browser and complete Claude sign-in:\n') - console.log(`${authorization.url}\n`) - const code = await ask( - 'Paste the full callback URL or authorization code here: ', - ) - const result = await exchangeImpl( - code, - authorization.verifier, - authorization.redirectUri, - authorization.state, - ) - - if (result.type === 'failed') { - throw new Error('Authentication failed') - } - - const now = Date.now() - const account = { - id: label || crypto.randomUUID(), - label: label || undefined, - type: 'oauth', - authLineageId: crypto.randomUUID(), - access: result.access, - refresh: result.refresh, - expires: result.expires, - enabled: true, - addedAt: now, - lastUsed: now, - lastRefreshedAt: now, - } as const - await addAccountPersistent(account) - - console.log(`\nSaved fallback account${label ? ` "${label}"` : ''}.`) + await withNativeAccounts(async (runtime) => { + const snapshot = await runtime.read() + if (snapshot.mode !== 'local') + throw new Error( + 'Exit vault custody with offline setup before local login', + ) + const label = + labelArg?.trim() || (await ask('Fallback account label (optional): ')) + const authorization = await authorizeImpl('max') + console.log( + '\nOpen this URL in your browser and complete Claude sign-in:\n', + ) + console.log(`${authorization.url}\n`) + const code = await ask( + 'Paste the full callback URL or authorization code here: ', + ) + const result = await exchangeImpl( + code, + authorization.verifier, + authorization.redirectUri, + authorization.state, + ) + if (result.type === 'failed') throw new Error('Authentication failed') + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) + throw new Error( + 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set', + ) + const routeId = label && label !== 'main' ? label : crypto.randomUUID() + const replace = snapshot.accounts.some( + (account) => account.id === routeId && account.source === 'local', + ) + await runtime.loginOAuth({ + routeId, + replace, + label: label || undefined, + credential: { + access: result.access, + refresh: result.refresh, + expires: result.expires, + }, + }) + console.log(`\nSaved native fallback account${label ? ` "${label}"` : ''}.`) + }) } /** - * Dependencies the `api add` command talks to the outside world through. The - * prompt defaults to the real readline-backed prompt so the production - * `api add` path is unchanged; tests inject canned answers to exercise the - * full route-add flow in-process without a subprocess or stdin. + * Tests can supply prompts to check API keys and proxy route settings. + * New keys go to the shared account pool after migration has committed; + * OpenCode's auth.json and the old sidecar files remain unchanged. */ export interface ApiAddDeps { prompt?: (message: string) => Promise } export async function addApiRoute(labelArg?: string, deps: ApiAddDeps = {}) { + await withNativeAccounts((runtime) => runtime.read()) const ask = deps.prompt ?? prompt const label = labelArg?.trim() || (await ask('API fallback label (optional): ')) @@ -382,18 +381,21 @@ export async function addApiRoute(labelArg?: string, deps: ApiAddDeps = {}) { .toLowerCase() const authHeader = authHeaderInput === 'x-api-key' ? 'x-api-key' : 'authorization-bearer' - const now = Date.now() - - await addAccountPersistent({ - id: label || crypto.randomUUID(), - label: label || undefined, - type: 'api', - apiKey: apiKey.trim(), - baseURL, - authHeader, - enabled: true, - addedAt: now, - lastUsed: now, + + await withNativeAccounts(async (runtime) => { + const snapshot = await runtime.read() + const routeId = label && label !== 'main' ? label : crypto.randomUUID() + const existing = snapshot.accounts.find((account) => account.id === routeId) + if (existing?.source === 'vault') + throw new Error('Vault routes cannot be replaced with a local API key') + await runtime.addApi({ + routeId, + replace: Boolean(existing), + label: label || undefined, + apiKey: apiKey.trim(), + baseURL, + authHeader, + }) }) console.log( @@ -402,29 +404,32 @@ export async function addApiRoute(labelArg?: string, deps: ApiAddDeps = {}) { } async function listAccounts() { - const storage = await loadAccounts() - if (!storage?.accounts.length) { - console.log(`No fallback accounts found at ${getAccountStoragePath()}.`) - return - } - - for (const [index, account] of storage.accounts.entries()) { - const label = account.label || account.id - const status = account.enabled === false ? 'disabled' : 'enabled' - if (!isOAuthAccount(account)) { - console.log( - `${index + 1}. ${label} (${status}) — API route ${account.baseURL}`, - ) - continue + await withNativeAccounts(async (runtime) => { + const accounts = (await runtime.read()).accounts.filter( + (account) => account.id !== 'main', + ) + if (!accounts.length) { + console.log('No native fallback accounts found.') + return } - const fiveHour = account.quota?.five_hour?.remainingPercent - const sevenDay = account.quota?.seven_day?.remainingPercent - const quota = - fiveHour === undefined && sevenDay === undefined - ? 'quota unknown' - : `5h ${fiveHour ?? '?'}%, 1w ${sevenDay ?? '?'}% remaining` - console.log(`${index + 1}. ${label} (${status}) — ${quota}`) - } + for (const [index, account] of accounts.entries()) { + const label = account.label || account.id + const status = account.enabled ? 'enabled' : 'disabled' + if (account.type === 'api') { + console.log( + `${index + 1}. ${label} (${status}) — API route ${account.baseURL}`, + ) + continue + } + const fiveHour = account.quota?.five_hour?.remainingPercent + const sevenDay = account.quota?.seven_day?.remainingPercent + const quota = + fiveHour === undefined && sevenDay === undefined + ? 'quota unknown' + : `5h ${fiveHour ?? '?'}%, 1w ${sevenDay ?? '?'}% remaining` + console.log(`${index + 1}. ${label} (${status}) — ${quota}`) + } + }) } import { runSetupCommand } from './setup/command.ts' diff --git a/packages/opencode/src/custody-mode.ts b/packages/opencode/src/custody-mode.ts index 9f79620bd..b118cf930 100644 --- a/packages/opencode/src/custody-mode.ts +++ b/packages/opencode/src/custody-mode.ts @@ -1,5 +1,3 @@ -export const OPENCODE_MAIN_OAUTH_REFRESH_LOCK = 'opencode-main-oauth-refresh' - export class CustodyStateMismatchError extends Error { readonly code = 'custody_state_mismatch' constructor( @@ -22,14 +20,31 @@ export class CustodyStateMismatchError extends Error { } } -/** Only a scoped roster and a non-secret host tombstone can activate custody. */ +/** + * L/C select local/Claustrum custody. Main R/T/X means stored tokens, an empty + * activation marker, or neither; fallback R/T/M means local accounts, tokenless + * vault accounts, or missing roster metadata. V/N marks verified/missing evidence. + * Only migrated OAuth uses these checks; ordinary OpenCode API keys are untouched. + * The activation marker enables the provider but cannot authenticate a request. + * Before each send, validate the local account and current tokens or obtain + * fresh authorization for that account from the vault. + */ export function reconcileCustodyStartup(input: { mode: 'L' | 'C' main: 'R' | 'T' | 'X' fallbacks: 'R' | 'T' | 'M' evidence: 'V' | 'N' + authority?: 'committed' | 'retired' }): { verdict: 'LOCAL_SERVE' | 'CLAUSTRUM_SERVE' } { - if (input.mode === 'L' && input.main === 'R' && input.fallbacks === 'R') { + if (input.authority !== 'committed' && input.authority !== 'retired') { + throw new CustodyStateMismatchError('MIGRATION_REQUIRED', input) + } + if ( + input.mode === 'L' && + input.main === 'T' && + input.fallbacks === 'R' && + input.evidence === 'V' + ) { return { verdict: 'LOCAL_SERVE' } } if ( diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 69fb5666d..fd1e5dcbd 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -3,8 +3,6 @@ import { type AccountCommandStatusProjection, type AccountStorage, type ApiKeyAccount, - acquireRefreshFileLock, - addAccountPersistent, applyCustomHeaders, assertNotCustodyTombstone, authorize, @@ -12,20 +10,14 @@ import { buildClaudeQuotaSummary, buildFallbackQuotaSummaries, buildPrimeRequestBody, - buildRefreshOperationError, - CACHE_1H_COMMAND_NAME, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, CacheKeepSessionRegistry, - CLAUDE_ACCOUNT_COMMAND_NAME, - CLAUDE_CACHE_KEEP_COMMAND_NAME, - CLAUDE_DUMP_COMMAND_NAME, CLAUDE_FABLE_MYTHOS_5_1_PRICING, CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW, CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS, CLAUDE_FABLE_MYTHOS_5_MODEL_SPECS, CLAUDE_FABLE_MYTHOS_5_PRICING, - CLAUDE_FAST_COMMAND_NAME, CLAUDE_HAIKU_4_5_MODEL_ID, CLAUDE_HAIKU_5_5_CONTEXT_WINDOW, CLAUDE_HAIKU_5_5_LONG_CONTEXT_PRICING, @@ -34,35 +26,25 @@ import { CLAUDE_HAIKU_5_5_MODEL_ID, CLAUDE_HAIKU_5_5_PRICING, CLAUDE_HAIKU_5_5_RELEASE_DATE, - CLAUDE_LOGGING_COMMAND_NAME, CLAUDE_OPUS_5_5_CONTEXT_WINDOW, CLAUDE_OPUS_5_5_MAX_OUTPUT_TOKENS, CLAUDE_OPUS_5_5_MODEL_ID, CLAUDE_OPUS_5_5_PRICING, CLAUDE_OPUS_5_5_RELEASE_DATE, - CLAUDE_PRIME_COMMAND_NAME, - CLAUDE_QUOTAS_COMMAND_NAME, - CLAUDE_ROUTING_COMMAND_NAME, CLAUDE_SONNET_5_5_CONTEXT_WINDOW, CLAUDE_SONNET_5_5_MAX_OUTPUT_TOKENS, CLAUDE_SONNET_5_5_MODEL_ID, CLAUDE_SONNET_5_5_PRICING, CLAUDE_SONNET_5_5_RELEASE_DATE, - CLAUDE_START_COMMAND_NAME, CLAUSTRUM_OPENCODE_ENROLLMENT_NAME, - type ClaustrumScopedAttempt, - type ClaustrumScopedClient, - ClaustrumScopedRuntime, - type ClaustrumScopedRuntimeOptions, type CustodyStatusState, - CustodyTombstoneLoginError, computeXxhash64Hex, configuredAnthropicOAuthAccountCount, - connectClaustrumScopedClient, createEmptyStorage, + createNativeAccountRuntime, createStickyNoRouteResponse, + custodyTombstoneOAuth, type DumpHandle, - decideScopedRetryAfter401, decideStickyQuotaFailure, detectClaustrumConnection, dumpDirectRequest, @@ -78,10 +60,9 @@ import { executeLoggingCommand, executePrimeCommand, executeRoutingCommand, - FallbackAccountManager, + FALLBACK_BACKGROUND_TICK_MS, fallbackAccountUuidForLineage, - fetchOAuthAccountProfile, - fetchOAuthQuotaSnapshot, + type fetchOAuthAccountProfile, formatEnrollmentStatus, formatOAuthAccountTier, formatQuotaBackoffMessage, @@ -90,24 +71,19 @@ import { getCache1hMode, getCache1hPersistentMode, getCacheKeepWindow, + getClaudeCodeIdentityForVerifiedAccount, getClaudeFableMythos5ReleaseDate, getClaustrumMode, getDefaultCacheKeepRegistryDirectory, getFallbackReauthLabels, getHostClaustrumEnrollmentPaths, getKillswitchConfig, - getOrCreateMainAccountId, - getOrCreatePrimeAuthLineageId, getPersistedLogLevel, - getPersistedMainQuota, getQuotaNextRefreshAt, - getRelayConfig, getRoutingMode, getStickyRoutingStatePath, getThinkingPrefixMismatchBehavior, - hashRefreshToken, type IdentityState, - incrementPrimeUsagePersistent, isApiKeyAccount, isCache1hEnabled, isCache1hPersistentlyEnabled, @@ -134,21 +110,23 @@ import { isPrimePersistentlyEnabled, isQuotaBearingHeaderFrame, isValidApiBaseURL, - KILLSWITCH_COMMAND_NAME, killswitchPassesPolicy, killswitchRetryAfterSeconds, - loadAccounts, log, logger, mergeAnthropicBetas, mergeHeaderQuotaForPersistence, - mergeMainQuotaErrorClearedAt, - mergeMainRefreshErrorClearedAt, + type NativeAccountRuntimeOptions, + type NativeAccountView, + type NativeCustodyClient, + type NativeCustodyReceipt, + type NativeLocalCredentialValidation as NativeKnownCredentialSubject, + type NativeMenuDispatch, + type NativeUiOptions, normalizeQuotaHeaders, type OAuthAccount, type OAuthQuotaSnapshot, oauthProfileIsFresh, - oauthProfileMatchesIdentity, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, PrimeManager, type PrimeManagerOptions, @@ -164,8 +142,6 @@ import { parseLoggingCommandAction, parsePrimeCommandAction, parseRoutingCommandAction, - persistFallbackQuotaHeaderPersistent, - primeStorageFingerprint, QUOTA_HEADER_FEED_SCHEMA_VERSION, type QuotaAccountSummary, type QuotaEntry, @@ -179,36 +155,19 @@ import { quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, readClaustrumEnrollmentStatus, + readNativeMigrationJournal, refreshBackoffActive, - refreshClaudeOAuthToken, remapRequestBodyModel, - removeAccountPersistent, - reorderAccountsPersistent, resetClaustrumEnrollmentState, - resolveClaudeCodeIdentity, + resolveNativePoolPaths, STICKY_ROUTING_MAIN_ACCOUNT_ID, type StickyRouteCandidate, StickySessionRouter, - saveAccountState, - saveOAuthProfileState, sendViaRelay, - setAccountEnabledPersistent, - setCache1hPersistentEnabled, - setCache1hPersistentMode, setCache1hState, - setCacheKeepPersistentAlways, - setCacheKeepPersistentEnabled, - setCacheKeepPersistentWindow, - setCacheKeepSubagentsEnabled, setDumpEnabled, - setDumpPersistentEnabled, setFastModeEnabled, - setFastModePersistentEnabled, - setKillswitchPersistent, setLogLevel, - setLogLevelPersistent, - setPrimePersistentEnabled, - setRoutingMode, shouldFallbackStatus, stickyQuotaSnapshotIsFresh, stickyRouteFamilyForModel, @@ -243,7 +202,6 @@ import { } from './custody-dimensions.ts' import { CustodyStateMismatchError, - OPENCODE_MAIN_OAUTH_REFRESH_LOCK, reconcileCustodyStartup, } from './custody-mode.ts' import { @@ -264,6 +222,7 @@ import { LANE_START_REQUEST_HEADER, LaneStartTracker, } from './lane-start.ts' +import { createNativeCommand } from './native-command.js' import { adoptPrimeManager } from './prime-manager-registry.ts' import { resolvePromptContext } from './prompt-context.ts' import { @@ -375,15 +334,10 @@ function trailingAssistantHistoryFailureResponse( } const MAIN_AUTH_REFRESH_TICK_MS = 60_000 const MAIN_AUTH_REFRESH_TICK_JITTER_MS = 60_000 -const CONCURRENT_MAIN_REFRESH_WAIT_MS = 5_000 -const CONCURRENT_MAIN_REFRESH_POLL_BASE_MS = 200 const MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240 const DEFAULT_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES const SIDEBAR_ROUTING_FRESH_MS = 10 * 60 * 1000 -// OpenCode creates one plugin instance per project, but those instances share -// the same host OAuth credential and account sidecar within a server process. -const mainAccessRefreshesByStoragePath = new Map>() function hasEnabledOAuthAccount( storage: AccountStorage | null, @@ -469,7 +423,7 @@ async function resolveFreshSidebarRouting( } async function resolveInitialSidebarRouting( - accountStoragePath: string, + loadStorage: () => Promise, ): Promise<{ activeId: string | undefined route: string @@ -478,7 +432,7 @@ async function resolveInitialSidebarRouting( await getInitialSidebarRoutingTestHooks()?.beforeStorageLoad?.() let freshStorage: AccountStorage | null try { - freshStorage = await loadAccounts(accountStoragePath) + freshStorage = await loadStorage() } catch { return { activeId: 'main', route: 'main', freshStorage: null } } finally { @@ -824,6 +778,8 @@ type ServedQuotaHeaders = { authLineageId?: string anthropicAccountUuid?: ProviderAccountUuid | null mainQuotaIdentity?: MainQuotaIdentityBinding + localSubject?: NativeKnownCredentialSubject + scopedAttempt?: NativeCustodyReceipt } function asProviderAccountUuid( @@ -1125,7 +1081,8 @@ type PluginRuntimeOverrides = Partial<{ setInterval: typeof globalThis.setInterval clearInterval: typeof globalThis.clearInterval scopedRosterPollIntervalMs: number - claustrumScopedConnect: () => Promise + claustrumScopedConnect: () => Promise + nativeLocal: NativeAccountRuntimeOptions['local'] cacheKeepAggregateRefreshIntervalMs: number }> @@ -1206,14 +1163,137 @@ const anthropicAuthPlugin = async ( return entry } - let initialStorage: AccountStorage | null + const nativePaths = await resolveNativePoolPaths(accountStoragePath) + const nativeAccounts = createNativeAccountRuntime({ + paths: nativePaths, + host: 'opencode', + local: runtimeOverrides.nativeLocal, + vault: runtimeOverrides.claustrumScopedConnect + ? { connect: runtimeOverrides.claustrumScopedConnect } + : undefined, + }) + // The AccountStorage-shaped projection supplies quota, settings, and account + // metadata to routing and display code. It contains no OAuth access/refresh + // tokens or API keys; credential material comes only from native authorization. + async function loadAccounts( + path = accountStoragePath, + ): Promise { + if (path !== accountStoragePath) + throw new Error('Native account storage mismatch') + const snapshot = await nativeAccounts.read() + if ( + snapshot.mode !== 'claustrum' || + !snapshot.accounts.some((account) => account.source === 'vault') + ) + return snapshot.policyStorage + const primary = snapshot.accounts.find( + (account) => account.id === 'main' && account.source === 'vault', + ) + const primaryUuid = asProviderAccountUuid(primary?.accountIdentity) + // Existing routing predicates use the scopedRoster flag. Derive that + // metadata only from native vault rows, not legacy config; every physical + // request still obtains its own receipt from the shared vault runtime. + return { + ...snapshot.policyStorage, + claustrum: { + ...snapshot.policyStorage.claustrum, + mode: 'claustrum', + scopedRoster: true, + primaryAccount: + primary?.credentialId && primaryUuid + ? { + credentialId: primary.credentialId, + accountId: primaryUuid, + state: primary.state ?? 'cold', + } + : undefined, + }, + } + } + async function updateNativeSection( + section: + | 'claudeCache' + | 'cacheKeep' + | 'dump' + | 'claudeFast' + | 'logging' + | 'prime' + | 'routing' + | 'killswitch', + patch: Record, + ) { + await nativeAccounts.updateSettings((settings) => { + const previous = settings[section] + return { + ...settings, + [section]: { + ...(previous && + typeof previous === 'object' && + !Array.isArray(previous) + ? previous + : {}), + ...patch, + }, + } + }) + return loadAccounts() + } + const setCache1hPersistentEnabled = (enabled: boolean) => + updateNativeSection('claudeCache', { enabled }) + const setCache1hPersistentMode = ( + mode: NonNullable['mode'], + ) => updateNativeSection('claudeCache', { mode }) + const setCacheKeepPersistentEnabled = (enabled: boolean) => + updateNativeSection('cacheKeep', { enabled }) + const setCacheKeepPersistentAlways = () => + updateNativeSection('cacheKeep', { enabled: true, always: true }) + const setCacheKeepPersistentWindow = (startHour: number, endHour: number) => + updateNativeSection('cacheKeep', { + enabled: true, + always: false, + startHour, + endHour, + }) + const setCacheKeepSubagentsEnabled = (subagents: boolean) => + updateNativeSection('cacheKeep', { subagents }) + const setDumpPersistentEnabled = (enabled: boolean) => + updateNativeSection('dump', { enabled }) + const setFastModePersistentEnabled = (enabled: boolean) => + updateNativeSection('claudeFast', { enabled }) + const setLogLevelPersistent = ( + level: NonNullable['level'], + ) => updateNativeSection('logging', { level }) + const setPrimePersistentEnabled = ( + enabled: boolean, + _path = accountStoragePath, + ) => updateNativeSection('prime', { enabled }) + const setRoutingMode = ( + mode: NonNullable['mode'], + _path = accountStoragePath, + ) => updateNativeSection('routing', { mode }) + const setKillswitchPersistent = ( + killswitch: NonNullable, + ) => updateNativeSection('killswitch', killswitch) + const setAccountEnabledPersistent = ( + routeId: string, + enabled: boolean, + _path = accountStoragePath, + ) => nativeAccounts.setEnabled(routeId, enabled) + const removeAccountPersistent = ( + routeId: string, + _path = accountStoragePath, + ) => nativeAccounts.remove(routeId) + const reorderAccountsPersistent = ( + routeIds: string[], + _path = accountStoragePath, + ) => nativeAccounts.reorder(routeIds) + + let initialStorage: AccountStorage | null = null + let nativeStartupError: unknown try { - initialStorage = await loadAccounts(accountStoragePath) + initialStorage = await loadAccounts() } catch (error) { - const message = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to load account store: ${message}`, { - cause: error instanceof Error ? error : undefined, - }) + nativeStartupError = error } function isScopedCustodyActive( storage: AccountStorage | null | undefined, @@ -1224,53 +1304,178 @@ const anthropicAuthPlugin = async ( storage.claustrum?.scopedRoster === true, ) } - - const openCodeScopedRuntimes = new Map() - function getOpenCodeScopedRuntime( storagePath = accountStoragePath, - ctxDirectory = ctx.directory, - overrides: Partial = {}, - ): ClaustrumScopedRuntime { - let runtime = openCodeScopedRuntimes.get(storagePath) - if (!runtime) { - const identity = { - project_root: ctxDirectory ?? process.cwd(), - harness: 'opencode', - session: `store-${primeStorageFingerprint(storagePath)}`, + _ctxDirectory = ctx.directory, + ) { + if (storagePath !== accountStoragePath) + throw new Error('Native vault storage mismatch') + return nativeAccounts.vault + } + function closeOpenCodeScopedRuntime(_storagePath = accountStoragePath): void { + nativeAccounts.close() + } + + function assertNativeEnvironment() { + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) + throw new Error( + 'Native OAuth cannot serve while OPENCODE_AUTH_CONTENT is set', + ) + } + type NativeOAuthAuthorization = { + accessToken: string + expires?: number + accountIdentity?: string + localSubject?: NativeKnownCredentialSubject + localSource?: 'current' | 'validated' | 'rotated' | 'adopted' + scopedAttempt?: NativeCustodyReceipt + } + function nativeWireIdentity(accountIdentity: string | undefined) { + const accountUuid = asProviderAccountUuid(accountIdentity) + if (!accountUuid) + throw new Error( + 'Native wire identity requires an admitted provider account', + ) + // Credential admission already established this account UUID. Signing uses + // stable client identity metadata, never another profile lookup with an + // earlier token or vault receipt. + return getClaudeCodeIdentityForVerifiedAccount(accountUuid, accountUuid) + } + class NativeModelPolicyError extends Error { + constructor(readonly account: NativeAccountView) { + super( + 'OAuth account cannot serve the requested model under current quota policy', + ) + } + } + async function authorizeOAuth( + routeId: string, + signal?: AbortSignal, + rejectedAccessToken?: string, + modelId?: string, + intent: 'serve' | 'refresh' = 'serve', + ): Promise { + assertNativeEnvironment() + signal?.throwIfAborted() + const snapshot = await nativeAccounts.read() + const account = snapshot.accounts.find( + (candidate) => candidate.id === routeId, + ) + if (account?.type !== 'oauth' || !account.enabled) { + throw new Error('Native OAuth account is unavailable') + } + const checkModelPolicy = ( + view: NativeAccountView, + storage: AccountStorage, + ) => { + if ( + modelId && + ((isKillswitchEnabled(storage) && + !killswitchPassesPolicy( + view.quota, + storage, + routeId === 'main' ? undefined : routeId, + modelId, + )) || + !quotaSnapshotPassesModelScope(view.quota, modelId)) + ) + throw new NativeModelPolicyError(view) + } + checkModelPolicy(account, snapshot.policyStorage) + let result: NativeOAuthAuthorization + if (account.source === 'vault') { + const receipt = await nativeAccounts.authorizeVault(routeId, signal) + result = { + accessToken: receipt.accessToken, + expires: receipt.expiresAtMs ?? undefined, + accountIdentity: receipt.accountIdentity, + scopedAttempt: receipt, } - runtime = new ClaustrumScopedRuntime({ - storagePath, - tokenPath: getHostClaustrumEnrollmentPaths('opencode').tokenPath, - connect: () => - runtimeOverrides.claustrumScopedConnect - ? runtimeOverrides.claustrumScopedConnect() - : connectClaustrumScopedClient({ - storagePath, - identity, - ...(getConfiguredClaustrumConnectionFile() && { - connectionFile: getConfiguredClaustrumConnectionFile(), - }), - }), - pollIntervalMs: runtimeOverrides.scopedRosterPollIntervalMs ?? 2000, - onRoster: async () => { - await fallbackManager.refreshQuotaForDueAccounts().catch(() => {}) - await refreshSidebarQuota().catch(() => {}) - }, - ...overrides, + } else { + const authorization = await nativeAccounts.authorizeLocal(routeId, { + signal, + rejectedAccessToken, + intent: rejectedAccessToken ? 'refresh' : intent, }) - openCodeScopedRuntimes.set(storagePath, runtime) + if (authorization.status !== 'usable') + throw new Error( + `Native OAuth authorization refused: ${authorization.status}`, + ) + result = { + accessToken: authorization.access, + expires: authorization.expires, + accountIdentity: authorization.binding.identity, + localSubject: authorization.subject, + localSource: authorization.source, + } } - return runtime + // Concurrent requests can share token refresh while using different models. + // After authorization waits, recheck that the account is still enabled and + // that this request's model quota and killswitch rules still permit sending. + const current = await nativeAccounts.read() + const selected = current.accounts.find( + (candidate) => candidate.id === routeId, + ) + if (signal?.aborted) throw signal.reason ?? new Error('Request aborted') + if ( + !selected?.enabled || + selected.source !== account.source || + selected.accountIdentity !== result.accountIdentity + ) + throw new Error('Native OAuth account changed before dispatch') + if ( + result.localSubject && + (!selected.binding || + selected.binding.storageId !== result.localSubject.binding.storageId || + selected.binding.rowId !== result.localSubject.binding.rowId || + selected.binding.credentialEpoch !== + result.localSubject.binding.credentialEpoch || + selected.binding.identity !== result.localSubject.binding.identity) + ) + throw new Error('Native local account binding changed before dispatch') + if ( + result.scopedAttempt && + selected.credentialId !== result.scopedAttempt.credentialId + ) + throw new Error('Native vault credential binding changed before dispatch') + checkModelPolicy(selected, current.policyStorage) + return result } - function closeOpenCodeScopedRuntime(storagePath = accountStoragePath): void { - openCodeScopedRuntimes.get(storagePath)?.close() - openCodeScopedRuntimes.delete(storagePath) + async function resetNativeBackoff(routeId: string, signal?: AbortSignal) { + const snapshot = await nativeAccounts.read() + const account = snapshot.accounts.find( + (candidate) => candidate.id === routeId, + ) + if (account?.type !== 'oauth') + throw new Error('Native OAuth account is unavailable') + const fence = + account.source === 'vault' + ? await nativeAccounts.authorizeVault(routeId, signal) + : await nativeAccounts.captureLocalSubject(routeId) + if (!(await nativeAccounts.resetBackoff(routeId, fence))) + throw new Error('Native reset fence changed') } - - if (isScopedCustodyActive(initialStorage)) { - getOpenCodeScopedRuntime(accountStoragePath, ctx.directory).start() + const nativeLocalRefreshFailures = new WeakMap() + const reportedVault401 = new WeakSet() + async function reportVault401( + receipt: NativeCustodyReceipt, + source: 'direct' | 'relay_status_field', + ) { + if (reportedVault401.has(receipt)) return + reportedVault401.add(receipt) + await nativeAccounts.vault.reportFailure(receipt, 401, source).catch(() => { + logger.warn('claustrum', 'native auth-failure report unavailable') + }) + } + async function prepareVaultRetry( + receipt: NativeCustodyReceipt, + signal?: AbortSignal, + source: 'direct' | 'relay_status_field' = 'direct', + ) { + await reportVault401(receipt, source) + const result = await nativeAccounts.vault.prepareRetry(receipt, signal) + return result.retry ? result.receipt : undefined } const fallbackMode = resolveContentFilterFallbackMode( @@ -1294,152 +1499,19 @@ const anthropicAuthPlugin = async ( process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE || getStickyRoutingStatePath(accountStoragePath), }) - const persistFallbackQuotaError = async ( - accountId: string, - error: NonNullable, - ) => { - try { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && isOAuthAccount(candidate), - ) - if (!storage || !account) return - if ( - account.lastQuotaRefreshError?.checkedAt !== undefined && - account.lastQuotaRefreshError.checkedAt > error.checkedAt - ) { - return - } - if (quotaSnapshotCheckedAt(account.quota) > error.checkedAt) return - account.lastQuotaRefreshError = error - await saveAccountState(storage, accountStoragePath, { - accounts: [accountId], - }) - } catch (caught) { - logger.warn('quota', 'failed to persist fallback backoff state', { - accountId, - error: caught instanceof Error ? caught.message : String(caught), - }) - } - } + // The runtime stores quota readings for the account and tokens used to fetch + // them. QuotaManager caches those readings and when they were checked. + // A failed poll supplies no exhaustion evidence and cannot prove that a + // rejected refresh token became usable, so it must not clear refresh errors. const quotaManager = new QuotaManager({ storage: initialStorage, - fetchQuotaSnapshot: async (request) => { - const storage = await loadAccounts(accountStoragePath) - if (getClaustrumMode(storage) === 'claustrum') { - if (!isScopedCustodyActive(storage)) - throw new Error('Claustrum scoped custody setup is incomplete') - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - const routeId = request.kind === 'main' ? 'main' : request.accountId - if (!routeId) - throw new Error('Claustrum quota request has no account identity') - return runtime.fetchQuota(routeId) - } - return fetchOAuthQuotaSnapshot({ - accessToken: request.accessToken, - }) - }, - onMainQuotaFetched: async ( - quota, - checkedAt, - tokenFingerprint, - fetchStartedAt, - quotaErrorGeneration, - quotaErrorClearedAt, - ) => { - try { - const storage = - (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() - const persisted = getPersistedMainQuota(storage) - if ( - persisted && - persisted.checkedAt >= fetchStartedAt && - getQuotaNextRefreshAt(persisted.quota, storage, persisted.checkedAt) > - Date.now() - ) { - quotaManager.seedMainFromStorage(storage, mainQuotaAccountId) - return - } - storage.quota = storage.quota ?? {} - storage.quota.mainQuota = quota - storage.quota.mainQuotaCheckedAt = checkedAt - storage.quota.mainQuotaToken = tokenFingerprint - storage.quota.mainLastQuotaApiError = undefined - storage.quota.mainQuotaErrorGeneration = quotaErrorGeneration - storage.quota.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt( - storage.quota.mainQuotaErrorClearedAt, - quotaErrorClearedAt, - ) - await saveAccountState(storage, accountStoragePath, { mainQuota: true }) - } catch (error) { - logger.warn('quota', 'failed to persist main quota', { - error: error instanceof Error ? error.message : String(error), - }) - } + fetchQuotaSnapshot: (request) => { + assertNativeEnvironment() + const routeId = request.kind === 'main' ? 'main' : request.accountId + if (!routeId) throw new Error('Quota request has no account identity') + return nativeAccounts.fetchQuota(routeId) }, - onApiError: async (error, quotaErrorGeneration) => { - try { - const storage = - (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() - storage.quota = storage.quota ?? {} - storage.quota.mainLastQuotaApiError = error - storage.quota.mainQuotaErrorGeneration = quotaErrorGeneration - await saveAccountState(storage, accountStoragePath, { mainQuota: true }) - } catch (e) { - logger.warn('quota', 'failed to persist backoff state', { - error: e instanceof Error ? e.message : String(e), - }) - } - }, - onFallbackQuotaFetched: async ( - accountId, - quota, - _checkedAt, - fetchStartedAt, - ) => { - try { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && isOAuthAccount(candidate), - ) - if (!storage || !account) return - const persistedCheckedAt = quotaSnapshotCheckedAt(account.quota) - if ( - persistedCheckedAt >= fetchStartedAt && - getQuotaNextRefreshAt(account.quota, storage, persistedCheckedAt) > - Date.now() - ) { - quotaManager.seedFallbacksFromAccounts( - storage.accounts.filter(isOAuthAccount), - ) - return - } - if ( - account.lastQuotaRefreshError?.checkedAt !== undefined && - account.lastQuotaRefreshError.checkedAt > fetchStartedAt - ) { - return - } - account.quota = quota - account.lastQuotaRefreshError = undefined - await saveAccountState(storage, accountStoragePath, { - accounts: [accountId], - }) - } catch (error) { - logger.warn('quota', 'failed to persist fallback quota', { - accountId, - error: error instanceof Error ? error.message : String(error), - }) - } - }, - onFallbackApiError: persistFallbackQuotaError, }) - async function reconcileMainQuotaAccountIdentity( accessToken: string, quotaKey: string | undefined, @@ -1448,72 +1520,24 @@ const anthropicAuthPlugin = async ( if ( mainQuotaIdentityAccessToken === accessToken && mainQuotaAccountId === quotaKey - ) { + ) return - } - const providerIdentityChanged = mainProviderAccountUuid !== undefined && providerAccountUuid !== undefined && mainProviderAccountUuid !== providerAccountUuid mainQuotaIdentityAccessToken = accessToken mainQuotaAccountId = quotaKey - const clearedGeneration = quotaManager.setMainQuotaAccountIdentity( + quotaManager.setMainQuotaAccountIdentity( quotaKey, - accessToken.startsWith('sk-ant-oat'), + true, providerIdentityChanged, ) - if (quotaKey === undefined) return - - const storage = await loadAccounts(accountStoragePath) - const persistedError = storage?.quota?.mainLastQuotaApiError - if ( - !storage || - !persistedError || - persistedError.accountIdentity === quotaKey || - (!accessToken.startsWith('sk-ant-oat') && - persistedError.accountIdentity === undefined) - ) { - return - } - - storage.quota = storage.quota ?? {} - storage.quota.mainLastQuotaApiError = undefined - storage.quota.mainQuotaErrorGeneration = Math.max( - storage.quota.mainQuotaErrorGeneration ?? 0, - clearedGeneration ?? quotaManager.getMainQuotaErrorGeneration(), - ) - storage.quota.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt( - storage.quota.mainQuotaErrorClearedAt, - quotaManager.getMainQuotaErrorClearedAt() ?? Date.now(), - ) - await saveAccountState(storage, accountStoragePath, { mainQuota: true }) - } - - async function persistFallbackAnthropicAccountUuid( - accountId: string, - anthropicAccountUuid: ProviderAccountUuid, - ): Promise { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && isOAuthAccount(candidate), - ) - if ( - !storage || - !account || - account.anthropicAccountUuid === anthropicAccountUuid - ) - return - account.anthropicAccountUuid = anthropicAccountUuid - await saveAccountState(storage, accountStoragePath, { - accounts: [accountId], - }) } async function resolveMainQuotaAccountIdentity( accessToken: string, - model?: string, + _model?: string, credentialAccountUuid?: string, credentialRecordVersion?: number, ): Promise { @@ -1529,12 +1553,15 @@ const anthropicAuthPlugin = async ( const resolution = (async () => { const resolutionGeneration = ++mainQuotaIdentityResolutionGeneration const mainSlotQuotaKey = mainAccountId - const identity = await resolveClaudeCodeIdentity( - accessToken, - model, - credentialAccountUuid ?? mainSlotQuotaKey, + + const currentSnapshot = await nativeAccounts.read() + const currentStorage = currentSnapshot.policyStorage + const nativePrimaryIdentity = currentSnapshot.accounts.find( + (account) => account.id === 'main', + )?.accountIdentity + const identity = nativeWireIdentity( + credentialAccountUuid ?? nativePrimaryIdentity, ) - const currentStorage = await loadAccounts(accountStoragePath) const scopedCustodyActive = isScopedCustodyActive(currentStorage) const rosterPrimaryAccountId = scopedCustodyActive ? currentStorage?.claustrum?.primaryAccount?.accountId @@ -1544,10 +1571,9 @@ const anthropicAuthPlugin = async ( // 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) + (credentialAccountUuid !== undefined && + credentialAccountUuid !== nativePrimaryIdentity) || + (scopedCustodyActive && credentialAccountUuid === undefined) ) { return { quotaKey: undefined, @@ -1562,7 +1588,8 @@ const anthropicAuthPlugin = async ( // 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 = rosterPrimaryAccountId ?? mainSlotQuotaKey + const quotaKey = + nativePrimaryIdentity ?? rosterPrimaryAccountId ?? mainSlotQuotaKey const persistedProviderAccountUuid = currentStorage?.main?.profile?.providerAccountUuid ?? (currentStorage?.main?.profile?.accountIdentity as @@ -1650,22 +1677,20 @@ const anthropicAuthPlugin = async ( async function hydrateProfileOnce( accountId: string, accountIdentity: string | undefined, - accessToken: string, - providerAccountUuid?: ProviderAccountUuid, + _accessToken: string, + _providerAccountUuid?: ProviderAccountUuid, signal?: AbortSignal, ) { if (signal?.aborted) return undefined + assertNativeEnvironment() const attemptKey = `${accountIdentity ?? accountId}` let attempt = profileHydrationAttempts.get(attemptKey) if (!attempt) { - const fetchAttempt = fetchOAuthAccountProfile({ - accessToken, - accountIdentity, - providerAccountUuid, - fetchImpl: profileFetch, - signal, - }) - .catch((error) => { + const fetchAttempt: Promise< + Awaited> | undefined + > = nativeAccounts + .fetchProfile(accountId, profileFetch, signal) + .catch((error: unknown) => { logger.debug('quota', 'failed to hydrate account profile', { account: accountId, error: error instanceof Error ? error.message : String(error), @@ -1696,226 +1721,69 @@ const anthropicAuthPlugin = async ( }) } - async function persistProfileStateBestEffort(input: { - accountId: 'main' | string - accountIdentity?: string - providerAccountUuid?: ProviderAccountUuid - accessToken: string - profile: OAuthAccount['profile'] - }): Promise { - try { - return await saveOAuthProfileState( - { - accountId: input.accountId, - profile: input.profile, - accountIdentity: input.accountIdentity, - }, - accountStoragePath, - ) - } catch (error) { - logger.debug('quota', 'failed to persist account profile', { - account: input.accountId, - error: error instanceof Error ? error.message : String(error), - }) - return undefined - } - } - async function ensureProfilesForQuotaDisplay( storage: AccountStorage, - mainAccessToken?: string, + _mainAccessToken?: string, signal?: AbortSignal, - mainProviderAccountUuid?: ProviderAccountUuid, + _mainProviderAccountUuid?: ProviderAccountUuid, ): Promise { - if (process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION === '1') { + if (process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION === '1') return storage - } - const now = Date.now() - const mainIdentity = mainQuotaAccountId - if ( - mainAccessToken && - storage.main?.profile && - mainProviderAccountUuid && - storage.main.profile.providerAccountUuid !== mainProviderAccountUuid - ) { - storage.main.profile = undefined - void persistProfileStateBestEffort({ - accountId: 'main', - accountIdentity: mainIdentity, - providerAccountUuid: mainProviderAccountUuid, - accessToken: mainAccessToken, - profile: undefined, - }).catch(() => {}) - } - if ( - mainAccessToken && - storage.main?.profile && - mainIdentity !== undefined && - storage.main.profile.accountIdentity === undefined - ) { - storage.main.profile = { - ...storage.main.profile, - accountIdentity: mainIdentity, - } - void persistProfileStateBestEffort({ - accountId: 'main', - accountIdentity: mainIdentity, - providerAccountUuid: mainProviderAccountUuid, - accessToken: mainAccessToken, - profile: storage.main.profile, - }).catch(() => {}) - } - if (mainAccessToken && !oauthProfileIsFresh(storage.main?.profile, now)) { - const profile = await hydrateProfileOnce( - 'main', - undefined, - mainAccessToken, - mainProviderAccountUuid, - signal, - ) - if (profile) { - storage.main = { - type: 'opencode', - provider: 'anthropic', - profile, - } - void persistProfileStateBestEffort({ - accountId: 'main', - accountIdentity: mainIdentity, - providerAccountUuid: mainProviderAccountUuid, - accessToken: mainAccessToken, - profile, - }).catch(() => {}) - } - } - - for (const account of storage.accounts) { + const snapshot = await nativeAccounts.read() + for (const account of snapshot.accounts) { if (signal?.aborted) break - if (!isOAuthAccount(account)) continue - const vaultEnabled = isScopedCustodyActive(storage) - if (vaultEnabled && !isFallbackAccountVaultServed(account.id, storage)) - continue - const accessToken = vaultEnabled - ? ( - await getOpenCodeScopedRuntime() - .authorize(account.id, signal) - .catch(() => undefined) - )?.accessToken - : account.access - if (!accessToken) continue if ( - account.profile && - !oauthProfileMatchesIdentity(account.profile, account.id) - ) { - account.profile = undefined - void persistProfileStateBestEffort({ - accountId: account.id, - accountIdentity: account.id, - accessToken, - profile: undefined, - }).catch(() => {}) - } - if (account.profile && account.profile.accountIdentity === undefined) { - account.profile = { ...account.profile, accountIdentity: account.id } - void persistProfileStateBestEffort({ - accountId: account.id, - accountIdentity: account.id, - accessToken, - profile: account.profile, - }).catch(() => {}) - } - if (oauthProfileIsFresh(account.profile, now)) continue - const profile = await hydrateProfileOnce( - account.id, + account.type !== 'oauth' || + !account.enabled || + oauthProfileIsFresh(account.profile, Date.now()) + ) + continue + await hydrateProfileOnce( account.id, - accessToken, - undefined, + account.accountIdentity, + '', + asProviderAccountUuid(account.accountIdentity), signal, ) - if (profile) { - account.profile = profile - void persistProfileStateBestEffort({ - accountId: account.id, - accountIdentity: account.id, - accessToken, - profile, - }).catch(() => {}) - } } - return storage + // Profile polling saved the tier and organization for the account it read. + // Reload current account metadata so replacing this route cannot display + // the profile fetched for its previous credentials. + return (await nativeAccounts.read()).policyStorage } const warnedQuotaNormalizeErrors = new Set() - const warnedStaleFallbackQuotaPersists = new Set() async function persistPushedQuota( served: ServedQuotaHeaders, entry: QuotaEntry, ): Promise { - const storage = - (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() - if (served.accountId === 'main') { - const quotaKey = served.mainQuotaIdentity?.quotaKey - if (!quotaKey || entry.quota.accountIdentity !== quotaKey) { - logger.trace( - 'quota', - 'skipped quota persistence without verified main account identity', - ) - return null - } - storage.quota = storage.quota ?? {} - storage.quota.mainQuota = { - ...entry.quota, - accountIdentity: quotaKey, - } - storage.quota.mainQuotaCheckedAt = entry.checkedAt - await saveAccountState(storage, accountStoragePath, { mainQuota: true }) - const reloaded = await loadAccounts(accountStoragePath) - const persistedQuota = reloaded?.quota?.mainQuota - const persistedQuotaBelongsToRequest = Boolean( - persistedQuota && persistedQuota.accountIdentity === quotaKey, - ) - const mergedQuota = persistedQuotaBelongsToRequest - ? mergeHeaderQuotaForPersistence(persistedQuota, entry.quota) - : entry.quota - return persistedQuota - ? { - ...entry, - quota: mergedQuota, - checkedAt: entry.checkedAt, - } - : entry - } - const persisted = await persistFallbackQuotaHeaderPersistent( - { - accountId: served.accountId, - authLineageId: served.authLineageId, - quota: entry.quota, - anthropicAccountUuid: served.anthropicAccountUuid ?? undefined, - }, - accountStoragePath, + const snapshot = await nativeAccounts.read() + const account = snapshot.accounts.find( + (candidate) => candidate.id === served.accountId, + ) + if ( + !account || + !served.anthropicAccountUuid || + account.accountIdentity !== served.anthropicAccountUuid ) + return null + const quota = { + ...mergeHeaderQuotaForPersistence(account.quota, entry.quota), + accountIdentity: account.accountIdentity, + } + const persisted = served.scopedAttempt + ? await nativeAccounts.vault.publish(served.scopedAttempt, { quota }) + : served.localSubject + ? await nativeAccounts.publishLocal(served.localSubject, { quota }) + : false if (!persisted) { - const key = `${served.accountId}:${served.authLineageId ?? ''}` - if (!warnedStaleFallbackQuotaPersists.has(key)) { - warnedStaleFallbackQuotaPersists.add(key) - logger.debug( - 'quota', - 'skipped stale fallback quota persistence after lineage change', - { - accountId: served.accountId, - servedLineage: served.authLineageId, - }, - ) - } - // The persistence fence is authoritative; discard the optimistic cache - // write unless a newer replacement observation already won the race. - if (quotaManager.getAllFallbacks().get(served.accountId) === entry) { + if (served.accountId === 'main') quotaManager.clearMain() + else if (quotaManager.getAllFallbacks().get(served.accountId) === entry) quotaManager.clearFallback(served.accountId) - } return null } - return entry + return { ...entry, quota } } function logPersistFailure(error: unknown) { @@ -2103,46 +1971,197 @@ const anthropicAuthPlugin = async ( } } - // Local OAuth and scoped custody share quota/routing machinery. The latter - // never exposes a bearer from sidecar storage or a capability-handle cache. - const fallbackManager = new FallbackAccountManager({ - quotaManager, - isFallbackAccountVaultServed, - isFallbackAccountVaultEnabled: (_accountId, storage) => - isScopedCustodyActive(storage), - resolveFallbackAccessToken: (account, storage) => - getClaustrumMode(storage) === 'local' && account.access - ? { token: account.access, source: 'sidecar' as const } - : undefined, - setIntervalImpl: runtimeTimers.setInterval, - clearIntervalImpl: runtimeTimers.clearInterval, - onFallbackStorageChanged: () => { - void refreshSidebarQuota().catch(() => {}) - }, - }) - const fallbackRefreshReady = fallbackManager.startBackgroundRefresh() - const fallbackCustodyStateFor = ( - accountId: string, - storage: AccountStorage | null, - ): Exclude => { - const state = custodyStateFor({ id: accountId, role: 'fallback' }, storage) - return state === 'na' ? 'off' : state + async function refreshFallbackQuotas(force = false) { + const snapshot = await nativeAccounts.read() + if (!force && snapshot.policyStorage.quota?.enabled === false) return + for (const account of snapshot.accounts) { + if (account.id === 'main' || account.type !== 'oauth' || !account.enabled) + continue + if ( + !force && + getQuotaNextRefreshAt( + account.quota, + snapshot.policyStorage, + quotaSnapshotCheckedAt(account.quota), + ) > Date.now() + ) + continue + try { + await quotaManager.refreshFallback( + account.id, + '', + snapshot.policyStorage.accounts.find( + (candidate) => + candidate.id === account.id && isOAuthAccount(candidate), + ) as OAuthAccount | undefined, + ) + } catch { + // The runtime already recorded the quota-fetch failure for its account. + // Keep any separate refresh-token rejection; a usage poll cannot fix it. + } + } } - const cacheDiagnosticsTracker = new CacheDiagnosticsTracker() - const cacheDiagnosticsBetaTracker = new CacheDiagnosticsBetaTracker() - type CacheDiagnosticsResponse = { - request?: CacheDiagnosticsRequestContext - trackSessionId?: string - source: CacheDiagnosticsSource - accountId: string - synthetic: boolean - betasHash: string - betas: string[] - requestedModel?: string - dump: DumpHandle | null - status: number - streaming: boolean - dumpWrite: Promise + const fallbackManager = { + refreshQuotaForDueAccounts: () => refreshFallbackQuotas(), + refreshQuotaForAllAccounts: (_options?: { force?: boolean }) => + refreshFallbackQuotas(true), + refreshAccount: async ( + account: OAuthAccount, + _storage: AccountStorage, + options?: { force?: boolean }, + ) => { + const credential = await authorizeOAuth( + account.id, + undefined, + options?.force ? account.access : undefined, + ) + return { + ...account, + access: credential.accessToken, + expires: credential.expires, + } + }, + refreshAccountQuota: async ( + account: OAuthAccount, + _storage: AccountStorage, + ) => { + assertNativeEnvironment() + const quota = await nativeAccounts.fetchQuota(account.id) + return { account: { ...account, quota }, fetched: true } + }, + getUsableFallbackAccounts: async ( + storage: AccountStorage | null | undefined, + options?: { modelId?: string }, + ) => { + storage = storage ?? null + const usable: OAuthAccount[] = [] + for (const account of storage?.accounts ?? []) { + if (!isOAuthAccount(account) || account.enabled === false) continue + const quota = + quotaManager.getAllFallbacks().get(account.id)?.quota ?? account.quota + if ( + !quotaSnapshotPassesPolicy(quota, storage) || + !quotaSnapshotPassesModelScope(quota, options?.modelId) || + (isKillswitchEnabled(storage) && + !killswitchPassesPolicy( + quota, + storage, + account.id, + options?.modelId, + )) + ) + continue + try { + const credential = await authorizeOAuth( + account.id, + undefined, + undefined, + options?.modelId, + ) + usable.push({ + ...account, + access: credential.accessToken, + expires: credential.expires, + }) + } catch { + // Do not use credentials that failed authorization. A failed or + // missing quota reading also cannot prove the account's general + // limits are exhausted and therefore cannot permit paid fallback. + } + } + return usable + }, + } + async function refreshNativeFallbacks() { + assertNativeEnvironment() + const snapshot = await nativeAccounts.read() + if ( + snapshot.mode === 'local' && + snapshot.policyStorage.refresh?.enabled !== false + ) { + for (const account of snapshot.accounts) { + if ( + account.id === 'main' || + account.type !== 'oauth' || + !account.enabled + ) + continue + try { + const credential = await authorizeOAuth(account.id) + if ( + credential.localSource !== 'rotated' && + credential.localSource !== 'adopted' && + (!credential.expires || + credential.expires - Date.now() <= + mainRefreshBeforeExpiryMs(snapshot.policyStorage)) + ) { + await authorizeOAuth( + account.id, + undefined, + undefined, + undefined, + 'refresh', + ) + } + } catch { + // The runtime records whether refresh failed temporarily or the + // provider rejected the refresh token with invalid_grant. A subsequent + // quota poll cannot repair that rejected token or clear its error. + } + } + } + await refreshFallbackQuotas() + } + const canStartNativeServices = + initialStorage !== null && process.env.OPENCODE_AUTH_CONTENT === undefined + const fallbackRefreshReady = canStartNativeServices + ? (isScopedCustodyActive(initialStorage) + ? nativeAccounts.vault.refresh().then(() => refreshNativeFallbacks()) + : refreshNativeFallbacks() + ).catch(() => {}) + : Promise.resolve() + const fallbackRefreshTimer = canStartNativeServices + ? runtimeTimers.setInterval(() => { + void refreshNativeFallbacks().catch(() => {}) + }, FALLBACK_BACKGROUND_TICK_MS + + jitterMs(MAIN_AUTH_REFRESH_TICK_JITTER_MS)) + : null + fallbackRefreshTimer?.unref?.() + const rosterRefreshTimer = + canStartNativeServices && + isScopedCustodyActive(initialStorage) && + runtimeOverrides.scopedRosterPollIntervalMs !== 0 + ? runtimeTimers.setInterval(() => { + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) return + void nativeAccounts.vault + .refresh() + .then(() => refreshSidebarQuota()) + .catch(() => {}) + }, runtimeOverrides.scopedRosterPollIntervalMs ?? 2000) + : null + rosterRefreshTimer?.unref?.() + const fallbackCustodyStateFor = ( + accountId: string, + storage: AccountStorage | null, + ): Exclude => { + const state = custodyStateFor({ id: accountId, role: 'fallback' }, storage) + return state === 'na' ? 'off' : state + } + const cacheDiagnosticsTracker = new CacheDiagnosticsTracker() + const cacheDiagnosticsBetaTracker = new CacheDiagnosticsBetaTracker() + type CacheDiagnosticsResponse = { + request?: CacheDiagnosticsRequestContext + trackSessionId?: string + source: CacheDiagnosticsSource + accountId: string + synthetic: boolean + betasHash: string + betas: string[] + requestedModel?: string + dump: DumpHandle | null + status: number + streaming: boolean + dumpWrite: Promise messageStart?: Record } const cacheDiagnosticsResponses = new WeakMap< @@ -2314,7 +2333,9 @@ const anthropicAuthPlugin = async ( cacheDiagnosticsResponses.set(response, { ...input, dumpWrite }) } - let latestRefreshMainAccessToken: (() => Promise) | null = null + let latestGetActivation: + | (() => Promise<{ type: string; active: boolean }>) + | null = null const cacheKeepRegistry = new CacheKeepSessionRegistry({ directory: process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR || @@ -2323,7 +2344,7 @@ const anthropicAuthPlugin = async ( let aggregateCacheKeepSessions: ReturnType< CacheKeepManager['trackedSessions'] > = [] - const cacheKeepScopedAttempts = new Map() + const cacheKeepScopedAttempts = new Map() const cacheKeepManager = new CacheKeepManager({ loadStorage: () => loadAccounts(accountStoragePath), setIntervalImpl: runtimeTimers.setInterval, @@ -2373,13 +2394,9 @@ const anthropicAuthPlugin = async ( receivedAt, attempt, }) => { - const receipt = cacheKeepScopedAttempts.get(attempt.id) - if (status === 401 && receipt) { - await getOpenCodeScopedRuntime().reportFailure( - receipt, - status, - 'direct', - ) + const authorization = cacheKeepScopedAttempts.get(attempt.id) + if (status === 401 && authorization?.scopedAttempt) { + await reportVault401(authorization.scopedAttempt, 'direct') } const prepared = cacheKeepDiagnosticsRequests.get(target.id) if (!prepared?.betasHash || !prepared.betas) { @@ -2429,94 +2446,64 @@ const anthropicAuthPlugin = async ( retryOnUnauthorized: async ({ target, headers, attempt }) => { const served = cacheKeepScopedAttempts.get(attempt.id) if (!served) return undefined - let current: ClaustrumScopedAttempt | undefined + let modelId: string | undefined try { - current = await getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ).authorize(target.oauthAccountId ?? 'main', attempt.signal) - } catch { - // No verified replacement: fall through so the decision records - // reauthorize-failed, then keep the original 401. - } - if (!decideScopedRetryAfter401('cachekeep', served, current)) + const body = JSON.parse(target.bodyText) + if (typeof body.model === 'string') modelId = body.model + } catch {} + if ( + served.scopedAttempt && + !(await prepareVaultRetry(served.scopedAttempt, attempt.signal)) + ) return undefined - logger.info( - 'claustrum', - 'retrying CacheKeep after scoped credential rotation', - { - accountId: target.oauthAccountId ?? 'main', - previousVersion: served.recordVersion, - newVersion: current.recordVersion, - }, + const current = await authorizeOAuth( + target.oauthAccountId ?? 'main', + attempt.signal, + served.scopedAttempt ? undefined : served.accessToken, + modelId, + ) + if ( + current.accountIdentity !== served.accountIdentity || + Boolean(current.scopedAttempt) !== Boolean(served.scopedAttempt) ) + return undefined + if ( + served.scopedAttempt && + (!current.scopedAttempt || + current.scopedAttempt.credentialId !== + served.scopedAttempt.credentialId || + current.scopedAttempt.recordVersion <= + served.scopedAttempt.recordVersion) + ) + return undefined + if (!served.scopedAttempt && current.accessToken === served.accessToken) + return undefined const rotatedHeaders = new Headers(headers) rotatedHeaders.set('authorization', `Bearer ${current.accessToken}`) cacheKeepScopedAttempts.set(attempt.id, current) return rotatedHeaders }, prepareHeaders: async (headers, target, attempt) => { - let accessToken: string | undefined - let scopedAttempt: ClaustrumScopedAttempt | undefined - const accountId = target.oauthAccountId - if (accountId && accountId !== 'main') { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && - candidate.enabled !== false && - isOAuthAccount(candidate), - ) - if (!account || !storage) { - throw new Error( - `OAuth account ${accountId} is unavailable for cache prewarm`, - ) - } - if ( - isScopedCustodyActive(storage) && - account.claustrumScopedCredentialId - ) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - scopedAttempt = await runtime.authorize(accountId, attempt.signal) - accessToken = scopedAttempt.accessToken - } else if (getClaustrumMode(storage) === 'local') { - let current = account - try { - current = await fallbackManager.refreshAccount(account, storage) - } catch (error) { - logger.warn('cachekeep', 'fallback token refresh failed', { - accountId, - error: error instanceof Error ? error.message : String(error), - }) - } - accessToken = current.access - } else { - throw new Error('CacheKeep requires a scoped Claustrum account') - } - if (!accessToken) { - throw new Error( - `OAuth account ${accountId} has no access token for cache prewarm`, - ) - } - } else { - const credential = await getCurrentMainCredential(attempt.signal) - accessToken = credential.accessToken - scopedAttempt = credential.scopedAttempt - } - if (!accessToken) return headers + const accountId = target.oauthAccountId ?? 'main' + let modelId: string | undefined + try { + const body = JSON.parse(target.bodyText) + if (typeof body.model === 'string') modelId = body.model + } catch {} + const credential = await authorizeOAuth( + accountId, + attempt.signal, + undefined, + modelId, + ) + const accessToken = credential.accessToken + cacheKeepScopedAttempts.set(attempt.id, credential) try { const parsedBody = JSON.parse(target.bodyText) as Record< string, unknown > - const identity = await resolveClaudeCodeIdentity( - accessToken, - typeof parsedBody.model === 'string' ? parsedBody.model : undefined, - accountId === 'main' ? mainAccountId : accountId, - ) + const identity = nativeWireIdentity(credential.accountIdentity) headers.delete('anthropic-beta') setOAuthHeaders(headers, accessToken, { body: parsedBody, @@ -2538,59 +2525,48 @@ const anthropicAuthPlugin = async ( } catch { setOAuthHeaders(headers, accessToken) } - if (scopedAttempt) cacheKeepScopedAttempts.set(attempt.id, scopedAttempt) + + const finalCredential = await authorizeOAuth( + accountId, + attempt.signal, + undefined, + modelId, + ) + if ( + finalCredential.accountIdentity !== credential.accountIdentity || + Boolean(finalCredential.scopedAttempt) !== + Boolean(credential.scopedAttempt) + ) + throw new Error( + 'CacheKeep credential authority changed before dispatch', + ) + headers.set('authorization', `Bearer ${finalCredential.accessToken}`) + cacheKeepScopedAttempts.set(attempt.id, finalCredential) return headers }, }) const recoveryWarmChains = new Map>() - // -- /claude-prime manager: direct OAuth request to start each account's - // five-hour quota window ~60s after reset, exactly once across concurrent - // OpenCode processes via atomic marker claim. In-process manager mirrors the - // cacheKeep singleton shape; cross-process exclusivity is the marker's job. + // Prime sends a minimal Haiku request after the account's quota reset to + // start its next five-hour usage window. One shared manager and an atomic + // claim file prevent different projects or processes firing for the same reset. - // Resolve one current main credential for Prime. In Claustrum mode the - // OpenCode slot is an inert tombstone, so both quota checks and fires must - // use the resident vault credential and must never enter local refresh. + // OpenCode stores an empty OAuth marker to enable this provider, not a token + // that can authenticate requests. Prime and CacheKeep must still validate + // the current local credentials or freshly authorize them through the vault. async function getCurrentMainCredential(signal?: AbortSignal): Promise<{ accessToken: string credentialAccountId?: ProviderAccountUuid - scopedAttempt?: ClaustrumScopedAttempt + scopedAttempt?: NativeCustodyReceipt + localSubject?: NativeKnownCredentialSubject }> { - if (!latestGetAuth) { - throw new Error('prime: main auth loader is not available') - } - const auth = await latestGetAuth() - if (auth.type !== 'oauth') { - throw new Error('prime: main account is not an OAuth account') - } - const storage = await loadAccounts(accountStoragePath) - if (getClaustrumMode(storage) === 'claustrum') { - if (!isScopedCustodyActive(storage)) - throw new Error('Scoped Claustrum custody is not configured') - const attempt = await getOpenCodeScopedRuntime().authorize('main', signal) - return { - accessToken: attempt.accessToken, - credentialAccountId: asProviderAccountUuid(attempt.accountId), - scopedAttempt: attempt, - } - } - if (auth.access && (!auth.expires || auth.expires > Date.now())) { - return { accessToken: auth.access } - } - if (!latestRefreshMainAccessToken) { - throw new Error('prime: main token refresh unavailable') - } - try { - return { accessToken: await latestRefreshMainAccessToken() } - } catch (error) { - if (error instanceof Error) { - ;( - error as Error & { isPrimeTokenRefresh?: boolean } - ).isPrimeTokenRefresh = true - } - throw error + if (!latestGetActivation || !(await latestGetActivation()).active) + throw new Error('Native main OAuth activation is not available') + const credential = await authorizeOAuth('main', signal) + return { + ...credential, + credentialAccountId: asProviderAccountUuid(credential.accountIdentity), } } @@ -2604,9 +2580,9 @@ const anthropicAuthPlugin = async ( 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. + // The usage poll independently validates local credentials or obtains vault + // authorization. Report a 401 against the tokens used for that actual poll, + // not the tokens obtained above to resolve the main account's identity. const result = await quotaManager.refreshMainWithMetadata( mainQuotaAccountId, credential.accessToken, @@ -2617,6 +2593,7 @@ const anthropicAuthPlugin = async ( async function refreshPrimeFallbackQuota( accountId: string, ): Promise { + assertNativeEnvironment() const storage = await loadAccounts(accountStoragePath) const account = storage?.accounts.find( (candidate): candidate is OAuthAccount => @@ -2627,101 +2604,53 @@ const anthropicAuthPlugin = async ( if (!account || !storage) { throw new Error(`prime: OAuth account ${accountId} is unavailable`) } - // Reuse the same fallback-manager refresh path used by the - // background quota refresh — it persists the refreshed quota to the - // state file (M1 persist requirement) and is the SINGLE usage-API - // call per tick for fallback fresh-checks. The redundant - // `quotaManager.refreshFallback` call was collapsed (R2). + // This poll stores current quota for the account and tokens used to fetch it. + // Prime uses that reading to decide whether to start the next window; + // sendPrime sends the minimal Haiku request without repeating this poll. const refreshed = await fallbackManager.refreshAccountQuota( account, storage, ) - await fallbackManager.save(storage, [accountId]) + return { quota: refreshed.account.quota ?? {}, fresh: refreshed.fetched, } } + const primePhysicalAttempts = new Map() async function sendPrime( accountId: 'main' | string, ): Promise { const start = performance.now() let accessToken: string | undefined let resolvedModel: string | undefined - let scopedAttempt: ClaustrumScopedAttempt | undefined + let scopedAttempt: NativeCustodyReceipt | undefined try { - if (accountId === 'main') { - // Use the same refresh path the fresh-check uses, so a missing or - // expired access token is refreshed BEFORE the request — not used - // as-is. The previous early-return-on-!auth.access made the - // refresh arm unreachable (M2). - try { - const credential = await getCurrentMainCredential() - accessToken = credential.accessToken - scopedAttempt = credential.scopedAttempt - } catch (error) { - const isTokenRefresh = - error instanceof Error && - (error as Error & { isPrimeTokenRefresh?: boolean }) - .isPrimeTokenRefresh === true - return { - ok: false, - ...(isTokenRefresh && { reason: 'token-refresh' as const }), - error: error instanceof Error ? error.message : String(error), - } - } - resolvedModel = CLAUDE_HAIKU_4_5_MODEL_ID - } else { - const storage = await loadAccounts(accountStoragePath) - const account = storage?.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === accountId && - candidate.enabled !== false && - isOAuthAccount(candidate), - ) - if (!account || !storage) { - return { - ok: false, - error: `prime: OAuth account ${accountId} is unavailable`, - } - } - if (isScopedCustodyActive(storage)) { - scopedAttempt = await getOpenCodeScopedRuntime().authorize(account.id) - accessToken = scopedAttempt.accessToken - } else if (getClaustrumMode(storage) === 'local') { - let current = account - try { - // R2: the fire path refreshes ONLY the token, not the quota. - // The fresh-check already performed the single usage-API - // call and persisted the result; the fire path reuses the - // in-memory quota via the headers / URL contract. This keeps - // the cycle at exactly one quota API call per account. - current = await fallbackManager.refreshAccount(account, storage) - } catch (error) { - return { - ok: false, - reason: 'token-refresh', - error: error instanceof Error ? error.message : String(error), - } - } - accessToken = current.access - } else { - throw new Error('Scoped Claustrum custody is not configured') - } - resolvedModel = CLAUDE_HAIKU_4_5_MODEL_ID - } + // The manager already checked current quota. Validate the account and + // tokens for this Haiku send without another quota poll. An upstream 401 + // may retry once with newer credentials for the same account. + if ( + accountId === 'main' && + (!latestGetActivation || !(await latestGetActivation()).active) + ) + throw new Error('Native main OAuth activation is not available') + const initialAuthorization = await authorizeOAuth( + accountId, + undefined, + undefined, + CLAUDE_HAIKU_4_5_MODEL_ID, + ) + accessToken = initialAuthorization.accessToken + scopedAttempt = initialAuthorization.scopedAttempt + resolvedModel = CLAUDE_HAIKU_4_5_MODEL_ID if (!accessToken) { return { ok: false, error: 'prime: no access token available' } } const primeBody = buildPrimeRequestBody() - const identity = await resolveClaudeCodeIdentity( - accessToken, - resolvedModel, - accountId === 'main' ? mainAccountId : accountId, - ) + const identity = nativeWireIdentity(initialAuthorization.accountIdentity) const body = await rewriteRequestBody(JSON.stringify(primeBody), { identity, }) @@ -2744,19 +2673,34 @@ const anthropicAuthPlugin = async ( primeRequest.url?.toString() ?? primeRequest.input.toString() const signal = AbortSignal.timeout(30_000) const primeInit = { method: 'POST', headers, body, signal } + let physicalAuthorization = await authorizeOAuth( + accountId, + signal, + undefined, + resolvedModel, + ) + if ( + physicalAuthorization.accountIdentity !== + initialAuthorization.accountIdentity || + Boolean(physicalAuthorization.scopedAttempt) !== + Boolean(initialAuthorization.scopedAttempt) + ) + throw new Error('Prime credential authority changed before dispatch') + scopedAttempt = physicalAuthorization.scopedAttempt + headers.set( + 'authorization', + `Bearer ${physicalAuthorization.accessToken}`, + ) let response = await fetch(primeUrl, primeInit) if (response.status === 401 && scopedAttempt && !signal.aborted) { - let current: ClaustrumScopedAttempt | undefined + let current: NativeCustodyReceipt | undefined try { - current = await getOpenCodeScopedRuntime().authorize( - accountId, - signal, - ) + current = await prepareVaultRetry(scopedAttempt, signal) } catch { // The first 401 belongs to the original physical attempt unless // the vault confirms a newer version of this same account. } - if (decideScopedRetryAfter401('prime', scopedAttempt, current)) { + if (current) { await response.body?.cancel().catch(() => {}) logger.info( 'claustrum', @@ -2767,8 +2711,61 @@ const anthropicAuthPlugin = async ( newVersion: current.recordVersion, }, ) - scopedAttempt = current - headers.set('authorization', `Bearer ${current.accessToken}`) + physicalAuthorization = await authorizeOAuth( + accountId, + signal, + undefined, + resolvedModel, + ) + const successor = physicalAuthorization.scopedAttempt + if ( + !successor || + successor.credentialId !== scopedAttempt.credentialId || + successor.accountIdentity !== scopedAttempt.accountIdentity || + successor.recordVersion <= scopedAttempt.recordVersion + ) + throw new Error('Prime vault retry lost its strict newer receipt') + scopedAttempt = successor + headers.set('authorization', `Bearer ${successor.accessToken}`) + response = await fetch(primeUrl, primeInit) + } + } + if ( + response.status === 401 && + !physicalAuthorization.scopedAttempt && + !signal.aborted + ) { + const rejectedAccess = physicalAuthorization.accessToken + const replacement = await authorizeOAuth( + accountId, + signal, + rejectedAccess, + resolvedModel, + ).catch(() => undefined) + if ( + replacement && + replacement.accessToken !== rejectedAccess && + replacement.accountIdentity === + initialAuthorization.accountIdentity && + !replacement.scopedAttempt + ) { + await response.body?.cancel().catch(() => {}) + physicalAuthorization = await authorizeOAuth( + accountId, + signal, + undefined, + resolvedModel, + ) + if ( + physicalAuthorization.scopedAttempt || + physicalAuthorization.accountIdentity !== + initialAuthorization.accountIdentity + ) + throw new Error('Prime local authority changed before retry') + headers.set( + 'authorization', + `Bearer ${physicalAuthorization.accessToken}`, + ) response = await fetch(primeUrl, primeInit) } } @@ -2777,11 +2774,7 @@ const anthropicAuthPlugin = async ( const reason = (await response.text().catch(() => '')) || `HTTP ${response.status}` if (response.status === 401 && scopedAttempt) { - await getOpenCodeScopedRuntime().reportFailure( - scopedAttempt, - 401, - 'direct', - ) + await reportVault401(scopedAttempt, 'direct') } return { ok: false, status: response.status, ms, error: reason } } @@ -2805,10 +2798,12 @@ const anthropicAuthPlugin = async ( : undefined, } : undefined + primePhysicalAttempts.set(accountId, physicalAuthorization) return { ok: true, status: response.status, ms, ...(usage && { usage }) } } catch (error) { return { ok: false, + ...(!accessToken && { reason: 'token-refresh' as const }), ms: Math.round(performance.now() - start), error: error instanceof Error ? error.message : String(error), } @@ -2819,9 +2814,17 @@ const anthropicAuthPlugin = async ( storagePath: accountStoragePath, loadStorage: () => loadAccounts(accountStoragePath), getAccountFingerprint: async (accountId) => { - const authLineageId = await getOrCreatePrimeAuthLineageId( + if ( + accountId === 'main' && + (!latestGetActivation || !(await latestGetActivation()).active) + ) + return undefined + const credential = await authorizeOAuth(accountId) + const fence = credential.scopedAttempt ?? credential.localSubject + if (!fence) return undefined + const authLineageId = await nativeAccounts.getOrCreateAuthLineage( accountId, - accountStoragePath, + fence, ) return authLineageId ? tokenFingerprint(authLineageId) : undefined }, @@ -2830,8 +2833,27 @@ const anthropicAuthPlugin = async ( return refreshPrimeFallbackQuota(accountId) }, sendPrime, - recordSuccess: (accountId, usage) => - incrementPrimeUsagePersistent(accountId, usage, accountStoragePath), + recordSuccess: async (accountId, usage) => { + const credential = primePhysicalAttempts.get(accountId) + primePhysicalAttempts.delete(accountId) + const fence = credential?.scopedAttempt ?? credential?.localSubject + if ( + !fence || + !(await nativeAccounts.incrementPrimeUsage(accountId, fence, usage)) + ) { + throw new Error( + 'Prime usage publication lost its native credential fence', + ) + } + const current = (await nativeAccounts.read()).accounts.find( + (account) => account.id === accountId, + )?.prime + if (!current) + throw new Error( + 'Prime usage counters are unavailable after publication', + ) + return current + }, setIntervalImpl: runtimeTimers.setInterval, clearIntervalImpl: runtimeTimers.clearInterval, setTimeoutImpl: runtimeTimers.setTimeout, @@ -2952,22 +2974,6 @@ const anthropicAuthPlugin = async ( } let rpcServerAdoption: RpcServerAdoption | null = null - if (ctx.directory) { - const rpcDir = getRpcDir(ctx.directory) - try { - rpcServerAdoption = await adoptRpcServer(rpcDir, () => - startRpcServer({ - dir: rpcDir, - drain: drainNotifications, - apply: applyCommand, - }), - ) - } catch (error) { - logger.warn('rpc', 'failed to start', { - error: error instanceof Error ? error.message : String(error), - }) - } - } const dispose: NonNullable = async () => { try { closeOpenCodeScopedRuntime(accountStoragePath) @@ -3001,7 +3007,9 @@ const anthropicAuthPlugin = async ( }) } try { - fallbackManager.stopBackgroundRefresh() + if (fallbackRefreshTimer) + runtimeTimers.clearInterval(fallbackRefreshTimer) + if (rosterRefreshTimer) runtimeTimers.clearInterval(rosterRefreshTimer) } catch (error) { logger.warn('fallback-background', 'failed to stop', { error: error instanceof Error ? error.message : String(error), @@ -3058,50 +3066,9 @@ const anthropicAuthPlugin = async ( mainAccessToken?: string mainRefreshToken?: string routingAuthoritative?: boolean - useHydratedProfiles?: boolean skipFallbackQuotaSeed?: boolean } - function mergeHydratedProfilesForSidebar( - latest: Awaited>, - hydrated: Awaited>, - mainAccessToken?: string, - ) { - if (!latest || !hydrated) return latest - const merged: AccountStorage = { - ...latest, - accounts: latest.accounts.map((account) => { - const hydratedAccount = hydrated.accounts.find( - (candidate) => candidate.id === account.id, - ) - if ( - !isOAuthAccount(account) || - !hydratedAccount || - !isOAuthAccount(hydratedAccount) || - !account.access || - hydratedAccount.access !== account.access - ) { - return account - } - return { ...account, profile: hydratedAccount.profile } - }), - } - const latestMainProfile = latest.main?.profile - const mainState = latest.main ?? hydrated.main - if ( - mainAccessToken && - mainState && - (!latestMainProfile || - oauthProfileMatchesIdentity(latestMainProfile, mainAccountId)) - ) { - merged.main = { - ...mainState, - profile: hydrated.main?.profile, - } - } - return merged - } - function buildSidebarState( storage: Awaited>, options: SidebarStateInput, @@ -3178,15 +3145,10 @@ const anthropicAuthPlugin = async ( }), activeId: options.activeId, route: options.route, - relay: (() => { - const currentRelayConfig = getRelayConfig(storage) - return currentRelayConfig - ? { - enabled: true, - transport: currentRelayConfig.transport ?? 'http', - } - : null - })(), + relay: + storage?.relay?.enabled && storage.relay.url + ? { enabled: true, transport: storage.relay.transport ?? 'http' } + : null, fastMode: isFastModeEnabled(), cacheKeep: { enabled: isCacheKeepHybridActive(storage), @@ -3235,13 +3197,10 @@ const anthropicAuthPlugin = async ( () => loadAccounts(accountStoragePath), { activeId: state.activeId, route: state.route }, ) - const displayStorage = options.useHydratedProfiles - ? mergeHydratedProfilesForSidebar( - preservedRouting.freshStorage, - storage, - options.mainAccessToken, - ) - : preservedRouting.freshStorage + // Display the profile from current storage. A route may have been + // replaced while polling, so the earlier fetched profile could + // describe a different account. + const displayStorage = preservedRouting.freshStorage return { activeId: preservedRouting.activeId, route: preservedRouting.route, @@ -3361,159 +3320,59 @@ const anthropicAuthPlugin = async ( return storage?.refresh?.enabled !== false } - async function clearStaleMainRefreshError( - accountIdentity?: string, - currentRefreshTokenFingerprint?: string, - ) { - if (!accountIdentity) return - const storage = await loadAccounts(accountStoragePath) - const error = storage?.refresh?.mainLastRefreshError - if (!storage?.refresh || !error) return - const identityChanged = - Boolean(error.accountIdentity) && - error.accountIdentity !== accountIdentity - const refreshTokenChanged = - Boolean(error.refreshTokenFingerprint) && - Boolean(currentRefreshTokenFingerprint) && - error.refreshTokenFingerprint !== currentRefreshTokenFingerprint - if (!identityChanged && !refreshTokenChanged) return - storage.refresh.mainLastRefreshError = undefined - storage.refresh.mainRefreshErrorClearedAt = mergeMainRefreshErrorClearedAt( - storage.refresh.mainRefreshErrorClearedAt, - Date.now(), - ) - if (!mainQuotaIdentityAccessToken?.startsWith('sk-ant-oat')) { - const quotaError = storage.quota?.mainLastQuotaApiError - if (quotaError && quotaError.accountIdentity !== mainQuotaAccountId) { - const clearedGeneration = quotaManager.clearMainBackoff() - storage.quota = storage.quota ?? {} - storage.quota.mainLastQuotaApiError = undefined - storage.quota.mainQuotaErrorGeneration = Math.max( - storage.quota.mainQuotaErrorGeneration ?? 0, - clearedGeneration ?? quotaManager.getMainQuotaErrorGeneration(), - ) - storage.quota.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt( - storage.quota.mainQuotaErrorClearedAt, - quotaManager.getMainQuotaErrorClearedAt() ?? Date.now(), - ) + async function buildQuotaCommandSummary(refresh = false) { + const errors = new Map() + if (refresh) { + // Only the explicit refresh action polls usage. Opening the menu reads + // saved account metadata and cannot start credential recovery or a poll. + if (latestGetActivation && (await latestGetActivation()).active) { + try { + await nativeAccounts.fetchQuota('main') + } catch { + errors.set('main', 'Native primary usage refresh unavailable') + } } + await refreshFallbackQuotas(true) } - await saveAccountState(storage, accountStoragePath, { - mainRefresh: true, - ...(!mainQuotaIdentityAccessToken?.startsWith('sk-ant-oat') && { - mainQuota: true, - }), - }) - log( - '[refresh] opencode main oauth cleared stale backoff after token rotation', - { - previousCheckedAt: error.checkedAt, - previousNextRetryAt: error.nextRetryAt, - previousRetryCount: error.retryCount, - }, + const snapshot = await nativeAccounts.read() + quotaManager.updateStorage(snapshot.policyStorage) + quotaManager.seedMainFromStorage(snapshot.policyStorage, mainQuotaAccountId) + quotaManager.seedFallbacksFromAccounts( + snapshot.policyStorage.accounts.filter(isOAuthAccount), + ) + const accounts: QuotaAccountSummary[] = [] + const main = snapshot.accounts.find( + (account) => account.id === 'main' && account.type === 'oauth', ) + if (main) + accounts.push({ + name: main.label ?? 'OpenCode anthropic', + role: 'main', + quota: main.quota, + tierLabel: formatOAuthAccountTier(main.profile), + error: errors.get('main') ?? main.lastQuotaRefreshError?.message, + }) + accounts.push( + ...buildFallbackQuotaSummaries(snapshot.policyStorage, errors), + ) + const checkedAt = Math.max( + 0, + ...snapshot.accounts.map((account) => + quotaSnapshotCheckedAt(account.quota), + ), + ) + return buildClaudeQuotaSummary({ + accounts, + refreshedAt: checkedAt || undefined, + }) } - async function buildQuotaCommandSummary() { - const accounts: QuotaAccountSummary[] = [] - let mainAccessToken: string | undefined + async function refreshSidebarAfterMutation( + updatedStorage: AccountStorage | null, + ) { if (latestGetAuth) { try { - const auth = await latestGetAuth() - // 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 - 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. - const quota = await quotaManager.refreshMain( - mainQuotaAccountId, - commandAccessToken, - ) - accounts.push({ - name: 'OpenCode anthropic', - role: 'main', - quota, - }) - } else if (auth.type === 'oauth') { - accounts.push({ - name: 'OpenCode anthropic', - role: 'main', - error: - 'missing access token; send a request first or reconnect auth', - }) - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - accounts.push({ - name: 'OpenCode anthropic', - role: 'main', - error: msg.includes('429') - ? 'Usage API rate limited — try again in a moment' - : msg, - }) - } - } - - // Force a real fallback refresh and PERSIST it to anthropic-auth.json. - // refreshQuotaForAllAccounts({ force: true }) bypasses the staleness skip, - // routes through the unified QuotaManager path (429 backoff respected - // per-account), saves refreshed snapshots, and clears stale errors. - const { storage, errors } = - await fallbackManager.refreshQuotaForAllAccounts({ force: true }) - const displayStorage = await ensureProfilesForQuotaDisplay( - storage ?? createEmptyStorage(), - mainAccessToken, - AbortSignal.timeout(3_000), - mainProviderAccountUuid, - ) - const mainSummary = accounts.find((account) => account.role === 'main') - if (mainSummary) { - mainSummary.tierLabel = formatOAuthAccountTier( - displayStorage.main?.profile, - ) - } - const errorMap = new Map(errors.map((e) => [e.accountId, e.message])) - accounts.push(...buildFallbackQuotaSummaries(displayStorage, errorMap)) - - if (!latestGetAuth) { - accounts.unshift({ - name: 'OpenCode anthropic', - role: 'main', - error: 'auth loader has not run yet; send a request first', - }) - } - - return buildClaudeQuotaSummary({ accounts, refreshedAt: Date.now() }) - } - - async function refreshSidebarAfterMutation( - updatedStorage: AccountStorage | null, - ) { - if (latestGetAuth) { - try { - await getAllTrackedCacheKeepSessions().catch(() => {}) + await getAllTrackedCacheKeepSessions().catch(() => {}) const auth = await latestGetAuth() await writeSidebarState(updatedStorage, { activeId: lastSidebarRouting.activeId, @@ -4047,8 +3906,9 @@ const anthropicAuthPlugin = async ( async function executePersistentAccountCommand( argumentsText: string, sessionId?: string, + checkedAction?: ReturnType, ) { - const action = parseAccountCommandAction(argumentsText) + const action = checkedAction ?? parseAccountCommandAction(argumentsText) // -- add-apikey -------------------------------------------------------- if (action.type === 'add-apikey') { @@ -4084,7 +3944,13 @@ const anthropicAuthPlugin = async ( addedAt: now, lastUsed: now, } - await addAccountPersistent(account, accountStoragePath) + await nativeAccounts.addApi({ + routeId: account.id, + apiKey: action.apiKey, + label: account.label, + baseURL: resolvedBaseURL, + authHeader: resolvedAuthHeader, + }) logger.info('commands', 'account added', { id: account.id, label: account.label, @@ -4176,7 +4042,16 @@ const anthropicAuthPlugin = async ( lastUsed: now, lastRefreshedAt: now, } - await addAccountPersistent(account, accountStoragePath) + assertNativeEnvironment() + await nativeAccounts.loginOAuth({ + routeId: account.id, + label: account.label, + credential: { + access: result.access, + refresh: result.refresh, + expires: result.expires, + }, + }) logger.info('commands', 'account added', { id: account.id, label: account.label, @@ -4311,44 +4186,9 @@ const anthropicAuthPlugin = async ( label: account?.label, }) } else if (result.updated.action === 'reset-backoff') { - const mainIdentity = mainAccountId ?? storage?.mainAccountId - storage = storage ?? createEmptyStorage() - storage.refresh = storage.refresh ?? {} - storage.refresh.mainLastRefreshError = undefined - storage.refresh.mainRefreshErrorClearedAt = - mergeMainRefreshErrorClearedAt( - storage.refresh.mainRefreshErrorClearedAt, - Date.now(), - ) - const quotaError = storage.quota?.mainLastQuotaApiError - if ( - !quotaError?.accountIdentity || - !mainIdentity || - quotaError.accountIdentity === mainIdentity - ) { - const clearedGeneration = quotaManager.clearMainBackoff() - storage.quota = storage.quota ?? {} - storage.quota.mainLastQuotaApiError = undefined - storage.quota.mainQuotaErrorGeneration = Math.max( - storage.quota.mainQuotaErrorGeneration ?? 0, - clearedGeneration ?? quotaManager.getMainQuotaErrorGeneration(), - ) - storage.quota.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt( - storage.quota.mainQuotaErrorClearedAt, - quotaManager.getMainQuotaErrorClearedAt() ?? Date.now(), - ) - } - await saveAccountState(storage, accountStoragePath, { - mainRefresh: true, - mainQuota: true, - }) - logger.info( - 'commands', - 'main OAuth refresh and quota backoff cleared', - { - mainIdentity, - }, - ) + await resetNativeBackoff('main') + quotaManager.clearMainBackoff() + logger.info('commands', 'native main refresh and quota backoff cleared') } const updatedStorage = await loadAccounts(accountStoragePath) @@ -4572,13 +4412,275 @@ const anthropicAuthPlugin = async ( } } - async function applyCommand(request: ApplyRequest): Promise { + async function applyCommand(_request: ApplyRequest): Promise { + // Old dialog requests return guidance instead of changing accounts or + // settings. The /claude menu requires confirmation and checks which + // actions are allowed without an interactive UI before dispatching them. + return { text: 'Open /claude to manage native Claude settings.', knobs: {} } + } + + const dispatchNative: NativeMenuDispatch = async (request, options) => { + if (options.signal?.aborted) + throw options.signal.reason ?? new Error('Menu action aborted') + let text: string + switch (request.action) { + case 'enable': + case 'disable': + await nativeAccounts.setEnabled( + request.values.id, + request.action === 'enable', + ) + text = `Account ${request.values.id} ${request.action}d.` + break + case 'remove': + await nativeAccounts.remove(request.values.id) + text = `Account ${request.values.id} removed.` + break + case 'move-up': + case 'move-down': { + const order = (await nativeAccounts.read()).accounts + .filter((account) => account.id !== 'main') + .map((account) => account.id) + const index = order.indexOf(request.values.id) + if (index < 0) throw new Error('Native account not found') + const target = index + (request.action === 'move-up' ? -1 : 1) + if (target >= 0 && target < order.length) { + const neighbor = order[target] + if (neighbor === undefined) + throw new Error('Native account order changed') + order[target] = request.values.id + order[index] = neighbor + await nativeAccounts.reorder(order) + } + text = 'Native account order updated.' + break + } + case 'add-apikey': + await nativeAccounts.addApi({ ...request.values }) + text = 'Native API fallback route added.' + break + case 'add-oauth-start': + text = ( + await executePersistentAccountCommand('', request.sessionId, { + type: 'add-oauth-start', + }) + ).text + break + case 'add-oauth-finish': + text = ( + await executePersistentAccountCommand('', request.sessionId, { + type: 'add-oauth-finish', + ...request.values, + }) + ).text + break + case 'reset-backoff': { + await resetNativeBackoff('main', options.signal) + quotaManager.clearMainBackoff() + text = + 'Native main refresh and quota backoff cleared; credential validation and quarantine are unchanged.' + break + } + case 'enrollment-reset': + text = (await executePersistentAccountCommand('enrollment-reset')).text + break + case 'quota-refresh': + text = await buildQuotaCommandSummary(true) + break + case 'routing-mode': + text = await executePersistentRoutingCommand(request.values.mode) + break + case 'routing-reset': + text = await executePersistentRoutingCommand('reset', request.sessionId) + break + case 'cache-on': + case 'cache-off': + text = await executePersistentCache1hCommand( + request.action === 'cache-on' ? 'on' : 'off', + ) + break + case 'cache-mode': + text = await executePersistentCache1hCommand( + `mode ${request.values.mode}`, + ) + break + case 'cachekeep-always': + text = await executePersistentCacheKeepCommand('always') + break + case 'cachekeep-off': + text = await executePersistentCacheKeepCommand('off') + break + case 'cachekeep-window': + text = await executePersistentCacheKeepCommand( + `${request.values.startHour}-${request.values.endHour}`, + ) + break + case 'cachekeep-subagents': + text = await executePersistentCacheKeepCommand( + `subagents ${request.values.enabled}`, + ) + break + case 'dump-on': + case 'dump-off': + text = await executePersistentDumpCommand( + request.action === 'dump-on' ? 'on' : 'off', + ) + break + case 'fast-on': + case 'fast-off': + text = await executePersistentFastModeCommand( + request.action === 'fast-on' ? 'on' : 'off', + ) + break + case 'prime-on': + case 'prime-off': + text = await executePersistentPrimeCommand( + request.action === 'prime-on' ? 'on' : 'off', + ) + break + case 'logging-level': + text = await executePersistentLoggingCommand(request.values.level) + break + case 'killswitch-on': + case 'killswitch-off': + case 'killswitch-set': { + const snapshot = await nativeAccounts.read() + const accountIds = snapshot.accounts + .filter((account) => account.id !== 'main') + .map((account) => account.id) + await nativeAccounts.updateSettings((settings) => { + const config = getKillswitchConfig({ + ...settings, + version: 1, + accounts: [], + }) + if (request.action !== 'killswitch-set') { + const result = executeKillswitchCommand({ + argumentsText: request.action === 'killswitch-on' ? 'on' : 'off', + config, + accountIds, + }) + return { ...settings, killswitch: result.updatedConfig ?? config } + } + const accounts = { ...config.accounts } + const updated: NonNullable = { + ...config, + enabled: true, + accounts, + } + for (const entry of request.values.entries) { + const thresholds = { + five_hour: entry.fh, + seven_day: entry.sd, + ...(entry.scoped === undefined ? {} : { scoped: entry.scoped }), + } + if (entry.account === 'main' || entry.account === 'all') + updated.main = thresholds + if (entry.account === 'all') { + for (const id of accountIds) accounts[id] = thresholds + } else if (entry.account !== 'main') + accounts[entry.account] = thresholds + } + return { ...settings, killswitch: updated } + }) + text = 'Native killswitch policy updated.' + break + } + case 'start-fire': + text = await executePersistentStartCommand('', request.sessionId) + break + default: { + const unsupported: never = request + throw new Error(`Unsupported native menu action: ${unsupported}`) + } + } + await refreshSidebarAfterMutation(await loadAccounts()) + return { + ok: true, + text: text + .split('\n') + .filter((line) => !line.includes('/claude-')) + .join('\n'), + } + } + + const readNativeStatus: NativeUiOptions['readStatus'] = async ( + command, + invocation, + ) => { + if (command === 'account') { + const snapshot = await nativeAccounts.read() + const enrollment = + snapshot.mode === 'claustrum' + ? await readClaustrumEnrollmentStatus( + getHostClaustrumEnrollmentPaths('opencode'), + CLAUSTRUM_OPENCODE_ENROLLMENT_NAME, + ) + : undefined + return [ + `Custody mode: ${snapshot.mode}`, + ...(enrollment + ? formatEnrollmentStatus( + enrollment, + snapshot.accounts.some( + (account) => + account.id === 'main' && + account.source === 'vault' && + account.enabled, + ), + ) + : []), + ...snapshot.accounts.map( + (account) => + `${account.label ?? account.id} [${account.id === 'main' ? 'main' : 'fallback'}] ${account.enabled ? 'enabled' : 'disabled'} — ${account.source}${account.state ? ` (${account.state})` : ''}${formatOAuthAccountTier(account.profile) ? ` — ${formatOAuthAccountTier(account.profile)}` : ''}`, + ), + ].join('\n') + } + const modalNames = { + account: 'claude-account', + quota: 'claude-quota', + routing: 'claude-routing', + killswitch: 'claude-killswitch', + cache: 'claude-cache', + cachekeep: 'claude-cachekeep', + dump: 'claude-dump', + logging: 'claude-logging', + fast: 'claude-fast', + prime: 'claude-prime', + } satisfies Record< + Parameters[0], + CommandModalName + > const payload = await buildDialogPayload( - request.command, - request.arguments, - request.sessionId, + modalNames[command], + '', + invocation.sessionId, ) - return { text: payload.text, knobs: payload.knobs } + return payload.text + .split('\n') + .filter((line) => !line.includes('/claude-')) + .join('\n') + } + const nativeCommands = new Map< + string, + { interactive: boolean; command: ReturnType } + >() + function getNativeCommand(sessionId: string) { + const interactive = isTuiConnected(sessionId) + let entry = nativeCommands.get(sessionId) + if (!entry || entry.interactive !== interactive) { + entry = { + interactive, + command: createNativeCommand({ + host: 'opencode', + dispatch: dispatchNative, + readStatus: readNativeStatus, + interactive, + }), + } + nativeCommands.set(sessionId, entry) + } + return entry.command } function quotaBar(pct: number, width = 10): string { @@ -4684,6 +4786,23 @@ const anthropicAuthPlugin = async ( ?.catch?.(() => {}) } + if (ctx.directory) { + const rpcDir = getRpcDir(ctx.directory) + try { + rpcServerAdoption = await adoptRpcServer(rpcDir, () => + startRpcServer({ + dir: rpcDir, + drain: drainNotifications, + apply: applyCommand, + applyMenu: (request) => + getNativeCommand(request.sessionId ?? '').apply(request), + }), + ) + } catch { + logger.warn('rpc', 'native menu server unavailable') + } + } + return { 'experimental.chat.messages.transform': async ( _input: Record, @@ -4825,6 +4944,8 @@ const anthropicAuthPlugin = async ( } if (value.type === 'session.deleted') { + nativeCommands.delete(sessionId) + oauthPending.delete(sessionId) laneStartTracker.clearSession(sessionId) fableRecoveryNotices.delete(sessionId) pendingDesktopNotices.delete(sessionId) @@ -4841,63 +4962,15 @@ const anthropicAuthPlugin = async ( } }, config: async (config: { command?: Record }) => { + const commands = { ...(config.command ?? {}) } + // Retire only commands owned by this plugin; unrelated user commands stay. + for (const command of COMMAND_MODAL_NAMES) delete commands[command] config.command = { - ...(config.command ?? {}), - [CACHE_1H_COMMAND_NAME]: { - template: CACHE_1H_COMMAND_NAME, - description: - 'Show or toggle 1-hour Anthropic ephemeral prompt cache TTL.', - }, - [CLAUDE_ACCOUNT_COMMAND_NAME]: { - template: CLAUDE_ACCOUNT_COMMAND_NAME, - description: - 'Manage fallback accounts — list, enable/disable, reorder, remove, or add (API key or OAuth).', - }, - [CLAUDE_CACHE_KEEP_COMMAND_NAME]: { - template: CLAUDE_CACHE_KEEP_COMMAND_NAME, - description: - 'Keep hybrid Claude cache warm always or during a local time window.', - }, - [CLAUDE_PRIME_COMMAND_NAME]: { - template: CLAUDE_PRIME_COMMAND_NAME, + ...commands, + claude: { + template: 'claude', description: - "Start each OAuth account's five-hour quota window after reset.", - }, - [CLAUDE_START_COMMAND_NAME]: { - template: CLAUDE_START_COMMAND_NAME, - description: - 'Send a billed one-token synthetic turn to warm and renew the current Claude session cache.', - }, - - [CLAUDE_QUOTAS_COMMAND_NAME]: { - template: CLAUDE_QUOTAS_COMMAND_NAME, - description: - 'Show current Claude OAuth quota usage for all accounts.', - }, - [CLAUDE_DUMP_COMMAND_NAME]: { - template: CLAUDE_DUMP_COMMAND_NAME, - description: - 'Show or toggle Anthropic request dump capture for debugging.', - }, - [CLAUDE_FAST_COMMAND_NAME]: { - template: CLAUDE_FAST_COMMAND_NAME, - description: - 'Show or toggle Anthropic fast mode for supported Opus models.', - }, - [CLAUDE_LOGGING_COMMAND_NAME]: { - template: CLAUDE_LOGGING_COMMAND_NAME, - description: - 'Show or set the plugin log level (error, warn, info, debug, trace).', - }, - [CLAUDE_ROUTING_COMMAND_NAME]: { - template: CLAUDE_ROUTING_COMMAND_NAME, - description: - 'Show or change Claude account routing: main-first, fallback-first, or sticky-balanced.', - }, - [KILLSWITCH_COMMAND_NAME]: { - template: KILLSWITCH_COMMAND_NAME, - description: - 'Manage killswitch — hard-block requests when quota drops below per-account thresholds.', + 'Manage Claude accounts, quotas, routing, and cache settings.', }, } }, @@ -4936,37 +5009,32 @@ const anthropicAuthPlugin = async ( arguments: string sessionID: string }) => { - if (!COMMAND_MODAL_NAMES.includes(input.command as CommandModalName)) - return - const command = input.command as CommandModalName - const payload = await buildDialogPayload( - command, - input.arguments, + if (input.command !== 'claude') return + const payload = await getNativeCommand(input.sessionID).open( input.sessionID, ) - if (command === 'claude-quota') { - const cmdStorage = await loadAccounts() - const cmdAuth = latestGetAuth - ? await latestGetAuth().catch(() => undefined) - : undefined - writeSidebarState(cmdStorage, { - activeId: lastSidebarRouting.activeId, - route: lastSidebarRouting.route, - mainAccessToken: cmdAuth?.access, - routingAuthoritative: false, - }) - } if (isTuiConnected(input.sessionID)) { pushNotification(payload, input.sessionID) } else { - await sendIgnoredMessage(ctx, input.sessionID, payload.text) + const text = [ + payload.menu.title, + ...payload.menu.sections.flatMap((section) => [ + `\n## ${section.title}`, + ...section.lines, + ...section.items.map( + (item) => + `${item.label}${item.detail ? ` — ${item.detail}` : ''}`, + ), + ]), + ].join('\n') + await sendIgnoredMessage(ctx, input.sessionID, text) } cleanAbort() }, auth: { provider: 'anthropic', async loader( - getAuth: () => Promise<{ + hostGetAuth: () => Promise<{ type: string access?: string refresh?: string @@ -4974,8 +5042,109 @@ const anthropicAuthPlugin = async ( }>, _provider: { models: Record }, ) { + latestGetActivation = async () => { + const current = await hostGetAuth() + return { + type: current.type, + active: isCustodyTombstoneOAuth(current, 'anthropic'), + } + } + const activation = await hostGetAuth() + // Ordinary OpenCode API-key authentication remains unchanged. After + // migration, the OAuth marker only enables our provider. Each send must + // validate current local credentials or obtain new vault authorization. + if (activation.type !== 'oauth') return {} + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) { + return { + apiKey: '', + fetch: async () => { + throw new Error( + 'Native OAuth cannot serve while OPENCODE_AUTH_CONTENT is set', + ) + }, + } + } + if (nativeStartupError) { + return { + apiKey: '', + fetch: async () => { + throw nativeStartupError + }, + } + } + const journal = await readNativeMigrationJournal(nativePaths) + const getAuth = async (modelId?: string, signal?: AbortSignal) => { + const currentActivation = await hostGetAuth() + if (currentActivation.type !== 'oauth') + return { + type: currentActivation.type, + access: '', + expires: 0, + refresh: undefined, + nativeAccountIdentity: undefined, + nativeLocalSource: undefined, + nativeScopedAttempt: undefined, + modelDenied: false, + } + if (!isCustodyTombstoneOAuth(currentActivation, 'anthropic')) { + throw new Error( + 'Native OAuth requires inert OpenCode activation; run offline setup', + ) + } + let credential: NativeOAuthAuthorization | undefined + let denied: NativeAccountView | undefined + try { + credential = await authorizeOAuth( + 'main', + signal, + undefined, + modelId, + ) + } catch (error) { + if (error instanceof NativeModelPolicyError) { + denied = error.account + // A missing usage snapshot may itself trigger the killswitch. + // Its neutral usage poll is not permission to send this model. + if ( + !quotaSnapshotHasStandardWindows(denied.quota) && + !quotaSnapshotModelScopeIsExhausted(denied.quota, modelId) + ) { + try { + await nativeAccounts.fetchQuota('main', undefined, signal) + credential = await authorizeOAuth( + 'main', + signal, + undefined, + modelId, + ) + denied = undefined + } catch (retryError) { + if (retryError instanceof NativeModelPolicyError) + denied = retryError.account + } + } + } + } + signal?.throwIfAborted() + return { + type: 'oauth', + access: credential?.accessToken ?? '', + expires: credential?.expires ?? 0, + refresh: undefined, + nativeAccountIdentity: + credential?.accountIdentity ?? denied?.accountIdentity, + modelDenied: Boolean(denied), + nativeLocalSource: credential?.localSource, + nativeScopedAttempt: credential?.scopedAttempt, + } + } latestGetAuth = getAuth - const auth = await getAuth() + const auth: { + type: string + access: string + refresh?: string + expires: number + } = { type: 'oauth', access: '', expires: 0 } const custodyStorage = await loadAccounts(accountStoragePath) if ( getClaustrumMode(custodyStorage) === 'claustrum' && @@ -4998,494 +5167,115 @@ const anthropicAuthPlugin = async ( headers: { 'content-type': 'application/json' }, }, ), - } - } - // Provider initialization also runs for sessions using other models. - // Never make that shared path wait on account discovery: dispatch-time - // scoped authorization verifies the chosen route before Anthropic I/O. - const custodyMode = getClaustrumMode(custodyStorage) - const main = mainCustodyDimension(auth) - const fallbackDimensions = fallbackCustodyDimensions(custodyStorage) - const mainEvidence = - custodyMode !== 'claustrum' || - (main === 'T' && - custodyStorage?.claustrum?.primaryAccount?.state === 'active') - ? 'V' - : 'N' - const scopedMainRoute = - custodyMode === 'claustrum' && - main === 'T' && - isScopedCustodyActive(custodyStorage) - if (auth.type === 'oauth') { - try { - reconcileCustodyStartup({ - mode: custodyMode === 'claustrum' ? 'C' : 'L', - main, - fallbacks: fallbackDimensions.fallbacks, - evidence: mainEvidence, - }) - } catch (error) { - if (!(error instanceof CustodyStateMismatchError)) throw error - custodyStartupMismatchVerdict = error.verdict - return { - apiKey: '', - fetch: async () => { - throw error - }, - } - } - } - if (auth.type === 'oauth') { - if ( - isCustodyTombstoneOAuth(auth, 'anthropic') && - custodyMode !== 'claustrum' - ) { - throw new CustodyTombstoneLoginError('anthropic') - } - mainAccountId = await getOrCreateMainAccountId(accountStoragePath) - mainQuotaAccountId ??= - custodyMode === 'claustrum' - ? custodyStorage?.claustrum?.primaryAccount?.accountId - : mainAccountId - if (auth.access) { - await resolveMainQuotaAccountIdentity(auth.access) - } - async function refreshMainAccessToken(rejectedAccess?: string) { - if (rejectedAccess) { - const currentAuth = await getAuth() - if ( - currentAuth.type === 'oauth' && - currentAuth.access && - currentAuth.access !== rejectedAccess && - (!currentAuth.expires || currentAuth.expires > Date.now()) - ) { - log( - '[refresh] opencode main oauth reused concurrently rotated access token', - { - expiresInMs: currentAuth.expires - ? currentAuth.expires - Date.now() - : undefined, - }, - ) - return currentAuth.access - } - } - - let refreshPromise = - mainAccessRefreshesByStoragePath.get(accountStoragePath) - if (!refreshPromise) { - refreshPromise = (async () => { - const maxRetries = 2 - const baseDelayMs = 500 - let leaseId: string | null = null - let leaseTokenHash: string | null = null - let releaseFileLock: (() => Promise) | null = null - - async function updateMainRefreshState( - update: (storage: AccountStorage) => void, - ) { - const storage: AccountStorage = - (await loadAccounts(accountStoragePath)) ?? - createEmptyStorage() - storage.refresh = storage.refresh ?? {} - update(storage) - await saveAccountState(storage, accountStoragePath, { - mainRefresh: true, - }) - } - - async function waitForConcurrentMainRefresh(previous: { - access?: string - refresh?: string - expires?: number - }) { - const deadline = Date.now() + CONCURRENT_MAIN_REFRESH_WAIT_MS - while (Date.now() < deadline) { - await new Promise((resolve) => - runtimeTimers.setTimeout( - resolve, - CONCURRENT_MAIN_REFRESH_POLL_BASE_MS + - jitterMs(CONCURRENT_MAIN_REFRESH_POLL_BASE_MS), - ), - ) - const latest = await getAuth() - if (latest.type !== 'oauth' || !latest.access) continue - const changed = - latest.access !== previous.access || - latest.refresh !== previous.refresh || - (latest.expires ?? 0) > (previous.expires ?? 0) + 60_000 - if ( - changed && - (!latest.expires || latest.expires > Date.now()) - ) { - log( - '[refresh] opencode main oauth joined concurrent refresh', - { - expiresInMs: latest.expires - ? latest.expires - Date.now() - : undefined, - }, - ) - return latest.access - } - } - return null - } - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - let freshAuth: Awaited> | null = - null - try { - if (attempt > 0) { - const delay = baseDelayMs * 2 ** (attempt - 1) - await new Promise((resolve) => - runtimeTimers.setTimeout(resolve, delay), - ) - } - - // Re-read auth to get the latest refresh token. - // The outer `auth` snapshot may be stale if tokens - // were rotated since the fetch() call was made. - freshAuth = await getAuth() - - if (!freshAuth.refresh) { - throw new Error( - 'Token refresh failed: missing refresh token', - ) - } - - const storage = await loadAccounts(accountStoragePath) - const refreshTokenHash = hashRefreshToken(freshAuth.refresh) - const mainError = storage?.refresh?.mainLastRefreshError - log('[refresh] opencode main oauth refresh check', { - attempt, - expiresInMs: freshAuth.expires - ? freshAuth.expires - Date.now() - : undefined, - hasBackoff: Boolean(mainError), - backoffActive: mainError - ? refreshBackoffActive( - mainError, - mainAccountId ?? storage?.mainAccountId, - Date.now(), - tokenFingerprint(freshAuth.refresh), - ) - : false, - retryCount: mainError?.retryCount, - nextRetryAt: mainError?.nextRetryAt, - }) - if ( - mainError && - refreshBackoffActive( - mainError, - mainAccountId ?? storage?.mainAccountId, - Date.now(), - tokenFingerprint(freshAuth.refresh), - ) - ) { - log( - '[refresh] opencode main oauth refresh skipped backoff', - { - nextRetryAt: mainError.nextRetryAt, - retryCount: mainError.retryCount, - }, - ) - throw new Error( - formatRefreshBackoffMessage(mainError, Date.now()), - ) - } - if ( - storage?.refresh?.mainRefreshLeaseUntil && - storage.refresh.mainRefreshLeaseUntil > Date.now() && - storage.refresh.mainRefreshLeaseTokenHash === - refreshTokenHash - ) { - log( - '[refresh] opencode main oauth refresh skipped lease', - { - leaseUntil: storage.refresh.mainRefreshLeaseUntil, - }, - ) - const concurrentAccess = - await waitForConcurrentMainRefresh(freshAuth) - if (concurrentAccess) return concurrentAccess - throw new Error( - 'Claude OAuth refresh is already in progress', - ) - } - - const fileLock = await acquireRefreshFileLock({ - name: OPENCODE_MAIN_OAUTH_REFRESH_LOCK, - ttlMs: 2 * 60_000, - renew: true, - }) - if (!fileLock) { - log( - '[refresh] opencode main oauth refresh skipped file lock', - ) - const concurrentAccess = - await waitForConcurrentMainRefresh(freshAuth) - if (concurrentAccess) return concurrentAccess - throw new Error( - 'Claude OAuth refresh is already in progress', - ) - } - releaseFileLock = fileLock.release - - leaseId = randomUUID() - leaseTokenHash = refreshTokenHash - await updateMainRefreshState((nextStorage) => { - nextStorage.refresh = nextStorage.refresh ?? {} - nextStorage.refresh.mainRefreshLeaseId = - leaseId ?? undefined - nextStorage.refresh.mainRefreshLeaseUntil = - Date.now() + 2 * 60_000 - nextStorage.refresh.mainRefreshLeaseTokenHash = - refreshTokenHash - }) - const latestLease = await loadAccounts(accountStoragePath) - log( - '[refresh] opencode main oauth refresh lease acquired', - { - attempt, - leaseUntil: Date.now() + 2 * 60_000, - }, - ) - if ( - latestLease?.refresh?.mainRefreshLeaseId !== leaseId || - latestLease.refresh.mainRefreshLeaseTokenHash !== - refreshTokenHash - ) { - throw new Error( - 'Claude OAuth refresh is already in progress', - ) - } - - log('[refresh] opencode main oauth refresh request start', { - attempt, - }) - const refreshed = await refreshClaudeOAuthToken({ - refreshToken: freshAuth.refresh, - // Main OpenCode OAuth already has request-path retry, - // persisted backoff, and cross-process serialization here. - // Keep the shared helper single-shot in this path so the - // two retry layers cannot multiply endpoint pressure. - maxRetries: 0, - }) - - // biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set - await (client as any).auth.set({ - path: { - id: 'anthropic', - }, - body: { - type: 'oauth', - refresh: refreshed.refresh, - access: refreshed.access, - expires: refreshed.expires, - }, - }) - - await updateMainRefreshState((storage) => { - if (!storage?.refresh) return - storage.refresh.mainLastRefreshError = undefined - if (storage.refresh.mainRefreshLeaseId === leaseId) { - storage.refresh.mainRefreshLeaseId = undefined - storage.refresh.mainRefreshLeaseUntil = undefined - storage.refresh.mainRefreshLeaseTokenHash = undefined - } - }) - - log('[refresh] opencode main oauth refresh succeeded', { - attempt, - expiresInMs: refreshed.expires - Date.now(), - }) - return refreshed.access - } catch (error) { - const isNetworkError = - error instanceof Error && - (error.message.includes('fetch failed') || - ('code' in error && - (error.code === 'ECONNRESET' || - error.code === 'ECONNREFUSED' || - error.code === 'ETIMEDOUT' || - error.code === 'UND_ERR_CONNECT_TIMEOUT'))) - - if ( - attempt < maxRetries && - (isNetworkError || - (() => { - const s = (error as { status?: number }).status - return typeof s === 'number' && s >= 500 - })()) - ) { - continue - } - - log( - '[refresh] opencode main oauth refresh attempt failed', - { - attempt, - error: - error instanceof Error - ? error.message - : String(error), - transient: isNetworkError, - }, - ) - - const failedRefreshToken = freshAuth?.refresh - if ( - failedRefreshToken && - (error as { isRefreshError?: boolean }).isRefreshError - ) { - await updateMainRefreshState((storage) => { - storage.refresh = storage.refresh ?? {} - storage.refresh.mainLastRefreshError = - buildRefreshOperationError({ - error, - now: Date.now(), - accountIdentity: - mainAccountId ?? storage.mainAccountId, - refreshTokenFingerprint: - tokenFingerprint(failedRefreshToken), - previous: storage.refresh.mainLastRefreshError, - }) - }) - } - - throw error - } finally { - if (leaseId) { - await updateMainRefreshState((storage) => { - if (!storage?.refresh) return - if ( - storage.refresh.mainRefreshLeaseId === leaseId && - storage.refresh.mainRefreshLeaseTokenHash === - leaseTokenHash - ) { - storage.refresh.mainRefreshLeaseId = undefined - storage.refresh.mainRefreshLeaseUntil = undefined - storage.refresh.mainRefreshLeaseTokenHash = undefined - } - }).catch(() => {}) - } - await releaseFileLock?.().catch(() => {}) - } - } - // Unreachable — each iteration either returns or throws. - // Kept as a TypeScript exhaustiveness guard. - throw new Error('Token refresh exhausted all retries') - })() - mainAccessRefreshesByStoragePath.set( - accountStoragePath, - refreshPromise, - ) - } - try { - return await refreshPromise - } finally { - if ( - mainAccessRefreshesByStoragePath.get(accountStoragePath) === - refreshPromise - ) { - mainAccessRefreshesByStoragePath.delete(accountStoragePath) - } + } + } + // Other providers share initialization, so do not block them while + // the vault lists Claude accounts. Each Anthropic send separately + // checks that its selected vault account can supply credentials. + const custodyMode = getClaustrumMode(custodyStorage) + const main = mainCustodyDimension(activation) + const fallbackDimensions = fallbackCustodyDimensions(custodyStorage) + const mainEvidence = + custodyMode !== 'claustrum' || + (main === 'T' && + custodyStorage?.claustrum?.primaryAccount?.state === 'active') + ? 'V' + : 'N' + const scopedMainRoute = + custodyMode === 'claustrum' && + main === 'T' && + isScopedCustodyActive(custodyStorage) + if (auth.type === 'oauth') { + try { + reconcileCustodyStartup({ + mode: custodyMode === 'claustrum' ? 'C' : 'L', + main, + fallbacks: fallbackDimensions.fallbacks, + evidence: mainEvidence, + authority: + journal?.phase === 'committed' || journal?.phase === 'retired' + ? journal.phase + : undefined, + }) + } catch (error) { + if (!(error instanceof CustodyStateMismatchError)) throw error + custodyStartupMismatchVerdict = error.verdict + return { + apiKey: '', + fetch: async () => { + throw error + }, } } - - latestRefreshMainAccessToken = refreshMainAccessToken + } + if (auth.type === 'oauth') { + const initialSnapshot = await nativeAccounts.read() + const initialMain = initialSnapshot.accounts.find( + (account) => account.id === 'main', + ) + mainAccountId = + initialMain?.binding?.rowId ?? initialMain?.accountIdentity + mainQuotaAccountId ??= initialMain?.accountIdentity ?? mainAccountId + if (auth.access) { + await resolveMainQuotaAccountIdentity(auth.access) + } + async function refreshMainAccessToken( + rejectedAccess?: string, + modelId?: string, + signal?: AbortSignal, + ) { + const credential = await authorizeOAuth( + 'main', + signal, + rejectedAccess, + modelId, + 'refresh', + ) + return credential.accessToken + } function startMainBackgroundRefresh() { - if (mainBackgroundRefreshTimer) { + if (mainBackgroundRefreshTimer) runtimeTimers.clearInterval(mainBackgroundRefreshTimer) - mainBackgroundRefreshTimer = null - } - const run = async () => { try { const storage = await loadAccounts(accountStoragePath) if (!mainRefreshEnabled(storage)) return const latestAuth = await getAuth() if (latestAuth.type !== 'oauth') return - await clearStaleMainRefreshError( - mainAccountId ?? storage?.mainAccountId, - latestAuth.refresh - ? tokenFingerprint(latestAuth.refresh) - : undefined, - ) - if (!latestAuth.expires) return - const expiresInMs = latestAuth.expires - Date.now() - const refreshBeforeMs = mainRefreshBeforeExpiryMs(storage) - if (expiresInMs > refreshBeforeMs) { - return - } - log('[refresh] opencode main oauth background due', { - expiresInMs, - refreshBeforeMs, - }) if ( - refreshBackoffActive( - storage?.refresh?.mainLastRefreshError, - mainAccountId ?? storage?.mainAccountId, - Date.now(), - latestAuth.refresh - ? tokenFingerprint(latestAuth.refresh) - : undefined, - ) - ) { - log( - '[refresh] opencode main oauth background skipped backoff', - { - nextRetryAt: - storage?.refresh?.mainLastRefreshError?.nextRetryAt, - retryCount: - storage?.refresh?.mainLastRefreshError?.retryCount, - expiresInMs, - }, - ) + latestAuth.nativeLocalSource === 'rotated' || + latestAuth.nativeLocalSource === 'adopted' + ) return - } if ( - latestAuth.refresh && - storage?.refresh?.mainRefreshLeaseUntil && - storage.refresh.mainRefreshLeaseUntil > Date.now() && - storage.refresh.mainRefreshLeaseTokenHash === - hashRefreshToken(latestAuth.refresh) - ) { + latestAuth.expires && + latestAuth.expires - Date.now() > + mainRefreshBeforeExpiryMs(storage) + ) return - } - await refreshMainAccessToken() - const refreshedAuth = await getAuth() - log('[refresh] opencode main oauth refreshed in background', { - newExpiresInMs: refreshedAuth.expires - ? refreshedAuth.expires - Date.now() - : undefined, - }) - } catch (error) { - logger.warn('refresh', 'opencode main oauth refresh failed', { - message: - error instanceof Error ? error.message : String(error), - }) + } catch { + logger.warn( + 'refresh', + 'native main OAuth background refresh unavailable', + ) } } - mainBackgroundRefreshTimer = runtimeTimers.setInterval(() => { void run() }, MAIN_AUTH_REFRESH_TICK_MS + jitterMs(MAIN_AUTH_REFRESH_TICK_JITTER_MS)) - if ('unref' in mainBackgroundRefreshTimer) { - mainBackgroundRefreshTimer.unref() - } + mainBackgroundRefreshTimer.unref?.() } if (!scopedMainRoute) startMainBackgroundRefresh() quotaManager.seedFallbacksFromAccounts( (initialStorage?.accounts ?? []).filter(isOAuthAccount), ) - const initialSidebarRouting = - await resolveInitialSidebarRouting(accountStoragePath) + const initialSidebarRouting = await resolveInitialSidebarRouting(() => + loadAccounts(accountStoragePath), + ) await writeSidebarState(initialSidebarRouting.freshStorage, { activeId: initialSidebarRouting.activeId, route: initialSidebarRouting.route, @@ -5506,7 +5296,6 @@ const anthropicAuthPlugin = async ( route: 'main', mainAccessToken: auth.access, routingAuthoritative: false, - useHydratedProfiles: true, }) }) .catch(() => {}) @@ -5668,6 +5457,48 @@ const anthropicAuthPlugin = async ( applyCustomHeaders(headers) } + async function authorizePaidFallback( + routeId: string, + accessToken: string | undefined, + identity: MainQuotaIdentityBinding | undefined, + signal?: AbortSignal, + ) { + assertNativeEnvironment() + if (!accessToken) { + const general = identity?.quotaKey + ? quotaManager.getMain(identity.quotaKey) + : null + if ( + !identity?.providerAccountUuid || + !general || + general.refreshAfter <= Date.now() || + !quotaSnapshotIsExhausted(general.quota) + ) { + throw new Error( + 'API fallback requires fresh general primary OAuth exhaustion', + ) + } + // This primary authorization validates the existing general-quota + // gate for an API route. It does not permit the denied Claude model. + const primary = await authorizeOAuth('main', signal) + if (primary.accountIdentity !== identity.providerAccountUuid) + throw new Error('Primary identity changed before API fallback') + accessToken = primary.accessToken + } + const credential = await nativeAccounts.authorizeApi( + routeId, + signal, + ) + // Recheck the primary account's fresh quota after obtaining the + // API credential. Only account-wide OAuth exhaustion permits API + // fallback; model-only limits, auth errors or an unknown account + // do not justify using a paid API route. + if (!mainQuotaEntryIsFreshExhausted(accessToken, identity)) + throw new Error( + 'API fallback requires fresh general primary OAuth exhaustion', + ) + return credential + } async function sendWithApiAccount( input: string | URL | Request, init: RequestInit | undefined, @@ -5676,6 +5507,8 @@ const anthropicAuthPlugin = async ( route = 'api_fallback', currentStorage?: Awaited>, fableRequest?: FableRequestContext, + mainAccessToken?: string, + mainQuotaIdentity?: MainQuotaIdentityBinding, ) { void currentStorage if (fableRequest?.plan.downgraded) { @@ -5760,6 +5593,14 @@ const anthropicAuthPlugin = async ( const cacheDiagnosticsBetas = await getCacheDiagnosticsBetas(requestHeaders) + const apiCredential = await authorizePaidFallback( + account.id, + mainAccessToken, + mainQuotaIdentity, + init?.signal ?? undefined, + ) + account = { ...account, ...apiCredential } + configureApiRouteHeaders(requestHeaders, account) const rewritten = rewriteUrl(input, { baseURL: account.baseURL }) const sendStart = nowMs() let response: Response @@ -5815,6 +5656,10 @@ const anthropicAuthPlugin = async ( totalSendWithAccessMs: roundMs(nowMs() - start), baseURL: account.baseURL, }) + if (response.ok) + await nativeAccounts.publishApi(apiCredential.subject, { + lastUsed: Date.now(), + }) return response } @@ -5830,7 +5675,7 @@ const anthropicAuthPlugin = async ( fableRequest?: FableRequestContext, laneStartRequest = false, mainQuotaIdentity?: MainQuotaIdentityResolution, - scopedAttempt?: ClaustrumScopedAttempt, + scopedAttempt?: NativeCustodyReceipt, ) { assertNotCustodyTombstone(accessToken, 'anthropic') const start = nowMs() @@ -5888,34 +5733,28 @@ const anthropicAuthPlugin = async ( bodyBytes: body.length, }) } - const identityStart = nowMs() - const identity = await resolveClaudeCodeIdentity( - accessToken, + let nativeAuthorization = await authorizeOAuth( + oauthAccountId, + init?.signal ?? undefined, + undefined, modelForIdentity, - oauthAccountId === 'main' - ? (mainQuotaIdentity?.quotaKey ?? - asProviderAccountUuid(scopedAttempt?.accountId) ?? - (!accessToken.startsWith('sk-ant-oat') - ? mainAccountId - : undefined)) - : oauthAccountId, ) - if (oauthAccountId !== 'main' && identity.accountUuid) { - void persistFallbackAnthropicAccountUuid( - oauthAccountId, - identity.accountUuid, - ).catch((error) => { - logger.warn( - 'claustrum', - 'failed to persist fallback identity', - { - accountId: oauthAccountId, - error: - error instanceof Error ? error.message : String(error), - }, - ) - }) + accessToken = nativeAuthorization.accessToken + if ( + oauthAccountId === 'main' && + mainQuotaIdentity?.providerAccountUuid && + nativeAuthorization.accountIdentity !== + mainQuotaIdentity.providerAccountUuid + ) { + throw new Error( + 'Main account identity changed after request routing', + ) } + scopedAttempt = nativeAuthorization.scopedAttempt + const identityStart = nowMs() + const identity = nativeWireIdentity( + nativeAuthorization.accountIdentity, + ) trace?.mark('resolve_claude_code_identity', { route, ms: roundMs(nowMs() - identityStart), @@ -6105,39 +5944,62 @@ const anthropicAuthPlugin = async ( } } + const requestAuthority = nativeAuthorization + let retryFromVault: NativeCustodyReceipt | undefined + const authorizePhysical = async (rejectedAccessToken?: string) => { + const current = await authorizeOAuth( + oauthAccountId, + init?.signal ?? undefined, + rejectedAccessToken, + modelForIdentity, + ) + if ( + current.accountIdentity !== requestAuthority.accountIdentity || + Boolean(current.scopedAttempt) !== + Boolean(requestAuthority.scopedAttempt) + ) { + throw new Error( + 'Native credential authority changed during the request', + ) + } + if ( + retryFromVault && + (!current.scopedAttempt || + current.scopedAttempt.credentialId !== + retryFromVault.credentialId || + current.scopedAttempt.accountIdentity !== + retryFromVault.accountIdentity || + current.scopedAttempt.recordVersion <= + retryFromVault.recordVersion) + ) { + throw new Error( + 'Native vault retry has no strict newer same-account receipt', + ) + } + return current + } let usedDirectFetch = false let directAttempt = scopedAttempt - let retryDirectAttempt: ClaustrumScopedAttempt | undefined + const directFetch = async () => { usedDirectFetch = true - if (scopedAttempt) { - // A relay-to-direct fallback is another physical attempt. Its - // original receipt may have rotated while the relay was failing. - directAttempt = - retryDirectAttempt ?? - (relayConfig?.enabled - ? await getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ).authorize( - oauthAccountId, - init?.signal instanceof AbortSignal - ? init.signal - : undefined, - ) - : scopedAttempt) - retryDirectAttempt = undefined - requestHeaders.set( - 'authorization', - `Bearer ${directAttempt.accessToken}`, - ) - served = { - ...served, - accessToken: directAttempt.accessToken, - anthropicAccountUuid: asProviderAccountUuid( - directAttempt.accountId, - ), - } + // Validate current local credentials or obtain new vault + // authorization before every direct send, including relay rescue. + // Tokens checked earlier may have expired or been revoked. + nativeAuthorization = await authorizePhysical() + directAttempt = nativeAuthorization.scopedAttempt + requestHeaders.set( + 'authorization', + `Bearer ${nativeAuthorization.accessToken}`, + ) + served = { + ...served, + accessToken: nativeAuthorization.accessToken, + anthropicAccountUuid: asProviderAccountUuid( + nativeAuthorization.accountIdentity, + ), + localSubject: nativeAuthorization.localSubject, + scopedAttempt: nativeAuthorization.scopedAttempt, } try { const response = await fetch(rewritten.input, { @@ -6181,7 +6043,7 @@ const anthropicAuthPlugin = async ( } const requestStorageForIdentity = await getRequestStorage() - const relayConfig = getRelayConfig(requestStorageForIdentity) + const relayConfig = await nativeAccounts.getRelayConfig() const persistedFallbackAccount = oauthAccountId === 'main' ? undefined @@ -6198,30 +6060,20 @@ const anthropicAuthPlugin = async ( accessToken, authLineageId: fallbackAuthLineageId, anthropicAccountUuid: - asProviderAccountUuid(scopedAttempt?.accountId) ?? + asProviderAccountUuid(scopedAttempt?.accountIdentity) ?? (oauthAccountId === 'main' ? mainQuotaIdentity?.providerAccountUuid : (asProviderAccountUuid(identity.accountUuid) ?? asProviderAccountUuid(persistedFallbackAccountUuid))), + localSubject: nativeAuthorization.localSubject, + scopedAttempt: nativeAuthorization.scopedAttempt, ...(oauthAccountId === 'main' && mainQuotaIdentity ? { mainQuotaIdentity } : {}), } const sendStart = nowMs() - let relay401Attempt: ClaustrumScopedAttempt | undefined - let relayReturned = false - const reportScoped401 = ( - attempt: ClaustrumScopedAttempt, - source: 'direct' | 'relay_status_field', - ) => - getOpenCodeScopedRuntime(accountStoragePath, ctx.directory) - .reportFailure(attempt, 401, source) - .catch(() => { - logger.warn( - 'claustrum', - 'OpenCode scoped auth-failure report unavailable', - ) - }) + let relay401Attempt: NativeCustodyReceipt | undefined + const reportScoped401 = reportVault401 const sendOnce = () => sendViaRelay({ config: relayConfig, @@ -6232,46 +6084,37 @@ const anthropicAuthPlugin = async ( fallback: directFetch, affinity: relayAffinity, optimisticResponse: relayConfig?.transport === 'websocket', - authorizeAttempt: scopedAttempt - ? async () => { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - const nextAttempt = await runtime.authorize( - oauthAccountId, - init?.signal instanceof AbortSignal - ? init.signal - : undefined, - ) - served = { - ...served, - accessToken: nextAttempt.accessToken, - anthropicAccountUuid: asProviderAccountUuid( - nextAttempt.accountId, - ), - } - const relayHeaders = new Headers(requestHeaders) - relayHeaders.set( - 'authorization', - `Bearer ${nextAttempt.accessToken}`, - ) - return { - headers: relayHeaders, - onUpstreamStatus: (status: number) => { - if (status !== 401) return - relay401Attempt = nextAttempt - // A WebSocket can deliver a real upstream 401 after - // its optimistic response has been handed to the host. - if (relayReturned) - void reportScoped401( - nextAttempt, - 'relay_status_field', - ) - }, - } - } - : undefined, + authorizeAttempt: async () => { + const nextAuthorization = await authorizePhysical() + const nextAttempt = nextAuthorization.scopedAttempt + nativeAuthorization = nextAuthorization + served = { + ...served, + accessToken: nextAuthorization.accessToken, + anthropicAccountUuid: asProviderAccountUuid( + nextAuthorization.accountIdentity, + ), + localSubject: nextAuthorization.localSubject, + scopedAttempt: nextAttempt, + } + const relayHeaders = new Headers(requestHeaders) + relayHeaders.set( + 'authorization', + `Bearer ${nextAuthorization.accessToken}`, + ) + return { + headers: relayHeaders, + onUpstreamStatus: (status: number) => { + if (status !== 401 || !nextAttempt) return + relay401Attempt = nextAttempt + // The relay can return an optimistic 200 before Anthropic + // answers. If Anthropic later rejects the tokens, report + // the vault credential and version used for this send, + // not a newer version obtained after the rejection. + void reportScoped401(nextAttempt, 'relay_status_field') + }, + } + }, onResponseHeaders: (headers) => { harvestQuotaHeaders(headers, served) billingLineageTracker.commit( @@ -6293,25 +6136,17 @@ const anthropicAuthPlugin = async ( (fetchMethod(input, init) ?? '').toUpperCase() === 'POST' && !init?.signal?.aborted ) { - let rotated: ClaustrumScopedAttempt | undefined + let rotated: NativeCustodyReceipt | undefined try { - rotated = await getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ).authorize( - oauthAccountId, - init?.signal instanceof AbortSignal ? init.signal : undefined, + rotated = await prepareVaultRetry( + relay401Attempt, + init?.signal ?? undefined, + 'relay_status_field', ) } catch { // Keep the 401 and report the exact relay-served record below. } - if ( - decideScopedRetryAfter401( - 'model-relay', - relay401Attempt, - rotated, - ) - ) { + if (rotated) { await response.body?.cancel().catch(() => {}) logger.info( 'claustrum', @@ -6323,6 +6158,7 @@ const anthropicAuthPlugin = async ( transport: 'relay', }, ) + retryFromVault = relay401Attempt relay401Attempt = undefined // sendOnce obtains its own fresh receipt for this HTTP retry. response = await sendOnce() @@ -6336,19 +6172,16 @@ const anthropicAuthPlugin = async ( (fetchMethod(input, init) ?? '').toUpperCase() === 'POST' && !init?.signal?.aborted ) { - let rotated: ClaustrumScopedAttempt | undefined + let rotated: NativeCustodyReceipt | undefined try { - rotated = await getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ).authorize( - oauthAccountId, - init?.signal instanceof AbortSignal ? init.signal : undefined, + rotated = await prepareVaultRetry( + directAttempt, + init?.signal ?? undefined, ) } catch { // Preserve the genuine 401 if no replacement can be verified. } - if (decideScopedRetryAfter401('model', directAttempt, rotated)) { + if (rotated) { await response.body?.cancel().catch(() => {}) logger.info( 'claustrum', @@ -6360,12 +6193,34 @@ const anthropicAuthPlugin = async ( transport: 'direct', }, ) - retryDirectAttempt = rotated + + retryFromVault = directAttempt // Transport failures on the new attempt must propagate. They // are not evidence that the new record's token was rejected. response = await directFetch() } } + if ( + response.status === 401 && + !nativeAuthorization.scopedAttempt && + typeof body === 'string' && + (fetchMethod(input, init) ?? '').toUpperCase() === 'POST' && + !init?.signal?.aborted + ) { + const rejectedAccess = nativeAuthorization.accessToken + let replacement: NativeOAuthAuthorization | undefined + try { + replacement = await authorizePhysical(rejectedAccess) + } catch (error) { + nativeLocalRefreshFailures.set(response, error) + } + if (replacement && replacement.accessToken !== rejectedAccess) { + await response.body?.cancel().catch(() => {}) + response = usedDirectFetch + ? await directFetch() + : await sendOnce() + } + } trace?.mark('send_headers_received', { route, ms: roundMs(nowMs() - sendStart), @@ -6396,10 +6251,19 @@ const anthropicAuthPlugin = async ( if (usedDirectFetch && directAttempt && response.status === 401) { await reportScoped401(directAttempt, 'direct') } - relayReturned = true if (!usedDirectFetch && relay401Attempt) { await reportScoped401(relay401Attempt, 'relay_status_field') } + if (response.ok) { + if (served.scopedAttempt) + await nativeAccounts.vault.publish(served.scopedAttempt, { + lastUsed: Date.now(), + }) + else if (served.localSubject) + await nativeAccounts.publishLocal(served.localSubject, { + lastUsed: Date.now(), + }) + } return response } @@ -6519,7 +6383,6 @@ const anthropicAuthPlugin = async ( options.includeApiRoutes === true && isApiKeyAccount(account) && account.enabled !== false && - account.apiKey && isValidApiBaseURL(account.baseURL) ) { usable.push(account) @@ -6542,6 +6405,7 @@ const anthropicAuthPlugin = async ( storage: AccountStorage | null mainAccessToken: string mainRefreshToken?: string + mainPolicyDenied?: boolean requestedModelId?: string mainQuotaIdentity?: MainQuotaIdentityBinding }) { @@ -6592,6 +6456,7 @@ const anthropicAuthPlugin = async ( }) } } else if ( + (input.mainAccessToken || input.mainPolicyDenied) && !isPermanentRefreshError( latestStorage?.refresh?.mainLastRefreshError, ) @@ -6654,11 +6519,11 @@ const anthropicAuthPlugin = async ( if (stored.enabled === false || !isOAuthAccount(stored)) continue const account = usableFallbacksById.get(stored.id) ?? stored - if ( - !account.access || - isPermanentRefreshError(account.lastRefreshError) - ) - continue + // Retain temporarily unavailable account metadata so sticky + // sessions can keep their account assignment. Only IDs with + // separately authorized credentials can serve requests; + // these empty access fields cannot supply a bearer token. + if (isPermanentRefreshError(account.lastRefreshError)) continue let accountQuota = getFallbackQuota(account) if ( !stickyQuotaSnapshotIsFresh( @@ -6671,14 +6536,14 @@ const anthropicAuthPlugin = async ( try { accountQuota = await quotaManager.refreshFallback( account.id, - account.access, + account.access ?? '', account, ) } catch {} } allRoutes.push({ id: account.id, - access: account.access, + access: account.access ?? '', quota: accountQuota, identity: { kind: 'known', accountId: account.id }, order: index + 1, @@ -6847,6 +6712,8 @@ const anthropicAuthPlugin = async ( trace?: PerfTrace, options?: { returnLastOnExhausted?: boolean + mainAccessToken?: string + mainQuotaIdentity?: MainQuotaIdentityBinding onSuccess?: (account: { id: string access?: string @@ -6869,7 +6736,6 @@ const anthropicAuthPlugin = async ( for (const [index, account] of accounts.entries()) { let response: Response if (isApiKeyAccount(account)) { - if (!account.apiKey) continue await cancelCurrentResponse() response = await sendWithApiAccount( input, @@ -6879,19 +6745,24 @@ const anthropicAuthPlugin = async ( `api_fallback_${index}`, storage, options?.fableRequest, + options?.mainAccessToken, + options?.mainQuotaIdentity, ) } else if ( isScopedCustodyActive(storage) && account.claustrumScopedCredentialId ) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - const scopedAttempt = await runtime.authorize( + const credential = await authorizeOAuth( account.id, - init?.signal instanceof AbortSignal ? init.signal : undefined, + init?.signal ?? undefined, + undefined, + parseRequestModel(await fetchBody(input, init)), ) + const scopedAttempt = credential.scopedAttempt + if (!scopedAttempt) + throw new Error( + 'Native vault fallback credential is unavailable', + ) await cancelCurrentResponse() response = await sendWithAccessToken( input, @@ -6941,7 +6812,7 @@ const anthropicAuthPlugin = async ( response, isApiKeyAccount(account) ? 'api' : 'oauth', ) - await fallbackManager.markUsed(account) + await options?.onSuccess?.(account) // Active-route every-N refresh: this fallback just served the // request, so keep its quota fresh on the same cadence as main. @@ -7005,7 +6876,6 @@ const anthropicAuthPlugin = async ( account.enabled !== false && (isOAuthAccount(account) || (isApiKeyAccount(account) && - Boolean(account.apiKey) && isValidApiBaseURL(account.baseURL))), ) if (!hasPotentialFallbackRoute) return mainResponse @@ -7088,6 +6958,8 @@ const anthropicAuthPlugin = async ( trace, { onSuccess: onFallbackSuccess, + mainAccessToken, + mainQuotaIdentity, fableRequest, laneStartRequest, }, @@ -7098,6 +6970,8 @@ const anthropicAuthPlugin = async ( return { apiKey: '', async fetch(input: string | URL | Request, init?: RequestInit) { + if (input instanceof Request && init?.signal === undefined) + init = { ...init, signal: input.signal } const incomingHeaders = mergeHeaders(input, init) const laneStartRequest = incomingHeaders.get(LANE_START_REQUEST_HEADER) === '1' @@ -7312,7 +7186,13 @@ const anthropicAuthPlugin = async ( }) } const authStart = nowMs() - const auth = await getAuth() + const credentialModelId = + fablePlan?.effectiveModel ?? + parseRequestModel(await fetchBody(input, init)) + const auth = await getAuth( + credentialModelId, + init?.signal ?? undefined, + ) trace.mark('get_auth', { ms: roundMs(nowMs() - authStart), authType: auth.type, @@ -7340,32 +7220,9 @@ const anthropicAuthPlugin = async ( return response } const storage = await loadAccounts() - let requestMainProviderUuid: ProviderAccountUuid | undefined - let requestMainScopedAttempt: ClaustrumScopedAttempt | undefined - if (isScopedCustodyActive(storage)) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - try { - const attempt = await runtime.authorize( - 'main', - init?.signal instanceof AbortSignal - ? init.signal - : undefined, - ) - auth.access = attempt.accessToken - auth.expires = attempt.expiresAtMs - mainServedAccessToken = attempt.accessToken - requestMainScopedAttempt = attempt - requestMainProviderUuid = asProviderAccountUuid( - attempt.accountId, - ) - } catch { - auth.access = '' - auth.expires = 0 - } - } + const requestMainProviderUuid: ProviderAccountUuid | undefined = + asProviderAccountUuid(auth.nativeAccountIdentity) + const requestMainScopedAttempt = auth.nativeScopedAttempt let requestMainQuotaIdentity: | MainQuotaIdentityResolution | undefined @@ -7386,11 +7243,25 @@ const anthropicAuthPlugin = async ( mainServedAccessToken = auth.access mainProviderAccountUuid = resolution.providerAccountUuid } + } else if (auth.modelDenied && requestMainProviderUuid) { + // The saved account UUID identifies which quota to inspect; + // it does not authorize credentials for this denied model. + // An API fallback still requires its separate primary-account + // credential check and fresh account-wide exhaustion proof. + await reconcileMainQuotaAccountIdentity( + '', + requestMainProviderUuid, + requestMainProviderUuid, + ) + requestMainQuotaIdentity = { + quotaKey: requestMainProviderUuid, + providerAccountUuid: requestMainProviderUuid, + generation: quotaManager.getMainQuotaIdentityGeneration(), + stale: false, + state: isScopedCustodyActive(storage) ? 'on-cold' : 'na', + } } - await clearStaleMainRefreshError( - mainAccountId, - auth.refresh ? tokenFingerprint(auth.refresh) : undefined, - ) + const loadStart = nowMs() trace.mark('load_storage', { ms: roundMs(nowMs() - loadStart) }) quotaManager.updateStorage(storage) @@ -7411,7 +7282,34 @@ const anthropicAuthPlugin = async ( clearFableRecoveryNotice(sessionId, storage, auth) } const replayableRequest = isReplayableRequest(input, init?.body) - const requestModelId = parseRequestModel(init?.body) + const requestModelId = + parseRequestModel(init?.body) ?? credentialModelId + const modelDeniedResponse = () => { + const retryAfter = killswitchRetryAfterSeconds( + quotaManager.getMain(requestMainQuotaIdentity?.quotaKey) + ?.quota, + (storage?.accounts ?? []).filter(isOAuthAccount), + Date.now(), + credentialModelId, + ) + return new Response( + JSON.stringify({ + type: 'error', + error: { + type: 'rate_limit_error', + message: + 'No enabled OAuth route can serve the requested model under current quota policy.', + }, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': String(retryAfter), + }, + }, + ) + } // Count every replayable request up front — before the // fallback-first early return — so the every-N refresh cadence // (quota.refreshEveryNRequests) advances for main and the active @@ -7465,8 +7363,7 @@ const anthropicAuthPlugin = async ( if ( replayableRequest && sessionId && - getRoutingMode(storage) === 'sticky-balanced' && - auth.access + getRoutingMode(storage) === 'sticky-balanced' ) { const routingModelId = fablePlan?.effectiveModel ?? requestModelId @@ -7485,6 +7382,7 @@ const anthropicAuthPlugin = async ( storage, mainAccessToken: auth.access, mainRefreshToken: auth.refresh, + mainPolicyDenied: auth.modelDenied, requestedModelId: routingModelId, mainQuotaIdentity: requestMainQuotaIdentity, }) @@ -7522,11 +7420,13 @@ const anthropicAuthPlugin = async ( ) let resolution = await resolveRoute() if (!resolution && incompleteQuotaPool) { + if (auth.modelDenied) return modelDeniedResponse() throw new Error( 'Sticky-balanced routing is waiting for current OAuth quota snapshots', ) } if (!resolution) { + if (auth.modelDenied) return modelDeniedResponse() const response = createStickyNoRouteResponse({ mainRefreshError: stickyRoutes.storage?.refresh?.mainLastRefreshError, @@ -7563,16 +7463,17 @@ const anthropicAuthPlugin = async ( } const sendRoute = async (selected: StickyOAuthRoute) => { if (selected.scoped) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) - const scopedAttempt = await runtime.authorize( + const credential = await authorizeOAuth( selected.id, - init?.signal instanceof AbortSignal - ? init.signal - : undefined, + init?.signal ?? undefined, + undefined, + routingModelId, ) + const scopedAttempt = credential.scopedAttempt + if (!scopedAttempt) + throw new Error( + 'Native vault sticky credential is unavailable', + ) return sendWithAccessToken( input, init, @@ -7609,11 +7510,8 @@ const anthropicAuthPlugin = async ( const completeRoute = async ( selected: StickyOAuthRoute, response: Response, - markUsed = true, + _markUsed = true, ) => { - if (markUsed && selected.account) { - await fallbackManager.markUsed(selected.account) - } await writeCurrentSidebarState( selected.id, 'sticky-balanced', @@ -7687,7 +7585,7 @@ const anthropicAuthPlugin = async ( ) } - let inspected = await inspectResponse( + const inspected = await inspectResponse( await sendRoute(route), ) let permanentAuthFailure = false @@ -7696,58 +7594,28 @@ const anthropicAuthPlugin = async ( } if (inspected.response.status === 401) { - const authRouteId = route.id - try { - if (route.scoped) { - permanentAuthFailure = true - } else if ( - authRouteId === STICKY_ROUTING_MAIN_ACCOUNT_ID - ) { - auth.access = await refreshMainAccessToken( - route.access ?? '', - ) - route = { ...route, access: auth.access } - } else if (route.account && stickyRoutes.storage) { - const refreshed = - await fallbackManager.refreshAccount( - route.account, - stickyRoutes.storage, - { force: true }, - ) - if (refreshed.access) { - route = { - ...route, - access: refreshed.access, - account: refreshed, - } - } - } - if (!permanentAuthFailure) { - await inspected.response.body - ?.cancel() - .catch(() => {}) - inspected = await inspectResponse( - await sendRoute(route), - ) - if (!inspected.routeFailure) { - return completeRoute(route, inspected.response) - } - permanentAuthFailure = - inspected.response.status === 401 - } - } catch (error) { + const failure = nativeLocalRefreshFailures.get( + inspected.response, + ) + if (failure) { const latest = await loadAccounts(accountStoragePath) + const failedRouteId = route.id const refreshError = - authRouteId === STICKY_ROUTING_MAIN_ACCOUNT_ID + route.id === STICKY_ROUTING_MAIN_ACCOUNT_ID ? latest?.refresh?.mainLastRefreshError : latest?.accounts.find( (account): account is OAuthAccount => - account.id === authRouteId && + account.id === failedRouteId && isOAuthAccount(account), )?.lastRefreshError - if (!isPermanentRefreshError(refreshError)) throw error - permanentAuthFailure = true + // This request already retried after token rejection; + // do not rotate again. Temporary refresh or network + // failure keeps the current account. Switch only after + // the runtime confirms its credentials are permanently invalid. + if (!isPermanentRefreshError(refreshError)) + throw failure } + permanentAuthFailure = true } let migrate = @@ -7805,6 +7673,7 @@ const anthropicAuthPlugin = async ( storage: stickyRoutes.storage, mainAccessToken: auth.access, mainRefreshToken: auth.refresh, + mainPolicyDenied: auth.modelDenied, requestedModelId: routingModelId, mainQuotaIdentity: requestMainQuotaIdentity, }) @@ -7860,6 +7729,8 @@ const anthropicAuthPlugin = async ( account.id, 'sticky-balanced', ), + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, fableRequest, laneStartRequest, }, @@ -7918,6 +7789,8 @@ const anthropicAuthPlugin = async ( trace, { returnLastOnExhausted: false, + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback-first'), fableRequest, @@ -7939,19 +7812,23 @@ const anthropicAuthPlugin = async ( } } - if (!auth.access || !auth.expires || auth.expires < Date.now()) { + if ( + !auth.modelDenied && + (!auth.access || !auth.expires || auth.expires < Date.now()) + ) { if (isScopedCustodyActive(storage)) { - const runtime = getOpenCodeScopedRuntime( - accountStoragePath, - ctx.directory, - ) try { - const scopedAttempt = await runtime.authorize( + const credential = await authorizeOAuth( 'main', - init?.signal instanceof AbortSignal - ? init.signal - : undefined, + init?.signal ?? undefined, + undefined, + credentialModelId, ) + const scopedAttempt = credential.scopedAttempt + if (!scopedAttempt) + throw new Error( + 'Native vault main credential is unavailable', + ) mainServedAccessToken = scopedAttempt.accessToken const response = await sendWithAccessToken( input, @@ -7984,6 +7861,8 @@ const anthropicAuthPlugin = async ( { onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, fableRequest, laneStartRequest, }, @@ -8031,13 +7910,17 @@ const anthropicAuthPlugin = async ( }, ) const refreshStart = nowMs() - auth.access = await refreshMainAccessToken() + auth.access = await refreshMainAccessToken( + undefined, + credentialModelId, + init?.signal ?? undefined, + ) trace.mark('refresh_main_access', { ms: roundMs(nowMs() - refreshStart), }) } - if (!auth.access) { + if (!auth.access && !auth.modelDenied) { trace.done('missing_access_error') throw new Error('OAuth access token is missing after refresh') } @@ -8187,6 +8070,8 @@ const anthropicAuthPlugin = async ( { onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, fableRequest, laneStartRequest, }, @@ -8345,6 +8230,8 @@ const anthropicAuthPlugin = async ( // is wrong once the killswitch hands off to a fallback. onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, laneStartRequest, }, ) @@ -8406,6 +8293,31 @@ const anthropicAuthPlugin = async ( ) } + if (auth.modelDenied) { + const deniedResponse = modelDeniedResponse() + const fallbackAccounts = replayableRequest + ? await getRoutableFallbackAccounts(storage, { + modelId: credentialModelId, + }) + : [] + const response = await tryUsableFallbackAccounts( + input, + init, + fallbackAccounts, + storage, + deniedResponse, + trace, + { + onSuccess: (account) => + writeCurrentSidebarState(account.id, 'fallback'), + fableRequest, + laneStartRequest, + mainAccessToken: auth.access, + mainQuotaIdentity: requestMainQuotaIdentity, + }, + ) + return wrapResponse(response ?? deniedResponse) + } const mainResponse = await sendWithAccessToken( input, init, @@ -8479,7 +8391,24 @@ const anthropicAuthPlugin = async ( result.redirectUri, result.state, ) - return exchanged + if (exchanged.type === 'failed') return exchanged + assertNativeEnvironment() + await nativeAccounts.loginOAuth({ + routeId: 'main', + replace: true, + credential: { + access: exchanged.access, + refresh: exchanged.refresh, + expires: exchanged.expires, + }, + }) + const activation = custodyTombstoneOAuth('anthropic') + return { + type: 'success' as const, + access: activation.access, + refresh: activation.refresh, + expires: activation.expires, + } }, } }, @@ -8530,7 +8459,6 @@ const anthropicAuthPlugin = async ( __resolveMainQuotaIdentityForTest: resolveMainQuotaAccountIdentity, __resolveSidebarQuotaAccessForTest: resolveSidebarQuotaAccess, __mainProviderAccountUuidForTest: () => mainProviderAccountUuid, - __persistFallbackQuotaErrorForTest: persistFallbackQuotaError, __fallbackRefreshReady: fallbackRefreshReady, get __scopedRuntime() { return getOpenCodeScopedRuntime(accountStoragePath, ctx.directory) diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index f1e3c9bf6..9bdf5cd96 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -1004,9 +1004,16 @@ describe('account command INFO logs (via plugin)', () => { await executeCommand(plugin, 'claude-account', 'custody fallback-1 on') - const payload = drainNotifications(0, 'ses_test').at(-1)?.payload - expect(payload?.text).toContain('/claude-account claustrum') - expect(payload?.text).toContain('/claude-account local') + const notice = drainNotifications(0, 'ses_test').at(-1) + expect(notice).toBeDefined() + if ( + notice?.type !== 'open-dialog' || + notice.payload.command !== 'claude-account' + ) + throw new Error('Expected legacy account guidance payload') + const payload = notice.payload + expect(payload.text).toContain('/claude-account claustrum') + expect(payload.text).toContain('/claude-account local') expect(await readFile(accountPath, 'utf8')).toBe(before) expect((await fs.stat(accountPath)).mtimeMs).toBe(beforeMtime) }) @@ -1026,9 +1033,15 @@ describe('account command INFO logs (via plugin)', () => { const before = await readFile(accountPath, 'utf8') drainNotifications(0, 'ses_test') await executeCommand(plugin, 'claude-account', 'claustrum') - expect(drainNotifications(0, 'ses_test').at(-1)?.payload.text).toContain( - 'setup', + const notice = drainNotifications(0, 'ses_test').at(-1) + expect(notice).toBeDefined() + if ( + notice?.type !== 'open-dialog' || + notice.payload.command !== 'claude-account' ) + throw new Error('Expected legacy account guidance payload') + const payload = notice.payload + expect(payload.text).toContain('setup') expect(await readFile(accountPath, 'utf8')).toBe(before) }) diff --git a/packages/opencode/src/tests/custody-mode.test.ts b/packages/opencode/src/tests/custody-mode.test.ts index cbe04528f..d9af2792d 100644 --- a/packages/opencode/src/tests/custody-mode.test.ts +++ b/packages/opencode/src/tests/custody-mode.test.ts @@ -4,13 +4,14 @@ import { reconcileCustodyStartup, } from '../custody-mode.ts' -test('local OAuth and enrolled scoped custody are the only serving states', () => { +test('committed native local and enrolled vault custody require inert activation', () => { expect( reconcileCustodyStartup({ mode: 'L', - main: 'R', + main: 'T', fallbacks: 'R', evidence: 'V', + authority: 'committed', }).verdict, ).toBe('LOCAL_SERVE') expect( @@ -19,16 +20,27 @@ test('local OAuth and enrolled scoped custody are the only serving states', () = main: 'T', fallbacks: 'T', evidence: 'V', + authority: 'retired', }).verdict, ).toBe('CLAUSTRUM_SERVE') for (const input of [ { mode: 'C', main: 'T', fallbacks: 'M', evidence: 'V' }, { mode: 'C', main: 'R', fallbacks: 'T', evidence: 'V' }, { mode: 'C', main: 'T', fallbacks: 'T', evidence: 'N' }, + { mode: 'L', main: 'R', fallbacks: 'R', evidence: 'V' }, + { mode: 'L', main: 'T', fallbacks: 'R', evidence: 'N' }, + ] as const) { + expect(() => + reconcileCustodyStartup({ ...input, authority: 'committed' }), + ).toThrow(CustodyStateMismatchError) + } +}) + +test('activation and roster cannot bypass the committed migration journal', () => { + for (const input of [ { mode: 'L', main: 'T', fallbacks: 'R', evidence: 'V' }, + { mode: 'C', main: 'T', fallbacks: 'T', evidence: 'V' }, ] as const) { - expect(() => reconcileCustodyStartup(input)).toThrow( - CustodyStateMismatchError, - ) + expect(() => reconcileCustodyStartup(input)).toThrow('MIGRATION_REQUIRED') } }) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index e1d7b4a5f..51221d34a 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -749,7 +749,10 @@ describe('scoped enrollment is explicit', () => { ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') const notices = drainNotifications(0, sessionId) notificationId = notices.at(-1)?.id ?? 0 - const accountDialog = notices.at(-1)?.payload + const notice = notices.at(-1) + if (notice?.type !== 'open-dialog') + throw new Error('Expected a legacy account dialog notification') + const accountDialog = notice.payload expect(accountDialog?.command).toBe('claude-account') expect(accountDialog?.knobs?.accounts).toContainEqual( expect.objectContaining({ @@ -7639,7 +7642,7 @@ describe('auth.loader', () => { expect(state.relay).toEqual({ enabled: true, transport: 'websocket' }) }) - test('registers and handles /claude-cache slash command with ignored status replies', async () => { + test('registers /claude and handles cache settings with ignored status replies', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const mockClient = createMockClient() const plugin = await getPlugin(mockClient) @@ -7647,30 +7650,13 @@ describe('auth.loader', () => { await plugin.config(config) - expect(config.command?.['claude-cache']).toMatchObject({ - template: 'claude-cache', - description: expect.stringContaining('1-hour'), - }) - expect(config.command?.['claude-quota']).toMatchObject({ - template: 'claude-quota', - description: expect.stringContaining('Claude OAuth quota'), - }) - expect(config.command?.['claude-dump']).toMatchObject({ - template: 'claude-dump', - description: expect.stringContaining('dump'), - }) - expect(config.command?.['claude-fast']).toMatchObject({ - template: 'claude-fast', - description: expect.stringContaining('fast mode'), - }) - expect(config.command?.['claude-cachekeep']).toMatchObject({ - template: 'claude-cachekeep', - description: expect.stringContaining('cache warm'), - }) - expect(config.command?.['claude-routing']).toMatchObject({ - template: 'claude-routing', - description: expect.stringContaining('account routing'), + expect(config.command?.claude).toMatchObject({ + template: 'claude', + description: expect.stringContaining('Claude accounts'), }) + for (const command of COMMAND_MODAL_NAMES) { + expect(config.command?.[command]).toBeUndefined() + } await expectHandledCommandResponse( plugin['command.execute.before']({ @@ -7716,41 +7702,36 @@ describe('auth.loader', () => { expect(saved.claudeCache).toEqual({ enabled: true, mode: 'explicit' }) }) - test('config hook registers every modal command so they appear in the command palette', async () => { + test('config hook registers only /claude and retires owned aliases without clobbering foreign commands', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const plugin = await getPlugin() - - // Seed a pre-existing command from another plugin / opencode itself — the - // config hook must MERGE into config.command, never clobber it. const preExisting = { template: 'other-plugin-cmd', description: 'foreign' } const result: { command?: Record } = { - command: { 'other-plugin-cmd': preExisting }, + command: { + 'other-plugin-cmd': preExisting, + ...Object.fromEntries( + COMMAND_MODAL_NAMES.map((name) => [ + name, + { + template: name, + description: 'previous plugin registration', + }, + ]), + ), + }, } await plugin.config(result) - const registered = Object.keys(result.command ?? {}) - - // Passthrough-survival: the foreign command must still be present (the hook - // spreads ...(config.command ?? {}) — dropping that spread would silently - // wipe every other plugin's commands). expect(result.command?.['other-plugin-cmd']).toEqual(preExisting) - - // Every modal command must be registered — if one is missing it won't appear - // in the slash-command palette and users will get "No matching items". + expect(result.command?.claude).toMatchObject({ template: 'claude' }) for (const name of COMMAND_MODAL_NAMES) { - expect(registered).toContain(name) + expect(result.command?.[name]).toBeUndefined() } - - // The config hook must not register extra claude-* commands beyond the - // shared modal-command set (drift in either direction is a bug). The foreign - // 'other-plugin-cmd' is excluded from this count via the claude- prefix. - const claudeRegistered = registered.filter((name) => - name.startsWith('claude-'), - ) - expect(claudeRegistered).toHaveLength(COMMAND_MODAL_NAMES.length) - expect([...claudeRegistered].sort()).toEqual( - [...COMMAND_MODAL_NAMES].sort(), - ) + expect(Object.keys(result.command ?? {}).sort()).toEqual([ + 'claude', + 'other-plugin-cmd', + ]) + await plugin.dispose?.() }) test('handles /claude-start by injecting one visible synthetic prompt', async () => { diff --git a/packages/opencode/src/tests/native-cli.test.ts b/packages/opencode/src/tests/native-cli.test.ts new file mode 100644 index 000000000..e7285770c --- /dev/null +++ b/packages/opencode/src/tests/native-cli.test.ts @@ -0,0 +1,188 @@ +import { afterEach, expect, mock } from 'bun:test' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { authorize, exchange } from '@cortexkit/anthropic-auth-core' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { addApiRoute, login, relaySetup } from '../cli.ts' + +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_AUTH_CONTENT', +] as const +const cancellations = new Set() +let teardownRequested = false +afterEach(() => { + teardownRequested = true + const closing = [...cancellations] + for (const controller of closing) controller.abort() +}) +const lifetimes = createTestLifetimeSuite() +function test(name: string, body: () => unknown, timeout?: number) { + lifetimes.test( + name, + async () => { + teardownRequested = false + await setupBody() + return body() + }, + timeout, + ) +} +const originalFetch = globalThis.fetch +let directory: string +let path: string +let originalBytes: string +const prompt = mock(async (_question: string) => 'synthetic-answer') +const authorizeImpl = mock( + async (): Promise>> => ({ + url: 'https://example.test/sign-in', + verifier: 'synthetic-verifier', + state: 'synthetic-state', + redirectUri: 'https://example.test/callback', + }), +) +const exchangeImpl = mock( + async (): Promise>> => ({ + type: 'failed', + }), +) +const network = mock(async (): Promise => { + throw new Error('Unexpected external request in native CLI refusal test') +}) + +async function setupBody() { + const savedFetch = globalThis.fetch + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const controller = new AbortController() + cancellations.add(controller) + if (teardownRequested) controller.abort() + const ownedDirectory = await mkdtemp(join(tmpdir(), 'oc1-native-cli-')) + directory = ownedDirectory + lifetimes.deferCleanup(async () => { + // TestLifetime waits for the test and its registered background reads + // before deleting that test's directory or restoring its environment. + controller.abort() + try { + await rm(ownedDirectory, { recursive: true, force: true }) + } finally { + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + cancellations.delete(controller) + } + }) + path = join(directory, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = path + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = join( + directory, + 'anthropic-auth-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + directory, + 'enrollment.json', + ) + delete process.env.OPENCODE_AUTH_CONTENT + originalBytes = JSON.stringify({ + version: 1, + accounts: [ + { + id: 'legacy', + type: 'oauth', + access: 'synthetic-legacy-access-canary', + refresh: 'synthetic-legacy-refresh-canary', + expires: Date.now() + 60_000, + }, + ], + }) + await writeFile(path, originalBytes, { mode: 0o600 }) + prompt.mockClear() + network.mockClear() + authorizeImpl.mockClear() + exchangeImpl.mockClear() + globalThis.fetch = Object.assign( + async () => { + controller.signal.throwIfAborted() + return network() + }, + { preconnect: originalFetch.preconnect }, + ) +} + +async function expectNoMutationOrExternalWork() { + expect(await readFile(path, 'utf8')).toBe(originalBytes) + expect(await readdir(directory)).toEqual(['anthropic-auth.json']) + expect(prompt).not.toHaveBeenCalled() + expect(authorizeImpl).not.toHaveBeenCalled() + expect(exchangeImpl).not.toHaveBeenCalled() + expect(network).not.toHaveBeenCalled() +} + +test('CLI OAuth login refuses an unmigrated store before prompting or creating the sign-in URL', async () => { + await expect( + login('synthetic-label', { + prompt, + authorize: authorizeImpl, + exchange: exchangeImpl, + }), + ).rejects.toThrow('migration') + await expectNoMutationOrExternalWork() +}) + +test('CLI API route add refuses an unmigrated store before collecting any credential', async () => { + await expect(addApiRoute('synthetic-label', { prompt })).rejects.toThrow( + 'migration', + ) + await expectNoMutationOrExternalWork() +}) + +test('CLI relay setup refuses an unmigrated store before Cloudflare provisioning', async () => { + await expect(relaySetup({ prompt })).rejects.toThrow('migration') + await expectNoMutationOrExternalWork() +}) + +test('supervised auth content refuses CLI OAuth login without replacing host or legacy auth', async () => { + process.env.OPENCODE_AUTH_CONTENT = '{}' + await expect( + login('synthetic-label', { + prompt, + authorize: authorizeImpl, + exchange: exchangeImpl, + }), + ).rejects.toThrow('OPENCODE_AUTH_CONTENT') + await expectNoMutationOrExternalWork() +}) + +test('supervised auth content refuses native API route and relay mutations', async () => { + process.env.OPENCODE_AUTH_CONTENT = '{}' + await expect(addApiRoute('synthetic-label', { prompt })).rejects.toThrow( + 'OPENCODE_AUTH_CONTENT', + ) + await expect(relaySetup({ prompt })).rejects.toThrow('OPENCODE_AUTH_CONTENT') + await expectNoMutationOrExternalWork() +}) + +test('native CLI suite lifetime joins final fixture reads before restoring body environment', async () => { + const ownedPath = path + const expectedBytes = originalBytes + const savedAccountPath = process.env.OPENCODE_ANTHROPIC_AUTH_FILE + const readGate = lifetimes.gate() + let completed = false + lifetimes.trackDetached( + (async () => { + await readGate.wait + expect(process.env.OPENCODE_ANTHROPIC_AUTH_FILE).toBe(savedAccountPath) + expect(await readFile(ownedPath, 'utf8')).toBe(expectedBytes) + completed = true + })(), + ) + lifetimes.deferCleanup(() => { + expect(completed).toBe(true) + }) + readGate.open() +}) diff --git a/packages/opencode/src/tests/native-serving.test.ts b/packages/opencode/src/tests/native-serving.test.ts new file mode 100644 index 000000000..8c40455f2 --- /dev/null +++ b/packages/opencode/src/tests/native-serving.test.ts @@ -0,0 +1,1190 @@ +import { afterEach, expect, mock } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type authorize, + CacheKeepManager, + createNativeAccountRuntime, + custodyTombstoneOAuth, + discoverNativeVaultInventory, + type NativeCustodyClient, + PrimeManager, + publishNativeVaultRosterSeed, + publishNativeVaultRuntimeSeed, + resetClaudeCodeIdentityCachesForTest, + resolveNativePoolPaths, + runNativeMigration, +} from '@cortexkit/anthropic-auth-core' +import { createOpencodeClient, type Provider } from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { MockRelayServer } from '../../../e2e-tests/src/mock-relay.ts' +import { AnthropicAuthPlugin } from '../index.ts' +import { COMMAND_MODAL_NAMES } from '../rpc/protocol.ts' + +type PluginHooks = Awaited> +type PluginFetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise + +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_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_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', + 'CLAUDE_CONFIG_DIR', +] as const +const originalFetch = globalThis.fetch +const provider: Provider = { + id: 'anthropic', + name: 'Anthropic', + source: 'custom', + env: [], + options: {}, + models: {}, +} +type BodyOwner = { + directory: string + controller: AbortController + plugins: PluginHooks[] + relays: Set +} +const owners = new Set() +let currentOwner: BodyOwner | undefined +let teardownRequested = false +// Register cancellation before createTestLifetimeSuite's afterEach hook: +// stop test I/O before that hook waits for timed-out tests to finish. +afterEach(async () => { + teardownRequested = true + const closing = [...owners] + const pluginsToClose = closing.flatMap((owner) => [...owner.plugins]) + const relaysToClose = closing.flatMap((owner) => [...owner.relays]) + for (const owner of closing) owner.controller.abort() + const siblings = [ + ...pluginsToClose.map((plugin) => + Promise.resolve().then(() => plugin.dispose?.()), + ), + ...relaysToClose.map((relay) => Promise.resolve().then(() => relay.stop())), + ] + const results = await Promise.allSettled(siblings) + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Native serving cancellation failed', + ) +}) +const lifetimes = createTestLifetimeSuite() +function test(name: string, body: () => unknown, timeout?: number) { + lifetimes.test( + name, + async () => { + teardownRequested = false + await setupBody() + return body() + }, + timeout, + ) +} +let relays: Set +let directory: string +let configPath: string +let legacyBytes: string +const network = mock( + async ( + _input: Parameters[0], + _init?: Parameters[1], + ): Promise => { + throw new Error( + 'Unexpected network request in native admission refusal test', + ) + }, +) + +async function setupBody() { + const savedFetch = globalThis.fetch + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const ownedDirectory = await mkdtemp(join(tmpdir(), 'oc1-native-refusal-')) + directory = ownedDirectory + const owner: BodyOwner = { + directory: ownedDirectory, + controller: new AbortController(), + plugins: [], + relays: new Set(), + } + currentOwner = owner + if (teardownRequested) owner.controller.abort() + owners.add(owner) + relays = owner.relays + lifetimes.deferCleanup(async () => { + const remainingPlugins = owner.plugins.splice(0) + const remainingRelays = [...owner.relays] + owner.relays.clear() + owner.controller.abort() + const siblings = [ + ...remainingPlugins.map((plugin) => + Promise.resolve().then(() => plugin.dispose?.()), + ), + ...remainingRelays.map((relay) => + Promise.resolve().then(() => relay.stop()), + ), + ] + const results = await Promise.allSettled(siblings) + try { + await rm(ownedDirectory, { recursive: true, force: true }) + } finally { + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + owners.delete(owner) + if (currentOwner === owner) currentOwner = undefined + } + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Native serving cleanup failed', + ) + }) + configPath = join(directory, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = configPath + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = join( + directory, + 'anthropic-auth-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( + directory, + 'sidebar.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( + directory, + 'cachekeep', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( + directory, + 'quota-feed', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = join(directory, 'rpc') + process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE = join( + directory, + 'routing.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + directory, + 'enrollment.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = join( + directory, + 'synthetic-connection.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + delete process.env.OPENCODE_AUTH_CONTENT + legacyBytes = JSON.stringify({ + version: 1, + refresh: { enabled: false }, + quota: { enabled: false }, + claudeCache: { enabled: false }, + accounts: [ + { + id: 'legacy-route', + type: 'oauth', + access: 'synthetic-legacy-access-canary', + refresh: 'synthetic-legacy-refresh-canary', + expires: Date.now() + 60_000, + }, + ], + }) + await writeFile(configPath, legacyBytes, { mode: 0o600 }) + process.env.CLAUDE_CONFIG_DIR = join(directory, 'synthetic-claude') + resetClaudeCodeIdentityCachesForTest() + network.mockClear() + globalThis.fetch = Object.assign( + ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const signal = AbortSignal.any([ + owner.controller.signal, + ...(init?.signal ? [init.signal] : []), + ]) + signal.throwIfAborted() + return network(input, { ...init, signal }) + }, + { preconnect: originalFetch.preconnect }, + ) +} + +async function createPlugin( + options: Parameters[1] = {}, +) { + const owner = currentOwner + if (!owner) throw new Error('Native serving fixture has no body owner') + const creation = AnthropicAuthPlugin( + { + client: createOpencodeClient({ + baseUrl: 'http://127.0.0.1:9', + fetch: globalThis.fetch, + }), + project: { + id: 'native-refusal-fixture', + worktree: directory, + time: { created: Date.now() }, + }, + directory, + worktree: directory, + serverUrl: new URL('http://127.0.0.1:9'), + experimental_workspace: { register() {} }, + $, + }, + { + scopedRosterPollIntervalMs: 0, + cacheKeepAggregateRefreshIntervalMs: 0, + ...options, + }, + ) + lifetimes.trackDetached(creation) + const plugin = await creation + owner.plugins.push(plugin) + if (owner.controller.signal.aborted) await plugin.dispose?.() + return plugin +} + +function requestFetch(options: Record): PluginFetch { + const handler = options.fetch + if (typeof handler !== 'function') + throw new Error('Plugin did not install its request handler') + return async (input, init) => { + const owner = currentOwner + if (!owner) throw new Error('Native request has no body owner') + const signal = AbortSignal.any([ + owner.controller.signal, + ...(init?.signal ? [init.signal] : []), + ]) + const response: unknown = await handler(input, { ...init, signal }) + if (!(response instanceof Response)) + throw new Error('Plugin request handler did not return a Response') + return response + } +} + +async function load( + plugin: PluginHooks, + auth: Parameters['loader']>>[0], +) { + const loader = plugin.auth?.loader + if (!loader) throw new Error('Plugin did not install its auth loader') + return loader(auth, provider) +} + +test('unmigrated local OAuth refuses foreground and background network without adopting legacy secrets', async () => { + const plugin = await createPlugin() + const options = await load(plugin, async () => ({ + type: 'oauth', + access: 'synthetic-host-access-canary', + refresh: 'synthetic-host-refresh-canary', + expires: Date.now() + 60_000, + })) + await expect( + requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] }), + }), + ).rejects.toThrow('migration') + expect(network).not.toHaveBeenCalled() + expect(await readFile(configPath, 'utf8')).toBe(legacyBytes) +}) + +test('inert activation alone cannot serve before offline migration commits', async () => { + const plugin = await createPlugin() + const options = await load(plugin, async () => + custodyTombstoneOAuth('anthropic'), + ) + await expect( + requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: '{}', + }), + ).rejects.toThrow('migration') + expect(network).not.toHaveBeenCalled() + expect(await readFile(configPath, 'utf8')).toBe(legacyBytes) +}) + +test('supervised auth content refuses native OAuth before any provider request', async () => { + const plugin = await createPlugin() + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + anthropic: custodyTombstoneOAuth('anthropic'), + }) + const options = await load(plugin, async () => + custodyTombstoneOAuth('anthropic'), + ) + await expect( + requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: '{}', + }), + ).rejects.toThrow('OPENCODE_AUTH_CONTENT') + expect(network).not.toHaveBeenCalled() +}) + +test('ordinary OpenCode API auth stays passthrough even without native migration', async () => { + const plugin = await createPlugin() + expect( + await load(plugin, async () => ({ + type: 'api', + key: 'synthetic-stock-api-key', + })), + ).toEqual({}) + expect(network).not.toHaveBeenCalled() + expect(await readFile(configPath, 'utf8')).toBe(legacyBytes) +}) + +test('single claude registration removes owned aliases and preserves foreign commands', async () => { + const plugin = await createPlugin() + const config = { + command: { + foreign: { template: 'foreign' }, + ...Object.fromEntries( + COMMAND_MODAL_NAMES.map((name) => [name, { template: name }]), + ), + }, + } + await plugin.config?.(config) + expect(Object.keys(config.command).sort()).toEqual(['claude', 'foreign']) + expect(config.command.foreign).toEqual({ template: 'foreign' }) + expect(network).not.toHaveBeenCalled() +}) + +test('retired slash aliases cannot mutate settings or dispatch a native menu action', async () => { + const plugin = await createPlugin() + for (const command of COMMAND_MODAL_NAMES) { + await plugin['command.execute.before']?.( + { command, arguments: 'on', sessionID: 'synthetic-session' }, + { parts: [] }, + ) + } + expect(await readFile(configPath, 'utf8')).toBe(legacyBytes) + expect(network).not.toHaveBeenCalled() +}) + +test('local OAuth sign-in cannot begin before native authority migration', async () => { + const authorizeImpl: typeof authorize = mock(async () => ({ + url: 'https://example.test/oauth', + redirectUri: 'https://example.test/callback', + verifier: 'synthetic-verifier', + state: 'synthetic-state', + })) + const plugin = await createPlugin({ authorize: authorizeImpl }) + const method = plugin.auth?.methods[0] + if (method?.type !== 'oauth') + throw new Error('Plugin did not install native OAuth sign-in') + await expect(method.authorize()).rejects.toThrow('migration') + expect(authorizeImpl).not.toHaveBeenCalled() + expect(network).not.toHaveBeenCalled() +}) + +test('entrypoint retains only the plugin factory runtime export', async () => { + expect(Object.keys(await import('../index.ts'))).toEqual([ + 'AnthropicAuthPlugin', + ]) +}) + +const mainIdentity = '11111111-1111-4111-8111-111111111111' +const fallbackIdentity = '22222222-2222-4222-8222-222222222222' + +async function migrateServingFixture( + mode: 'local' | 'claustrum', + fallback = false, +) { + const paths = await resolveNativePoolPaths( + configPath, + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE, + ) + const hostAuthPath = join(directory, 'host-auth.json') + const scopedReports: Array<{ + credentialId: string + recordVersion: number + providerStatus: number + }> = [] + const authorizedGets: string[] = [] + let vaultVersion = 1 + const scopedClient: NativeCustodyClient = { + async listScoped() { + return { + view: 'synthetic-native-view', + rows: [ + { + id: 'oauth:anthropic', + accountId: mainIdentity, + credentialType: 'oauth', + state: 'active', + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + refreshAdapter: 'anthropic', + recordVersion: vaultVersion, + operations: ['read', 'invalidate'], + createdAtMs: 1, + }, + ...(fallback + ? [ + { + id: 'oauth:secondary', + accountId: fallbackIdentity, + credentialType: 'oauth', + state: 'active', + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + refreshAdapter: 'anthropic', + recordVersion: vaultVersion, + operations: ['read', 'invalidate'], + createdAtMs: 1, + }, + ] + : []), + ], + } + }, + async getScoped({ credentialId }) { + authorizedGets.push(credentialId) + return { + credentialId, + material: `sk-ant-oat01-vault-${credentialId === 'oauth:anthropic' ? 'main' : 'fallback'}-v${vaultVersion}`, + accountId: + credentialId === 'oauth:anthropic' ? mainIdentity : fallbackIdentity, + recordVersion: vaultVersion, + expiresAtMs: Date.now() + 8 * 60 * 60_000, + } + }, + async reportAuthFailureScoped(report) { + scopedReports.push(report) + }, + close() {}, + } + const config = { + version: 1, + mainAccountId: 'primary-route', + refresh: { enabled: false }, + quota: { enabled: false }, + routing: { mode: 'main-first' }, + ...(mode === 'claustrum' + ? { + claustrum: { + mode, + scopedRoster: true, + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: mainIdentity, + state: 'active', + }, + }, + } + : {}), + accounts: fallback + ? [ + { + id: 'fallback-route', + type: 'oauth', + enabled: true, + ...(mode === 'claustrum' + ? { + refresh: '', + claustrumScopedCredentialId: 'oauth:secondary', + anthropicAccountUuid: fallbackIdentity, + } + : { + access: 'sk-ant-oat01-local-fallback', + refresh: 'synthetic-fallback-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }), + }, + ] + : [], + } + await writeFile(paths.legacyConfig, JSON.stringify(config), { mode: 0o600 }) + await writeFile( + hostAuthPath, + JSON.stringify({ + anthropic: + mode === 'claustrum' + ? custodyTombstoneOAuth('anthropic') + : { + type: 'oauth', + access: 'sk-ant-oat01-local-main', + refresh: 'synthetic-primary-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + other: { type: 'api', key: 'synthetic-unrelated-provider' }, + }), + { mode: 0o600 }, + ) + await writeFile( + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE!, + JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const journal = await runNativeMigration({ + paths, + host: 'opencode', + hostAuthPath, + routingSourcePath: join(directory, 'legacy-routing.json'), + routingDestinationPath: join(directory, 'native-routing.json'), + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: false, + ...(mode === 'claustrum' + ? { + custody: { + discover: () => + discoverNativeVaultInventory({ + paths, + host: 'opencode', + connect: async () => scopedClient, + }), + publishSeed: publishNativeVaultRosterSeed, + publishRuntimeSeed: publishNativeVaultRuntimeSeed, + }, + } + : {}), + }) + expect(journal.phase).toBe('retired') + expect(journal.version).toBe(3) + expect(JSON.parse(await readFile(hostAuthPath, 'utf8')).other).toEqual({ + type: 'api', + key: 'synthetic-unrelated-provider', + }) + const records: Array<{ url: string; authorization: string; body: string }> = + [] + const fetchProvider = async ( + input: Parameters[0], + init?: Parameters[1], + ): Promise => { + const url = input instanceof Request ? input.url : input.toString() + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ) + const body = typeof init?.body === 'string' ? init.body : '' + records.push({ + url, + authorization: headers.get('authorization') ?? '', + body, + }) + if (url.includes('/oauth/token')) { + const payload = JSON.parse(body) + const isFallback = String(payload.refresh_token).includes('fallback') + return Response.json({ + access_token: `sk-ant-oat01-local-${isFallback ? 'fallback' : 'main'}-rotated`, + refresh_token: `synthetic-${isFallback ? 'fallback' : 'primary'}-successor`, + expires_in: 28800, + }) + } + if (url.includes('/api/claude_cli/bootstrap')) { + const uuid = headers.get('authorization')?.includes('fallback') + ? fallbackIdentity + : mainIdentity + return Response.json({ oauth_account: { account_uuid: uuid } }) + } + if (url.includes('/api/oauth/profile')) { + const uuid = headers.get('authorization')?.includes('fallback') + ? fallbackIdentity + : mainIdentity + return Response.json({ + account: { uuid }, + organization: { + uuid: 'synthetic-org', + billing_type: 'stripe', + rate_limit_tier: 'default_claude_max_5x', + }, + }) + } + if (url.includes('/api/oauth/usage')) + return Response.json({ + five_hour: { + utilization: 5, + resets_at: new Date(Date.now() + 5 * 60 * 60_000).toISOString(), + }, + seven_day: { + utilization: 10, + resets_at: new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString(), + }, + }) + if (url.includes('/v1/messages')) + return Response.json({ + id: 'synthetic-message', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'native-serving-ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }) + throw new Error(`Unexpected synthetic provider path: ${url}`) + } + network.mockImplementation(fetchProvider) + return { + paths, + records, + scopedReports, + authorizedGets, + scopedClient, + fetchProvider, + rotateVault: () => { + vaultVersion++ + }, + } +} + +async function sendNative(plugin: PluginHooks, model = 'claude-sonnet-4-6') { + const options = await load(plugin, async () => + custodyTombstoneOAuth('anthropic'), + ) + return requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-session-affinity': `synthetic-session-${directory}`, + }, + body: JSON.stringify({ + model, + max_tokens: 16, + messages: [{ role: 'user', content: 'Synthetic native serving probe' }], + }), + }) +} + +test('real offline local migration serves only positively admitted pool OAuth, never SDK or legacy secrets', async () => { + const fixture = await migrateServingFixture('local') + const plugin = await createPlugin() + const response = await sendNative(plugin) + expect(response.status).toBe(200) + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-local-main-rotated') + expect(sent[0]?.body).not.toContain('synthetic-primary-refresh') + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + const snapshot = await runtime.read() + expect( + snapshot.accounts.find((account) => account.id === 'main') + ?.accountIdentity, + ).toBe(mainIdentity) + expect(JSON.stringify(snapshot.policyStorage)).not.toContain('sk-ant-oat01') + } finally { + runtime.close() + } +}) + +test('real offline vault migration freshly authorizes direct model attempts without local token rescue', async () => { + const fixture = await migrateServingFixture('claustrum') + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + expect((await sendNative(plugin)).status).toBe(200) + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-vault-main-v1') + expect( + fixture.records.some((record) => record.url.includes('/oauth/token')), + ).toBe(false) + expect(fixture.scopedReports).toHaveLength(0) +}) + +test('supervised content after real migration refuses foreground and background dispatch', async () => { + const fixture = await migrateServingFixture('local') + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + anthropic: custodyTombstoneOAuth('anthropic'), + }) + const plugin = await createPlugin() + await expect(sendNative(plugin)).rejects.toThrow('OPENCODE_AUTH_CONTENT') + expect(fixture.records).toHaveLength(0) +}) + +for (const transport of ['http', 'websocket'] as const) { + test(`migrated vault ${transport} relay uses native private relay config and fresh vault receipt`, async () => { + const fixture = await migrateServingFixture('claustrum') + const relay = new MockRelayServer() + relays.add(relay) + const address = await relay.start({ token: 'synthetic-native-relay-token' }) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await runtime.updateRelay({ + enabled: true, + url: address.url, + token: 'synthetic-native-relay-token', + transport, + fallbackToDirect: false, + }) + expect(JSON.stringify(await runtime.read())).not.toContain( + 'synthetic-native-relay-token', + ) + } finally { + runtime.close() + } + network.mockImplementation((input, init) => { + const url = input instanceof Request ? input.url : input.toString() + return url.startsWith(address.url) + ? originalFetch(input, init) + : fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + const response = await sendNative(plugin) + expect(response.status).toBe(200) + expect(await response.text()).toContain('native-serving-ok') + expect(relay.acceptedRequests()).toBe(1) + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-vault-main-v1') + }) +} + +test('vault direct 401 retry reports each rejected physical credential version exactly', async () => { + const fixture = await migrateServingFixture('claustrum') + let attempts = 0 + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.includes('/v1/messages')) { + attempts++ + fixture.records.push({ + url, + authorization: new Headers(init?.headers).get('authorization') ?? '', + body: String(init?.body ?? ''), + }) + if (attempts === 1) fixture.rotateVault() + return Response.json( + { + type: 'error', + error: { + type: 'authentication_error', + message: 'Synthetic physical rejection', + }, + }, + { status: 401 }, + ) + } + return fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + expect((await sendNative(plugin)).status).toBe(401) + expect(attempts).toBe(2) + expect( + fixture.scopedReports.map((report) => ({ + credentialId: report.credentialId, + recordVersion: report.recordVersion, + })), + ).toEqual([ + { credentialId: 'oauth:anthropic', recordVersion: 1 }, + { credentialId: 'oauth:anthropic', recordVersion: 2 }, + ]) + expect( + fixture.records + .filter((record) => record.url.includes('/v1/messages')) + .map((record) => record.authorization), + ).toEqual([ + 'Bearer sk-ant-oat01-vault-main-v1', + 'Bearer sk-ant-oat01-vault-main-v2', + ]) +}) + +test('native CacheKeep background prewarm cannot reuse a stored legacy bearer', async () => { + const fixture = await migrateServingFixture('claustrum') + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + let storage: Awaited>['policyStorage'] + try { + await runtime.updateSettings((settings) => ({ + ...settings, + claudeCache: { enabled: true, mode: 'hybrid' }, + cacheKeep: { enabled: true, always: true }, + })) + storage = (await runtime.read()).policyStorage + } finally { + runtime.close() + } + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + await load(plugin, async () => custodyTombstoneOAuth('anthropic')) + const cacheKeep: unknown = Reflect.get(plugin, '__cacheKeepManager') + if (!(cacheKeep instanceof CacheKeepManager)) + throw new Error('Native host CacheKeep manager is unavailable') + cacheKeep.track({ + sessionId: 'synthetic-background-session', + url: 'https://api.anthropic.com/v1/messages', + headers: new Headers({ + authorization: 'Bearer synthetic-legacy-prewarm-canary', + 'content-type': 'application/json', + }), + bodyText: JSON.stringify({ + model: 'claude-sonnet-4-6', + max_tokens: 1, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Synthetic warm', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], + }), + storage, + cacheMode: 'hybrid', + oauthAccountId: 'main', + }) + await cacheKeep.prewarmNow({ + sessionId: 'synthetic-background-session', + url: 'https://api.anthropic.com/v1/messages', + headers: new Headers({ + authorization: 'Bearer synthetic-legacy-prewarm-canary', + 'content-type': 'application/json', + }), + bodyText: JSON.stringify({ + model: 'claude-sonnet-4-6', + max_tokens: 1, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Synthetic warm', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], + }), + oauthAccountId: 'main', + isSubagent: false, + }) + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-vault-main-v1') + expect(JSON.stringify(fixture.records)).not.toContain( + 'synthetic-legacy-prewarm-canary', + ) +}) + +test('vault relay transport failure reauthorizes direct fallback without false credential rejection', async () => { + const fixture = await migrateServingFixture('claustrum') + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + const relayUrl = 'https://synthetic-native-relay.invalid' + try { + await runtime.updateRelay({ + enabled: true, + url: relayUrl, + token: 'synthetic-native-relay-token', + transport: 'http', + fallbackToDirect: true, + }) + } finally { + runtime.close() + } + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.startsWith(relayUrl)) { + fixture.rotateVault() + throw new Error('Synthetic relay connection failure') + } + return fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + expect((await sendNative(plugin)).status).toBe(200) + expect( + fixture.records + .filter((record) => record.url.includes('/v1/messages')) + .map((record) => record.authorization), + ).toEqual(['Bearer sk-ant-oat01-vault-main-v2']) + expect(fixture.scopedReports).toHaveLength(0) +}) + +test('native Prime performs one fresh usage poll before one minimal Haiku send', async () => { + const fixture = await migrateServingFixture('claustrum') + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await runtime.updateSettings((settings) => ({ + ...settings, + prime: { enabled: true }, + })) + } finally { + runtime.close() + } + let usagePolls = 0 + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.includes('/api/oauth/usage')) { + usagePolls++ + fixture.records.push({ + url, + authorization: new Headers(init?.headers).get('authorization') ?? '', + body: '', + }) + return Response.json({ + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() - 120_000).toISOString(), + }, + seven_day: { + utilization: 0, + resets_at: new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString(), + }, + }) + } + return fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + await load(plugin, async () => custodyTombstoneOAuth('anthropic')) + const prime: unknown = Reflect.get(plugin, '__primeManager') + if (!(prime instanceof PrimeManager)) + throw new Error('Native host Prime manager is unavailable') + await prime.tick() + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(usagePolls).toBe(1) + expect(sent[0]?.authorization).toBe('Bearer sk-ant-oat01-vault-main-v1') + expect(JSON.parse(sent[0]?.body ?? '{}')).toMatchObject({ + max_tokens: 1, + model: expect.stringContaining('haiku'), + }) + prime.stop() +}) + +for (const fallback of [false, true]) { + test(`model-only primary denial ${fallback ? 'preserves eligible OAuth fallback' : 'returns local 429'} without primary token authorization`, async () => { + const fixture = await migrateServingFixture('claustrum', fallback) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + vault: { connect: async () => fixture.scopedClient }, + }) + try { + const receipt = await runtime.authorizeVault('main') + expect( + await runtime.vault.publish(receipt, { + quota: { + accountIdentity: mainIdentity, + checkedAt: Date.now(), + five_hour: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), + }, + seven_day: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + remainingPercent: 0, + usedPercent: 100, + checkedAt: Date.now(), + resetsAt: new Date(Date.now() + 24 * 60 * 60_000).toISOString(), + }, + ], + }, + }), + ).toBe(true) + } finally { + runtime.close() + } + fixture.authorizedGets.length = 0 + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + const response = await sendNative(plugin, 'claude-fable-5-1') + expect(response.status).toBe(fallback ? 200 : 429) + expect(fixture.authorizedGets).not.toContain('oauth:anthropic') + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(fallback ? 1 : 0) + if (fallback) + expect(sent[0]?.authorization).toBe( + 'Bearer sk-ant-oat01-vault-fallback-v1', + ) + expect( + fixture.records.some((record) => record.url.includes('/oauth/token')), + ).toBe(false) + }) +} + +test('OAuth SDK callback commits native credentials and returns only inert activation', async () => { + const fixture = await migrateServingFixture('local') + const authorization: typeof authorize = mock(async () => ({ + url: 'https://example.test/oauth', + verifier: 'synthetic-verifier', + state: 'synthetic-state', + redirectUri: 'https://example.test/callback', + })) + const plugin = await createPlugin({ authorize: authorization }) + const method = plugin.auth?.methods[0] + if (method?.type !== 'oauth') + throw new Error('Native OAuth method is unavailable') + const flow = await method.authorize() + if (flow.method !== 'code') + throw new Error('Expected code-based OAuth sign-in') + const result = await flow.callback('synthetic-code#synthetic-state') + const activation = custodyTombstoneOAuth('anthropic') + expect(result).toMatchObject({ + type: 'success', + access: activation.access, + refresh: activation.refresh, + expires: activation.expires, + }) + expect(JSON.stringify(result)).not.toContain( + 'sk-ant-oat01-local-main-rotated', + ) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + expect( + (await runtime.read()).accounts.some( + (account) => account.id === 'main' && account.type === 'oauth', + ), + ).toBe(true) + } finally { + runtime.close() + } +}) + +test('optimistic websocket late 401 reports the exact served receipt, not a newer vault credential', async () => { + const fixture = await migrateServingFixture('claustrum') + const relay = new MockRelayServer() + relays.add(relay) + const address = await relay.start({ + token: 'synthetic-native-relay-token', + responseStartDelayMs: 5, + }) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await runtime.updateRelay({ + enabled: true, + url: address.url, + token: 'synthetic-native-relay-token', + transport: 'websocket', + fallbackToDirect: false, + }) + } finally { + runtime.close() + } + const reportGate = lifetimes.gate() + const reported = reportGate.wait + const reportObserved = reportGate.open + fixture.scopedClient.reportAuthFailureScoped = async (report) => { + fixture.scopedReports.push(report) + reportObserved() + } + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.includes('/v1/messages')) { + fixture.records.push({ + url, + authorization: new Headers(init?.headers).get('authorization') ?? '', + body: String(init?.body ?? ''), + }) + fixture.rotateVault() + return Response.json( + { + type: 'error', + error: { + type: 'authentication_error', + message: 'Synthetic late websocket rejection', + }, + }, + { status: 401 }, + ) + } + return fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + const response = await sendNative(plugin) + expect(response.status).toBe(200) + expect(await response.text()).toContain('Relay upstream returned HTTP 401') + await reported + expect( + fixture.scopedReports.map((report) => ({ + credentialId: report.credentialId, + recordVersion: report.recordVersion, + })), + ).toEqual([{ credentialId: 'oauth:anthropic', recordVersion: 1 }]) + expect( + fixture.records + .filter((record) => record.url.includes('/v1/messages')) + .map((record) => record.authorization), + ).toEqual(['Bearer sk-ant-oat01-vault-main-v1']) +}) + +test('native serving suite lifetime joins detached fixture reads before body-owned cleanup', async () => { + const ownedPath = configPath + const expectedBytes = legacyBytes + const readGate = lifetimes.gate() + let completed = false + lifetimes.trackDetached( + (async () => { + await readGate.wait + expect(await readFile(ownedPath, 'utf8')).toBe(expectedBytes) + completed = true + })(), + ) + lifetimes.deferCleanup(() => { + expect(completed).toBe(true) + }) + readGate.open() +}) From 6ef2bb26150d7b0f94a5e564541f289d0ee7bf12 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:26:23 +0200 Subject: [PATCH 076/169] mason: restore native committed-mutation effects in the shared runtime The native /claude dispatch on both hosts dropped the INFO logs that the legacy commands emitted for account enable/disable/remove/reorder/add and killswitch changes, and OpenCode never applied a changed logging level to the live logger. NativeAccountRuntime now owns these effects at its own commit boundaries, through the internal native-command-effects helper: - updateSettings: after a committed 'updated' write, diffs the killswitch and logging fields against the copy the write started from; logs 'killswitch changed' / 'killswitch thresholds changed' / 'log level changed' and then applies the new level with setLogLevel. Refused, failed or unchanged writes have no effect. - setEnabled / remove / reorder: log only when the row's state actually changed (enabled flag differs, store remove outcome 'removed', reorder outcome 'reordered' or a changed vault order). Removing a vault row declines its route, so it logs 'account disabled'. reorder takes an optional moved route id so the payload keeps the legacy {id, label}. - putAccount: logs 'account added' {id, label, type} only when the store's add outcome is 'added' or 'added-disabled' (not replace, 'rotated' or 'completed'), and only after the whole call succeeds. OpenCode's host-level duplicates (log level, killswitch, account enable/disable/remove/reorder/added) are removed; both hosts pass the moved id to reorder; Pi drops its own setLogLevel so the level is applied once. --- packages/core/src/native-account-runtime.ts | 73 ++++- packages/core/src/native-command-effects.ts | 114 +++++++ .../src/tests/native-account-runtime.test.ts | 278 ++++++++++++++++++ packages/opencode/src/index.ts | 61 +--- packages/pi/src/native-commands.ts | 5 +- 5 files changed, 466 insertions(+), 65 deletions(-) create mode 100644 packages/core/src/native-command-effects.ts diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index 87c087688..47fc27034 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -30,6 +30,13 @@ import { nativeVaultPrimaryRow, projectNativeAccountViews, } from './native-account-view.ts' +import { + applyCommittedNativeSettingsEffects, + captureNativeSettingsEffectFields, + logCommittedNativeAccountAdded, + logCommittedNativeAccountChange, + type NativeSettingsEffectFields, +} from './native-command-effects.ts' import { nativeLocalCredentialValidationMatches } from './native-credential-validation.ts' import { NativeCustodyError, @@ -167,7 +174,11 @@ export interface NativeAccountRuntime { }> setEnabled(routeId: string, enabled: boolean): Promise remove(routeId: string): Promise - reorder(routeIds: readonly string[]): Promise + /** + * `movedRouteId` names the account the caller moved; it identifies the + * account in the change's INFO log and does not affect the order written. + */ + reorder(routeIds: readonly string[], movedRouteId?: string): Promise captureLocalSubject(routeId: string): Promise publishLocal( subject: NativeKnownCredentialSubject, @@ -746,6 +757,10 @@ export function createNativeAccountRuntime( throw new NativeCustodyError('route-unavailable') const old = await poolRow(id) let resultId = id + // Only an add that wrote a new roster row reports an added account: a + // replacement, a re-add of a stored secret ('rotated') or the completion + // of an existing credential-less row ('completed') does not. + let created = false if (input.replace && old) { if (!old.credentialEpoch) throw new NativeRuntimeError('publication-refused') @@ -768,6 +783,8 @@ export function createNativeAccountRuntime( label: input.label, }) resultId = added.id + created = + added.outcome === 'added' || added.outcome === 'added-disabled' } if (input.routeId === 'main') await store.updateSettings((settings) => { @@ -777,6 +794,7 @@ export function createNativeAccountRuntime( const next = await runtime.read() const row = next.accounts.find((row) => row.binding?.rowId === resultId) if (!row) throw new NativeCustodyError('route-unavailable') + if (created) logCommittedNativeAccountAdded(row) return row }, addApi: (input) => @@ -793,7 +811,10 @@ export function createNativeAccountRuntime( }), async updateSettings(mutator) { await authority() - return store.updateSettings(async (settings) => { + // Filled from the copy the committed attempt started from; effects run + // only after the write has been committed and changed the settings. + const captured: { before?: NativeSettingsEffectFields } = {} + const result = await store.updateSettings(async (settings) => { function activation(value: Record) { const custody = value.claustrum as Record | undefined return [ @@ -806,6 +827,7 @@ export function createNativeAccountRuntime( ] } const before = JSON.stringify(activation(settings)) + captured.before = captureNativeSettingsEffectFields(settings) const result = await mutator(settings) const next = captureNativePlainData(result ?? settings) as Record< string, @@ -815,31 +837,53 @@ export function createNativeAccountRuntime( throw new NativeCustodyError('route-unavailable') return next }) + if (result.outcome === 'updated' && captured.before) + applyCommittedNativeSettingsEffects(captured.before, result.settings) + return result }, async setEnabled(routeId, enabled) { const current = await runtime.read() const row = current.accounts.find((row) => row.id === routeId) - if (row?.source === 'vault') return vault.setEnabled(routeId, enabled) - const id = await physicalId(routeId) - if (enabled) await store.enable(id) - else await store.disable(id, 'user-disabled') + if (row?.source === 'vault') await vault.setEnabled(routeId, enabled) + else { + const id = await physicalId(routeId) + if (enabled) await store.enable(id) + else await store.disable(id, 'user-disabled') + } + if (row && row.enabled !== enabled) + logCommittedNativeAccountChange( + enabled ? 'account enabled' : 'account disabled', + row, + ) }, async remove(routeId) { const current = await runtime.read() - if ( - current.accounts.find((row) => row.id === routeId)?.source === 'vault' - ) { + const row = current.accounts.find((row) => row.id === routeId) + if (row?.source === 'vault') { + // Vault membership is managed by the vault, so removing a vault row + // declines its route; the committed change is a disable. await vault.setEnabled(routeId, false) + if (row.enabled) + logCommittedNativeAccountChange('account disabled', row) return } - await store.remove(await physicalId(routeId)) + const removed = await store.remove(await physicalId(routeId)) + if (row && removed.outcome === 'removed') + logCommittedNativeAccountChange('account removed', row) }, - async reorder(routeIds) { + async reorder(routeIds, movedRouteId) { await authority() const snapshot = await runtime.read() const read = await store.read() if (read.status !== 'ready') throw new NativeRuntimeError('publication-refused') + const logReordered = () => + logCommittedNativeAccountChange( + 'account reordered', + snapshot.accounts.find((row) => row.id === movedRouteId) ?? { + id: movedRouteId, + }, + ) if (snapshot.mode === 'claustrum') { const order = [...routeIds] if ( @@ -889,6 +933,11 @@ export function createNativeAccountRuntime( await store.updateSettings((settings) => { settings.nativeAccountOrder = order }) + if ( + JSON.stringify(order) !== + JSON.stringify(snapshot.accounts.map((row) => row.id)) + ) + logReordered() return } const ids = await Promise.all(routeIds.map(physicalId)) @@ -896,7 +945,7 @@ export function createNativeAccountRuntime( (row) => row.id === snapshot.settings.mainAccountId, ) if (main && !ids.includes(main.id)) ids.unshift(main.id) - await store.reorder(ids) + if ((await store.reorder(ids)).outcome === 'reordered') logReordered() }, async captureLocalSubject(routeId) { await authority() diff --git a/packages/core/src/native-command-effects.ts b/packages/core/src/native-command-effects.ts new file mode 100644 index 000000000..3fc39ae98 --- /dev/null +++ b/packages/core/src/native-command-effects.ts @@ -0,0 +1,114 @@ +import { logger, parseLogLevel, setLogLevel } from './logger.ts' + +/** + * In-process effects of a committed native account or settings change. + * + * NativeAccountRuntime calls these after its write has been committed, and + * only when that write changed what was stored. Every host's /claude actions + * therefore emit the same INFO records and apply the same live logger level, + * while a refused, failed or no-op change has no effect at all. Payloads carry + * route ids, labels and setting values only, never credential material. + */ + +type Settings = Record + +function section(settings: Settings, key: string): Record { + const value = settings[key] + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +/** The setting values whose changes have effects, read before a settings mutator can edit its copy. */ +export interface NativeSettingsEffectFields { + killswitchEnabled: boolean + killswitchMain: string | undefined + killswitchAccounts: string | undefined + logLevel: string | undefined +} + +export function captureNativeSettingsEffectFields( + settings: Settings, +): NativeSettingsEffectFields { + const killswitch = section(settings, 'killswitch') + const level = section(settings, 'logging').level + return { + // A missing flag reads as disabled, so writing an explicit `false` over + // it is not reported as a change. + killswitchEnabled: killswitch.enabled === true, + killswitchMain: JSON.stringify(killswitch.main), + killswitchAccounts: JSON.stringify(killswitch.accounts), + logLevel: typeof level === 'string' ? level : undefined, + } +} + +/** + * Log the killswitch and logging-level changes between the settings before a + * committed write and the settings that write left on disk, then apply a + * changed logging level to this process's logger. + */ +export function applyCommittedNativeSettingsEffects( + before: NativeSettingsEffectFields, + committed: Settings, +): void { + const after = captureNativeSettingsEffectFields(committed) + const killswitch = section(committed, 'killswitch') + if (after.killswitchEnabled !== before.killswitchEnabled) + logger.info('commands', 'killswitch changed', { + enabled: after.killswitchEnabled, + }) + if ( + after.killswitchMain !== before.killswitchMain || + after.killswitchAccounts !== before.killswitchAccounts + ) + logger.info('commands', 'killswitch thresholds changed', { + thresholds: killswitch.main ?? killswitch.accounts, + }) + const level = parseLogLevel(after.logLevel) + if (level && level !== parseLogLevel(before.logLevel)) { + // Recorded at the previous level, so lowering verbosity (for example to + // warn) still leaves a record of the change in the log. + logger.info('commands', 'log level changed', { level }) + setLogLevel(level) + } +} + +export type NativeAccountChange = + | 'account enabled' + | 'account disabled' + | 'account removed' + | 'account reordered' + +/** + * Log one committed change to an account row, identified by route id and + * label. A reorder whose caller did not name the moved account has no id. + */ +export function logCommittedNativeAccountChange( + change: NativeAccountChange, + account: { id?: string; label?: string }, +): void { + logger.info('commands', change, { + id: account.id, + label: account.label, + ...(change === 'account enabled' || change === 'account disabled' + ? { enabled: change === 'account enabled' } + : {}), + }) +} + +/** + * Log an account row a committed add created. The payload names the route, + * its label and the credential kind ('apikey' or 'oauth'), never the + * credential, its endpoint or its headers. + */ +export function logCommittedNativeAccountAdded(account: { + id: string + label?: string + type: 'oauth' | 'api' +}): void { + logger.info('commands', 'account added', { + id: account.id, + label: account.label, + type: account.type === 'api' ? 'apikey' : 'oauth', + }) +} diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts index 58564db34..fb30c5de1 100644 --- a/packages/core/src/tests/native-account-runtime.test.ts +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -2,6 +2,12 @@ import { expect, spyOn } from 'bun:test' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { ProviderAccountUuid } from '../claude-code.ts' +import { + __setLogTestSink, + getLogLevel, + type LogTestRecord, + setLogLevel, +} from '../logger.ts' import { createNativeAccountRuntime, nativeAccountPolicy, @@ -714,3 +720,275 @@ test('cross-process remove and readd same native route and UUID creates new Prim ).not.toBe(lineage) expect((await f.runtime.read()).accounts[0]?.prime?.count).toBe(1) }) + +/** + * Capture the INFO records the runtime emits on the `commands` channel. The + * live logger level starts at info and the previous level and sink are + * restored after the test. Bun runs tests with NODE_ENV=test, under which the + * logger delivers records only to this sink and writes no log file. + */ +function captureCommandLogs() { + expect(process.env.NODE_ENV).toBe('test') + const previousLevel = getLogLevel() + const records: LogTestRecord[] = [] + setLogLevel('info') + __setLogTestSink((record) => { + if (record.channel === 'commands') records.push(record) + }) + deferCleanup(() => { + __setLogTestSink(null) + setLogLevel(previousLevel) + }) + return { + /** Records captured since the previous call, as message and payload. */ + take() { + const taken = records.map(({ level, message, payload }) => ({ + level, + message, + payload, + })) + records.length = 0 + return taken + }, + } +} + +test('committed killswitch and logging-level settings changes log once and update the live level only after commit', async () => { + const f = await fixture() + const logs = captureCommandLogs() + + await f.runtime.updateSettings((settings) => { + settings.killswitch = { + enabled: true, + main: { five_hour: 3, seven_day: 8 }, + } + }) + expect(logs.take()).toEqual([ + { + level: 'info', + message: 'killswitch changed', + payload: { enabled: true }, + }, + { + level: 'info', + message: 'killswitch thresholds changed', + payload: { thresholds: { five_hour: 3, seven_day: 8 } }, + }, + ]) + + // Rewriting the same values commits nothing and reports nothing. + await f.runtime.updateSettings((settings) => ({ + ...settings, + killswitch: { enabled: true, main: { five_hour: 3, seven_day: 8 } }, + })) + expect(logs.take()).toEqual([]) + + // Turning the switch off keeps the thresholds: only the switch is reported. + await f.runtime.updateSettings((settings) => ({ + ...settings, + killswitch: { ...(settings.killswitch as object), enabled: false }, + })) + expect(logs.take()).toEqual([ + { + level: 'info', + message: 'killswitch changed', + payload: { enabled: false }, + }, + ]) + + // A settings change unrelated to these fields has no effect. + await f.runtime.updateSettings((settings) => { + settings.dump = { enabled: true } + }) + expect(logs.take()).toEqual([]) + expect(getLogLevel()).toBe('info') + + await f.runtime.updateSettings((settings) => { + settings.logging = { level: 'debug' } + }) + expect(logs.take()).toEqual([ + { + level: 'info', + message: 'log level changed', + payload: { level: 'debug' }, + }, + ]) + expect(getLogLevel()).toBe('debug') + expect((await f.runtime.read()).settings.logging).toEqual({ level: 'debug' }) + + // Lowering verbosity is still recorded, at the level in force before it. + await f.runtime.updateSettings((settings) => { + settings.logging = { level: 'warn' } + }) + expect(logs.take()).toEqual([ + { level: 'info', message: 'log level changed', payload: { level: 'warn' } }, + ]) + expect(getLogLevel()).toBe('warn') + setLogLevel('info') + + // Selecting the stored level again writes nothing and changes nothing. + await f.runtime.updateSettings((settings) => { + settings.logging = { level: 'warn' } + }) + expect(logs.take()).toEqual([]) + expect(getLogLevel()).toBe('info') +}) + +test('refused or failed settings writes log nothing and leave the live level unchanged', async () => { + const f = await fixture() + const logs = captureCommandLogs() + + // The mutator throws after editing its copy: nothing is committed. + await expect( + f.runtime.updateSettings((settings) => { + settings.logging = { level: 'trace' } + settings.killswitch = { enabled: true } + throw new Error('synthetic mutator failure') + }), + ).rejects.toThrow('synthetic mutator failure') + // The runtime refuses a write that also changes custody activation. + await expect( + f.runtime.updateSettings((settings) => { + settings.logging = { level: 'trace' } + settings.killswitch = { enabled: true } + settings.mainAccountId = 'other' + }), + ).rejects.toThrow() + + expect(logs.take()).toEqual([]) + expect(getLogLevel()).toBe('info') + const stored = (await f.runtime.read()).settings + expect(stored.logging).toBeUndefined() + expect(stored.killswitch).toBeUndefined() +}) + +test('committed account enable, disable, remove and reorder log the changed account without credential material', async () => { + const f = await fixture() + for (const [routeId, label] of [ + ['fallback-a', 'Work'], + ['fallback-b', undefined], + ] as const) + await f.runtime.addApi({ + routeId, + apiKey: `synthetic-api-${routeId}`, + ...(label && { label }), + }) + const logs = captureCommandLogs() + + await f.runtime.setEnabled('fallback-a', false) + await f.runtime.setEnabled('fallback-a', false) + expect(logs.take()).toEqual([ + { + level: 'info', + message: 'account disabled', + payload: { id: 'fallback-a', label: 'Work', enabled: false }, + }, + ]) + await f.runtime.setEnabled('fallback-a', true) + await f.runtime.setEnabled('fallback-a', true) + expect(logs.take()).toEqual([ + { + level: 'info', + message: 'account enabled', + payload: { id: 'fallback-a', label: 'Work', enabled: true }, + }, + ]) + + const fallbackOrder = async () => + (await f.runtime.read()).accounts + .filter((row) => row.id !== 'main') + .map((row) => row.id) + expect(await fallbackOrder()).toEqual(['fallback-a', 'fallback-b']) + await f.runtime.reorder(['fallback-b', 'fallback-a'], 'fallback-b') + expect(await fallbackOrder()).toEqual(['fallback-b', 'fallback-a']) + // The order already on disk: nothing is written or reported. + await f.runtime.reorder(['fallback-b', 'fallback-a'], 'fallback-b') + expect(logs.take()).toEqual([ + { + level: 'info', + message: 'account reordered', + payload: { id: 'fallback-b', label: undefined }, + }, + ]) + + // A route that does not exist is refused and reported by no log. + await expect(f.runtime.remove('missing-route')).rejects.toThrow() + await expect(f.runtime.setEnabled('missing-route', false)).rejects.toThrow() + expect(logs.take()).toEqual([]) + + await f.runtime.remove('fallback-a') + expect(await fallbackOrder()).toEqual(['fallback-b']) + const removed = logs.take() + expect(removed).toEqual([ + { + level: 'info', + message: 'account removed', + payload: { id: 'fallback-a', label: 'Work' }, + }, + ]) + expect(JSON.stringify(removed)).not.toContain('synthetic-api') +}) + +test('only an add that creates a new account row logs account added, without credential material', async () => { + const f = await fixture() + const logs = captureCommandLogs() + + await f.runtime.addApi({ + routeId: 'fallback-a', + apiKey: 'synthetic-api-secret-a', + baseURL: 'https://example.test', + authHeader: 'x-api-key', + label: 'Work', + }) + const added = logs.take() + expect(added).toEqual([ + { + level: 'info', + message: 'account added', + payload: { id: 'fallback-a', label: 'Work', type: 'apikey' }, + }, + ]) + const text = JSON.stringify(added) + for (const secret of ['synthetic-api-secret-a', 'example.test', 'x-api-key']) + expect(text).not.toContain(secret) + + // Replacing the route's credential creates no account. + await f.runtime.addApi({ + routeId: 'fallback-a', + apiKey: 'synthetic-api-secret-b', + replace: true, + }) + // Re-adding a stored secret under another id rotates the existing row. + await f.runtime.addApi({ + routeId: 'fallback-dup', + apiKey: 'synthetic-api-secret-b', + }) + // An add over an existing route without replace is refused. + await expect( + f.runtime.addApi({ routeId: 'fallback-a', apiKey: 'synthetic-api-other' }), + ).rejects.toThrow() + expect(logs.take()).toEqual([]) + expect( + (await f.runtime.read()).accounts + .filter((row) => row.id !== 'main') + .map((row) => row.id), + ).toEqual(['fallback-a']) + + await f.runtime.loginOAuth({ + routeId: 'fallback-oauth', + credential: { + access: 'synthetic-oauth-access', + refresh: 'synthetic-oauth-refresh', + expires: now + 3_600_000, + }, + }) + const oauth = logs.take() + expect(oauth).toEqual([ + { + level: 'info', + message: 'account added', + payload: { id: 'fallback-oauth', label: undefined, type: 'oauth' }, + }, + ]) + expect(JSON.stringify(oauth)).not.toContain('synthetic-oauth') +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index fd1e5dcbd..e13dfd750 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1286,7 +1286,8 @@ const anthropicAuthPlugin = async ( const reorderAccountsPersistent = ( routeIds: string[], _path = accountStoragePath, - ) => nativeAccounts.reorder(routeIds) + movedRouteId?: string, + ) => nativeAccounts.reorder(routeIds, movedRouteId) let initialStorage: AccountStorage | null = null let nativeStartupError: unknown @@ -3839,8 +3840,9 @@ const anthropicAuthPlugin = async ( async function executePersistentLoggingCommand(argumentsText: string) { const action = parseLoggingCommandAction(argumentsText) if (action.type === 'level') { + // The native runtime logs a committed level change and applies it to + // this process's logger. await setLogLevelPersistent(action.level) - logger.info('commands', 'log level changed', { level: action.level }) return executeLoggingCommand({ argumentsText, level: action.level }) } @@ -3951,11 +3953,7 @@ const anthropicAuthPlugin = async ( baseURL: resolvedBaseURL, authHeader: resolvedAuthHeader, }) - logger.info('commands', 'account added', { - id: account.id, - label: account.label, - type: 'apikey', - }) + // The native runtime logs the committed 'account added' event. const updatedStorage = await loadAccounts(accountStoragePath) await refreshSidebarAfterMutation(updatedStorage) @@ -4052,11 +4050,7 @@ const anthropicAuthPlugin = async ( expires: result.expires, }, }) - logger.info('commands', 'account added', { - id: account.id, - label: account.label, - type: 'oauth', - }) + // The native runtime logs the committed 'account added' event. const updatedStorage = await loadAccounts(accountStoragePath) await refreshSidebarAfterMutation(updatedStorage) @@ -4148,43 +4142,25 @@ const anthropicAuthPlugin = async ( }, }) + // The native runtime logs each committed account change below. if (result.updated) { if ( result.updated.action === 'enable' || result.updated.action === 'disable' ) { - const enabled = result.updated.action === 'enable' await setAccountEnabledPersistent( result.updated.id, - enabled, + result.updated.action === 'enable', accountStoragePath, ) - const updatedId = result.updated.id - const account = storage?.accounts.find((a) => a.id === updatedId) - logger.info('commands', `account ${result.updated.action}d`, { - id: updatedId, - label: account?.label, - enabled, - }) } else if (result.updated.action === 'remove') { await removeAccountPersistent(result.updated.id, accountStoragePath) - const updatedId = result.updated.id - const account = storage?.accounts.find((a) => a.id === updatedId) - logger.info('commands', 'account removed', { - id: updatedId, - label: account?.label, - }) } else if (result.updated.action === 'reorder') { await reorderAccountsPersistent( result.updated.newOrder ?? result.updated.previousOrder ?? [], accountStoragePath, + result.updated.id, ) - const updatedId = result.updated.id - const account = storage?.accounts.find((a) => a.id === updatedId) - logger.info('commands', 'account reordered', { - id: updatedId, - label: account?.label, - }) } else if (result.updated.action === 'reset-backoff') { await resetNativeBackoff('main') quotaManager.clearMainBackoff() @@ -4387,23 +4363,8 @@ const anthropicAuthPlugin = async ( accountIds, }) if (result.updatedConfig) { + // The native runtime logs the committed killswitch changes. await setKillswitchPersistent(result.updatedConfig) - if (config.enabled !== result.updatedConfig.enabled) { - logger.info('commands', 'killswitch changed', { - enabled: result.updatedConfig.enabled === true, - }) - } - if ( - JSON.stringify(config.main) !== - JSON.stringify(result.updatedConfig.main) || - JSON.stringify(config.accounts) !== - JSON.stringify(result.updatedConfig.accounts) - ) { - logger.info('commands', 'killswitch thresholds changed', { - thresholds: - result.updatedConfig.main ?? result.updatedConfig.accounts, - }) - } } return { command, @@ -4450,7 +4411,7 @@ const anthropicAuthPlugin = async ( throw new Error('Native account order changed') order[target] = request.values.id order[index] = neighbor - await nativeAccounts.reorder(order) + await nativeAccounts.reorder(order, request.values.id) } text = 'Native account order updated.' break diff --git a/packages/pi/src/native-commands.ts b/packages/pi/src/native-commands.ts index ede2bd58d..3d9ceb9fe 100644 --- a/packages/pi/src/native-commands.ts +++ b/packages/pi/src/native-commands.ts @@ -24,7 +24,6 @@ import { type NativeMenuDispatch, type NativeMenuKillswitchEntry, setDumpEnabled, - setLogLevel, } from '@cortexkit/anthropic-auth-core' import type { PiNativeCommands } from './commands.ts' import { createPiCustodyCommands } from './custody.ts' @@ -113,7 +112,7 @@ export function createPiNativeCommands( return { ok: false, text: 'Native account order is unavailable.' } order[index] = other order[target] = id - await runtime.reorder(order) + await runtime.reorder(order, request.values.id) return { ok: true, text: 'Native account order updated.' } } case 'add-apikey': @@ -304,8 +303,8 @@ export function createPiNativeCommands( return { ok: true, text: 'Native request dumping setting updated.' } } case 'logging-level': + // The native runtime applies a committed level change to the logger. await setSection('logging', { level: request.values.level }) - setLogLevel(request.values.level) return { ok: true, text: 'Native logging level updated.' } case 'fast-on': case 'fast-off': From e3f29f0e71232cb1b483d36513dc097aa48df40f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:33:20 +0200 Subject: [PATCH 077/169] mason: route the OpenCode add-apikey menu action through the plugin's API-key flow The /claude add-apikey action called the generic native addApi directly, so it lost the plugin's own fallback defaults: the label as route id (UUID otherwise), base URL https://api.kie.ai/claude and the authorization-bearer header. It now calls executePersistentAccountCommand with a typed add-apikey action, as add-oauth-start/finish already do; menu null values map to absent. Core addApi defaults are unchanged, and the 'account added' log still comes only from the native runtime. --- packages/opencode/src/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index e13dfd750..09d2f67e6 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -4417,8 +4417,18 @@ const anthropicAuthPlugin = async ( break } case 'add-apikey': - await nativeAccounts.addApi({ ...request.values }) - text = 'Native API fallback route added.' + // The plugin's own API-key flow applies OpenCode's fallback defaults + // (the label as route id, the kie.ai base URL and a bearer header); + // the generic native runtime defaults would otherwise apply. + text = ( + await executePersistentAccountCommand('', request.sessionId, { + type: 'add-apikey', + apiKey: request.values.apiKey, + label: request.values.label ?? undefined, + baseURL: request.values.baseURL ?? undefined, + authHeader: request.values.authHeader ?? undefined, + }) + ).text break case 'add-oauth-start': text = ( From 84b37730a241d9998e932f0e6621283769cc3342 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:52:56 +0200 Subject: [PATCH 078/169] test(core): align explicit migration consent and redirect fixture types --- packages/core/src/tests/native-authority-fixture.ts | 1 + packages/core/src/tests/oauth-redirect-fixture.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tests/native-authority-fixture.ts b/packages/core/src/tests/native-authority-fixture.ts index 3c60698e3..b4663bebe 100644 --- a/packages/core/src/tests/native-authority-fixture.ts +++ b/packages/core/src/tests/native-authority-fixture.ts @@ -11,6 +11,7 @@ export async function initializeNativeTestAuthority( await runNativeMigration({ paths, host, + removePiAnthropicAuth: false, hostAuthPath: join(root, 'host-auth'), routingSourcePath: join(root, 'old-routing'), routingDestinationPath: join(root, 'routing'), diff --git a/packages/core/src/tests/oauth-redirect-fixture.ts b/packages/core/src/tests/oauth-redirect-fixture.ts index a079f50cc..4635537d3 100644 --- a/packages/core/src/tests/oauth-redirect-fixture.ts +++ b/packages/core/src/tests/oauth-redirect-fixture.ts @@ -12,7 +12,7 @@ let redirected = 0 const server = Bun.serve({ hostname: '127.0.0.1', port: 0, - fetch(request) { + fetch(request, activeServer): Response { if (new URL(request.url).pathname === '/redirected') { redirected++ return Response.json({ @@ -26,7 +26,7 @@ const server = Bun.serve({ if (status !== 200) return new Response(null, { status, - headers: { location: new URL('/redirected', server.url).href }, + headers: { location: new URL('/redirected', activeServer.url).href }, }) return Response.json({ access_token: 'synthetic-access', From 04037ab696e7e80c5d16cc90ee114ec50ab339ce Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:04:34 +0200 Subject: [PATCH 079/169] mason: normalize nullable quota reset windows Normalize Anthropic wire resets_at:null to undefined for standard and weekly scoped usage, without fabricating reset dates, coercing unsupported types, or weakening the public snapshot/native quota codec. Add isolated synthetic fetch-decoder/native-map roundtrip tests for the reported 40/13/22 usage body, scoped null resets, absent and exact ISO controls, empty scoped ownership, account/model/time metadata, and malformed reset types. Standard-only and scoped-only normalization removal each fail only the named null roundtrip assertion; all 14 companions remain green. Verification: remote Bun 1.3.14 with frozen offline install; 23 focused tests/215 assertions, owning Core build, unsuppressed changed-root TypeScript 7.0.2, and equivalent pre-commit Biome 2.5.14 check pass. Full Core types retain three parent-owned fixture errors. Two mistaken Bun cwd/test invocations accidentally ran the full Core suite (1314 pass/1 unrelated failure each); they are not compliant targeted gates or full-suite passes. Complete raw logs/control hashes are retained. Parent reviewed and approved the two-file source/comments before commit. The first commit attempt's automatic local hook required only import-name sorting; final sorted source passed the equivalent Biome check plus targeted tests/types remotely. Skip the duplicate local hook to keep explicit verification on Linux. Inherited author retained; no existing tests rewritten. --- packages/core/src/accounts.ts | 8 +- .../src/tests/quota-normalization.test.ts | 180 ++++++++++++++++++ 2 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/tests/quota-normalization.test.ts diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index e4ac76ef6..dfc84cae4 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -428,14 +428,14 @@ export type AccountStateSaveScope = { type OAuthUsageWindow = { utilization?: number - resets_at?: string + resets_at?: string | null } type OAuthUsageLimit = { kind?: string group?: string percent?: number - resets_at?: string + resets_at?: string | null is_active?: boolean scope?: { model?: { @@ -4002,7 +4002,7 @@ function mapScopedWeeklyLimits( modelName, usedPercent, remainingPercent: clampPercent(100 - usedPercent), - resetsAt: limit.resets_at, + resetsAt: limit.resets_at ?? undefined, checkedAt, }) } @@ -4076,7 +4076,7 @@ function mapUsageWindow( return { usedPercent, remainingPercent: clampPercent(100 - usedPercent), - resetsAt: window.resets_at, + resetsAt: window.resets_at ?? undefined, checkedAt, } } diff --git a/packages/core/src/tests/quota-normalization.test.ts b/packages/core/src/tests/quota-normalization.test.ts new file mode 100644 index 000000000..af5b41c49 --- /dev/null +++ b/packages/core/src/tests/quota-normalization.test.ts @@ -0,0 +1,180 @@ +import { expect, test } from 'bun:test' + +import { + fetchOAuthQuotaSnapshot, + type OAuthQuotaSnapshot, + QUOTA_URL, +} from '../accounts.ts' +import { + fromNativeQuotaMap, + NativeQuotaCodecError, + toNativeQuotaMap, +} from '../native-quota-codec.ts' + +const checkedAt = 1700000000000 +const syntheticAccount = { + provenSynthetic: true, + identity: 'synthetic-quota-normalization-account', + accessToken: 'synthetic-quota-normalization-token', +} as const +const resetISO = '2026-10-10T12:34:56.000Z' + +// This is the usage shape for a poll with unknown standard reset times. +const reportUsage = { + 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' } }, + }, + ], +} + +function usageWithReset(reset: Record) { + return { + five_hour: { utilization: 40, ...reset }, + seven_day: { utilization: 13, ...reset }, + limits: reportUsage.limits.map((limit) => ({ ...limit, ...reset })), + } +} + +async function decodeUsage(usage: unknown): Promise { + let requests = 0 + const fetchImpl: typeof fetch = Object.assign( + async (url: string | URL | Request, init?: RequestInit) => { + requests += 1 + expect(url).toBe(QUOTA_URL) + expect(init?.method).toBe('GET') + expect(init?.headers).toMatchObject({ + Authorization: `Bearer ${syntheticAccount.accessToken}`, + }) + return Response.json(usage) + }, + { preconnect: fetch.preconnect }, + ) + const snapshot = await fetchOAuthQuotaSnapshot({ + accessToken: syntheticAccount.accessToken, + now: () => checkedAt, + fetchImpl, + }) + expect(requests).toBe(1) + // The fetch decoder does not own account identity; attach only a synthetic one. + return { ...snapshot, accountIdentity: syntheticAccount.identity } +} + +function expectedQuota(scoped: boolean, resetsAt?: string): OAuthQuotaSnapshot { + const reset = resetsAt === undefined ? {} : { resetsAt } + return { + accountIdentity: syntheticAccount.identity, + checkedAt, + source: 'poll', + fieldSources: { five_hour: 'poll', seven_day: 'poll', scoped: 'poll' }, + five_hour: { usedPercent: 40, remainingPercent: 60, checkedAt, ...reset }, + seven_day: { usedPercent: 13, remainingPercent: 87, checkedAt, ...reset }, + scoped: scoped + ? [ + { + id: 'claude-weekly-scoped-claude-fable-5-1', + title: 'Fable only', + modelId: 'claude-fable-5-1', + modelName: 'Fable', + usedPercent: 22, + remainingPercent: 78, + checkedAt, + ...reset, + }, + ] + : [], + } +} + +function assertNativeRoundtrip( + snapshot: OAuthQuotaSnapshot, + expected: OAuthQuotaSnapshot, +) { + expect(() => fromNativeQuotaMap(toNativeQuotaMap(snapshot))).not.toThrow() + const map = toNativeQuotaMap(snapshot) + const roundtrip = fromNativeQuotaMap(JSON.parse(JSON.stringify(map))) + expect(roundtrip).toEqual(expected) + expect(roundtrip).toEqual(JSON.parse(JSON.stringify(snapshot))) + expect(Object.hasOwn(snapshot, 'scoped')).toBe(true) + expect(Object.hasOwn(roundtrip, 'scoped')).toBe(true) + return map +} + +test('null quota resets normalize through the fetch decoder and native roundtrip', async () => { + const cases = [ + reportUsage, + usageWithReset({ resets_at: null }), + { ...reportUsage, limits: [] }, + ] + for (const usage of cases) { + const snapshot = await decodeUsage(usage) + const map = assertNativeRoundtrip( + snapshot, + expectedQuota(usage.limits.length > 0), + ) + expect(snapshot.five_hour?.resetsAt).toBeUndefined() + expect(snapshot.seven_day?.resetsAt).toBeUndefined() + for (const window of snapshot.scoped ?? []) { + expect(window.resetsAt).toBeUndefined() + } + for (const reading of map.limits) { + expect(Object.hasOwn(reading, 'resetsAt')).toBe(false) + } + } +}) + +test('absent quota resets preserve usage and empty scoped ownership through native roundtrip', async () => { + const usage = usageWithReset({}) + for (const limits of [usage.limits, []]) { + const snapshot = await decodeUsage({ ...usage, limits }) + assertNativeRoundtrip(snapshot, expectedQuota(limits.length > 0)) + } +}) + +test('valid ISO quota resets preserve exact strings through the fetch decoder and native roundtrip', async () => { + const usage = usageWithReset({ resets_at: resetISO }) + for (const limits of [usage.limits, []]) { + const snapshot = await decodeUsage({ ...usage, limits }) + const map = assertNativeRoundtrip( + snapshot, + expectedQuota(limits.length > 0, resetISO), + ) + for (const reading of map.limits) { + expect(reading.kind).toBe('reading') + if (reading.kind === 'reading') { + expect(reading.resetsAt).toBe('2026-10-10T12:34:56.000Z') + } + } + } +}) + +const unsupportedResets = [ + ['number', 0], + ['boolean', false], + ['object', {}], + ['array', []], +] as const + +for (const location of ['five_hour', 'seven_day', 'weekly_scoped'] as const) { + for (const [type, value] of unsupportedResets) { + test(`${location} ${type} reset is not silently normalized or accepted by the native codec`, async () => { + const usage = usageWithReset({}) + if (location === 'weekly_scoped') { + Object.assign(usage.limits[0]!, { resets_at: value }) + } else { + Object.assign(usage[location], { resets_at: value }) + } + const snapshot = await decodeUsage(usage) + const window = + location === 'weekly_scoped' ? snapshot.scoped?.[0] : snapshot[location] + const resetValue: unknown = window?.resetsAt + expect(resetValue).toEqual(value) + expect(() => toNativeQuotaMap(snapshot)).toThrow(NativeQuotaCodecError) + }) + } +} From 9755de25c772dd82bf81d87cc10a297cb0396e63 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:16:48 +0200 Subject: [PATCH 080/169] mason: preserve usable native OAuth browser URLs The shared native /claude menu scrubbed every result through the common text redactor. authorize() mints its OAuth state as 32 hex digits, which the redactor masks as a possible secret, so the browser URL shown by a successful add-oauth-start carried state=***REDACTED***. The pasted code#state then never matched the pending login and sign-in could not finish. createNativeUi now keeps exactly one sign-in link verbatim in the result text. The link must be exactly what authorize() builds: a known AUTHORIZE_URLS endpoint in canonical form, the fixed client, callback and scopes, eight unique query keys, and a generated PKCE challenge and state shape. The exemption applies only when the executor reports a successful add-oauth-start with no protected input submitted. The text around the link is still scrubbed, and so are status lines, notifications, refusals, failures and every other action. The global redactor, the OAuth constants and the host dispatch result contract are unchanged. Tests use the real authorize() and exchange() with fetch answering only the token endpoint from a local fixture. They cover: - the shown URL is unchanged, and finish correlates its state and verifier; - untrusted or altered links stay masked; - the link is masked outside a successful checked start. --- packages/core/src/native-ui.ts | 100 +++++++- packages/core/src/tests/native-ui.test.ts | 286 ++++++++++++++++++++++ 2 files changed, 383 insertions(+), 3 deletions(-) diff --git a/packages/core/src/native-ui.ts b/packages/core/src/native-ui.ts index 8f663e0e8..0d7a1ea83 100644 --- a/packages/core/src/native-ui.ts +++ b/packages/core/src/native-ui.ts @@ -33,6 +33,12 @@ export { runPiCommandMenu, } from '@cortexkit/common-auth/commands' +import { + AUTHORIZE_URLS, + CLIENT_ID, + CODE_CALLBACK_URL, + OAUTH_SCOPES, +} from './constants.ts' import { createNativeMenuExecutor, type NativeMenuDispatch, @@ -150,6 +156,81 @@ function knob(parameter: NativeMenuParameter): MenuKnob { } } +/** + * The query parameters authorize() sets with fixed values. A sign-in link + * also carries its per-login PKCE challenge and state, and nothing else. + */ +const SIGN_IN_QUERY: Readonly> = { + code: 'true', + client_id: CLIENT_ID, + response_type: 'code', + redirect_uri: CODE_CALLBACK_URL, + scope: OAUTH_SCOPES.join(' '), + code_challenge_method: 'S256', +} +const SIGN_IN_KEYS = [...Object.keys(SIGN_IN_QUERY), 'code_challenge', 'state'] +const SIGN_IN_ENDPOINTS: ReadonlySet = new Set( + Object.values(AUTHORIZE_URLS), +) +/** Base64url of a SHA-256 digest, the S256 challenge generatePKCE() makes. */ +const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/ +/** A UUID's 32 lowercase hex digits, the state authorize() generates. */ +const SIGN_IN_STATE = /^[0-9a-f]{32}$/ + +/** + * True only for a URL exactly as authorize() builds it: a known Anthropic + * authorize endpoint, the fixed client, callback and scopes, one PKCE + * challenge and one state of the generated shapes, written in canonical form + * with no credentials, port, fragment or extra parameter. + */ +function isSignInLink(candidate: string): boolean { + let url: URL + try { + url = new URL(candidate) + } catch { + return false + } + if ( + url.href !== candidate || + candidate.includes('#') || + url.username !== '' || + url.password !== '' || + !SIGN_IN_ENDPOINTS.has(`${url.origin}${url.pathname}`) + ) + return false + const keys = [...url.searchParams.keys()] + if ( + keys.length !== SIGN_IN_KEYS.length || + !SIGN_IN_KEYS.every((key) => keys.includes(key)) + ) + return false + return ( + Object.entries(SIGN_IN_QUERY).every( + ([key, value]) => url.searchParams.get(key) === value, + ) && + PKCE_CHALLENGE.test(url.searchParams.get('code_challenge') ?? '') && + SIGN_IN_STATE.test(url.searchParams.get('state') ?? '') + ) +} + +/** + * Text for a successful OAuth start. The person must open the authorize URL + * with its state intact, or the pasted code cannot be matched to the pending + * login. The redactor masks the 32-hex-digit state as a possible secret, so + * exactly one link that passes isSignInLink is kept verbatim and the text + * around it is still scrubbed. The state and PKCE challenge are public parts + * of the browser request; the PKCE verifier never appears in the link. Any + * other text, or more than one distinct link, is scrubbed in full. + */ +function showSignInLink(text: string, scrub: (text: string) => string) { + const links = new Set( + (text.match(/https:\/\/\S+/g) ?? []).filter(isSignInLink), + ) + const [link] = links + if (links.size !== 1 || link === undefined) return scrub(text) + return text.split(link).map(scrub).join(link) +} + function outcome(result: NativeMenuExecutionResult): { ok: boolean text: string @@ -306,10 +387,15 @@ export function createNativeUi(options: NativeUiOptions): CommandMenu { safe = safe.split(value).join('[REDACTED]') return redact(safe) } - const finish = async (result: ReturnType) => ({ + const finish = async ( + result: ReturnType, + signInLink = false, + ) => ({ command: 'claude', ...result, - text: scrub(result.text), + text: signInLink + ? showSignInLink(result.text, scrub) + : scrub(result.text), // Without readable data fields there is no safe way to identify a // submitted secret. Do not invoke status callbacks that could echo it. menu: await model(invocation, scrub, readable), @@ -361,7 +447,15 @@ export function createNativeUi(options: NativeUiOptions): CommandMenu { sessionId: invocation.sessionId, }, ) - return finish(outcome(result)) + // Only the checked OAuth start that the host reports as successful may + // show its sign-in link. Status lines and notifications stay scrubbed. + return finish( + outcome(result), + result.status === 'executed' && + result.action === 'add-oauth-start' && + result.ok && + protectedValues.length === 0, + ) }, } } diff --git a/packages/core/src/tests/native-ui.test.ts b/packages/core/src/tests/native-ui.test.ts index d737d05a3..d3d26c634 100644 --- a/packages/core/src/tests/native-ui.test.ts +++ b/packages/core/src/tests/native-ui.test.ts @@ -3,6 +3,8 @@ import type { CommandApplyRequest, KnobValues, } from '@cortexkit/common-auth/commands' +import { authorize, exchange } from '../auth.ts' +import { TOKEN_URL } from '../constants.ts' import type { NativeMenuDispatchRequest } from '../native-menu-executor.ts' import type { NativeMenuActionId, @@ -542,3 +544,287 @@ describe('native UI contract', () => { expect(result.menu.sections[0]?.lines[0]).toContain('status unavailable') }) }) + +describe('OAuth sign-in link', () => { + type TokenCall = { url: string; body: Record } + + // Replace fetch so only the token endpoint answers, from a local fixture. + // Any other URL throws, so these tests never reach the network. + async function withTokenFixture( + run: (calls: TokenCall[]) => Promise, + ): Promise { + const calls: TokenCall[] = [] + const nativeFetch = globalThis.fetch + const tokenFetch: typeof fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const url = input instanceof Request ? input.url : String(input) + if (url !== TOKEN_URL) + throw new Error(`unexpected network request: ${url}`) + calls.push({ url, body: JSON.parse(String(init?.body)) }) + return Response.json({ + access_token: 'synthetic-access', + refresh_token: 'synthetic-refresh', + expires_in: 3600, + }) + }, + { preconnect: nativeFetch.preconnect }, + ) + globalThis.fetch = tokenFetch + try { + return await run(calls) + } finally { + globalThis.fetch = nativeFetch + } + } + + // Mirrors the OpenCode host: start keeps the real authorize() result as + // pending sign-in state and shows its URL; finish exchanges the pasted + // code#state against that pending state. + function signInMenu( + statusText: (url: string | undefined) => string = () => 'public', + ) { + let pending: Awaited> | undefined + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + dispatch: async (request) => { + if (request.action === 'add-oauth-start') { + pending = await authorize('max') + return { + ok: true, + text: `Open this URL in your browser:\n${pending.url}`, + } + } + if (request.action === 'add-oauth-finish' && pending) { + const result = await exchange( + request.values.code, + pending.verifier, + pending.redirectUri, + pending.state, + ) + return result.type === 'success' + ? { ok: true, text: 'OAuth account added.' } + : { + ok: true, + text: 'OAuth authentication failed. Please check the code and try again.', + } + } + return { ok: false, text: 'unexpected' } + }, + readStatus: async (_command, context) => { + const text = statusText(pending?.url) + context.notify(text, 'info') + return text + }, + }) + return { menu, pending: () => pending } + } + + test('successful start shows the real authorize URL unchanged and finish correlates its state', async () => { + await withTokenFixture(async (calls) => { + const notifications: string[] = [] + const session = { + sessionId: 'oauth-session', + notify(text: string) { + notifications.push(text) + }, + } + const { menu, pending } = signInMenu((url) => + url ? `Pending sign-in: ${url}` : 'No pending sign-in', + ) + const started = await menu.apply( + { + command: 'claude', + sectionId: 'Accounts', + actionId: 'add-oauth-start', + values: {}, + }, + session, + ) + const auth = pending() + expect(auth).toBeDefined() + if (!auth) return + expect(started.ok).toBe(true) + expect(started.text).toBe(`Open this URL in your browser:\n${auth.url}`) + const shown = new URL(started.text.split('\n')[1] ?? '') + expect(shown.searchParams.get('state')).toBe(auth.state) + expect(shown.searchParams.get('code_challenge')).toMatch( + /^[A-Za-z0-9_-]{43}$/, + ) + // The PKCE verifier stays with the host; only its public challenge shows. + expect(JSON.stringify({ started, notifications })).not.toContain( + auth.verifier, + ) + // Only the result text is exempt: status lines and notifications that + // quote the same link still have its state masked. + expect(JSON.stringify(started.menu)).toContain('***REDACTED***') + expect(JSON.stringify(started.menu)).not.toContain(auth.state) + expect(notifications.length).toBeGreaterThan(0) + for (const text of notifications) expect(text).not.toContain(auth.state) + + // A person pastes the code with the state copied from the shown URL. + const finished = await menu.apply( + { + command: 'claude', + sectionId: 'Accounts', + actionId: 'add-oauth-finish', + values: { + code: `synthetic-code#${shown.searchParams.get('state')}`, + }, + }, + session, + ) + expect(finished.text).toBe('OAuth account added.') + expect(calls).toHaveLength(1) + expect(calls[0]?.body).toMatchObject({ + code: 'synthetic-code', + state: auth.state, + grant_type: 'authorization_code', + redirect_uri: auth.redirectUri, + code_verifier: auth.verifier, + }) + }) + }) + + // A menu whose capability answers every action with the given outcome. + function fixedOutcome(outcome: { ok: boolean; text: string }) { + let dispatches = 0 + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + dispatch: async () => { + dispatches++ + return outcome + }, + readStatus: async () => 'public', + }) + return { menu, dispatches: () => dispatches } + } + + async function startText( + text: string, + ok = true, + actionId: NativeMenuActionId = 'add-oauth-start', + ): Promise { + const { menu } = fixedOutcome({ ok, text }) + const result = await menu.apply( + { + command: 'claude', + sectionId: fixtures[actionId][0], + actionId, + values: fixtures[actionId][1], + }, + invocation, + ) + return result.text + } + + test('text around a valid link and any changed or untrusted link stay redacted', async () => { + const auth = await authorize('max') + const state = auth.state + const hex = 'a'.repeat(40) + const around = await startText( + `Open this URL:\n${auth.url}\nBearer synthetic-bearer sk-ant-synthetic-key ${hex}`, + ) + expect(around).toBe( + `Open this URL:\n${auth.url}\n***REDACTED*** ***REDACTED*** ***REDACTED***`, + ) + + const variant = (change: (url: URL) => void) => { + const url = new URL(auth.url) + change(url) + return url.href + } + const untrusted = { + 'other host': auth.url.replace( + 'https://claude.com/', + 'https://claude.com.example.invalid/', + ), + 'plain http': auth.url.replace('https://', 'http://'), + 'explicit port': auth.url.replace( + 'https://claude.com/', + 'https://claude.com:8443/', + ), + 'embedded credentials': auth.url.replace( + 'https://', + 'https://person:pw@', + ), + 'other path': auth.url.replace('/cai/oauth/authorize', '/oauth/token'), + fragment: `${auth.url}#token=synthetic`, + 'extra secret parameter': variant((url) => + url.searchParams.set('client_secret', 'synthetic-client-secret'), + ), + 'duplicate state': variant((url) => + url.searchParams.append('state', 'b'.repeat(32)), + ), + 'reflected callback': variant((url) => + url.searchParams.set('redirect_uri', 'https://example.invalid/cb'), + ), + 'other client': variant((url) => + url.searchParams.set('client_id', 'synthetic-client'), + ), + 'unexpected state shape': variant((url) => + url.searchParams.set('state', `${state}${state}`), + ), + 'missing challenge': variant((url) => + url.searchParams.delete('code_challenge'), + ), + } + for (const [name, link] of Object.entries(untrusted)) { + const text = await startText(`Open this URL:\n${link}`) + expect({ name, leaked: text.includes(state) }).toEqual({ + name, + leaked: false, + }) + } + // Two different links cannot both be the pending login; neither shows. + const second = await authorize('max') + const both = await startText(`${auth.url}\n${second.url}`) + expect(both).not.toContain(state) + expect(both).not.toContain(second.state) + }) + + test('a valid link is masked outside a successful checked OAuth start', async () => { + const auth = await authorize('max') + const text = `Open this URL in your browser:\n${auth.url}` + expect(await startText(text, false)).not.toContain(auth.state) + for (const actionId of ['add-oauth-finish', 'cache-on'] as const) + expect(await startText(text, true, actionId)).not.toContain(auth.state) + + const { menu, dispatches } = fixedOutcome({ ok: true, text }) + const request = { + command: 'claude', + sectionId: 'Accounts', + actionId: 'add-oauth-start', + } + const refusals = [ + // An OAuth start takes no input, so submitted values are refused. + await menu.apply( + { ...request, values: { code: 'synthetic-code' } }, + invocation, + ), + await menu.apply({ ...request, sessionId: 'other-session' }, invocation), + await menu.apply({ ...request, actionId: 'add-oauth-begin' }, invocation), + await menu.apply({ ...request, sectionId: 'Quota' }, invocation), + ] + expect(refusals.map((result) => result.code)).toEqual([ + 'unknown-parameter', + 'invalid-request', + 'invalid-request', + 'invalid-request', + ]) + expect(dispatches()).toBe(0) + const headless = createNativeUi({ + host: 'opencode', + interactive: false, + dispatch: async () => ({ ok: true, text }), + readStatus: async () => 'public', + }) + expect((await headless.apply(request, invocation)).code).toBe( + 'interactive-required', + ) + }) +}) From b8c51a2402bdf24100a13955f72f8d0a47d5c300 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:42:06 +0200 Subject: [PATCH 081/169] mason: report only the final rejected vault 401 record prepareVaultRetry (OpenCode) and fetchReading (Core quota/profile) reported the served receipt before asking the vault for a newer version, so a successful, network-failed or also-rejected replay still reported the obsolete version. Both now re-authorize first (bounded one replay, strictly newer same credential and account) and report only the receipt whose response is final; a replay transport error is not reported. The relay upstream-status callback records the 401 until the dispatch has made its final decision (it still reports late optimistic WebSocket 401s at once). Core prepareRetry takes an optional site and logs the legacy diagnostic 'scoped 401 re-authorized' {site, credentialId, servedVersion, currentVersion, retry, reason} without tokens or raw errors. Test expectation changes (protocol correction, not relaxation): three tests from the native runtime commits encoded report-both-versions: native-serving 'reports each rejected physical credential version exactly' -> 'reports only the final rejected physical credential version' ([v2]); Core 'reports each exact rejected receipt' -> 'reports only the final rejected receipt' ([8]); Core failed-telemetry test [7,8] -> [8]. New matrices cover success/replay-401/replay-network/same-version/ reauthorize-failure across direct, HTTP relay, CacheKeep, Prime and the Prime quota preflight. Remote git index was read-only; mutation controls were staged, applied and restored locally while remote jobs built/tested the synced source. --- packages/core/src/native-vault-runtime.ts | 144 +++++-- .../src/tests/native-vault-runtime.test.ts | 129 +++++- packages/opencode/src/index.ts | 40 +- .../opencode/src/tests/native-serving.test.ts | 397 +++++++++++++++++- 4 files changed, 645 insertions(+), 65 deletions(-) diff --git a/packages/core/src/native-vault-runtime.ts b/packages/core/src/native-vault-runtime.ts index 9b536f00a..ea859ca77 100644 --- a/packages/core/src/native-vault-runtime.ts +++ b/packages/core/src/native-vault-runtime.ts @@ -15,6 +15,7 @@ import { type PrimeUsageDelta, } from './accounts.ts' import { resolveClaustrumConnectionPath } from './claustrum.ts' +import { logger } from './logger.ts' import { applyNativeMetadataPatch, captureNativeMetadataPatch, @@ -64,6 +65,14 @@ export interface NativeVaultRuntimeOptions { now?: () => number } +/** Send classes that may replay once after a vault credential's 401, named in diagnostics. */ +export type NativeScoped401Site = + | 'model' + | 'model-relay' + | 'cachekeep' + | 'prime' + | 'quota-profile' + export interface NativeVaultRuntime { read(): Promise refresh(signal?: AbortSignal): Promise @@ -76,9 +85,17 @@ export interface NativeVaultRuntime { status: number, source: NativeCustodyReporterSource, ): Promise + /** + * Re-authorize after a 401 and admit one replay only for a strictly newer + * version of the same credential and account. With a `site`, the decision is + * logged as 'scoped 401 re-authorized' (versions and reason only, never + * credential material). It never reports the 401: the caller reports only + * the receipt whose response is final. + */ prepareRetry( receipt: NativeCustodyReceipt, signal?: AbortSignal, + site?: NativeScoped401Site, ): Promise publish( receipt: NativeCustodyReceipt, @@ -332,33 +349,52 @@ export function createNativeVaultRuntime( requireIssued(receipt) await custody.reportFailure(receipt, status, source) }, - async prepareRetry(served, signal) { + async prepareRetry(served, signal, site) { const route = requireIssued(served) - if (retryUsed.has(served)) - return { retry: false, reason: 'version-not-newer' } - retryUsed.add(served) - let next: NativeCustodyReceipt - try { - next = await runtime.authorize(route.logicalId, signal) - } catch (error) { - check(signal) - return { - retry: false, - reason: 'reauthorize-failed', - code: - error instanceof NativeCustodyError ? error.code : 'unavailable', + // The re-authorized receipt, kept beside the decision for diagnostics. + const decide = async (): Promise< + [NativeCustodyRetry, NativeCustodyReceipt | undefined] + > => { + if (retryUsed.has(served)) + return [{ retry: false, reason: 'version-not-newer' }, undefined] + retryUsed.add(served) + let next: NativeCustodyReceipt + try { + next = await runtime.authorize(route.logicalId, signal) + } catch (error) { + check(signal) + const code = + error instanceof NativeCustodyError ? error.code : 'unavailable' + return [ + { retry: false, reason: 'reauthorize-failed', code }, + undefined, + ] } + if (next.credentialId !== served.credentialId) + return [{ retry: false, reason: 'credential-changed' }, next] + if (next.assertedAccountIdentity !== served.assertedAccountIdentity) + return [{ retry: false, reason: 'account-changed' }, next] + if (!(next.recordVersion > served.recordVersion)) + return [{ retry: false, reason: 'version-not-newer' }, next] + // Mark the retry-issued receipt too, so handing it back cannot restart + // the request's one permitted strict-newer-version 401 retry. + retryUsed.add(next) + return [{ retry: true, receipt: next }, next] } - if (next.credentialId !== served.credentialId) - return { retry: false, reason: 'credential-changed' } - if (next.assertedAccountIdentity !== served.assertedAccountIdentity) - return { retry: false, reason: 'account-changed' } - if (!(next.recordVersion > served.recordVersion)) - return { retry: false, reason: 'version-not-newer' } - // Mark the retry-issued receipt too, so handing it back cannot restart the - // request's one permitted strict-newer-version 401 retry. - retryUsed.add(next) - return { retry: true, receipt: next } + const [result, current] = await decide() + // Record each retry decision so a 401 reported against an old version + // can be matched with the vault refresh that replaced it. The credential + // id and versions identify the record; no token or raw error is logged. + if (site) + logger.debug('claustrum', 'scoped 401 re-authorized', { + site, + credentialId: served.credentialId, + servedVersion: served.recordVersion, + currentVersion: current?.recordVersion ?? null, + retry: result.retry, + reason: result.retry ? 'rotated' : result.reason, + }) + return result }, publish(receipt, rawPatch) { const patch = captureNativeMetadataPatch(rawPatch) @@ -617,30 +653,45 @@ export function createNativeVaultRuntime( let response = await fetchImpl(input, { ...init, signal: combined }) responseStatus = response.status if (response.status === 401 && !combined.aborted) { - // Cleanup and best-effort telemetry can await. Complete them before - // final retry admission so a decline/revocation during either wins. + // Body cleanup can await. Complete it before final retry admission + // so a decline or revocation during it wins. await response.body?.cancel().catch(() => {}) - await runtime.reportFailure(served, 401, 'direct').catch(() => { - check(combined) - }) - check(combined) - const retry = await runtime.prepareRetry(served, combined) - if (retry.retry) { - served = retry.receipt - const headers = new Headers(init?.headers) - headers.set('authorization', `Bearer ${served.accessToken}`) - responseStatus = undefined - response = await fetchImpl(input, { - ...init, - headers, - signal: combined, + const report = (receipt: NativeCustodyReceipt) => + runtime.reportFailure(receipt, 401, 'direct').catch(() => { + check(combined) }) - responseStatus = response.status - if (response.status === 401) - await runtime.reportFailure(served, 401, 'direct').catch(() => { - check(combined) - }) + // Ask for a strictly newer version before reporting anything. When + // one exists the served version is already obsolete, so only the + // receipt whose response is final may be reported as rejected. + const rejected = served + let retry: NativeCustodyRetry + try { + retry = await runtime.prepareRetry( + rejected, + combined, + 'quota-profile', + ) + } catch (error) { + await report(rejected) + throw error + } + if (!retry.retry) { + await report(rejected) + return response } + served = retry.receipt + const headers = new Headers(init?.headers) + headers.set('authorization', `Bearer ${served.accessToken}`) + responseStatus = undefined + // A transport failure here propagates unreported: it is not evidence + // that the newer version was rejected. + response = await fetchImpl(input, { + ...init, + headers, + signal: combined, + }) + responseStatus = response.status + if (response.status === 401) await report(served) } return response }, @@ -712,7 +763,8 @@ export function acquireNativeVaultRuntime( authorize: (id, signal) => use().authorize(id, signal), reportFailure: (receipt, status, source) => use().reportFailure(receipt, status, source), - prepareRetry: (receipt, signal) => use().prepareRetry(receipt, signal), + prepareRetry: (receipt, signal, site) => + use().prepareRetry(receipt, signal, site), publish: (receipt, patch) => use().publish(receipt, patch), fetchQuota: (id, transport, signal) => use().fetchQuota(id, transport, signal), diff --git a/packages/core/src/tests/native-vault-runtime.test.ts b/packages/core/src/tests/native-vault-runtime.test.ts index 24718fa13..8dd67fc5f 100644 --- a/packages/core/src/tests/native-vault-runtime.test.ts +++ b/packages/core/src/tests/native-vault-runtime.test.ts @@ -6,6 +6,12 @@ import { projectVaultRoster, readVaultRoster, } from '@cortexkit/common-auth/claustrum' +import { + __setLogTestSink, + getLogLevel, + type LogTestRecord, + setLogLevel, +} from '../logger.ts' import type { NativeCustodyClient } from '../native-custody.ts' import { nativeQuotaCodec } from '../native-quota-codec.ts' import { readNativeRuntime, updateNativeRuntime } from '../native-runtime.ts' @@ -325,7 +331,7 @@ test('closing a pending vault list cannot publish its late result', async () => expect(await readVaultRoster(f.paths.roster)).toBeUndefined() }) -test('vault background 401 reports each exact rejected receipt and retries only once on strict newer version', async () => { +test('vault background 401 reports only the final rejected receipt and retries only once on strict newer version', async () => { const f = await fixture() const headers: string[] = [] const transport: typeof fetch = Object.assign( @@ -341,7 +347,7 @@ test('vault background 401 reports each exact rejected receipt and retries only 'Bearer synthetic-bearer-7', 'Bearer synthetic-bearer-8', ]) - expect(f.reports.map((report) => report.recordVersion)).toEqual([7, 8]) + expect(f.reports.map((report) => report.recordVersion)).toEqual([8]) expect(f.counts.gets).toBe(2) const served = await f.runtime.authorize('main') f.version(9) @@ -352,6 +358,123 @@ test('vault background 401 reports each exact rejected receipt and retries only expect(await f.runtime.prepareRetry(served)).toMatchObject({ retry: false }) }) +// A 401 is reported only for the receipt whose response is final. A strictly +// newer replay makes the served version obsolete whatever the replay's outcome. +const finalReportCases = [ + { + outcome: 'replay succeeds', + reports: [], + http: 2, + decision: { currentVersion: 8, retry: true, reason: 'rotated' }, + }, + { + outcome: 'replay is also rejected', + reports: [8], + http: 2, + decision: { currentVersion: 8, retry: true, reason: 'rotated' }, + }, + { + outcome: 'replay has a network error', + reports: [], + http: 2, + decision: { currentVersion: 8, retry: true, reason: 'rotated' }, + }, + { + outcome: 'vault holds the same version', + reports: [7], + http: 1, + decision: { + currentVersion: 7, + retry: false, + reason: 'version-not-newer', + }, + }, + { + outcome: 're-authorization fails', + reports: [7], + http: 1, + decision: { + currentVersion: null, + retry: false, + reason: 'reauthorize-failed', + }, + }, +] as const + +for (const scenario of finalReportCases) { + test(`vault quota 401 reports only the final rejected record when the ${scenario.outcome}`, async () => { + const f = await fixture() + let failGets = false + const runtime = createNativeVaultRuntime({ + ...f.options, + connect: async () => ({ + ...f.client, + getScoped: async (input) => { + if (failGets) throw new Error('synthetic vault unavailable') + return f.client.getScoped(input) + }, + }), + }) + deferCleanup(() => runtime.close()) + const headers: string[] = [] + const transport: typeof fetch = Object.assign( + async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get('authorization') ?? '') + if (headers.length === 1) { + if (scenario.outcome === 're-authorization fails') failGets = true + else if (scenario.outcome !== 'vault holds the same version') + f.version(8) + return new Response('{}', { status: 401 }) + } + if (scenario.outcome === 'replay has a network error') + throw new TypeError('synthetic replay network failure') + if (scenario.outcome === 'replay is also rejected') + return new Response('{}', { status: 401 }) + return new Response('{}', { status: 200 }) + }, + { preconnect: fetch.preconnect }, + ) + const records: LogTestRecord[] = [] + const previousLevel = getLogLevel() + setLogLevel('debug') + __setLogTestSink((record) => records.push(record)) + let outcome: unknown + try { + outcome = await runtime.fetchQuota('main', transport).then( + () => 'ok', + (error: unknown) => error, + ) + } finally { + __setLogTestSink(null) + setLogLevel(previousLevel) + } + if (scenario.outcome === 'replay succeeds') expect(outcome).toBe('ok') + else expect(outcome).toBeInstanceOf(Error) + expect(headers).toEqual( + ['Bearer synthetic-bearer-7', 'Bearer synthetic-bearer-8'].slice( + 0, + scenario.http, + ), + ) + expect(f.reports.map((report) => report.recordVersion)).toEqual([ + ...scenario.reports, + ]) + const decisions = records.filter( + (record) => record.message === 'scoped 401 re-authorized', + ) + expect(decisions.map((record) => record.payload)).toEqual([ + { + site: 'quota-profile', + credentialId: id, + servedVersion: 7, + ...scenario.decision, + }, + ]) + expect(JSON.stringify(decisions)).not.toContain('synthetic-bearer') + expect(JSON.stringify(decisions)).not.toContain('synthetic vault') + }) +} + test('vault receipt ownership and version fences reject copied, replaced and delayed observations', async () => { const f = await fixture() const first = await f.runtime.authorize('main') @@ -602,7 +725,7 @@ test('vault failed failure-report telemetry cannot block its one fresh retry or status: 401, }) expect(http).toBe(2) - expect(f.reports.map((report) => report.recordVersion)).toEqual([7, 8]) + expect(f.reports.map((report) => report.recordVersion)).toEqual([8]) }) test('vault quota success and failure cannot inherit another account runtime clear floor', async () => { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index fd1e5dcbd..86f654f80 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1468,13 +1468,20 @@ const anthropicAuthPlugin = async ( logger.warn('claustrum', 'native auth-failure report unavailable') }) } + // Re-authorize after a 401 before anything is reported. When the vault admits + // a strictly newer version of the same credential and account, the served + // version is obsolete: the caller replays once and reports only the receipt + // whose response is final. A refusal leaves the served receipt final. async function prepareVaultRetry( receipt: NativeCustodyReceipt, + site: 'model' | 'model-relay' | 'cachekeep' | 'prime', signal?: AbortSignal, - source: 'direct' | 'relay_status_field' = 'direct', ) { - await reportVault401(receipt, source) - const result = await nativeAccounts.vault.prepareRetry(receipt, signal) + const result = await nativeAccounts.vault.prepareRetry( + receipt, + signal, + site, + ) return result.retry ? result.receipt : undefined } @@ -2453,7 +2460,11 @@ const anthropicAuthPlugin = async ( } catch {} if ( served.scopedAttempt && - !(await prepareVaultRetry(served.scopedAttempt, attempt.signal)) + !(await prepareVaultRetry( + served.scopedAttempt, + 'cachekeep', + attempt.signal, + )) ) return undefined const current = await authorizeOAuth( @@ -2695,7 +2706,7 @@ const anthropicAuthPlugin = async ( if (response.status === 401 && scopedAttempt && !signal.aborted) { let current: NativeCustodyReceipt | undefined try { - current = await prepareVaultRetry(scopedAttempt, signal) + current = await prepareVaultRetry(scopedAttempt, 'prime', signal) } catch { // The first 401 belongs to the original physical attempt unless // the vault confirms a newer version of this same account. @@ -6073,6 +6084,10 @@ const anthropicAuthPlugin = async ( } const sendStart = nowMs() let relay401Attempt: NativeCustodyReceipt | undefined + // Set once this dispatch has made its final retry decision. Before + // that, a relayed upstream 401 is only recorded: the dispatch may + // still replay on a newer version, which makes this one obsolete. + let relayReturned = false const reportScoped401 = reportVault401 const sendOnce = () => sendViaRelay({ @@ -6107,11 +6122,12 @@ const anthropicAuthPlugin = async ( onUpstreamStatus: (status: number) => { if (status !== 401 || !nextAttempt) return relay401Attempt = nextAttempt - // The relay can return an optimistic 200 before Anthropic - // answers. If Anthropic later rejects the tokens, report - // the vault credential and version used for this send, - // not a newer version obtained after the rejection. - void reportScoped401(nextAttempt, 'relay_status_field') + // A WebSocket can deliver a real upstream 401 after its + // optimistic response was handed to the host. Report + // then the exact version used for this send, never a + // newer version obtained after the rejection. + if (relayReturned) + void reportScoped401(nextAttempt, 'relay_status_field') }, } }, @@ -6140,8 +6156,8 @@ const anthropicAuthPlugin = async ( try { rotated = await prepareVaultRetry( relay401Attempt, + 'model-relay', init?.signal ?? undefined, - 'relay_status_field', ) } catch { // Keep the 401 and report the exact relay-served record below. @@ -6176,6 +6192,7 @@ const anthropicAuthPlugin = async ( try { rotated = await prepareVaultRetry( directAttempt, + 'model', init?.signal ?? undefined, ) } catch { @@ -6251,6 +6268,7 @@ const anthropicAuthPlugin = async ( if (usedDirectFetch && directAttempt && response.status === 401) { await reportScoped401(directAttempt, 'direct') } + relayReturned = true if (!usedDirectFetch && relay401Attempt) { await reportScoped401(relay401Attempt, 'relay_status_field') } diff --git a/packages/opencode/src/tests/native-serving.test.ts b/packages/opencode/src/tests/native-serving.test.ts index 8c40455f2..b39c5ae48 100644 --- a/packages/opencode/src/tests/native-serving.test.ts +++ b/packages/opencode/src/tests/native-serving.test.ts @@ -3,11 +3,14 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { + __setLogTestSink, type authorize, CacheKeepManager, createNativeAccountRuntime, custodyTombstoneOAuth, discoverNativeVaultInventory, + getLogLevel, + type LogTestRecord, type NativeCustodyClient, PrimeManager, publishNativeVaultRosterSeed, @@ -15,6 +18,7 @@ import { resetClaudeCodeIdentityCachesForTest, resolveNativePoolPaths, runNativeMigration, + setLogLevel, } from '@cortexkit/anthropic-auth-core' import { createOpencodeClient, type Provider } from '@opencode-ai/sdk' import { $ } from 'bun' @@ -762,7 +766,7 @@ for (const transport of ['http', 'websocket'] as const) { }) } -test('vault direct 401 retry reports each rejected physical credential version exactly', async () => { +test('vault direct 401 retry reports only the final rejected physical credential version', async () => { const fixture = await migrateServingFixture('claustrum') let attempts = 0 network.mockImplementation(async (input, init) => { @@ -798,10 +802,7 @@ test('vault direct 401 retry reports each rejected physical credential version e credentialId: report.credentialId, recordVersion: report.recordVersion, })), - ).toEqual([ - { credentialId: 'oauth:anthropic', recordVersion: 1 }, - { credentialId: 'oauth:anthropic', recordVersion: 2 }, - ]) + ).toEqual([{ credentialId: 'oauth:anthropic', recordVersion: 2 }]) expect( fixture.records .filter((record) => record.url.includes('/v1/messages')) @@ -1188,3 +1189,389 @@ test('native serving suite lifetime joins detached fixture reads before body-own }) readGate.open() }) + +// Every vault send site reports a 401 only for the receipt whose response is +// final. A strictly newer replay makes the served version obsolete whatever +// the replay's outcome; without one, the served version is the final record. +const rotatedDecision = { currentVersion: 2, retry: true, reason: 'rotated' } +const final401Cases = [ + { + outcome: 'replay succeeds', + reports: [], + wire: [1, 2], + decision: rotatedDecision, + }, + { + outcome: 'replay is also rejected', + reports: [2], + wire: [1, 2], + decision: rotatedDecision, + }, + { + outcome: 'replay has a network error', + reports: [], + wire: [1, 2], + decision: rotatedDecision, + }, + { + outcome: 'vault holds the same version', + reports: [1], + wire: [1], + decision: { currentVersion: 1, retry: false, reason: 'version-not-newer' }, + }, + { + outcome: 're-authorization fails', + reports: [1], + wire: [1], + decision: { + currentVersion: null, + retry: false, + reason: 'reauthorize-failed', + }, + }, +] as const +type Final401Case = (typeof final401Cases)[number] +type ServingFixture = Awaited> + +/** A synthetic Anthropic endpoint that rejects the first send it receives. */ +function final401Upstream(fixture: ServingFixture, scenario: Final401Case) { + const wire: string[] = [] + // An Anthropic request id is what lets a relayed status count as upstream. + const provenance = { 'request-id': 'req_synthetic_final_401' } + const rejection = () => + Response.json( + { + type: 'error', + error: { + type: 'authentication_error', + message: 'Synthetic physical rejection', + }, + }, + { status: 401, headers: provenance }, + ) + const handle = async (init?: RequestInit) => { + wire.push(new Headers(init?.headers).get('authorization') ?? '') + if (wire.length === 1) { + if (scenario.outcome === 're-authorization fails') + fixture.scopedClient.getScoped = async () => { + throw new Error('synthetic vault unavailable') + } + else if (scenario.outcome !== 'vault holds the same version') + fixture.rotateVault() + return rejection() + } + if (scenario.outcome === 'replay has a network error') + throw new TypeError('synthetic replay network failure') + if (scenario.outcome === 'replay is also rejected') return rejection() + return Response.json( + { + id: 'synthetic-message', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'native-serving-ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }, + { headers: provenance }, + ) + } + return { wire, handle } +} + +async function scoped401Decisions(run: () => Promise) { + const records: LogTestRecord[] = [] + const previousLevel = getLogLevel() + setLogLevel('debug') + __setLogTestSink((record) => records.push(record)) + try { + await run() + } finally { + __setLogTestSink(null) + setLogLevel(previousLevel) + } + return records.filter( + (record) => record.message === 'scoped 401 re-authorized', + ) +} + +function expectFinal401Record( + fixture: ServingFixture, + scenario: Pick & { + reports: readonly number[] + wire: readonly number[] + }, + wire: string[], + decisions: LogTestRecord[], + site: string, +) { + expect(wire).toEqual( + scenario.wire.map( + (version) => `Bearer sk-ant-oat01-vault-main-v${version}`, + ), + ) + expect( + fixture.scopedReports.map((report) => ({ + credentialId: report.credentialId, + recordVersion: report.recordVersion, + })), + ).toEqual( + scenario.reports.map((recordVersion) => ({ + credentialId: 'oauth:anthropic', + recordVersion, + })), + ) + expect(decisions.map((record) => record.payload)).toEqual([ + { + site, + credentialId: 'oauth:anthropic', + servedVersion: 1, + ...scenario.decision, + }, + ]) + expect(JSON.stringify(decisions)).not.toContain('sk-ant-oat01') + expect(JSON.stringify(decisions)).not.toContain('synthetic vault') +} + +const primeWindows = () => + Response.json({ + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() - 120_000).toISOString(), + }, + seven_day: { + utilization: 0, + resets_at: new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString(), + }, + }) + +const cacheKeepPrewarmBody = JSON.stringify({ + model: 'claude-sonnet-4-6', + max_tokens: 1, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Synthetic warm', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], +}) + +async function enablePrime(fixture: ServingFixture) { + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await runtime.updateSettings((settings) => ({ + ...settings, + prime: { enabled: true }, + })) + } finally { + runtime.close() + } +} + +async function tickPrime(plugin: PluginHooks) { + await load(plugin, async () => custodyTombstoneOAuth('anthropic')) + const prime: unknown = Reflect.get(plugin, '__primeManager') + if (!(prime instanceof PrimeManager)) + throw new Error('Native host Prime manager is unavailable') + try { + return await scoped401Decisions(() => prime.tick().catch(() => {})) + } finally { + prime.stop() + } +} + +for (const scenario of final401Cases) { + test(`vault direct model 401 reports only the final record when the ${scenario.outcome}`, async () => { + const fixture = await migrateServingFixture('claustrum') + const upstream = final401Upstream(fixture, scenario) + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + return url.includes('/v1/messages') + ? upstream.handle(init) + : fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + let status: unknown + const decisions = await scoped401Decisions(async () => { + status = await sendNative(plugin).then( + (response) => response.status, + (error: unknown) => error, + ) + }) + if (scenario.outcome === 'replay succeeds') expect(status).toBe(200) + else if (scenario.outcome === 'replay has a network error') + expect(status).not.toBe(200) + else expect(status).toBe(401) + expectFinal401Record(fixture, scenario, upstream.wire, decisions, 'model') + }) + + test(`vault HTTP relay 401 reports only the final record when the ${scenario.outcome}`, async () => { + const fixture = await migrateServingFixture('claustrum') + const relay = new MockRelayServer() + relays.add(relay) + const address = await relay.start({ token: 'synthetic-native-relay-token' }) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await runtime.updateRelay({ + enabled: true, + url: address.url, + token: 'synthetic-native-relay-token', + transport: 'http', + fallbackToDirect: false, + }) + } finally { + runtime.close() + } + const upstream = final401Upstream(fixture, scenario) + // A replay that cannot reach the relay at all is this transport's network + // error; the relay itself never forwards it upstream. + const relayNetworkError = scenario.outcome === 'replay has a network error' + let replayRefused = false + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.startsWith(address.url)) { + if ( + relayNetworkError && + String(init?.body).includes('sk-ant-oat01-vault-main-v2') + ) { + replayRefused = true + throw new TypeError('synthetic relay network failure') + } + return originalFetch(input, init) + } + return url.includes('/v1/messages') + ? upstream.handle(init) + : fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + let status: unknown + const decisions = await scoped401Decisions(async () => { + status = await sendNative(plugin).then( + (response) => response.status, + (error: unknown) => error, + ) + }) + if (scenario.outcome === 'replay succeeds') expect(status).toBe(200) + else if (scenario.outcome === 'replay has a network error') + expect(status).not.toBe(200) + else expect(status).toBe(401) + expect(replayRefused).toBe(relayNetworkError) + const forwarded = relayNetworkError ? { ...scenario, wire: [1] } : scenario + expect(relay.acceptedRequests()).toBe(forwarded.wire.length) + expectFinal401Record( + fixture, + forwarded, + upstream.wire, + decisions, + 'model-relay', + ) + }) + + test(`vault CacheKeep prewarm 401 reports only the final record when the ${scenario.outcome}`, async () => { + const fixture = await migrateServingFixture('claustrum') + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + let storage: Awaited>['policyStorage'] + try { + await runtime.updateSettings((settings) => ({ + ...settings, + claudeCache: { enabled: true, mode: 'hybrid' }, + cacheKeep: { enabled: true, always: true }, + })) + storage = (await runtime.read()).policyStorage + } finally { + runtime.close() + } + const upstream = final401Upstream(fixture, scenario) + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + return url.includes('/v1/messages') + ? upstream.handle(init) + : fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + await load(plugin, async () => custodyTombstoneOAuth('anthropic')) + const cacheKeep: unknown = Reflect.get(plugin, '__cacheKeepManager') + if (!(cacheKeep instanceof CacheKeepManager)) + throw new Error('Native host CacheKeep manager is unavailable') + const target = { + sessionId: 'synthetic-final-401-session', + url: 'https://api.anthropic.com/v1/messages', + headers: new Headers({ 'content-type': 'application/json' }), + bodyText: cacheKeepPrewarmBody, + oauthAccountId: 'main', + } + cacheKeep.track({ ...target, storage, cacheMode: 'hybrid' }) + const decisions = await scoped401Decisions(() => + cacheKeep.prewarmNow({ ...target, isSubagent: false }), + ) + expectFinal401Record( + fixture, + scenario, + upstream.wire, + decisions, + 'cachekeep', + ) + }) + + test(`vault Prime 401 reports only the final record when the ${scenario.outcome}`, async () => { + const fixture = await migrateServingFixture('claustrum') + await enablePrime(fixture) + const upstream = final401Upstream(fixture, scenario) + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.includes('/api/oauth/usage')) return primeWindows() + return url.includes('/v1/messages') + ? upstream.handle(init) + : fixture.fetchProvider(input, init) + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + const decisions = await tickPrime(plugin) + expectFinal401Record(fixture, scenario, upstream.wire, decisions, 'prime') + }) + + test(`vault Prime quota preflight 401 reports only the final record when the ${scenario.outcome}`, async () => { + const fixture = await migrateServingFixture('claustrum') + await enablePrime(fixture) + const usage = final401Upstream(fixture, scenario) + network.mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (!url.includes('/api/oauth/usage')) + return fixture.fetchProvider(input, init) + const response = await usage.handle(init) + return response.ok ? primeWindows() : response + }) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + const decisions = await tickPrime(plugin) + expectFinal401Record( + fixture, + scenario, + usage.wire, + decisions, + 'quota-profile', + ) + }) +} From 420164ddc5f991d08f822745145a5bfb1901b56c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:36:29 +0200 Subject: [PATCH 082/169] mason: judge native account and setting effects on locked outcomes Review of the shared committed-effects layer found that setEnabled, vault remove and vault reorder decided whether to log from a snapshot read before the write, so two writers could both log one change, and that a logging-level command did not reconcile a live level that differed from the unchanged stored level. - Local enable/disable: common-auth 0.11.7 returns only { id } for a plain enable or disable. A store opened for the one operation reads the row with the pure store.read() at each awaited 'before-config-write' step, while every store lock is held; the last read wins and shows torn rows as completed. The normal producer enable/disable runs unchanged, so its guards still refuse. A log is written only when that held enabled flag differs from the request. - Vault setEnabled (native-vault-runtime) makes the acceptVaultRoute / declineVaultRoute roster change inside mutateVaultRoster and returns whether the held flag changed; vault remove uses that result. - Vault reorder logs on a changed locked roster order or an 'updated' settings write; local remove logs only on outcome 'removed'. - Killswitch threshold changes are compared and logged through a closed numeric projection, so other stored fields never reach the log. - NativeAccountRuntime.setLoggingLevel stores the level, then sets the live level if it still differs; OpenCode and Pi use it. Unrelated settings writes no longer touch the live level. The earlier test expecting a re-selected stored level to leave a different live level unchanged encoded the wrong behaviour and was corrected. - Comment-only clarifications; emitted JS was checked equal before and after them. The two-writer enable test fails on the previous stale-read decision and passes now; negative controls for the vault result, live-level reconcile, threshold projection and reorder outcome each turn their named tests red. --- packages/core/src/native-account-runtime.ts | 160 +++++++--- packages/core/src/native-command-effects.ts | 110 +++++-- packages/core/src/native-vault-runtime.ts | 49 ++- .../src/tests/native-account-runtime.test.ts | 282 ++++++++++++++++-- .../src/tests/native-vault-runtime.test.ts | 162 +++++++++- packages/opencode/src/index.ts | 22 +- packages/pi/src/native-commands.ts | 4 +- 7 files changed, 671 insertions(+), 118 deletions(-) diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index 47fc27034..2d4bd5b3c 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -17,6 +17,7 @@ import { quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, } from './accounts.ts' +import type { LogLevel } from './logger.ts' import { applyNativeMetadataPatch, captureNativeMetadataPatch, @@ -32,6 +33,7 @@ import { } from './native-account-view.ts' import { applyCommittedNativeSettingsEffects, + applyNativeLogLevel, captureNativeSettingsEffectFields, logCommittedNativeAccountAdded, logCommittedNativeAccountChange, @@ -172,6 +174,12 @@ export interface NativeAccountRuntime { settings: Record outcome: 'updated' | 'unchanged' }> + /** + * Store `level` as the logging level, then make it this process's live + * logger level if it is not already, even when the stored value was + * unchanged. Nothing is applied when the write fails. + */ + setLoggingLevel(level: LogLevel): Promise setEnabled(routeId: string, enabled: boolean): Promise remove(routeId: string): Promise /** @@ -841,35 +849,86 @@ export function createNativeAccountRuntime( applyCommittedNativeSettingsEffects(captured.before, result.settings) return result }, + async setLoggingLevel(level) { + // A changed stored level is applied and logged by updateSettings; an + // unchanged one still brings a different live level in line with it. + await runtime.updateSettings((settings) => ({ + ...settings, + logging: { + ...(settings.logging && + typeof settings.logging === 'object' && + !Array.isArray(settings.logging) + ? settings.logging + : {}), + level, + }, + })) + applyNativeLogLevel(level) + }, async setEnabled(routeId, enabled) { + // The earlier read only chooses the vault or the pool and supplies the + // label; whether the flag changed is judged under the write's locks. const current = await runtime.read() const row = current.accounts.find((row) => row.id === routeId) - if (row?.source === 'vault') await vault.setEnabled(routeId, enabled) - else { - const id = await physicalId(routeId) - if (enabled) await store.enable(id) - else await store.disable(id, 'user-disabled') + const account = { id: routeId, label: row?.label } + const change = enabled ? 'account enabled' : 'account disabled' + if (row?.source === 'vault') { + if (await vault.setEnabled(routeId, enabled)) + logCommittedNativeAccountChange(change, account) + return } - if (row && row.enabled !== enabled) - logCommittedNativeAccountChange( - enabled ? 'account enabled' : 'account disabled', - row, - ) + const id = await physicalId(routeId) + const operation = enabled ? 'enable' : 'disable' + // Enable and disable return account metadata, not whether its enabled + // flag changed. A store opened for this one operation therefore reads the + // row just before each of its config writes, while all of the store's + // locks are held. An enable + // of a row that is already enabled writes no config. Repairing a row + // torn by an interrupted write can write the config first, so the last + // read wins; reads show a torn row as completed, so the repair itself is + // never taken for the operation's change. + const captured: { before?: boolean } = {} + const transition = createNativePoolStore({ + paths, + quota: nativeQuotaCodec, + now, + onStep: async (step, info) => { + if ( + step !== 'before-config-write' || + info.operation !== operation || + info.rowId !== id + ) + return + captured.before = undefined + try { + const read = await store.read() + if (read.status === 'ready') + captured.before = read.rows.find((row) => row.id === id)?.enabled + } catch { + // An unreadable pool leaves the change unknown, so nothing is logged. + } + }, + }) + if (enabled) await transition.enable(id) + else await transition.disable(id, 'user-disabled') + if (captured.before !== undefined && captured.before !== enabled) + logCommittedNativeAccountChange(change, account) }, async remove(routeId) { const current = await runtime.read() const row = current.accounts.find((row) => row.id === routeId) + const account = { id: routeId, label: row?.label } if (row?.source === 'vault') { // Vault membership is managed by the vault, so removing a vault row // declines its route; the committed change is a disable. - await vault.setEnabled(routeId, false) - if (row.enabled) - logCommittedNativeAccountChange('account disabled', row) + if (await vault.setEnabled(routeId, false)) + logCommittedNativeAccountChange('account disabled', account) return } + // 'removed' means this call dropped the account's roster row. const removed = await store.remove(await physicalId(routeId)) - if (row && removed.outcome === 'removed') - logCommittedNativeAccountChange('account removed', row) + if (removed.outcome === 'removed') + logCommittedNativeAccountChange('account removed', account) }, async reorder(routeIds, movedRouteId) { await authority() @@ -897,46 +956,49 @@ export function createNativeAccountRuntime( order.some((id) => !snapshot.accounts.some((row) => row.id === id)) ) throw new NativeCustodyError('invalid-state') - await mutateVaultRoster(paths.roster, (roster) => { - if (!roster) throw new NativeCustodyError('route-unavailable') - const primary = nativeVaultPrimaryRow( - roster, - nativeVaultPrimaryPin(snapshot.settings), - ) - const ids = order - .filter( - (id) => - snapshot.accounts.find((row) => row.id === id)?.source === - 'vault', + const rosterReordered = await mutateVaultRoster( + paths.roster, + (roster) => { + if (!roster) throw new NativeCustodyError('route-unavailable') + const primary = nativeVaultPrimaryRow( + roster, + nativeVaultPrimaryPin(snapshot.settings), ) - .map((id) => (id === 'main' ? primary?.routeId : id)) - if ( - ids.length !== roster.rows.length || - ids.some((id) => !roster.rows.some((row) => row.routeId === id)) - ) - throw new NativeCustodyError('identity-changed') - return { - next: { - ...roster, - rows: ids.map((id) => { - const row = roster.rows.find((row) => row.routeId === id) - if (!row) throw new NativeCustodyError('identity-changed') - return row - }), - }, - result: undefined, - } - }) + const ids = order + .filter( + (id) => + snapshot.accounts.find((row) => row.id === id)?.source === + 'vault', + ) + .map((id) => (id === 'main' ? primary?.routeId : id)) + if ( + ids.length !== roster.rows.length || + ids.some((id) => !roster.rows.some((row) => row.routeId === id)) + ) + throw new NativeCustodyError('identity-changed') + return { + next: { + ...roster, + rows: ids.map((id) => { + const row = roster.rows.find((row) => row.routeId === id) + if (!row) throw new NativeCustodyError('identity-changed') + return row + }), + }, + // Compared against the roster read under its write lock. + result: ids.some( + (id, index) => roster.rows[index]?.routeId !== id, + ), + } + }, + ) // Ordering spans the pool account config and the native vault roster. // Their writes are not a two-file atomic transaction: an interruption // can leave mixed presentation order, but neither roster loses a member. - await store.updateSettings((settings) => { + const settingsWrite = await store.updateSettings((settings) => { settings.nativeAccountOrder = order }) - if ( - JSON.stringify(order) !== - JSON.stringify(snapshot.accounts.map((row) => row.id)) - ) + if (rosterReordered || settingsWrite.outcome === 'updated') logReordered() return } diff --git a/packages/core/src/native-command-effects.ts b/packages/core/src/native-command-effects.ts index 3fc39ae98..fe25ec6d8 100644 --- a/packages/core/src/native-command-effects.ts +++ b/packages/core/src/native-command-effects.ts @@ -1,13 +1,23 @@ -import { logger, parseLogLevel, setLogLevel } from './logger.ts' +import { + getLogLevel, + type LogLevel, + logger, + parseLogLevel, + setLogLevel, +} from './logger.ts' /** * In-process effects of a committed native account or settings change. * - * NativeAccountRuntime calls these after its write has been committed, and - * only when that write changed what was stored. Every host's /claude actions - * therefore emit the same INFO records and apply the same live logger level, - * while a refused, failed or no-op change has no effect at all. Payloads carry - * route ids, labels and setting values only, never credential material. + * NativeAccountRuntime calls these logging and live-level helpers after its + * write has been committed. Account + * and settings effects follow only from what that write changed, so every + * host's /claude actions emit the same INFO records, and a refused or failed + * write has no effect. The one exception to "unchanged means no effect" is the + * explicit logging-level command (NativeAccountRuntime.setLoggingLevel): when + * its stored level is already the requested one, it still sets this process's + * live logger level if that differs. Payloads carry account ids, labels and + * setting values only, never credential material. */ type Settings = Record @@ -19,11 +29,46 @@ function section(settings: Settings, key: string): Record { : {} } +/** The threshold fields a killswitch entry may hold (see KillswitchThresholds). */ +const THRESHOLD_FIELDS = ['five_hour', 'seven_day', '5h', '1w', 'scoped'] + +/** + * Copy only the finite numeric threshold fields of one killswitch entry. + * Settings are user-editable JSON, so anything else stored beside the + * thresholds never reaches a log record. + */ +function projectThresholds(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return undefined + const entry = value as Record + const projected: Record = {} + for (const field of THRESHOLD_FIELDS) { + const number = entry[field] + if (typeof number === 'number' && Number.isFinite(number)) + projected[field] = number + } + return projected +} + +/** Per-account threshold entries keyed by account id, each projected as above. */ +function projectAccountThresholds( + value: unknown, +): Record> | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return undefined + const projected: Record> = {} + for (const [id, entry] of Object.entries(value)) { + const thresholds = projectThresholds(entry) + if (thresholds) projected[id] = thresholds + } + return projected +} + /** The setting values whose changes have effects, read before a settings mutator can edit its copy. */ export interface NativeSettingsEffectFields { killswitchEnabled: boolean - killswitchMain: string | undefined - killswitchAccounts: string | undefined + killswitchMain: Record | undefined + killswitchAccounts: Record> | undefined logLevel: string | undefined } @@ -36,8 +81,8 @@ export function captureNativeSettingsEffectFields( // A missing flag reads as disabled, so writing an explicit `false` over // it is not reported as a change. killswitchEnabled: killswitch.enabled === true, - killswitchMain: JSON.stringify(killswitch.main), - killswitchAccounts: JSON.stringify(killswitch.accounts), + killswitchMain: projectThresholds(killswitch.main), + killswitchAccounts: projectAccountThresholds(killswitch.accounts), logLevel: typeof level === 'string' ? level : undefined, } } @@ -45,32 +90,44 @@ export function captureNativeSettingsEffectFields( /** * Log the killswitch and logging-level changes between the settings before a * committed write and the settings that write left on disk, then apply a - * changed logging level to this process's logger. + * changed stored logging level to this process's logger. Threshold changes + * are compared and logged through the numeric projection only. */ export function applyCommittedNativeSettingsEffects( before: NativeSettingsEffectFields, committed: Settings, ): void { const after = captureNativeSettingsEffectFields(committed) - const killswitch = section(committed, 'killswitch') if (after.killswitchEnabled !== before.killswitchEnabled) logger.info('commands', 'killswitch changed', { enabled: after.killswitchEnabled, }) if ( - after.killswitchMain !== before.killswitchMain || - after.killswitchAccounts !== before.killswitchAccounts + JSON.stringify(after.killswitchMain) !== + JSON.stringify(before.killswitchMain) || + JSON.stringify(after.killswitchAccounts) !== + JSON.stringify(before.killswitchAccounts) ) logger.info('commands', 'killswitch thresholds changed', { - thresholds: killswitch.main ?? killswitch.accounts, + thresholds: after.killswitchMain ?? after.killswitchAccounts, }) const level = parseLogLevel(after.logLevel) - if (level && level !== parseLogLevel(before.logLevel)) { - // Recorded at the previous level, so lowering verbosity (for example to - // warn) still leaves a record of the change in the log. - logger.info('commands', 'log level changed', { level }) - setLogLevel(level) - } + if (level && level !== parseLogLevel(before.logLevel)) + applyNativeLogLevel(level) +} + +/** + * Make `level` this process's live logger level when it is not already, and + * record the change. The INFO record is emitted before the new level is + * applied, so it is filtered by the level in force until then: changing from + * info to warn leaves the record in the log, while changing from warn or error + * to info emits none. Returns whether the live level changed. + */ +export function applyNativeLogLevel(level: LogLevel): boolean { + if (getLogLevel() === level) return false + logger.info('commands', 'log level changed', { level }) + setLogLevel(level) + return true } export type NativeAccountChange = @@ -80,8 +137,9 @@ export type NativeAccountChange = | 'account reordered' /** - * Log one committed change to an account row, identified by route id and - * label. A reorder whose caller did not name the moved account has no id. + * Log one committed change to an account, identified by its account id and + * label. A reorder whose caller did not say which account it moved is logged + * without an id. */ export function logCommittedNativeAccountChange( change: NativeAccountChange, @@ -97,9 +155,9 @@ export function logCommittedNativeAccountChange( } /** - * Log an account row a committed add created. The payload names the route, - * its label and the credential kind ('apikey' or 'oauth'), never the - * credential, its endpoint or its headers. + * Log an account that a committed add created. The payload holds the account + * id, its label and the credential kind ('apikey' or 'oauth'); it never holds + * the credential, its base URL or its auth header. */ export function logCommittedNativeAccountAdded(account: { id: string diff --git a/packages/core/src/native-vault-runtime.ts b/packages/core/src/native-vault-runtime.ts index 9b536f00a..9947b7291 100644 --- a/packages/core/src/native-vault-runtime.ts +++ b/packages/core/src/native-vault-runtime.ts @@ -1,8 +1,9 @@ import { randomUUID } from 'node:crypto' import { ClaustrumClient } from '@cortexkit/claustrum-client' import { - acceptVaultRoute, - declineVaultRoute, + acceptAccount, + ClaustrumConsumerError, + declineAccount, mutateVaultRoster, readVaultRoster, refreshVaultRoster, @@ -94,7 +95,11 @@ export interface NativeVaultRuntime { fetchImpl?: typeof fetch, signal?: AbortSignal, ): Promise - setEnabled(routeId: string, enabled: boolean): Promise + /** + * Accept or decline a vault route for the user. Resolves to whether the + * route's enabled flag changed, judged on the roster under its write lock. + */ + setEnabled(routeId: string, enabled: boolean): Promise resetBackoff( receipt: NativeCustodyReceipt, kind?: 'refresh' | 'quota' | 'all', @@ -497,10 +502,40 @@ export function createNativeVaultRuntime( const roster = await readVaultRoster(paths.roster) const row = await rowFor(roster, routeId) if (!row) throw new NativeCustodyError('route-unavailable') - await (enabled ? acceptVaultRoute : declineVaultRoute)( - paths.roster, - row.routeId, - ) + // Update acceptance and the enabled flag together under the roster write + // lock. Compare that held flag before changing it so repeated enable or + // disable calls report no change. It makes the same roster change as + // common-auth's acceptVaultRoute and declineVaultRoute. + return mutateVaultRoster(paths.roster, (current) => { + const held = current?.rows.find( + (entry) => entry.routeId === row.routeId, + ) + if (!current || !held) + throw new ClaustrumConsumerError( + 'route-unavailable', + 'Claustrum route is not in the roster', + ) + return { + next: { + ...current, + declined: enabled + ? acceptAccount( + current.declined, + [held.credentialId, ...(held.aliases ?? [])], + held.accountIdentity, + ) + : declineAccount( + current.declined, + held.credentialId, + held.accountIdentity, + ), + rows: current.rows.map((entry) => + entry === held ? { ...entry, enabled } : entry, + ), + }, + result: held.enabled !== enabled, + } + }) }, close() { if (shutdown.signal.aborted) return diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts index fb30c5de1..115924d46 100644 --- a/packages/core/src/tests/native-account-runtime.test.ts +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -753,7 +753,7 @@ function captureCommandLogs() { } } -test('committed killswitch and logging-level settings changes log once and update the live level only after commit', async () => { +test('committed killswitch changes log once, and the thresholds payload holds only numeric threshold fields', async () => { const f = await fixture() const logs = captureCommandLogs() @@ -776,14 +776,15 @@ test('committed killswitch and logging-level settings changes log once and updat }, ]) - // Rewriting the same values commits nothing and reports nothing. + // Writing the same values again leaves the settings unchanged, so nothing + // is logged. await f.runtime.updateSettings((settings) => ({ ...settings, killswitch: { enabled: true, main: { five_hour: 3, seven_day: 8 } }, })) expect(logs.take()).toEqual([]) - // Turning the switch off keeps the thresholds: only the switch is reported. + // Turning the switch off keeps the thresholds, so only the switch is logged. await f.runtime.updateSettings((settings) => ({ ...settings, killswitch: { ...(settings.killswitch as object), enabled: false }, @@ -796,16 +797,62 @@ test('committed killswitch and logging-level settings changes log once and updat }, ]) - // A settings change unrelated to these fields has no effect. - await f.runtime.updateSettings((settings) => { - settings.dump = { enabled: true } - }) + // Settings are hand-editable JSON, so a killswitch entry may hold other + // fields, including secret-looking ones. Adding only such fields is not a + // threshold change, so nothing is logged, and the threshold logs below + // must not contain them either. + const leaks = { + token: 'sk-ant-leak-token', + note: 'Bearer leak-bearer', + nested: { refresh: 'leak-refresh' }, + five_hour_text: 'leak-text', + } + await f.runtime.updateSettings((settings) => ({ + ...settings, + killswitch: { + enabled: false, + main: { five_hour: 3, seven_day: 8, ...leaks }, + }, + })) expect(logs.take()).toEqual([]) - expect(getLogLevel()).toBe('info') + await f.runtime.updateSettings((settings) => ({ + ...settings, + killswitch: { + enabled: false, + main: { five_hour: 4, seven_day: 'nine', '1w': 9, ...leaks }, + }, + })) + // Without main thresholds, the per-account thresholds are logged instead. + await f.runtime.updateSettings((settings) => ({ + ...settings, + killswitch: { + enabled: false, + accounts: { 'fallback-a': { scoped: 2, ...leaks }, broken: 'leak-entry' }, + }, + })) + const thresholdLogs = logs.take() + expect(thresholdLogs).toEqual([ + { + level: 'info', + message: 'killswitch thresholds changed', + payload: { thresholds: { five_hour: 4, '1w': 9 } }, + }, + { + level: 'info', + message: 'killswitch thresholds changed', + payload: { thresholds: { 'fallback-a': { scoped: 2 } } }, + }, + ]) + expect(JSON.stringify(thresholdLogs)).not.toContain('leak') +}) - await f.runtime.updateSettings((settings) => { - settings.logging = { level: 'debug' } - }) +test('setLoggingLevel stores the level and aligns the live logger level once, including when only the live level differs', async () => { + const f = await fixture() + const logs = captureCommandLogs() + const stored = async () => (await f.runtime.read()).settings.logging + + // A new stored level changes the live level and is logged once. + await f.runtime.setLoggingLevel('debug') expect(logs.take()).toEqual([ { level: 'info', @@ -814,31 +861,47 @@ test('committed killswitch and logging-level settings changes log once and updat }, ]) expect(getLogLevel()).toBe('debug') - expect((await f.runtime.read()).settings.logging).toEqual({ level: 'debug' }) + expect(await stored()).toEqual({ level: 'debug' }) - // Lowering verbosity is still recorded, at the level in force before it. - await f.runtime.updateSettings((settings) => { - settings.logging = { level: 'warn' } - }) + // The record of a change to warn is written while debug is still live, so + // lowering the verbosity is still logged. + await f.runtime.setLoggingLevel('warn') expect(logs.take()).toEqual([ { level: 'info', message: 'log level changed', payload: { level: 'warn' } }, ]) expect(getLogLevel()).toBe('warn') - setLogLevel('info') - // Selecting the stored level again writes nothing and changes nothing. + // Selecting the level that is both stored and live changes nothing. + await f.runtime.setLoggingLevel('warn') + expect(logs.take()).toEqual([]) + expect(getLogLevel()).toBe('warn') + + // Something else in the process changed the live level. Writes of other + // settings, or of the same stored level through updateSettings, leave it. + setLogLevel('info') + await f.runtime.updateSettings((settings) => { + settings.dump = { enabled: true } + }) await f.runtime.updateSettings((settings) => { settings.logging = { level: 'warn' } }) expect(logs.take()).toEqual([]) expect(getLogLevel()).toBe('info') + + // Selecting the stored level again brings the live level back to it. + await f.runtime.setLoggingLevel('warn') + expect(logs.take()).toEqual([ + { level: 'info', message: 'log level changed', payload: { level: 'warn' } }, + ]) + expect(getLogLevel()).toBe('warn') + expect(await stored()).toEqual({ level: 'warn' }) }) test('refused or failed settings writes log nothing and leave the live level unchanged', async () => { const f = await fixture() const logs = captureCommandLogs() - // The mutator throws after editing its copy: nothing is committed. + // The mutator throws after editing its copy, so nothing is written. await expect( f.runtime.updateSettings((settings) => { settings.logging = { level: 'trace' } @@ -846,7 +909,8 @@ test('refused or failed settings writes log nothing and leave the live level unc throw new Error('synthetic mutator failure') }), ).rejects.toThrow('synthetic mutator failure') - // The runtime refuses a write that also changes custody activation. + // The runtime refuses a settings write that would also change which account + // is main, so nothing is written. await expect( f.runtime.updateSettings((settings) => { settings.logging = { level: 'trace' } @@ -854,6 +918,15 @@ test('refused or failed settings writes log nothing and leave the live level unc settings.mainAccountId = 'other' }), ).rejects.toThrow() + // setLoggingLevel applies nothing when its settings write fails. + const write = spyOn(f.runtime, 'updateSettings').mockImplementationOnce(() => + Promise.reject(new Error('synthetic write failure')), + ) + deferCleanup(() => write.mockRestore()) + await expect(f.runtime.setLoggingLevel('trace')).rejects.toThrow( + 'synthetic write failure', + ) + expect(write).toHaveBeenCalledTimes(1) expect(logs.take()).toEqual([]) expect(getLogLevel()).toBe('info') @@ -901,7 +974,8 @@ test('committed account enable, disable, remove and reorder log the changed acco expect(await fallbackOrder()).toEqual(['fallback-a', 'fallback-b']) await f.runtime.reorder(['fallback-b', 'fallback-a'], 'fallback-b') expect(await fallbackOrder()).toEqual(['fallback-b', 'fallback-a']) - // The order already on disk: nothing is written or reported. + // Asking for the order that is already stored writes nothing, so only the + // first reorder above is logged. await f.runtime.reorder(['fallback-b', 'fallback-a'], 'fallback-b') expect(logs.take()).toEqual([ { @@ -911,7 +985,8 @@ test('committed account enable, disable, remove and reorder log the changed acco }, ]) - // A route that does not exist is refused and reported by no log. + // Removing or disabling an account id that does not exist is refused, and + // nothing is logged. await expect(f.runtime.remove('missing-route')).rejects.toThrow() await expect(f.runtime.setEnabled('missing-route', false)).rejects.toThrow() expect(logs.take()).toEqual([]) @@ -952,18 +1027,19 @@ test('only an add that creates a new account row logs account added, without cre for (const secret of ['synthetic-api-secret-a', 'example.test', 'x-api-key']) expect(text).not.toContain(secret) - // Replacing the route's credential creates no account. + // Replacing the credential of an existing account id creates no account. await f.runtime.addApi({ routeId: 'fallback-a', apiKey: 'synthetic-api-secret-b', replace: true, }) - // Re-adding a stored secret under another id rotates the existing row. + // Adding a key that is already stored, under a new account id, updates the + // account that holds it instead of creating one. await f.runtime.addApi({ routeId: 'fallback-dup', apiKey: 'synthetic-api-secret-b', }) - // An add over an existing route without replace is refused. + // An add to an existing account id without replace is refused. await expect( f.runtime.addApi({ routeId: 'fallback-a', apiKey: 'synthetic-api-other' }), ).rejects.toThrow() @@ -992,3 +1068,159 @@ test('only an add that creates a new account row logs account added, without cre ]) expect(JSON.stringify(oauth)).not.toContain('synthetic-oauth') }) + +test('two writers enabling the same disabled account log one change, judged by the write rather than an earlier read', async () => { + const f = await fixture() + await f.runtime.addApi({ + routeId: 'fallback-a', + apiKey: 'synthetic-api-fallback-a', + label: 'Work', + }) + await f.runtime.setEnabled('fallback-a', false) + // A second account runtime shares the same credential/configuration files, + // representing another process. + const other = createNativeAccountRuntime({ paths: f.paths, host: 'opencode' }) + deferCleanup(() => other.close()) + const logs = captureCommandLogs() + + // Writer A reads the account while it is still disabled. Before A writes, + // writer B enables the account completely; A's enable then changes nothing. + const read = f.runtime.read.bind(f.runtime) + let interleaved = false + const readSpy = spyOn(f.runtime, 'read').mockImplementation(async () => { + const snapshot = await read() + if (!interleaved) { + interleaved = true + expect( + snapshot.accounts.find((row) => row.id === 'fallback-a')?.enabled, + ).toBe(false) + await other.setEnabled('fallback-a', true) + } + return snapshot + }) + deferCleanup(() => readSpy.mockRestore()) + await f.runtime.setEnabled('fallback-a', true) + readSpy.mockRestore() + + expect(interleaved).toBe(true) + expect( + (await f.runtime.read()).accounts.find((row) => row.id === 'fallback-a') + ?.enabled, + ).toBe(true) + // Only writer B changed the account. + expect(logs.take()).toEqual([ + { + level: 'info', + message: 'account enabled', + payload: { id: 'fallback-a', label: 'Work', enabled: true }, + }, + ]) + + // A change made without interference is still logged once. + await f.runtime.setEnabled('fallback-a', false) + expect(logs.take()).toEqual([ + { + level: 'info', + message: 'account disabled', + payload: { id: 'fallback-a', label: 'Work', enabled: false }, + }, + ]) +}) + +test('local enable and disable log only a flag change made by their own write', async () => { + const f = await fixture() + await f.runtime.addApi({ + routeId: 'fallback-a', + apiKey: 'synthetic-api-fallback-a', + label: 'Work', + }) + const poolRow = async (id: string) => { + const read = await f.store.read() + if (read.status !== 'ready') throw new Error('Fixture pool unreadable') + const row = read.rows.find((row) => row.id === id) + if (!row) throw new Error(`Missing fixture row ${id}`) + return row + } + // Interrupt a credential replacement after its state write, leaving the + // row torn: the next write on the pool first repairs it with its own + // config write. + const tear = async (id: string) => { + const row = await poolRow(id) + const credentialEpoch = row.credentialEpoch + if (credentialEpoch === undefined) + throw new Error(`Fixture row ${id} has no credential epoch`) + const crashing = createNativePoolStore({ + paths: f.paths, + quota: nativeQuotaCodec, + now: () => now, + onStep: async (step, info) => { + if (step === 'before-config-write' && info.operation === 'replace') + throw new Error('synthetic interrupted replace') + }, + }) + await expect( + crashing.replace( + id, + { + type: 'api', + apiKey: `synthetic-api-${id}-replacement`, + baseURL: 'https://api.anthropic.com', + }, + {}, + { + attribution: { credentialEpoch, identity: row.identity }, + }, + ), + ).rejects.toThrow() + expect((await poolRow(id)).torn).toBe(true) + } + const logs = captureCommandLogs() + + // Repairing a torn, already-enabled row is the only config write; the + // enable itself changes nothing. + await tear('fallback-a') + await f.runtime.setEnabled('fallback-a', true) + expect((await poolRow('fallback-a')).torn).toBeUndefined() + expect(logs.take()).toEqual([]) + + // A torn, disabled row is repaired and then enabled: one change. + await f.runtime.setEnabled('fallback-a', false) + logs.take() + await tear('fallback-a') + await f.runtime.setEnabled('fallback-a', true) + expect((await poolRow('fallback-a')).enabled).toBe(true) + expect(logs.take()).toEqual([ + { + level: 'info', + message: 'account enabled', + payload: { id: 'fallback-a', label: 'Work', enabled: true }, + }, + ]) + + // An enabled row that still carries a disabled reason: the enable writes + // the config to clear the reason, but the enabled flag does not change. + // The reason lives in the row's pool entry beside the roster row. + const config = JSON.parse(await readFile(f.paths.config, 'utf8')) + config.commonAuthPool.rows['fallback-a'].disabledReason = 'synthetic-reason' + await writeFile(f.paths.config, JSON.stringify(config)) + expect((await poolRow('fallback-a')).disabledReason).toBe('synthetic-reason') + await f.runtime.setEnabled('fallback-a', true) + expect((await poolRow('fallback-a')).disabledReason).toBeUndefined() + expect(logs.take()).toEqual([]) + + // An OAuth row holding the main account's identity is stored disabled, and + // the store refuses to enable it: nothing is logged. + await f.store.add({ + id: 'fallback-dup', + identity: uuid, + credential: { + type: 'oauth', + access: 'synthetic-dup-access', + refresh: 'synthetic-dup-refresh', + expires: now + 3_600_000, + }, + }) + expect((await poolRow('fallback-dup')).enabled).toBe(false) + await expect(f.runtime.setEnabled('fallback-dup', true)).rejects.toThrow() + expect(logs.take()).toEqual([]) +}) diff --git a/packages/core/src/tests/native-vault-runtime.test.ts b/packages/core/src/tests/native-vault-runtime.test.ts index 24718fa13..0d00d032d 100644 --- a/packages/core/src/tests/native-vault-runtime.test.ts +++ b/packages/core/src/tests/native-vault-runtime.test.ts @@ -1,4 +1,4 @@ -import { expect } from 'bun:test' +import { expect, spyOn } from 'bun:test' import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { @@ -6,6 +6,13 @@ import { projectVaultRoster, readVaultRoster, } from '@cortexkit/common-auth/claustrum' +import { + __setLogTestSink, + getLogLevel, + type LogTestRecord, + setLogLevel, +} from '../logger.ts' +import { createNativeAccountRuntime } from '../native-account-runtime.ts' import type { NativeCustodyClient } from '../native-custody.ts' import { nativeQuotaCodec } from '../native-quota-codec.ts' import { readNativeRuntime, updateNativeRuntime } from '../native-runtime.ts' @@ -641,3 +648,156 @@ test('vault quota success and failure cannot inherit another account runtime cle else expect(entry?.lastQuotaRefreshError?.checkedAt).toBe(now) } }) + +/** + * Capture the INFO records native runtimes emit on the `commands` channel, + * starting from the info level, and restore the previous level and sink after + * the test. Bun runs tests with NODE_ENV=test, under which the logger + * delivers records only to this sink and writes no log file. + */ +function captureCommandLogs() { + expect(process.env.NODE_ENV).toBe('test') + const previousLevel = getLogLevel() + const records: LogTestRecord[] = [] + setLogLevel('info') + __setLogTestSink((record) => { + if (record.channel === 'commands') records.push(record) + }) + deferCleanup(() => { + __setLogTestSink(null) + setLogLevel(previousLevel) + }) + return { + /** Message and payload of each record captured since the previous call. */ + take() { + const taken = records.map(({ message, payload }) => ({ + message, + payload, + })) + records.length = 0 + return taken + }, + } +} + +/** A native account runtime over the fixture's pool and vault roster. */ +function accountRuntime(f: Awaited>) { + const runtime = createNativeAccountRuntime({ + paths: f.paths, + host: 'opencode', + vault: { + now: f.options.now, + connect: f.options.connect, + readToken: f.options.readToken, + }, + }) + deferCleanup(() => runtime.close()) + return runtime +} + +test('vault setEnabled reports whether the route flag changed, judged on the roster under its lock', async () => { + const f = await fixture() + await f.runtime.refresh() + const row = async () => (await readVaultRoster(f.paths.roster))?.rows[0] + + expect(await f.runtime.setEnabled('main', false)).toBe(true) + expect(await f.runtime.setEnabled('main', false)).toBe(false) + expect((await row())?.enabled).toBe(false) + expect((await readVaultRoster(f.paths.roster))?.declined).toEqual([ + { credentialId: id, accountIdentity: uuid }, + ]) + + expect(await f.runtime.setEnabled('main', true)).toBe(true) + expect(await f.runtime.setEnabled('main', true)).toBe(false) + expect((await row())?.enabled).toBe(true) + expect((await readVaultRoster(f.paths.roster))?.declined).toEqual([]) + + await expect( + f.runtime.setEnabled('missing-route', false), + ).rejects.toMatchObject({ code: 'route-unavailable' }) +}) + +test('two native runtimes disabling the same vault account log one change, judged under the roster lock', async () => { + const f = await fixture() + await f.runtime.refresh() + const a = accountRuntime(f) + const b = accountRuntime(f) + const logs = captureCommandLogs() + + // Two operations disable the same account. The first reads it enabled; the + // second completes the decline before the first writes, so the first + // changes no enabled flag. + const read = a.read.bind(a) + let interleaved = false + const readSpy = spyOn(a, 'read').mockImplementation(async () => { + const snapshot = await read() + if (!interleaved) { + interleaved = true + expect(snapshot.accounts.find((row) => row.id === 'main')?.enabled).toBe( + true, + ) + await b.setEnabled('main', false) + } + return snapshot + }) + deferCleanup(() => readSpy.mockRestore()) + await a.setEnabled('main', false) + readSpy.mockRestore() + expect(interleaved).toBe(true) + expect(logs.take()).toEqual([ + { + message: 'account disabled', + payload: expect.objectContaining({ id: 'main', enabled: false }), + }, + ]) + + // Removing a vault account declines it: the change is logged as a disable, + // once, and removing it again changes nothing. + await a.setEnabled('main', true) + await a.remove('main') + await a.remove('main') + expect(logs.take()).toEqual([ + { + message: 'account enabled', + payload: expect.objectContaining({ id: 'main', enabled: true }), + }, + { + message: 'account disabled', + payload: expect.objectContaining({ id: 'main', enabled: false }), + }, + ]) +}) + +test('vault account reorder logs once when the locked roster or stored order changes', async () => { + const f = await fixture() + f.rows([ + listed, + { ...listed, id: 'oauth:anthropic:work', accountId: uuid2 }, + { + ...listed, + id: 'oauth:anthropic:personal', + accountId: '33333333-2222-4333-8444-555555555555', + }, + ]) + await f.runtime.refresh() + const a = accountRuntime(f) + const fallbacks = async () => + (await a.read()).accounts + .filter((row) => row.id !== 'main') + .map((row) => row.id) + const [first, second] = await fallbacks() + if (!first || !second) throw new Error('Missing fixture fallback routes') + const logs = captureCommandLogs() + + await a.reorder([second, first], second) + expect(await fallbacks()).toEqual([second, first]) + // Asking for the order already stored writes nothing new, so only the + // reorder above is logged. + await a.reorder([second, first], second) + expect(logs.take()).toEqual([ + { + message: 'account reordered', + payload: expect.objectContaining({ id: second }), + }, + ]) +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 09d2f67e6..a0a6f807b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1260,9 +1260,13 @@ const anthropicAuthPlugin = async ( updateNativeSection('dump', { enabled }) const setFastModePersistentEnabled = (enabled: boolean) => updateNativeSection('claudeFast', { enabled }) - const setLogLevelPersistent = ( - level: NonNullable['level'], - ) => updateNativeSection('logging', { level }) + // Stores the level, then aligns this process's live logger level with it. + const setLogLevelPersistent = async ( + level: NonNullable['level']>, + ) => { + await nativeAccounts.setLoggingLevel(level) + return loadAccounts() + } const setPrimePersistentEnabled = ( enabled: boolean, _path = accountStoragePath, @@ -3840,8 +3844,8 @@ const anthropicAuthPlugin = async ( async function executePersistentLoggingCommand(argumentsText: string) { const action = parseLoggingCommandAction(argumentsText) if (action.type === 'level') { - // The native runtime logs a committed level change and applies it to - // this process's logger. + // The native runtime stores the level, applies it to this process's + // logger and logs a live level change. await setLogLevelPersistent(action.level) return executeLoggingCommand({ argumentsText, level: action.level }) } @@ -4417,9 +4421,11 @@ const anthropicAuthPlugin = async ( break } case 'add-apikey': - // The plugin's own API-key flow applies OpenCode's fallback defaults - // (the label as route id, the kie.ai base URL and a bearer header); - // the generic native runtime defaults would otherwise apply. + // Use this plugin's established API-route defaults when values are + // omitted: route id = the label, or a random UUID without one; base URL + // = https://api.kie.ai/claude; auth header = authorization-bearer. + // The generic native addApi would instead use a random UUID and + // https://api.anthropic.com with no auth header set. text = ( await executePersistentAccountCommand('', request.sessionId, { type: 'add-apikey', diff --git a/packages/pi/src/native-commands.ts b/packages/pi/src/native-commands.ts index 3d9ceb9fe..3f6976a49 100644 --- a/packages/pi/src/native-commands.ts +++ b/packages/pi/src/native-commands.ts @@ -303,8 +303,8 @@ export function createPiNativeCommands( return { ok: true, text: 'Native request dumping setting updated.' } } case 'logging-level': - // The native runtime applies a committed level change to the logger. - await setSection('logging', { level: request.values.level }) + // The native runtime stores the level and applies it to the logger. + await runtime.setLoggingLevel(request.values.level) return { ok: true, text: 'Native logging level updated.' } case 'fast-on': case 'fast-off': From f87f229371771ea4aad94a3a968c9ca9fca38e90 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:37:33 +0200 Subject: [PATCH 083/169] mason: run Group B scoped custody suites on real native migration Convert scoped-custody-serving, scoped-main-quota-persistence and fallback-token-use-sites from direct legacy enrollment/storage writes to the approved migrateNativeOpencodeFixture helper: real runNativeMigration plus the real roster/runtime seed publishers, fed by a synthetic discovery client that lists the same vault rows the test serves and refuses credential reads and reports. Each migration asserts its expected vault accounts exist. The loader receives the inert activation read back from the migrated host-auth file. Bodies use the core TestLifetime suite: plugins are disposed and sidebar writes drained before env/fetch/clock are restored and the root is removed. Assertions kept: wire tokens, statuses, fresh receipt per physical attempt, final rejected record versions, scoped-retry diagnostic, model quota and zero-token-persistence checks. Changed assertions (cutover adaptations, parent-reviewed): - CacheKeep fallback prewarm: native code signs with the verified account UUID and performs no bootstrap lookup, so "bootstrap carries only the vault token" becomes "zero bootstrap requests". - The deliberately unmigrated "Claustrum mode without a scoped roster" config now refuses with migration-required instead of the legacy 503 text; zero sends and unchanged legacy bytes are asserted. - Storage and race checks read the native account view instead of obsolete legacy config/state sidecars. The primary race moves the vault primary to a new account via a real roster refresh during the request; it fails closed with identity-changed, sends no bearer and binds no quota to the new account. - relay-to-direct: an ordered purpose trace of vault reads and sends replaces the old exact getScoped count (routing, selected-route authorization, relay, direct, retry check). - The retired /claude-quota alias becomes the unified /claude Quota quota-refresh action applied over the plugin's RPC server. Depends on merged fixes 04037ab6 (nullable quota resets) and b8c51a24 (report only the final rejected vault 401 record). Remote linux, bun 1.3.14: the three suites 28 pass / 0 fail; biome 2.5.14 and opencode typecheck (tsc 7.0.2) pass; the later comment-only refresh transpiles identically to the tested sources. --- .../tests/fallback-token-use-sites.test.ts | 254 +++- .../src/tests/scoped-custody-serving.test.ts | 1278 ++++++++--------- .../scoped-main-quota-persistence.test.ts | 555 ++++--- 3 files changed, 1124 insertions(+), 963 deletions(-) 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 c74c76e33..c5da46484 100644 --- a/packages/opencode/src/tests/fallback-token-use-sites.test.ts +++ b/packages/opencode/src/tests/fallback-token-use-sites.test.ts @@ -1,24 +1,25 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { afterEach, describe, expect, mock } from 'bun:test' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { __setLogTestSink, type AccountStorage, CACHE_KEEP_TICK_MS, - type ClaustrumScopedClient, + createNativeAccountRuntime, custodyTombstoneOAuth, getLogLevel, type LogTestRecord, + type NativeCustodyClient, resetCache1hState, resetClaudeCodeIdentityCachesForTest, - saveAccountState, - saveAccounts, setLogLevel, } from '@cortexkit/anthropic-auth-core' - +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { AnthropicAuthPlugin } from '../index' import { LANE_START_REQUEST_HEADER } from '../lane-start' +import { drainSidebarWrites } from '../sidebar-state' +import { migrateNativeOpencodeFixture } from './native-fixture' import { extractUrl, MESSAGES_URL } from './test-fetch' type Site = @@ -40,39 +41,94 @@ type OutboundRecord = { type IntervalRecord = { callback: () => unknown; ms: number } -const originalFetch = globalThis.fetch -const originalNow = Date.now -const activePlugins = new Set<{ dispose?: () => Promise | void }>() -const tempDirs = new Set() +type PluginHooks = { dispose?: () => Promise | void } + +// Every environment variable a test body or the migration fixture may set. +// Each body saves and restores all of them, so no path leaks into the next +// test. const fixtureEnvKeys = [ 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', - 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_AUTH_CONTENT', ] as const -const fixtureEnv = new Map(fixtureEnvKeys.map((key) => [key, process.env[key]])) +const livePlugins = new Set>() +// Registered before the lifetime suite's own afterEach: dispose every plugin +// (its timers and vault client) before that hook waits for a possibly +// still-running body and removes the body's files. afterEach(async () => { - for (const plugin of activePlugins) await plugin.dispose?.() - activePlugins.clear() - await Promise.all( - [...tempDirs].map((directory) => - rm(directory, { recursive: true, force: true }), - ), - ) - tempDirs.clear() - globalThis.fetch = originalFetch - Date.now = originalNow - for (const key of fixtureEnvKeys) { - const value = fixtureEnv.get(key) - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - resetCache1hState() - resetClaudeCodeIdentityCachesForTest() + const plugins = [...livePlugins].flatMap((owned) => { + const current = [...owned] + owned.clear() + return current + }) + await disposeAll(plugins, 'Fallback census plugin cancellation failed') }) +const lifetimes = createTestLifetimeSuite() +// The lifetime suite registers ordinary Bun tests, which run one at a time +// within this file unless Bun is started with --concurrent; the `serial` name +// keeps the original call sites unchanged. +const test = { serial: lifetimes.test } + +async function disposeAll(plugins: PluginHooks[], message: string) { + const results = await Promise.allSettled( + plugins.map((plugin) => Promise.resolve().then(() => plugin.dispose?.())), + ) + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + message, + ) +} + +/** + * Claim a fresh owner-only directory for this body. Teardown first disposes + * the body's plugins and waits for sidebar writes, then restores fetch, the + * clock, the environment and the process-wide caches, and only then removes + * the directory. + */ +async function startBody(prefix: string) { + const savedFetch = globalThis.fetch + const savedNow = Date.now + const savedEnv = new Map(fixtureEnvKeys.map((key) => [key, process.env[key]])) + const plugins = new Set() + livePlugins.add(plugins) + lifetimes.deferCleanup(async () => { + const remaining = [...plugins] + plugins.clear() + livePlugins.delete(plugins) + try { + await disposeAll(remaining, 'Fallback census plugin cleanup failed') + } finally { + await drainSidebarWrites() + globalThis.fetch = savedFetch + Date.now = savedNow + for (const key of fixtureEnvKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + resetCache1hState() + resetClaudeCodeIdentityCachesForTest() + } + }) + delete process.env.OPENCODE_AUTH_CONTENT + const root = await mkdtemp(join(tmpdir(), prefix)) + // migrateNativeOpencodeFixture also removes the root once it accepts it; + // this covers a body that fails before handing the root to it. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + return { root, plugins } +} function quota(now: number, fableRemaining = 90) { return { @@ -168,16 +224,6 @@ async function createFixture( const credentialGets: Array<{ credentialId: string; isMain: boolean }> = [] let refusalPending = options.recovery === true - if (options.now !== undefined) { - let clock = now - Date.now = mock(() => clock) as unknown as typeof Date.now - Object.defineProperty(intervals, 'clock', { - value: (next: number) => { - clock = next - }, - }) - } - const storage: AccountStorage = { version: 1, mainAccountId: `main-slot-${site}`, @@ -255,40 +301,19 @@ async function createFixture( claustrumScopedCredentialId: credentialId, claustrumScopedState: 'active', anthropicAccountUuid: accountId as never, - ...(options.quotaSnapshot ? { quota: options.quotaSnapshot } : {}), + // A legacy quota reading names the account it was read for in + // accountIdentity; the migration imports a reading only when that + // matches the account's anthropicAccountUuid. + ...(options.quotaSnapshot + ? { quota: { ...options.quotaSnapshot, accountIdentity: accountId } } + : {}), }, ], } - const directory = await mkdtemp(join(tmpdir(), `fallback-census-${site}-`)) - tempDirs.add(directory) - const accountFile = join(directory, 'anthropic-auth.json') - const tokenFile = join(directory, 'opencode-enrollment.json') - await writeFile( - tokenFile, - JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, - ) - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = tokenFile - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountFile - process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( - directory, - 'sidebar.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( - directory, - 'cachekeep', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join(directory, 'quota') - if (!options.profile) { - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - } - await saveAccounts(storage, accountFile) - if (storage.main?.profile) { - await saveAccountState(storage, accountFile, { mainProfile: true }) - } + const { root, plugins } = await startBody(`fallback-census-${site}-`) - const scopedClient: ClaustrumScopedClient = { + const scopedClient: NativeCustodyClient = { listScoped: async () => ({ view: `census-${site}`, rows: [ @@ -335,6 +360,89 @@ async function createFixture( close() {}, } + // Import the legacy seat through the real offline migration. Discovery + // lists the same vault rows the plugin serves from but refuses credential + // reads, so every recorded credential read happened while serving. + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: storage as unknown as Record, + hostAuth: { anthropic: custodyTombstoneOAuth('anthropic') }, + custody: { + connect: async () => ({ + listScoped: (token) => scopedClient.listScoped(token), + getScoped: async () => { + throw new Error('Migration discovery must not read credentials') + }, + reportAuthFailureScoped: async () => { + throw new Error('Migration discovery must not report credentials') + }, + close() {}, + }), + enrollment: { token: 'ab'.repeat(32), token_generation: 1 }, + }, + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + if (options.profile) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + else process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + // Both vault accounts are served from the migrated native pool, and the + // legacy quota readings the prime and recovery tests rely on were imported. + const migrated = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + const snapshot = await migrated.read() + expect( + snapshot.accounts.map( + ({ id, source, credentialId, accountIdentity }) => ({ + id, + source, + credentialId, + accountIdentity, + }), + ), + ).toEqual([ + { + id: 'main', + source: 'vault', + credentialId: mainCredentialId, + accountIdentity: mainProviderAccountId, + }, + { + id: accountId, + source: 'vault', + credentialId, + accountIdentity: accountId, + }, + ]) + const byId = new Map(snapshot.accounts.map((a) => [a.id, a])) + if (options.quotaSnapshot) + expect(byId.get(accountId)?.quota?.checkedAt).toBe( + options.quotaSnapshot.checkedAt, + ) + if (storage.quota?.mainQuota) + expect(byId.get('main')?.quota?.accountIdentity).toBe( + mainProviderAccountId, + ) + } finally { + migrated.close() + } + + // Freeze the clock only after migration, so the migration journal and + // locks see real time and only the plugin observes the test clock. + if (options.now !== undefined) { + let clock = now + Date.now = mock(() => clock) as unknown as typeof Date.now + Object.defineProperty(intervals, 'clock', { + value: (next: number) => { + clock = next + }, + }) + } + const refusalSse = [ 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_filtered"}}\n\n', 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":0}}\n\n', @@ -461,9 +569,11 @@ async function createFixture( clearInterval, }, )) as any - activePlugins.add(plugin) + plugins.add(plugin) const result = await plugin.auth.loader( - () => Promise.resolve(custodyTombstoneOAuth('anthropic')), + // The OpenCode auth entry exactly as the migration left it in host auth. + async () => + JSON.parse(await readFile(fixture.hostAuthPath, 'utf8')).anthropic, { models: {} }, ) await plugin.__fallbackRefreshReady @@ -589,7 +699,9 @@ describe('vault-served fallback outbound token census', () => { const bootstrap = fixture.records.filter((record) => record.url.includes('/claude_cli/bootstrap'), ) - expectOnlyVaultToken(bootstrap, 'cachekeep', fixture.credentialGets) + // Native signing uses the identity the vault already verified for this + // receipt, so a prewarm never sends a bootstrap lookup with any token. + expect(bootstrap, JSON.stringify(fixture.credentialGets)).toEqual([]) }) test.serial( diff --git a/packages/opencode/src/tests/scoped-custody-serving.test.ts b/packages/opencode/src/tests/scoped-custody-serving.test.ts index 68ddad920..b34105819 100644 --- a/packages/opencode/src/tests/scoped-custody-serving.test.ts +++ b/packages/opencode/src/tests/scoped-custody-serving.test.ts @@ -1,33 +1,202 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, describe, expect } from 'bun:test' import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { - type AccountStorage, - acquireRefreshFileLock, - type ClaustrumScopedClient, - getAccountStatePath, - isOAuthAccount, - type OAuthAccount, + createNativeAccountRuntime, + custodyTombstoneOAuth, + type NativeCustodyClient, } from '@cortexkit/anthropic-auth-core' import type { ScopedInventoryRow } from '@cortexkit/claustrum-client' +import { acquireRefreshFileLock } from '@cortexkit/common-auth/fs' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { AnthropicAuthPlugin } from '../index.ts' import { drainSidebarWrites, getSidebarStateFile } from '../sidebar-state.ts' +import { + migrateNativeOpencodeFixture, + type NativeOpencodeFixture, +} from './native-fixture.ts' + +type PluginHooks = Awaited> -const testDirs: string[] = [] -const originalFetch = globalThis.fetch +// Every environment variable a test body or the migration fixture may set. +// Each body saves and restores all of them, so no path leaks into the next +// test. +const bodyEnvKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', + 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_ANTHROPIC_AUTH_DUMP_DIR', + 'OPENCODE_AUTH_CONTENT', +] as const +const livePlugins = new Set>() +// Registered before the lifetime suite's own afterEach: dispose every plugin +// (its timers, vault client and RPC server) before that hook waits for a +// possibly still-running body and removes the body's files. 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_DUMP_DIR - delete process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE + const plugins = [...livePlugins].flatMap((owned) => { + const current = [...owned] + owned.clear() + return current + }) + await disposeAll(plugins, 'Scoped custody plugin cancellation failed') }) +const lifetimes = createTestLifetimeSuite() +const test = lifetimes.test + +async function disposeAll(plugins: PluginHooks[], message: string) { + const results = await Promise.allSettled( + plugins.map((plugin) => Promise.resolve().then(() => plugin.dispose?.())), + ) + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + message, + ) +} + +/** + * Claim a fresh owner-only directory for this body. Teardown first disposes + * the body's plugins and waits for sidebar writes, then restores fetch and + * the environment, and only then removes the directory. + */ +async function startBody(prefix: string) { + const savedFetch = globalThis.fetch + const savedEnv = new Map(bodyEnvKeys.map((key) => [key, process.env[key]])) + const plugins = new Set() + livePlugins.add(plugins) + lifetimes.deferCleanup(async () => { + const remaining = [...plugins] + plugins.clear() + livePlugins.delete(plugins) + try { + await disposeAll(remaining, 'Scoped custody plugin cleanup failed') + } finally { + await drainSidebarWrites() + globalThis.fetch = savedFetch + for (const key of bodyEnvKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + delete process.env.OPENCODE_AUTH_CONTENT + const root = await mkdtemp(join(tmpdir(), prefix)) + // migrateNativeOpencodeFixture also removes the root once it accepts it; + // this covers a body that fails before handing the root to it. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + return { root, plugins } +} + +/** + * Import a legacy scoped-custody seat through the real offline migration. The + * discovery client lists the same synthetic vault rows the test later serves + * from, and refuses any credential read, so every receipt a test observes was + * issued while serving. The returned env is applied for the plugin. + */ +async function migrateScopedSeat( + root: string, + options: { + rows: () => ScopedInventoryRow[] + legacyConfig: Record + token: string + expectedCredentials: string[] + }, +) { + let listed = 0 + const discovery: NativeCustodyClient = { + async listScoped() { + listed++ + return { view: 'migration-discovery', rows: options.rows() } + }, + async getScoped() { + throw new Error('Migration discovery must not read credential material') + }, + async reportAuthFailureScoped() { + throw new Error('Migration discovery must not report credentials') + }, + close() {}, + } + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: options.legacyConfig, + hostAuth: { anthropic: custodyTombstoneOAuth('anthropic') }, + custody: { + connect: async () => discovery, + enrollment: { token: options.token, token_generation: 1 }, + }, + }) + expect(listed).toBeGreaterThan(0) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + // The migrated pool really serves the expected vault rows; a test cannot + // pass against an empty roster. + const snapshot = await readNativeAccounts(fixture) + expect(snapshot.mode).toBe('claustrum') + expect( + snapshot.accounts + .filter((account) => account.source === 'vault') + .map((account) => account.credentialId) + .sort(), + ).toEqual([...options.expectedCredentials].sort()) + return fixture +} + +async function readNativeAccounts(fixture: NativeOpencodeFixture) { + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + return await runtime.read() + } finally { + runtime.close() + } +} + +/** The OpenCode auth entry exactly as the migration left it in host auth. */ +function migratedActivation(fixture: NativeOpencodeFixture) { + return async () => + JSON.parse(await readFile(fixture.hostAuthPath, 'utf8')).anthropic +} + +async function createPlugin( + plugins: Set, + fixture: { root: string }, + runtime: Record, +) { + const creation = AnthropicAuthPlugin( + { directory: fixture.root } as never, + runtime as never, + ) + lifetimes.trackDetached(creation) + const plugin = await creation + plugins.add(plugin) + return plugin +} + +async function filesUnder(directory: string): Promise { + const entries = await readdir(directory, { + recursive: true, + withFileTypes: true, + }) + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)) +} const MESSAGES_URL = 'https://api.anthropic.com/v1/messages' const EMPTY_POST = { @@ -68,58 +237,65 @@ const workRow: ScopedInventoryRow = { createdAtMs: null, } +/** + * Legacy scoped-custody config for the vault inventory above, with mainRow as + * the designated primary account, plus any extra legacy fields. + */ +function scopedLegacyConfig(extra: Record = {}) { + return { + version: 1, + accounts: [], + quota: { enabled: false }, + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: mainRow.id, + accountId: mainRow.accountId, + state: 'active', + }, + }, + ...extra, + } +} + describe('OpenCode scoped custody serving', () => { test('serves requests using scoped receipts and dynamically adopts newly logged-in accounts without restart', async () => { - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-serving-')) - 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_DUMP_DIR = join(root, 'dumps') - 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, - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: mainRow.id, - accountId: mainRow.accountId as any, - state: 'active', + const { root, plugins } = await startBody('opencode-scoped-serving-') + const rows = [mainRow, workRow] + const fixture = await migrateScopedSeat(root, { + rows: () => [...rows], + token: 'aa'.repeat(32), + expectedCredentials: [mainRow.id, workRow.id], + legacyConfig: { + version: 1, + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: mainRow.id, + accountId: mainRow.accountId, + state: 'active', + }, }, + dump: { enabled: true }, + accounts: [ + { + id: 'work', + label: 'work', + type: 'oauth', + enabled: true, + refresh: '', + claustrumScopedCredentialId: workRow.id, + anthropicAccountUuid: workRow.accountId, + claustrumScopedState: 'active', + }, + ], }, - dump: { enabled: true }, - accounts: [ - { - id: 'work', - label: 'work', - type: 'oauth', - enabled: true, - refresh: '', - claustrumScopedCredentialId: workRow.id, - anthropicAccountUuid: workRow.accountId as any, - claustrumScopedState: 'active', - }, - ], - } - await writeFile(storagePath, JSON.stringify(initialStorage, null, 2), { - mode: 0o600, }) + const dumpDir = join(fixture.root, 'dumps') + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir - const rows = [mainRow, workRow] const gets: Array<{ credentialId: string }> = [] const reports: Array<{ credentialId: string @@ -127,7 +303,7 @@ describe('OpenCode scoped custody serving', () => { providerStatus: number }> = [] - const scopedClient: ClaustrumScopedClient = { + const scopedClient: NativeCustodyClient = { listScoped: async () => ({ view: `view-${rows.length}`, rows: [...rows], @@ -173,121 +349,103 @@ describe('OpenCode scoped custody serving', () => { 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, - ) - - // 1. Initial request dispatches with main scoped credential - const firstResponse = await result.fetch(MESSAGES_URL, EMPTY_POST) - expect(firstResponse.status).toBe(200) - expect(authorizations).toHaveLength(1) - expect(authorizations[0]).toBe('Bearer scoped-access-oauth:anthropic') - expect(gets.some((g) => g.credentialId === 'oauth:anthropic')).toBe(true) - await firstResponse.text() - await drainSidebarWrites() - const serialized = [ - await readFile(storagePath, 'utf8'), - await readFile(getAccountStatePath(storagePath), 'utf8'), - await readFile(getSidebarStateFile(), 'utf8'), - ...(await Promise.all( - ( - await readdir(process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR!) - ).map((name) => - readFile( - join(process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR!, name), - 'utf8', - ), - ), - )), - ].join('\n') - expect(serialized).not.toContain('scoped-access-oauth:anthropic') - expect(serialized).not.toContain('aa'.repeat(32)) + const plugin = await createPlugin(plugins, fixture, { + claustrumScopedConnect: async () => scopedClient, + }) - // 2. Simulate user adding a 3rd account via `ck auth login` - const personalRow: ScopedInventoryRow = { - id: 'oauth:anthropic:personal', - accountId: 'provider-personal-uuid', - categories: ['anthropic-native'], - serves: ['anthropic'], - providerIds: [], - credentialType: 'oauth', - refreshAdapter: 'anthropic', - operations: ['read'], - state: 'active', - recordVersion: 1, - createdAtMs: null, - } - rows.push(personalRow) + const result = await (plugin as any).auth.loader( + migratedActivation(fixture), + { models: {} } as any, + ) - // Trigger discovery refresh (simulating background poll) - const runtime = (plugin as any).__scopedRuntime - expect(runtime).toBeDefined() - await runtime.refresh() + // 1. Initial request dispatches with main scoped credential + const firstResponse = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(firstResponse.status).toBe(200) + expect(authorizations).toHaveLength(1) + expect(authorizations[0]).toBe('Bearer scoped-access-oauth:anthropic') + expect(gets.some((g) => g.credentialId === 'oauth:anthropic')).toBe(true) + await firstResponse.text() + await drainSidebarWrites() + // Neither the served access token nor the enrollment token may reach any + // file the plugin or the migration wrote: pool config, state, runtime, + // roster, journal, host auth, sidebar or request dumps. Only the + // enrollment file itself holds the enrollment token. + const dumps = await readdir(dumpDir) + expect(dumps.length).toBeGreaterThan(0) + const written = (await filesUnder(fixture.root)).filter( + (path) => path !== fixture.enrollmentPath, + ) + for (const path of [ + fixture.paths.config, + fixture.paths.runtime, + fixture.paths.roster, + fixture.paths.journal, + fixture.hostAuthPath, + getSidebarStateFile(), + ...dumps.map((name) => join(dumpDir, name)), + ]) + expect(written).toContain(path) + const serialized = ( + await Promise.all(written.map((path) => readFile(path, 'utf8'))) + ).join('\n') + expect(serialized).not.toContain('scoped-access-oauth:anthropic') + expect(serialized).not.toContain('aa'.repeat(32)) - // 3. Verify storage was updated with the new account - const { loadAccounts } = await import('@cortexkit/anthropic-auth-core') - const updatedStorage = await loadAccounts(storagePath) - const personalAccount = updatedStorage?.accounts.find( - (a): a is OAuthAccount => - isOAuthAccount(a) && - a.anthropicAccountUuid === 'provider-personal-uuid', - ) - expect(personalAccount).toBeDefined() - expect(personalAccount?.claustrumScopedCredentialId).toBe( - 'oauth:anthropic:personal', - ) - expect(personalAccount?.claustrumScopedState).toBe('active') - } finally { - await plugin.dispose?.() + // 2. Simulate user adding a 3rd account via `ck auth login` + const personalRow: ScopedInventoryRow = { + id: 'oauth:anthropic:personal', + accountId: 'provider-personal-uuid', + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + credentialType: 'oauth', + refreshAdapter: 'anthropic', + operations: ['read'], + state: 'active', + recordVersion: 1, + createdAtMs: null, } - }) - - test('reports upstream 401 auth failure to Claustrum with exact served record version', async () => { - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-401-')) - testDirs.push(root) + // The startup discovery must be finished, or the refresh below would + // join it and miss the new row. + await (plugin as any).__fallbackRefreshReady + rows.push(personalRow) - 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 + // Trigger discovery refresh (simulating background poll) + const runtime = (plugin as any).__scopedRuntime + expect(runtime).toBeDefined() + await runtime.refresh() - await writeFile( - tokenPath, - JSON.stringify({ token: 'bb'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, + // 3. The native account view now serves the new vault account. + const updated = await readNativeAccounts(fixture) + const personalAccount = updated.accounts.find( + (account) => account.accountIdentity === 'provider-personal-uuid', ) + expect(personalAccount).toBeDefined() + expect(personalAccount?.source).toBe('vault') + expect(personalAccount?.credentialId).toBe('oauth:anthropic:personal') + expect(personalAccount?.state).toBe('active') + expect(reports).toEqual([]) + }) - const initialStorage: AccountStorage = { - version: 1, - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: mainRow.id, - accountId: mainRow.accountId as any, - state: 'active', + test('reports upstream 401 auth failure to Claustrum with exact served record version', async () => { + const { root, plugins } = await startBody('opencode-scoped-401-') + const fixture = await migrateScopedSeat(root, { + rows: () => [mainRow], + token: 'bb'.repeat(32), + expectedCredentials: [mainRow.id], + legacyConfig: { + version: 1, + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: mainRow.id, + accountId: mainRow.accountId, + state: 'active', + }, }, + accounts: [], }, - accounts: [], - } - await writeFile(storagePath, JSON.stringify(initialStorage, null, 2), { - mode: 0o600, }) const reports: Array<{ @@ -295,7 +453,7 @@ describe('OpenCode scoped custody serving', () => { recordVersion: number providerStatus: number }> = [] - const scopedClient: ClaustrumScopedClient = { + const scopedClient: NativeCustodyClient = { listScoped: async () => ({ view: 'v1', rows: [mainRow] }), getScoped: async (input) => ({ credentialId: input.credentialId, @@ -329,141 +487,117 @@ describe('OpenCode scoped custody serving', () => { ) }) as typeof fetch - const plugin = await AnthropicAuthPlugin( - { directory: root } as any, - { claustrumScopedConnect: async () => scopedClient } as any, - ) + const plugin = await createPlugin(plugins, fixture, { + claustrumScopedConnect: async () => scopedClient, + }) - 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 result = await (plugin as any).auth.loader( + migratedActivation(fixture), + { models: {} } as any, + ) - await result.fetch(MESSAGES_URL, EMPTY_POST).catch(() => {}) + await result.fetch(MESSAGES_URL, EMPTY_POST).catch(() => {}) - expect(modelSends).toBe(1) - expect(reports).toHaveLength(1) - expect(reports[0]).toMatchObject({ - credentialId: 'oauth:anthropic', - recordVersion: 10, - providerStatus: 401, - }) - } finally { - await plugin.dispose?.() - } + expect(modelSends).toBe(1) + expect(reports).toHaveLength(1) + expect(reports[0]).toMatchObject({ + credentialId: 'oauth:anthropic', + recordVersion: 10, + providerStatus: 401, + }) }) }) test('Claustrum mode without a scoped roster refuses to serve even with legacy local fallback material', async () => { + // Deliberately not migrated: legacy handle bindings have no native + // authority, so serving must refuse before any upstream request and must + // leave the legacy material untouched. + const { root, plugins } = await startBody('opencode-legacy-refusal-') process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - const root = await mkdtemp(join(tmpdir(), 'opencode-legacy-refusal-')) - testDirs.push(root) const storagePath = join(root, 'anthropic-auth.json') process.env.OPENCODE_ANTHROPIC_AUTH_FILE = storagePath + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = join( + root, + 'anthropic-auth-state.json', + ) process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( root, 'enrollment.json', ) - await writeFile( - storagePath, - JSON.stringify({ - version: 1, - claustrum: { mode: 'claustrum' }, - accounts: [ - { - id: 'old', - label: 'old', - type: 'oauth', - enabled: true, - access: 'stale-local-access', - refresh: 'stale-local-refresh', - expires: Date.now() + 3600000, - }, - ], - }), - { mode: 0o600 }, - ) + const legacyBytes = JSON.stringify({ + version: 1, + claustrum: { mode: 'claustrum' }, + accounts: [ + { + id: 'old', + label: 'old', + type: 'oauth', + enabled: true, + access: 'stale-local-access', + refresh: 'stale-local-refresh', + expires: Date.now() + 3600000, + }, + ], + }) + await writeFile(storagePath, legacyBytes, { mode: 0o600 }) let sends = 0 globalThis.fetch = (async () => { sends++ throw new Error('request must not reach upstream') }) as unknown as typeof fetch - const plugin = await AnthropicAuthPlugin( - { directory: root } as any, + const plugin = await createPlugin( + plugins, + { root }, { scopedRosterPollIntervalMs: 0, - } as any, + }, ) - try { - const loader = await (plugin as any).auth.loader( - async () => ({ - type: 'oauth', - access: '', - refresh: 'claustrum-tombstone:v1:anthropic', - expires: 0, - }), - { models: {} }, - ) - const response = await loader.fetch(MESSAGES_URL, EMPTY_POST) - expect(response.status).toBe(503) - expect(await response.text()).toContain( - 'Run setup to enable scoped Claustrum custody', - ) - expect(sends).toBe(0) - } finally { - await plugin.dispose?.() - } + const loader = await (plugin as any).auth.loader( + async () => custodyTombstoneOAuth('anthropic'), + { models: {} }, + ) + // Legacy storage used to answer this with a 503 asking for scoped-custody + // setup. Natively, a pool without a migration journal has no authority to + // serve at all, so the request is refused before any upstream call. + await expect(loader.fetch(MESSAGES_URL, EMPTY_POST)).rejects.toThrow( + 'Anthropic account migration is required', + ) + expect(sends).toBe(0) + expect(await readFile(storagePath, 'utf8')).toBe(legacyBytes) }) test('loader and model request remain usable when a peer holds the scoped roster lease', async () => { - const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-lease-')) - 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_DISABLE_PROFILE_HYDRATION = '1' - await writeFile( - tokenPath, - JSON.stringify({ token: 'cc'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, - ) - await writeFile( - storagePath, - JSON.stringify({ - version: 1, - accounts: [], - quota: { enabled: false }, - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: mainRow.id, - accountId: mainRow.accountId, - state: 'active', - }, - }, - }), - { mode: 0o600 }, - ) + const { root, plugins } = await startBody('opencode-scoped-lease-') + const fixture = await migrateScopedSeat(root, { + rows: () => [mainRow], + token: 'cc'.repeat(32), + expectedCredentials: [mainRow.id], + legacyConfig: scopedLegacyConfig(), + }) + // A peer process holds the vault roster's discovery lease, the lease every + // native roster refresh must take before listing the vault. const lease = await acquireRefreshFileLock({ - name: 'scoped-roster', - path: storagePath, + name: 'claustrum-roster', + path: fixture.paths.roster, ttlMs: 30_000, renew: true, }) if (!lease) throw new Error('test could not acquire roster lease') + try { + await serveWhileLeaseHeld(fixture, plugins) + } finally { + await lease.release() + } +}) + +async function serveWhileLeaseHeld( + fixture: NativeOpencodeFixture, + plugins: Set, +) { let lists = 0, gets = 0, sends = 0 - const scopedClient: ClaustrumScopedClient = { + const scopedClient: NativeCustodyClient = { listScoped: async () => { lists++ throw new Error('peer owns discovery') @@ -490,78 +624,43 @@ test('loader and model request remain usable when a peer holds the scoped roster sends++ return new Response('{}', { status: 200 }) }) as typeof fetch - let plugin: Awaited> | undefined - try { - plugin = await AnthropicAuthPlugin( - { directory: root } as never, - { - claustrumScopedConnect: async () => scopedClient, - scopedRosterPollIntervalMs: 0, - } as never, - ) - // OpenCode initializes plugins for all providers. A contested roster - // must not make an unrelated model's provider initialization fail. - const loader = await (plugin as any).auth.loader( - async () => ({ - type: 'oauth', - access: '', - refresh: 'claustrum-tombstone:v1:anthropic', - expires: 0, - }), - { models: {} }, - ) - expect(lists).toBe(0) - expect(gets).toBe(0) - expect((await loader.fetch(MESSAGES_URL, EMPTY_POST)).status).toBe(200) - expect(gets).toBeGreaterThan(0) - expect(sends).toBe(1) - expect(lists).toBe(0) - } finally { - await plugin?.dispose?.() - await lease.release() - } -}) + const plugin = await createPlugin(plugins, fixture, { + claustrumScopedConnect: async () => scopedClient, + scopedRosterPollIntervalMs: 0, + }) + // OpenCode initializes plugins for all providers. A contested roster + // must not make an unrelated model's provider initialization fail. + const loader = await (plugin as any).auth.loader( + migratedActivation(fixture), + { models: {} }, + ) + // The startup discovery found the lease held and kept the committed roster. + await (plugin as any).__fallbackRefreshReady + expect(lists).toBe(0) + expect(gets).toBe(0) + expect((await loader.fetch(MESSAGES_URL, EMPTY_POST)).status).toBe(200) + expect(gets).toBeGreaterThan(0) + expect(sends).toBe(1) + expect(lists).toBe(0) +} test('provider initialization does not wait for a stalled scoped discovery connection', async () => { - const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-loader-')) - 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_DISABLE_PROFILE_HYDRATION = '1' - await writeFile( - tokenPath, - JSON.stringify({ token: 'dd'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, - ) - await writeFile( - storagePath, - JSON.stringify({ - version: 1, - accounts: [], - quota: { enabled: false }, - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: mainRow.id, - accountId: mainRow.accountId, - state: 'active', - }, - }, - }), - { mode: 0o600 }, - ) + const { root, plugins } = await startBody('opencode-scoped-loader-') + const fixture = await migrateScopedSeat(root, { + rows: () => [mainRow], + token: 'dd'.repeat(32), + expectedCredentials: [mainRow.id], + legacyConfig: scopedLegacyConfig(), + }) let connectStarted!: () => void - let releaseConnect!: (value: ClaustrumScopedClient) => void + let releaseConnect!: (value: NativeCustodyClient) => void const entered = new Promise((resolve) => { connectStarted = resolve }) - const blocked = new Promise((resolve) => { + const blocked = new Promise((resolve) => { releaseConnect = resolve }) - const client: ClaustrumScopedClient = { + const client: NativeCustodyClient = { listScoped: async () => ({ rows: [mainRow], view: 'v' }), getScoped: async () => { throw new Error('no dispatch expected') @@ -569,29 +668,20 @@ test('provider initialization does not wait for a stalled scoped discovery conne reportAuthFailureScoped: async () => {}, close: () => {}, } - const plugin = await AnthropicAuthPlugin( - { directory: root } as never, - { - claustrumScopedConnect: () => { - connectStarted() - return blocked - }, - scopedRosterPollIntervalMs: 0, - } as never, - ) + const plugin = await createPlugin(plugins, fixture, { + claustrumScopedConnect: () => { + connectStarted() + return blocked + }, + scopedRosterPollIntervalMs: 0, + }) let deadline: ReturnType | undefined try { await entered const loader = await Promise.race([ - (plugin as any).auth.loader( - async () => ({ - type: 'oauth', - access: '', - refresh: 'claustrum-tombstone:v1:anthropic', - expires: 0, - }), - { models: {} }, - ), + (plugin as any).auth.loader(migratedActivation(fixture), { + models: {}, + }), new Promise((_, reject) => { deadline = setTimeout( () => @@ -607,53 +697,26 @@ test('provider initialization does not wait for a stalled scoped discovery conne } finally { if (deadline) clearTimeout(deadline) releaseConnect(client) - await plugin.dispose?.() } }) -test.each([ +for (const { outcome, finalStatus } of [ { outcome: '200', finalStatus: 200 }, { outcome: '401', finalStatus: 401 }, { outcome: 'network-error', finalStatus: 'network-error' }, -] as const)( - 'a scoped main rotated during HTTP dispatch retries once, final outcome $outcome', - async ({ finalStatus }) => { - const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-rotation-')) - testDirs.push(root) - const storagePath = join(root, 'anthropic-auth.json') - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = storagePath - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( - root, - 'enrollment.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - await writeFile( - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE, - JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, - ) - await writeFile( - storagePath, - JSON.stringify({ - version: 1, - accounts: [], - quota: { enabled: false }, - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: mainRow.id, - accountId: mainRow.accountId, - state: 'active', - }, - }, - }), - { mode: 0o600 }, - ) +] as const) { + test(`a scoped main rotated during HTTP dispatch retries once, final outcome ${outcome}`, async () => { + const { root, plugins } = await startBody('opencode-scoped-rotation-') + const fixture = await migrateScopedSeat(root, { + rows: () => [mainRow], + token: 'ab'.repeat(32), + expectedCredentials: [mainRow.id], + legacyConfig: scopedLegacyConfig(), + }) let version = 1 const reports: number[] = [] const wireTokens: string[] = [] - const scopedClient: ClaustrumScopedClient = { + const scopedClient: NativeCustodyClient = { listScoped: async () => ({ view: 'main-only', rows: [mainRow] }), getScoped: async ({ credentialId }) => ({ credentialId, @@ -680,93 +743,59 @@ test.each([ throw new Error('new record transport failed') return new Response('rotated token response', { status: finalStatus }) }) as typeof fetch - const plugin = await AnthropicAuthPlugin( - { directory: root } as never, - { - claustrumScopedConnect: async () => scopedClient, - scopedRosterPollIntervalMs: 0, - } as never, + const plugin = await createPlugin(plugins, fixture, { + claustrumScopedConnect: async () => scopedClient, + scopedRosterPollIntervalMs: 0, + }) + const loader = await (plugin as any).auth.loader( + migratedActivation(fixture), + { models: {} }, ) - try { - const loader = await (plugin as any).auth.loader( - async () => ({ - type: 'oauth', - access: '', - refresh: 'claustrum-tombstone:v1:anthropic', - expires: 0, - }), - { models: {} }, + if (finalStatus === 'network-error') { + await expect(loader.fetch(MESSAGES_URL, EMPTY_POST)).rejects.toThrow( + 'new record transport failed', ) - if (finalStatus === 'network-error') { - await expect(loader.fetch(MESSAGES_URL, EMPTY_POST)).rejects.toThrow( - 'new record transport failed', - ) - } else { - const response = await loader.fetch(MESSAGES_URL, EMPTY_POST) - expect(response.status).toBe(finalStatus) - expect(await response.text()).toBe('rotated token response') - } - expect(wireTokens).toEqual([ - 'Bearer scoped-version-1', - 'Bearer scoped-version-2', - ]) - expect(reports).toEqual(finalStatus === 401 ? [2] : []) - } finally { - await plugin.dispose?.() + } else { + const response = await loader.fetch(MESSAGES_URL, EMPTY_POST) + expect(response.status).toBe(finalStatus) + expect(await response.text()).toBe('rotated token response') } - }, -) + expect(wireTokens).toEqual([ + 'Bearer scoped-version-1', + 'Bearer scoped-version-2', + ]) + expect(reports).toEqual(finalStatus === 401 ? [2] : []) + }) +} -test.each(['transport-error', 'relay-auth-401'] as const)( - 'relay-to-direct after %s reauthorizes and reports only the direct credential', - async (mode) => { - const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-relay-direct-')) - testDirs.push(root) - const storagePath = join(root, 'anthropic-auth.json') - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = storagePath - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( - root, - 'enrollment.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - await writeFile( - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE, - JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, - ) - await writeFile( - storagePath, - JSON.stringify({ - version: 1, - accounts: [], - quota: { enabled: false }, - relay: { - enabled: true, - transport: 'http', - url: 'https://relay.example.test/forward', - token: 'relay-test', - fallbackToDirect: true, - }, - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: mainRow.id, - accountId: mainRow.accountId, - state: 'active', - }, - }, - }), - { mode: 0o600 }, - ) +/** Legacy HTTP relay settings; the migration moves the token into private runtime state. */ +const legacyHttpRelay = { + enabled: true, + transport: 'http', + url: 'https://relay.example.test/forward', + token: 'relay-test', + fallbackToDirect: true, +} + +for (const mode of ['transport-error', 'relay-auth-401'] as const) { + test(`relay-to-direct after ${mode} reauthorizes and reports only the direct credential`, async () => { + const { root, plugins } = await startBody('opencode-scoped-relay-direct-') + const fixture = await migrateScopedSeat(root, { + rows: () => [mainRow], + token: 'ab'.repeat(32), + expectedCredentials: [mainRow.id], + legacyConfig: scopedLegacyConfig({ relay: legacyHttpRelay }), + }) let version = 1 - const gets: number[] = [], - reports: number[] = [], + // Vault reads and wire sends in the order they happen, so each receipt + // can be matched to the send (if any) that used it. + const events: string[] = [] + const reports: number[] = [], direct: string[] = [] - const scopedClient: ClaustrumScopedClient = { + const scopedClient: NativeCustodyClient = { listScoped: async () => ({ view: 'main-only', rows: [mainRow] }), getScoped: async ({ credentialId }) => { - gets.push(version) + events.push(`vault read v${version}`) return { credentialId, accountId: mainRow.accountId, @@ -782,6 +811,10 @@ test.each(['transport-error', 'relay-auth-401'] as const)( } globalThis.fetch = (async (input: unknown, init?: RequestInit) => { if (String(input).includes('relay.example.test')) { + const payload = JSON.parse(String(init?.body)) as { + upstream: { headers: Record } + } + events.push(`relay send ${payload.upstream.headers.authorization}`) version = 2 if (mode === 'relay-auth-401') return new Response('relay secret rejected', { status: 401 }) @@ -790,87 +823,63 @@ test.each(['transport-error', 'relay-auth-401'] as const)( if (!String(input).includes('/v1/messages')) throw new Error('unexpected upstream call') direct.push(new Headers(init?.headers).get('authorization') ?? '') + events.push(`direct send ${direct.at(-1)}`) return new Response('rejected latest record', { status: 401 }) }) as typeof fetch - const plugin = await AnthropicAuthPlugin( - { directory: root } as never, - { - claustrumScopedConnect: async () => scopedClient, - scopedRosterPollIntervalMs: 0, - } as never, + const plugin = await createPlugin(plugins, fixture, { + claustrumScopedConnect: async () => scopedClient, + scopedRosterPollIntervalMs: 0, + }) + const loader = await (plugin as any).auth.loader( + migratedActivation(fixture), + { models: {} }, ) - try { - const loader = await (plugin as any).auth.loader( - async () => ({ - type: 'oauth', - access: '', - refresh: 'claustrum-tombstone:v1:anthropic', - expires: 0, - }), - { models: {} }, - ) - const response = await loader.fetch(MESSAGES_URL, { - ...EMPTY_POST, - headers: { 'x-session-affinity': 'relay-direct-rotation' }, - }) - expect(response.status).toBe(401) - expect(direct).toEqual(['Bearer scoped-version-2']) - expect(gets).toEqual([1, 1, 2, 2]) - expect(reports).toEqual([2]) - } finally { - await plugin.dispose?.() - } - }, -) + const response = await loader.fetch(MESSAGES_URL, { + ...EMPTY_POST, + headers: { 'x-session-affinity': 'relay-direct-rotation' }, + }) + expect(response.status).toBe(401) + expect(direct).toEqual(['Bearer scoped-version-2']) + // Every vault credential read, in order and by purpose, interleaved with + // the physical sends. A read is not a send: only the relay and direct + // sends carry a credential, and each uses the read just before it. + expect(events).toEqual([ + // Routing picks main and checks its model policy for this request. + 'vault read v1', + // After routing, the selected route is authorized again to verify it + // (enabled state, source, identity, binding, model policy) and to supply + // the request-signing identity. Its access token is not dispatched: the + // next transport attempt authorizes again. + 'vault read v1', + // Fresh access token and record version for the relay attempt. + 'vault read v1', + 'relay send Bearer scoped-version-1', + // Fresh access token and record version for the direct fallback, + // issued after the vault rotated. + 'vault read v2', + 'direct send Bearer scoped-version-2', + // After the direct 401 the vault is asked for a newer record; v2 is + // still current, so there is no retry, and reportAuthFailure records + // exactly version 2 as the final rejected record. + 'vault read v2', + ]) + expect(reports).toEqual([2]) + }) +} -test.each([200, 401])( - 'HTTP relay retries once after scoped rotation; final upstream status %i', - async (finalStatus) => { - const root = await mkdtemp( - join(tmpdir(), 'opencode-scoped-relay-rotation-'), - ) - testDirs.push(root) - const storagePath = join(root, 'anthropic-auth.json') - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = storagePath - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( - root, - 'enrollment.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - await writeFile( - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE, - JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, - ) - await writeFile( - storagePath, - JSON.stringify({ - version: 1, - accounts: [], - quota: { enabled: false }, - relay: { - enabled: true, - transport: 'http', - url: 'https://relay.example.test/forward', - token: 'relay-test', - fallbackToDirect: true, - }, - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: mainRow.id, - accountId: mainRow.accountId, - state: 'active', - }, - }, - }), - { mode: 0o600 }, - ) +for (const finalStatus of [200, 401]) { + test(`HTTP relay retries once after scoped rotation; final upstream status ${finalStatus}`, async () => { + const { root, plugins } = await startBody('opencode-scoped-relay-rotation-') + const fixture = await migrateScopedSeat(root, { + rows: () => [mainRow], + token: 'ab'.repeat(32), + expectedCredentials: [mainRow.id], + legacyConfig: scopedLegacyConfig({ relay: legacyHttpRelay }), + }) let version = 1 const reports: number[] = [], relayTokens: string[] = [] - const scopedClient: ClaustrumScopedClient = { + const scopedClient: NativeCustodyClient = { listScoped: async () => ({ view: 'main-only', rows: [mainRow] }), getScoped: async ({ credentialId }) => ({ credentialId, @@ -905,43 +914,31 @@ test.each([200, 401])( headers: { 'request-id': 'req_upstream_success' }, }) }) as typeof fetch - const plugin = await AnthropicAuthPlugin( - { directory: root } as never, - { - claustrumScopedConnect: async () => scopedClient, - scopedRosterPollIntervalMs: 0, - } as never, + const plugin = await createPlugin(plugins, fixture, { + claustrumScopedConnect: async () => scopedClient, + scopedRosterPollIntervalMs: 0, + }) + const loader = await (plugin as any).auth.loader( + migratedActivation(fixture), + { models: {} }, ) - try { - const loader = await (plugin as any).auth.loader( - async () => ({ - type: 'oauth', - access: '', - refresh: 'claustrum-tombstone:v1:anthropic', - expires: 0, - }), - { models: {} }, - ) - const response = await loader.fetch(MESSAGES_URL, { - ...EMPTY_POST, - headers: { 'x-session-affinity': 'relay-rotation' }, - }) - expect(response.status).toBe(finalStatus) - expect(await response.text()).toBe('new relay token response') - expect(relayTokens).toEqual([ - 'Bearer scoped-version-1', - 'Bearer scoped-version-2', - ]) - expect(reports).toEqual(finalStatus === 401 ? [2] : []) - } finally { - await plugin.dispose?.() - } - }, -) + const response = await loader.fetch(MESSAGES_URL, { + ...EMPTY_POST, + headers: { 'x-session-affinity': 'relay-rotation' }, + }) + expect(response.status).toBe(finalStatus) + expect(await response.text()).toBe('new relay token response') + expect(relayTokens).toEqual([ + 'Bearer scoped-version-1', + 'Bearer scoped-version-2', + ]) + expect(reports).toEqual(finalStatus === 401 ? [2] : []) + }) +} test('optimistic WebSocket reports only the scoped version used by its real upstream 401', async () => { - const root = await mkdtemp(join(tmpdir(), 'opencode-scoped-ws-401-')) - testDirs.push(root) + const { root, plugins } = await startBody('opencode-scoped-ws-401-') + const relayTokens: string[] = [] const server = Bun.serve({ hostname: '127.0.0.1', port: 0, @@ -985,81 +982,51 @@ test('optimistic WebSocket reports only the scoped version used by its real upst }, }, }) - const relayTokens: string[] = [] - const storagePath = join(root, 'anthropic-auth.json') - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = storagePath - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( - root, - 'enrollment.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - await writeFile( - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE, - JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, - ) - await writeFile( - storagePath, - JSON.stringify({ - version: 1, - accounts: [], - quota: { enabled: false }, - relay: { - enabled: true, - transport: 'websocket', - url: server.url.href, - token: 'relay-test', - fallbackToDirect: false, - }, - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: mainRow.id, - accountId: mainRow.accountId, - state: 'active', + try { + const fixture = await migrateScopedSeat(root, { + rows: () => [mainRow], + token: 'ab'.repeat(32), + expectedCredentials: [mainRow.id], + legacyConfig: scopedLegacyConfig({ + relay: { + enabled: true, + transport: 'websocket', + url: server.url.href, + token: 'relay-test', + fallbackToDirect: false, }, + }), + }) + let gets = 0 + const reports: number[] = [] + const reported = lifetimes.gate() + const scopedClient: NativeCustodyClient = { + listScoped: async () => ({ view: 'main-only', rows: [mainRow] }), + getScoped: async ({ credentialId }) => { + const version = Math.min(++gets, 2) + return { + credentialId, + accountId: mainRow.accountId, + material: `scoped-version-${version}`, + recordVersion: version, + expiresAtMs: Date.now() + 3_600_000, + } }, - }), - { mode: 0o600 }, - ) - let gets = 0 - const reports: number[] = [] - const scopedClient: ClaustrumScopedClient = { - listScoped: async () => ({ view: 'main-only', rows: [mainRow] }), - getScoped: async ({ credentialId }) => { - const version = Math.min(++gets, 2) - return { - credentialId, - accountId: mainRow.accountId, - material: `scoped-version-${version}`, - recordVersion: version, - expiresAtMs: Date.now() + 3_600_000, - } - }, - reportAuthFailureScoped: async ({ recordVersion }) => { - reports.push(recordVersion) - }, - close: () => {}, - } - globalThis.fetch = (async () => { - throw new Error('unexpected direct send') - }) as unknown as typeof fetch - const plugin = await AnthropicAuthPlugin( - { directory: root } as never, - { + reportAuthFailureScoped: async ({ recordVersion }) => { + reports.push(recordVersion) + reported.open() + }, + close: () => {}, + } + globalThis.fetch = (async () => { + throw new Error('unexpected direct send') + }) as unknown as typeof fetch + const plugin = await createPlugin(plugins, fixture, { claustrumScopedConnect: async () => scopedClient, scopedRosterPollIntervalMs: 0, - } as never, - ) - try { + }) const loader = await (plugin as any).auth.loader( - async () => ({ - type: 'oauth', - access: '', - refresh: 'claustrum-tombstone:v1:anthropic', - expires: 0, - }), + migratedActivation(fixture), { models: {} }, ) const response = await loader.fetch(MESSAGES_URL, { @@ -1068,12 +1035,15 @@ test('optimistic WebSocket reports only the scoped version used by its real upst }) expect(response.status).toBe(200) // local optimistic status, not Anthropic's 401 await response.text().catch(() => {}) - for (let attempt = 0; attempt < 50 && reports.length === 0; attempt++) - await Bun.sleep(10) + // Anthropic's late 401 is reported asynchronously, after the optimistic + // response. Wait for that report; if it never arrives, teardown resolves + // this wait so the test fails instead of hanging. + await reported.wait expect(relayTokens).toEqual(['Bearer scoped-version-2']) expect(reports).toEqual([2]) } finally { - await plugin.dispose?.() + await disposeAll([...plugins], 'Scoped custody plugin cleanup failed') + plugins.clear() await server.stop(true) } }) 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 214238817..02b5d0e62 100644 --- a/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts +++ b/packages/opencode/src/tests/scoped-main-quota-persistence.test.ts @@ -1,29 +1,103 @@ -import { afterEach, expect, test } from 'bun:test' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { afterEach, expect } from 'bun:test' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { - type AccountStorage, - type ClaustrumScopedClient, - getAccountStatePath, + createNativeAccountRuntime, + custodyTombstoneOAuth, + type NativeCustodyClient, } from '@cortexkit/anthropic-auth-core' import type { ScopedInventoryRow } from '@cortexkit/claustrum-client' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { AnthropicAuthPlugin } from '../index.ts' +import { createRpcClient } from '../rpc/rpc-client.ts' +import { getRpcDir } from '../rpc/rpc-dir.ts' import { drainSidebarWrites } from '../sidebar-state.ts' +import { + migrateNativeOpencodeFixture, + type NativeOpencodeFixture, +} from './native-fixture.ts' + +type PluginHooks = Awaited> -const testDirs: string[] = [] -const originalFetch = globalThis.fetch +// Every environment variable a test body or the migration fixture may set. +// Each body saves and restores all of them, so no path leaks into the next +// test. +const bodyEnvKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', + 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', +] as const +const livePlugins = new Set>() +// Registered before the lifetime suite's own afterEach: dispose every plugin +// (its timers, vault client and RPC server) before that hook waits for a +// possibly still-running body and removes the body's files. 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 plugins = [...livePlugins].flatMap((owned) => { + const current = [...owned] + owned.clear() + return current + }) + await disposeAll(plugins, 'Scoped quota plugin cancellation failed') }) +const lifetimes = createTestLifetimeSuite() +const test = lifetimes.test + +async function disposeAll(plugins: PluginHooks[], message: string) { + const results = await Promise.allSettled( + plugins.map((plugin) => Promise.resolve().then(() => plugin.dispose?.())), + ) + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + message, + ) +} + +/** + * Claim a fresh owner-only directory for this body. Teardown first disposes + * the body's plugins and waits for sidebar writes, then restores fetch and + * the environment, and only then removes the directory. + */ +async function startBody(prefix: string) { + const savedFetch = globalThis.fetch + const savedEnv = new Map(bodyEnvKeys.map((key) => [key, process.env[key]])) + const plugins = new Set() + livePlugins.add(plugins) + lifetimes.deferCleanup(async () => { + const remaining = [...plugins] + plugins.clear() + livePlugins.delete(plugins) + try { + await disposeAll(remaining, 'Scoped quota plugin cleanup failed') + } finally { + await drainSidebarWrites() + globalThis.fetch = savedFetch + for (const key of bodyEnvKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + delete process.env.OPENCODE_AUTH_CONTENT + const root = await mkdtemp(join(tmpdir(), prefix)) + // migrateNativeOpencodeFixture also removes the root once it accepts it; + // this covers a body that fails before handing the root to it. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + return { root, plugins } +} const PRIMARY_ACCOUNT_ID = 'e84ca8b4-bd13-41e9-98e4-13f7b6690b7e' const SLOT_ID = '874a76c0-309a-4ccb-9199-9106db83f521' @@ -42,83 +116,129 @@ const mainRow: ScopedInventoryRow = { createdAtMs: null, } -async function readMainQuota(storagePath: string) { - const state = JSON.parse( - await readFile(getAccountStatePath(storagePath), 'utf8'), - ) as { - main?: { - quota?: { +/** Main quota as the native account view exposes it; never a legacy state file. */ +async function readMainQuota(fixture: NativeOpencodeFixture) { + const snapshot = await readNativeAccounts(fixture) + return snapshot.accounts.find((account) => account.id === 'main')?.quota as + | { accountIdentity?: string scoped?: Array<{ id?: string }> } - } + | undefined +} + +async function readNativeAccounts(fixture: NativeOpencodeFixture) { + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + return await runtime.read() + } finally { + runtime.close() } - 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( +/** + * Import a scoped-custody seat through the real offline migration. The + * migration keeps the host's stable slot id (SLOT_ID) as mainAccountId, while + * main quota and runtime state bind to the Anthropic account UUID + * (PRIMARY_ACCOUNT_ID). Discovery here reads only roster rows; it refuses any + * credential read, so every receipt a test observes was issued while serving. + */ +async function setupScopedSeat(root: string, rows: () => ScopedInventoryRow[]) { + const discovery: NativeCustodyClient = { + listScoped: async () => ({ view: 'migration-discovery', rows: rows() }), + getScoped: async () => { + throw new Error('Migration discovery must not read credential material') + }, + reportAuthFailureScoped: async () => { + throw new Error('Migration discovery must not report credentials') + }, + close: () => {}, + } + const fixture = await migrateNativeOpencodeFixture({ 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', + lifetime: lifetimes, + legacyConfig: { + version: 1, + mainAccountId: SLOT_ID, + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: mainRow.id, + accountId: PRIMARY_ACCOUNT_ID, + state: 'active', + }, }, + accounts: [], }, - accounts: [], - } - await writeFile(storagePath, JSON.stringify(initialStorage, null, 2), { - mode: 0o600, + hostAuth: { anthropic: custodyTombstoneOAuth('anthropic') }, + custody: { + connect: async () => discovery, + enrollment: { token: 'aa'.repeat(32), token_generation: 1 }, + }, + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + // The migrated main really is the vault primary, and has no quota yet. + const main = (await readNativeAccounts(fixture)).accounts.find( + (account) => account.id === 'main', + ) + expect(main).toMatchObject({ + source: 'vault', + credentialId: mainRow.id, + accountIdentity: PRIMARY_ACCOUNT_ID, }) - return storagePath + expect(main?.quota).toBeUndefined() + return fixture } -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: () => {}, +/** The OpenCode auth entry exactly as the migration left it in host auth. */ +function migratedActivation(fixture: NativeOpencodeFixture) { + return async () => + JSON.parse(await readFile(fixture.hostAuthPath, 'utf8')).anthropic } -const TOMBSTONE_AUTH = () => - Promise.resolve({ - type: 'oauth', - access: '', - refresh: 'claustrum-tombstone:v1:anthropic', - expires: 0, - }) +async function createPlugin( + plugins: Set, + context: Record, + runtime: Record, +) { + const creation = AnthropicAuthPlugin(context as never, runtime as never) + lifetimes.trackDetached(creation) + const plugin = await creation + plugins.add(plugin) + return plugin +} + +function mainClient(): NativeCustodyClient { + return { + 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: () => {}, + } +} 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 { root, plugins } = await startBody('opencode-scoped-quota-cmd-') + const fixture = await setupScopedSeat(root, () => [mainRow]) + const loopbackFetch = globalThis.fetch const usageAuthorizations: string[] = [] globalThis.fetch = (async (input: unknown, init?: RequestInit) => { const url = String(input) + // The menu request below travels over the plugin's own loopback RPC server. + if (url.startsWith('http://127.0.0.1:')) + return loopbackFetch(input as Parameters[0], init) if (url.includes('/api/oauth/usage')) { usageAuthorizations.push( new Headers(init?.headers).get('authorization') ?? '', @@ -141,84 +261,39 @@ test('/claude-quota polls main through scoped custody and persists its scoped wi 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, + const plugin = await createPlugin( + plugins, + { directory: fixture.root, client: { session: {} } }, + { claustrumScopedConnect: async () => mainClient() }, ) - 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?.() - } + await (plugin as any).auth.loader(migratedActivation(fixture), { + models: {}, + } as any) + // The retired /claude-quota alias is now the unified /claude menu's + // "Refresh quota" action, applied the way the TUI applies it: through the + // plugin's RPC server. It returns only after the poll was persisted. + const result = await createRpcClient( + getRpcDir(fixture.root), + process.pid, + ).applyMenu({ + command: 'claude', + sectionId: 'Quota', + actionId: 'quota-refresh', + sessionId: 'session-1', + }) + expect(result.ok).toBe(true) + await drainSidebarWrites() + expect(usageAuthorizations).toContain('Bearer scoped-access-main') + const quota = await readMainQuota(fixture) + expect(quota?.accountIdentity).toBe(PRIMARY_ACCOUNT_ID) + expect(quota?.scoped?.map((window) => window.id)).toEqual([ + 'claude-weekly-scoped-claude-fable-5-1', + ]) }) 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 { root, plugins } = await startBody('opencode-scoped-main-quota-') + const fixture = await setupScopedSeat(root, () => [mainRow]) const resetSeconds = Math.floor(Date.now() / 1000) + 3_600 globalThis.fetch = (async (input: unknown) => { @@ -249,71 +324,68 @@ test('scoped custody persists main quota under the roster primary account id', a return new Response('not found', { status: 404 }) }) as typeof fetch - const plugin = await AnthropicAuthPlugin( - { directory: root } as any, - { - claustrumScopedConnect: async () => scopedClient, - } as any, + const plugin = await createPlugin( + plugins, + { directory: fixture.root }, + { claustrumScopedConnect: async () => mainClient() }, ) - 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() + const result = await (plugin as any).auth.loader( + migratedActivation(fixture), + { 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?.() + // Header quota is persisted in the background after the response returns. + let quota = await readMainQuota(fixture) + for (let i = 0; i < 60 && !quota; i++) { + await Bun.sleep(50) + quota = await readMainQuota(fixture) } + await drainSidebarWrites() + expect(quota).toBeDefined() + expect(quota?.accountIdentity).toBe(PRIMARY_ACCOUNT_ID) }) 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 { root, plugins } = await startBody('opencode-scoped-primary-race-') const NEW_PRIMARY = '11111111-2222-4333-8444-555555555555' + const rows = [mainRow] + const fixture = await setupScopedSeat(root, () => [...rows]) let getScopedCalls = 0 - const racingClient: ClaustrumScopedClient = { - listScoped: async () => ({ view: 'view-1', rows: [mainRow] }), + const racingClient: NativeCustodyClient = { + listScoped: async () => ({ + view: `view-${getScopedCalls}`, + rows: [...rows], + }), 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. + // The vault's primary record moves to account B after the plugin + // selected main but before the receipt for A is used. Offline setup + // alone may repoint the pinned primary, so the change arrives the way a + // running host sees it: a roster refresh observing the new account. 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, + rows[0] = { ...mainRow, accountId: NEW_PRIMARY, recordVersion: 11 } + const peer = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + vault: { connect: async () => racingClient }, }) + try { + await peer.vault.refresh() + } finally { + peer.close() + } } return { credentialId: input.credentialId, @@ -354,48 +426,55 @@ test('a scoped main receipt never binds its quota to a roster primary that chang return new Response('not found', { status: 404 }) }) as typeof fetch - const plugin = await AnthropicAuthPlugin( - { directory: root } as any, + const plugin = await createPlugin( + plugins, + { directory: fixture.root }, { claustrumScopedConnect: async () => racingClient, - } as any, + scopedRosterPollIntervalMs: 0, + }, ) - try { - const result = await (plugin as any).auth.loader(TOMBSTONE_AUTH, { + // Let the startup roster discovery finish so it cannot hold the roster + // lease when the mid-request refresh above runs. + await (plugin as any).__fallbackRefreshReady + const result = await (plugin as any).auth.loader( + migratedActivation(fixture), + { models: {}, - } as any) - let requestError: unknown - 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((error: unknown) => { - requestError = error - return undefined - }) - await response?.text().catch(() => {}) - await Bun.sleep(300) - await drainSidebarWrites() + } as any, + ) + let requestError: unknown + 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((error: unknown) => { + requestError = error + return undefined + }) + await response?.text().catch(() => {}) + 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) - expect(quota?.accountIdentity).not.toBe(NEW_PRIMARY) - } finally { - await plugin.dispose?.() - } + // The race really happened: A's receipt was served and the vault roster + // now binds the primary record to B. + expect(getScopedCalls).toBeGreaterThan(0) + const snapshot = await readNativeAccounts(fixture) + const accountB = snapshot.accounts.find( + (account) => account.accountIdentity === NEW_PRIMARY, + ) + expect(accountB?.credentialId).toBe(mainRow.id) + // The request failed closed on the identity fence before sending A's + // bearer, instead of failing for an unrelated reason. + expect(String(requestError)).toContain('identity-changed') + expect(messageAuthorizations).toEqual([]) + // And A's quota was never bound to B, nor to any account. + expect(accountB?.quota).toBeUndefined() + for (const account of snapshot.accounts) + expect(account.quota?.accountIdentity).not.toBe(NEW_PRIMARY) }) From 08aec56a91a609d177b3f1e6b8d8aec597cabbed Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:02:37 +0200 Subject: [PATCH 084/169] mason: omit unusable quota cache instead of refusing migration A legacy quota reading is cached usage data, not credential authority. Projection used to refuse the whole migration when a stored reading failed the native quota schema, so one stale or malformed cache entry could block valid credentials from migrating. Such a reading is now omitted instead. Exact UUID ownership binding, the existing codec and all credential and configuration validation are unchanged; malformed credentials still refuse. Tests: per-window timestamp missing, malformed scoped entry, non-object and null cache values, main-account cache, valid standard/scoped/extra-usage preservation, mismatched or unverified identity, and credential refusal with or without a cache. --- packages/core/src/native-migration-source.ts | 7 +- .../src/tests/native-migration-source.test.ts | 177 ++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/packages/core/src/native-migration-source.ts b/packages/core/src/native-migration-source.ts index d4688bcf8..0f8f216c2 100644 --- a/packages/core/src/native-migration-source.ts +++ b/packages/core/src/native-migration-source.ts @@ -328,7 +328,12 @@ function quota( accountIdentity: string | undefined, ): NativeQuotaMap | undefined { if (source === undefined) return undefined - if (!isNativeOAuthQuotaSnapshot(source)) refuse() + // Quota is cached usage data, not credential authority. Omit a reading the + // native format cannot represent rather than blocking valid credentials from + // migrating. Do not infer timestamps, usage or ownership; fresh quota polling + // must establish any missing observation. Credential and configuration + // validation remains unchanged. + if (!isNativeOAuthQuotaSnapshot(source)) return undefined // Slot labels such as "main" are not Claude UUIDs. Importing them would make // the next genuine UUID observation fail the codec's ownership check. if ( diff --git a/packages/core/src/tests/native-migration-source.test.ts b/packages/core/src/tests/native-migration-source.test.ts index 932e70a91..060f0d5b3 100644 --- a/packages/core/src/tests/native-migration-source.test.ts +++ b/packages/core/src/tests/native-migration-source.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'bun:test' import { projectNativeMigrationSource } from '../native-migration-source.ts' +import { tokenFingerprint } from '../token-fingerprint.ts' const hostAuth = { anthropic: { @@ -149,6 +150,182 @@ test('incompatible slot-tagged quota is omitted while exact ownership and [] sco ).toEqual([]) }) +const fallbackRow = { + id: 'one', + type: 'oauth', + refresh: 'one-refresh', + anthropicAccountUuid: 'uuid-one', +} +const validQuota = { + checkedAt: 100, + accountIdentity: 'uuid-one', + five_hour: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: 100, + resetsAt: '2030-01-01T00:00:00Z', + }, + seven_day: { usedPercent: 5, remainingPercent: 95, checkedAt: 100 }, + scoped: [ + { + id: 'opus', + title: 'Opus', + modelName: 'Claude Opus', + usedPercent: 40, + remainingPercent: 60, + checkedAt: 100, + }, + ], + extraUsage: { + used: { amountMinor: 100, currency: 'USD', exponent: 2 }, + limit: { amountMinor: 500, currency: 'USD', exponent: 2 }, + exhausted: false, + }, +} +const invalidQuotas: Record = { + 'window without its own checkedAt': { + checkedAt: 100, + accountIdentity: 'uuid-one', + five_hour: { usedPercent: 20, remainingPercent: 80 }, + }, + 'scoped entry without title': { + accountIdentity: 'uuid-one', + scoped: [ + { + id: 'opus', + modelName: 'Claude Opus', + usedPercent: 40, + remainingPercent: 60, + checkedAt: 100, + }, + ], + }, + 'non-object cache value': 'not-a-quota', + 'null cache value': null, +} + +test('a quota cache the native format cannot hold is omitted while the credential still imports', () => { + for (const [name, bad] of Object.entries(invalidQuotas)) { + const projected = source( + { accounts: [fallbackRow] }, + { accounts: { one: { quota: bad } } }, + ) + const account = projected.accounts[1] + expect([name, account?.id, account?.identity]).toEqual([ + name, + 'one', + 'uuid-one', + ]) + expect([name, account?.credential]).toEqual([ + name, + { type: 'oauth', refresh: 'one-refresh' }, + ]) + expect([name, account && Object.hasOwn(account, 'quota')]).toEqual([ + name, + false, + ]) + } +}) + +test('an unusable main quota cache is omitted while the main credential and identity import', () => { + const fingerprint = tokenFingerprint('synthetic-main-access') + const main = { + profile: { providerAccountUuid: 'uuid-main' }, + profileToken: fingerprint, + quotaToken: fingerprint, + } + const bad = source( + {}, + { + main: { + ...main, + quota: invalidQuotas['window without its own checkedAt'], + }, + }, + ).accounts[0] + expect(bad?.identity).toBe('uuid-main') + expect(bad?.credential).toMatchObject({ refresh: 'synthetic-main-refresh' }) + expect(bad && Object.hasOwn(bad, 'quota')).toBe(false) + const good = source( + {}, + { + main: { + ...main, + quota: { ...validQuota, accountIdentity: 'uuid-main' }, + }, + }, + ).accounts[0] + expect(good?.quota?.anthropic.accountIdentity).toBe('uuid-main') +}) + +test('a valid quota owned by the verified account survives with windows, scoped entries and extra usage', () => { + const imported = source( + { accounts: [fallbackRow] }, + { accounts: { one: { quota: validQuota } } }, + ).accounts[1]?.quota + expect(imported?.limits.map((limit) => limit.label)).toEqual([ + 'five_hour', + 'seven_day', + 'seven_day:opus', + ]) + expect(imported?.anthropic).toMatchObject({ + checkedAt: 100, + accountIdentity: 'uuid-one', + remainingPercent: { five_hour: 80, seven_day: 95 }, + scoped: [ + { + id: 'opus', + title: 'Opus', + modelName: 'Claude Opus', + remainingPercent: 60, + }, + ], + extraUsage: validQuota.extraUsage, + }) +}) + +test('a valid quota is never imported for another account or an account without a verified UUID', () => { + expect( + source( + { accounts: [fallbackRow] }, + { + accounts: { + one: { quota: { ...validQuota, accountIdentity: 'uuid-other' } }, + }, + }, + ).accounts[1]?.quota, + ).toBeUndefined() + const { anthropicAccountUuid: _, ...unverified } = fallbackRow + const account = source( + { accounts: [unverified] }, + { accounts: { one: { quota: validQuota } } }, + ).accounts[1] + expect(account?.credential).toMatchObject({ refresh: 'one-refresh' }) + expect(account?.quota).toBeUndefined() + const fingerprint = tokenFingerprint('synthetic-main-access') + expect( + source({}, { main: { quotaToken: fingerprint, quota: validQuota } }) + .accounts[0]?.quota, + ).toBeUndefined() +}) + +test('a malformed credential still refuses whether or not its quota cache is usable', () => { + const broken = { ...fallbackRow, expires: 'not-a-number' } + expect(() => source({ accounts: [broken] })).toThrow('cannot be imported') + for (const quota of [validQuota, invalidQuotas['scoped entry without title']]) + expect(() => + source({ accounts: [broken] }, { accounts: { one: { quota } } }), + ).toThrow('cannot be imported') + // The same row with a well-formed credential and a valid cache imports, so + // the refusals above come from the credential, not from the quota cache. + expect( + source( + { accounts: [fallbackRow] }, + { accounts: { one: { quota: validQuota } } }, + ).accounts[1]?.credential, + ).toMatchObject({ refresh: 'one-refresh' }) +}) + test('getter and proxy sources invoke no executable behavior and error contains no source', () => { let calls = 0 for (const config of [ From afb21039311c307361d58409e471e879171d36ad Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:34:49 +0200 Subject: [PATCH 085/169] mason: migrate Group C lifecycle, logging and RPC suites to native fixtures Positive setups in dispose-cleanup, info-logs and rpc-multi-project now build their accounts with the real offline migration helper (migrateNativeOpencodeFixture) and give the plugin a typed OpenCode PluginInput whose SDK client is answered in-process. Each test's own lifetime cleanup is registered before the fixture, so plugins and RPC servers are disposed, intervals checked and sidebar writes drained before any fixture root is removed. Obsolete assertion replaced, not dropped: the FallbackAccountManager stopBackgroundRefresh spy targeted a manager the plugin stopped building in 1a3b8cc7. The replacement proves dispose clears the exact fallback-account refresh interval handle created at construction. Settings commands now run through the single /claude menu over the plugin RPC server (the TUI path), and status tests open /claude. Lane start isolation is proved per project and per session. The retired text RPC apply is asserted to return guidance only. The killswitch INFO-log and live log-level assertions are unchanged; they pass only with the parent-approved production fix merged here (420164dd). Remote gates (bun 1.3.14) on the tested executables: 51 pass / 0 fail / 365 expects, with opencode typecheck and biome clean. The earlier 51/364 and intermediate 51/361 runs are separate records. Three directed mutation controls each failed their named test. --- .../src/tests/dispose-cleanup.test.ts | 422 +++++++++++----- packages/opencode/src/tests/info-logs.test.ts | 467 +++++++++++------- .../src/tests/rpc-multi-project.test.ts | 368 +++++++++----- 3 files changed, 827 insertions(+), 430 deletions(-) diff --git a/packages/opencode/src/tests/dispose-cleanup.test.ts b/packages/opencode/src/tests/dispose-cleanup.test.ts index 3954501a9..f686f2a80 100644 --- a/packages/opencode/src/tests/dispose-cleanup.test.ts +++ b/packages/opencode/src/tests/dispose-cleanup.test.ts @@ -1,88 +1,63 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' -import { mkdtemp, rm } from 'node:fs/promises' +import { describe, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { type AccountStorage, CacheKeepManager, - FallbackAccountManager, + custodyTombstoneOAuth, PrimeManager, type PrimeManagerOptions, - saveAccounts, } from '@cortexkit/anthropic-auth-core' +import type { Hooks, PluginInput } from '@opencode-ai/plugin' +import { + createOpencodeClient, + type OAuth, + type Provider, +} from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { AnthropicAuthPlugin } from '../index' import { adoptPrimeManager } from '../prime-manager-registry.ts' +import { drainSidebarWrites } from '../sidebar-state.ts' +import { migrateNativeOpencodeFixture } from './native-fixture.ts' +import { installDefaultFetchMock } from './test-fetch' import { createTimerTracking, type PluginTimerOverrides, } from './timer-tracking' -// Spies installed on shared prototypes before the plugin factory runs; -// restored in afterEach so unrelated tests are not affected. They call through -// so timer-tracking assertions exercise the real teardown implementations. -const originalCacheKeepStop = CacheKeepManager.prototype.stop -const originalFallbackStop = - FallbackAccountManager.prototype.stopBackgroundRefresh -const cacheKeepStopSpy = mock(function (this: CacheKeepManager) { - return originalCacheKeepStop.call(this) -}) -const fallbackStopSpy = mock(function (this: FallbackAccountManager) { - return originalFallbackStop.call(this) -}) +const lifetimes = createTestLifetimeSuite() const timerTracking = createTimerTracking() const { activeIntervals, disabledPluginTimerOverrides } = timerTracking -let tempDir: string -const originalFetch = globalThis.fetch - -beforeEach(async () => { - timerTracking.reset() - cacheKeepStopSpy.mockClear() - fallbackStopSpy.mockClear() - CacheKeepManager.prototype.stop = - cacheKeepStopSpy as unknown as typeof CacheKeepManager.prototype.stop - FallbackAccountManager.prototype.stopBackgroundRefresh = - fallbackStopSpy as unknown as typeof FallbackAccountManager.prototype.stopBackgroundRefresh - const { installDefaultFetchMock } = await import('./test-fetch') - installDefaultFetchMock() - tempDir = await mkdtemp(join(tmpdir(), 'anthropic-dispose-test-')) - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( - tempDir, - 'anthropic-auth.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( - tempDir, - 'sidebar-state.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( - tempDir, - 'cachekeep-registry', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( - tempDir, - 'quota-header-feed', - ) - await saveAccounts(baseStorage()) -}) - -afterEach(async () => { - try { - CacheKeepManager.prototype.stop = originalCacheKeepStop - FallbackAccountManager.prototype.stopBackgroundRefresh = - originalFallbackStop - const currentFetch = globalThis.fetch as typeof fetch | undefined - if (currentFetch) globalThis.fetch = originalFetch - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - delete process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE - delete process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR - delete process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) - } - } finally { - expect(activeIntervals.size).toBe(0) - } -}) +// Saved before each test and restored after it: every path variable that +// migrateNativeOpencodeFixture returns in its env, plus the profile-hydration +// switch and OPENCODE_AUTH_CONTENT, which this file sets or clears. +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', + 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', +] as const + +const provider: Provider = { + id: 'anthropic', + name: 'Anthropic', + source: 'custom', + env: [], + options: {}, + models: {}, +} function baseStorage(): AccountStorage { return { @@ -99,83 +74,276 @@ function baseStorage(): AccountStorage { } } -function createMockClient() { +// The OpenCode OAuth entry the offline migration moves into the native pool. +// Its expiry is far away, so nothing in these tests needs to refresh it. +function syntheticHostAuth() { return { - auth: { set: mock(() => Promise.resolve()) }, - session: { - promptAsync: mock((_input: unknown) => Promise.resolve()), + anthropic: { + type: 'oauth', + access: 'synthetic-dispose-main-access', + refresh: 'synthetic-dispose-main-refresh', + expires: Date.now() + 8 * 60 * 60_000, }, } } -async function getPlugin( - timerOverrides?: PluginTimerOverrides, - directory?: string, -) { - const { AnthropicAuthPlugin } = await import('../index') - const defaultTimerOverrides = disabledPluginTimerOverrides() - return (await ( - AnthropicAuthPlugin as unknown as ( - ctx: Parameters[0], - timers?: PluginTimerOverrides, - ) => ReturnType - )( - { - // @ts-expect-error: minimal mock for testing - client: createMockClient(), - ...(directory && { directory }), - }, - { ...defaultTimerOverrides, ...timerOverrides }, - )) as Promise +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null } -describe('dispose stops per-instance background services', () => { - test('dispose clears the fallback background refresh interval', async () => { - const plugin = await getPlugin() - // startBackgroundRefresh sets a real interval via runtimeTimers.setInterval; - // disabledPluginTimerOverrides replaced those with no-op mocks, so the - // call count is what proves the manager wired up its timer. - expect(timerTracking.disabledIntervalCalls).toBeGreaterThanOrEqual(1) - const intervalCallsBeforeDispose = timerTracking.disabledIntervalCalls - - await plugin.dispose?.() +/** The anthropic OAuth entry stored in OpenCode's auth file, checked field by field. */ +function storedAnthropicOAuth(text: string): OAuth { + const stored: unknown = JSON.parse(text) + const entry = isRecord(stored) ? stored.anthropic : undefined + if ( + !isRecord(entry) || + entry.type !== 'oauth' || + typeof entry.access !== 'string' || + typeof entry.refresh !== 'string' || + typeof entry.expires !== 'number' + ) + throw new Error('OpenCode auth file has no anthropic OAuth entry') + return { + type: 'oauth', + access: entry.access, + refresh: entry.refresh, + expires: entry.expires, + } +} - expect(fallbackStopSpy).toHaveBeenCalledTimes(1) - // Disposing must not schedule any additional intervals for this instance. - expect(timerTracking.disabledIntervalCalls).toBe(intervalCallsBeforeDispose) +/** + * The context OpenCode passes a server plugin, with a real SDK client. The + * client's HTTP transport is answered in this process: message history is + * empty and prompts are accepted, so nothing leaves the test. + */ +function hostContext(directory: string): PluginInput { + const client = createOpencodeClient({ + baseUrl: 'http://opencode.invalid', + fetch: async (request) => { + const path = new URL(request.url).pathname + if (request.method === 'GET' && /^\/session\/[^/]+\/message$/.test(path)) + return Response.json([]) + if ( + request.method === 'POST' && + /^\/session\/[^/]+\/prompt_async$/.test(path) + ) + return new Response(null, { status: 204 }) + return Response.json({ name: 'NotFound' }, { status: 404 }) + }, }) + return { + client, + project: { + id: 'dispose-cleanup-fixture', + worktree: directory, + time: { created: Date.now() }, + }, + directory, + worktree: directory, + serverUrl: new URL('http://opencode.invalid'), + experimental_workspace: { register() {} }, + $, + } +} - test('dispose calls cacheKeepManager.stop', async () => { - const plugin = await getPlugin() - cacheKeepStopSpy.mockClear() - - await plugin.dispose?.() +/** Plugin construction starts a fallback-account refresh and exposes its promise for tests. */ +function fallbackRefreshReady(hooks: Hooks): Promise { + const ready = + '__fallbackRefreshReady' in hooks ? hooks.__fallbackRefreshReady : undefined + if (!(ready instanceof Promise)) + throw new Error('Plugin did not expose its startup fallback refresh') + return ready +} - expect(cacheKeepStopSpy).toHaveBeenCalledTimes(1) +/** + * Migrate synthetic legacy accounts into a fresh native pool through the real + * offline migration and point this process at it, so the plugin starts with + * native services the way a migrated installation does. + * + * Lifetime cleanups run in registration order, and the fixture registers the + * removal of its root when it is called. Registering the teardown below first + * means every plugin is disposed, its interval handles are checked, and every + * queued sidebar write has finished before any fixture file is deleted. + */ +async function migratedPool(options: { hostAuth?: Record }) { + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const savedFetch = globalThis.fetch + const plugins = new Set() + lifetimes.deferCleanup(async () => { + const results = await Promise.allSettled( + [...plugins] + .reverse() + .map((plugin) => Promise.resolve().then(() => plugin.dispose?.())), + ) + plugins.clear() + try { + await drainSidebarWrites() + // Background intervals must not outlive the test-scoped fetch mock + // they captured. + expect(activeIntervals.size).toBe(0) + } finally { + timerTracking.reset() + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Plugin disposal failed', + ) }) - - test('dispose clears tracked fallback and main refresh intervals', async () => { - const plugin = await getPlugin({ - setInterval: timerTracking.trackedSetInterval, - clearInterval: timerTracking.trackedClearInterval, + timerTracking.reset() + installDefaultFetchMock() + const root = await mkdtemp(join(tmpdir(), 'anthropic-dispose-test-')) + // migrateNativeOpencodeFixture schedules removal of the directory only after + // it accepts it. Remove it here too, so a directory it refused is not left + // behind in the temp folder. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: { ...baseStorage() }, + ...(options.hostAuth && { hostAuth: options.hostAuth }), + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + // Startup otherwise fetches each OAuth account's profile in the background + // without awaiting it. That request is unrelated to these tests and could + // still be running when the temporary files are removed. + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + delete process.env.OPENCODE_AUTH_CONTENT + + async function getPlugin(timerOverrides?: PluginTimerOverrides) { + const creation = AnthropicAuthPlugin(hostContext(fixture.root), { + ...disabledPluginTimerOverrides(), + ...timerOverrides, }) - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: 'main-access', - refresh: 'main-refresh', - expires: Date.now() + 8 * 60 * 60_000, - }), - { models: {} }, - ) - - expect(activeIntervals.size).toBeGreaterThanOrEqual(2) + lifetimes.trackDetached(creation) + const plugin = await creation + plugins.add(plugin) + // Plugin construction starts a background refresh of fallback accounts + // without awaiting it. Track that promise so teardown waits for it before + // deleting the files. + lifetimes.trackDetached(fallbackRefreshReady(plugin)) + return plugin + } + async function dispose(plugin: Hooks) { + plugins.delete(plugin) await plugin.dispose?.() + } + + return { fixture, getPlugin, dispose } +} - expect(activeIntervals.size).toBe(0) +describe('dispose stops per-instance background services', () => { + // The plugin starts the fallback-account refresh interval after loading the + // shared native account pool. Injected timer functions let this test verify + // that dispose clears the same interval handle. + lifetimes.test( + 'dispose clears the native fallback refresh interval it started', + async () => { + const pool = await migratedPool({}) + + // With the disabled interval mock, count the intervals this instance + // schedules. The default overrides from timer-tracking disable + // CacheKeep's aggregate refresh, so the fallback-account refresh is the + // only interval started here. + const counted = await pool.getPlugin() + expect(timerTracking.disabledIntervalCalls).toBe(1) + await pool.dispose(counted) + // Disposing must not schedule any additional intervals for this instance. + expect(timerTracking.disabledIntervalCalls).toBe(1) + + // With real tracked intervals, the handle construction created is the + // one dispose clears. The tracked clear removes only handles passed to it. + const tracked = await pool.getPlugin({ + setInterval: timerTracking.trackedSetInterval, + clearInterval: timerTracking.trackedClearInterval, + }) + expect(activeIntervals.size).toBe(1) + const [fallbackRefresh] = activeIntervals + if (fallbackRefresh === undefined) + throw new Error('Plugin started no tracked interval') + // Nothing has cleared the construction interval before dispose. + expect(activeIntervals.has(fallbackRefresh)).toBe(true) + + await pool.dispose(tracked) + + expect(activeIntervals.has(fallbackRefresh)).toBe(false) + expect(activeIntervals.size).toBe(0) + }, + ) + + lifetimes.test('dispose calls cacheKeepManager.stop', async () => { + const pool = await migratedPool({}) + // Spy on the shared prototype for this body only. It calls through, so + // dispose still runs the real CacheKeep teardown. + const originalCacheKeepStop = CacheKeepManager.prototype.stop + const stopped: CacheKeepManager[] = [] + let stopCalls = 0 + CacheKeepManager.prototype.stop = function (this: CacheKeepManager) { + stopCalls++ + stopped.push(this) + return originalCacheKeepStop.call(this) + } + try { + const plugin = await pool.getPlugin() + const own = + '__cacheKeepManager' in plugin ? plugin.__cacheKeepManager : undefined + if (!(own instanceof CacheKeepManager)) + throw new Error('Plugin did not expose its CacheKeep manager') + stopCalls = 0 + stopped.length = 0 + + await pool.dispose(plugin) + + expect(stopCalls).toBe(1) + // The CacheKeep manager stopped is the one attached to this plugin, not + // a manager belonging to another plugin instance. + expect(stopped).toHaveLength(1) + expect(stopped[0]).toBe(own) + } finally { + CacheKeepManager.prototype.stop = originalCacheKeepStop + } }) + + lifetimes.test( + 'dispose clears tracked fallback and main refresh intervals', + async () => { + const pool = await migratedPool({ hostAuth: syntheticHostAuth() }) + const plugin = await pool.getPlugin({ + setInterval: timerTracking.trackedSetInterval, + clearInterval: timerTracking.trackedClearInterval, + }) + expect(activeIntervals.size).toBe(1) + + // Offline migration replaces OpenCode's OAuth credentials with a + // non-secret activation marker. Pass that stored marker to the loader, + // not the retired tokens. + const activation = storedAnthropicOAuth( + await readFile(pool.fixture.hostAuthPath, 'utf8'), + ) + expect(activation).toEqual(custodyTombstoneOAuth('anthropic')) + const loader = plugin.auth?.loader + if (!loader) throw new Error('Plugin did not install its auth loader') + const options = await loader(() => Promise.resolve(activation), provider) + expect(options.fetch).toBeFunction() + + // The loader adds a second interval, the main account's OAuth refresh, + // beside the fallback-account refresh started during construction. + expect(activeIntervals.size).toBe(2) + + await pool.dispose(plugin) + + expect(activeIntervals.size).toBe(0) + }, + ) }) describe('prime manager adoption leases', () => { diff --git a/packages/opencode/src/tests/info-logs.test.ts b/packages/opencode/src/tests/info-logs.test.ts index 15f7526f5..aa9f6f483 100644 --- a/packages/opencode/src/tests/info-logs.test.ts +++ b/packages/opencode/src/tests/info-logs.test.ts @@ -1,15 +1,23 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { describe, expect } from 'bun:test' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { __setLogTestSink, type AccountStorage, + createNativeAccountRuntime, type LogTestRecord, - saveAccounts, } from '@cortexkit/anthropic-auth-core' +import type { Hooks, PluginInput } from '@opencode-ai/plugin' +import { createOpencodeClient } from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { AnthropicAuthPlugin } from '../index' -import { DEFAULT_FETCH_MOCK, installDefaultFetchMock } from './test-fetch' +import { discoverPortFile } from '../rpc/port-file' +import { getRpcDir } from '../rpc/rpc-dir' +import { drainSidebarWrites } from '../sidebar-state.ts' +import { migrateNativeOpencodeFixture } from './native-fixture.ts' +import { installDefaultFetchMock } from './test-fetch' import { createTimerTracking, type PluginTimerOverrides, @@ -35,8 +43,11 @@ function createFallbackStorage(): AccountStorage { } } -let tempConfigDir: string -const originalFetch = globalThis.fetch +const lifetimes = createTestLifetimeSuite() +const test = lifetimes.test +// Captured before any test replaces fetch. The test preload still refuses +// non-loopback hosts; this is used only to call the plugin's local RPC server. +const loopbackFetch = globalThis.fetch const timerTracking = createTimerTracking() const { activeIntervals, @@ -44,84 +55,235 @@ const { trackedClearInterval, trackedSetInterval, } = timerTracking - -async function useTempAccountFile(storage: AccountStorage) { - if (tempConfigDir) { - await rm(tempConfigDir, { recursive: true, force: true }).catch(() => {}) - } - tempConfigDir = await mkdtemp(join(tmpdir(), 'anthropic-info-logs-test-')) - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( - tempConfigDir, - 'anthropic-auth.json', - ) - await saveAccounts(storage) -} - -function createMockClient() { +const sessionId = 'ses_test' + +// Saved before each test and restored after it: every path variable that +// migrateNativeOpencodeFixture returns in its env, plus the profile-hydration +// switch and OPENCODE_AUTH_CONTENT, which this file sets or clears. +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', + 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', +] as const + +/** + * The context OpenCode passes a server plugin, with a real SDK client. The + * client's HTTP transport is answered in this process: message history is + * empty and prompts are accepted, so nothing leaves the test. + */ +function hostContext(directory: string): PluginInput { + const client = createOpencodeClient({ + baseUrl: 'http://opencode.invalid', + fetch: async (request) => { + const path = new URL(request.url).pathname + if (request.method === 'GET' && /^\/session\/[^/]+\/message$/.test(path)) + return Response.json([]) + if ( + request.method === 'POST' && + /^\/session\/[^/]+\/prompt_async$/.test(path) + ) + return new Response(null, { status: 204 }) + return Response.json({ name: 'NotFound' }, { status: 404 }) + }, + }) return { - auth: { set: mock(() => Promise.resolve()) }, - session: { - promptAsync: mock(() => Promise.resolve()), + client, + project: { + id: 'info-logs-fixture', + worktree: directory, + time: { created: Date.now() }, }, + directory, + worktree: directory, + serverUrl: new URL('http://opencode.invalid'), + experimental_workspace: { register() {} }, + $, } } -async function getPlugin(timerOverrides?: PluginTimerOverrides) { - const defaultTimerOverrides = disabledPluginTimerOverrides() - return (await ( - AnthropicAuthPlugin as unknown as ( - ctx: Parameters[0], - timers?: PluginTimerOverrides, - ) => ReturnType - )( - { - // @ts-expect-error: minimal mock for testing - client: createMockClient(), - }, - { ...defaultTimerOverrides, ...timerOverrides }, - )) as Promise +/** Plugin construction starts a fallback-account refresh and exposes its promise for tests. */ +function fallbackRefreshReady(hooks: Hooks): Promise { + const ready = + '__fallbackRefreshReady' in hooks ? hooks.__fallbackRefreshReady : undefined + if (!(ready instanceof Promise)) + throw new Error('Plugin did not expose its startup fallback refresh') + return ready } let capturedRecords: LogTestRecord[] = [] -beforeEach(() => { +/** + * Migrate the synthetic legacy settings into a fresh native pool through the + * real offline migration, point this process at it, and start one plugin for + * a project directory. Settings change only through the single /claude menu, + * whose actions OpenCode's TUI sends to the plugin's local RPC server; this + * harness sends the same requests. + * + * Lifetime cleanups run in registration order, and the fixture registers the + * removal of its root when it is called. Registering the teardown below first + * means every plugin and its RPC server are stopped, interval handles are + * checked, and queued sidebar writes have finished before any fixture file + * is deleted. + */ +async function migratedClaude() { + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const savedFetch = globalThis.fetch + const plugins = new Set() + lifetimes.deferCleanup(async () => { + const results = await Promise.allSettled( + [...plugins] + .reverse() + .map((plugin) => Promise.resolve().then(() => plugin.dispose?.())), + ) + plugins.clear() + try { + await drainSidebarWrites() + // Background intervals must not outlive the test-scoped fetch mock + // they captured. + expect(activeIntervals.size).toBe(0) + } finally { + timerTracking.reset() + __setLogTestSink(null) + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Plugin disposal failed', + ) + }) timerTracking.reset() installDefaultFetchMock() + const root = await mkdtemp(join(tmpdir(), 'anthropic-info-logs-test-')) + // migrateNativeOpencodeFixture schedules removal of the directory only after + // it accepts it. Remove it here too, so a directory it refused is not left + // behind in the temp folder. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: { ...createFallbackStorage() }, + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + // Startup otherwise fetches each OAuth account's profile in the background + // without awaiting it. That request is unrelated to these tests and could + // still be running when the temporary files are removed. + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + delete process.env.OPENCODE_AUTH_CONTENT + // The legacy config the migration retired must never become a settings + // destination again; each change is read back through the native runtime. + const readLegacyConfig = () => + readFile(fixture.paths.legacyConfig, 'utf8').catch(() => undefined) + const legacyConfig = await readLegacyConfig() + const directory = fixture.root + + async function getPlugin(timerOverrides?: PluginTimerOverrides) { + const creation = AnthropicAuthPlugin(hostContext(directory), { + ...disabledPluginTimerOverrides(), + ...timerOverrides, + }) + lifetimes.trackDetached(creation) + const plugin = await creation + plugins.add(plugin) + // Plugin construction starts a background refresh of fallback accounts + // without awaiting it. Track that promise so teardown waits for it before + // deleting the files. + lifetimes.trackDetached(fallbackRefreshReady(plugin)) + return plugin + } + + // Construct before installing the sink so only command logs are captured. + const plugin = await getPlugin() capturedRecords = [] __setLogTestSink((record) => { capturedRecords.push(record) }) -}) -afterEach(async () => { - try { - // Restore only the fixture's tagged mock; an untagged custom mock left - // installed must reach the preload's leak detector, not be masked here. - const currentFetch = globalThis.fetch as - | (typeof fetch & { [DEFAULT_FETCH_MOCK]?: true }) - | undefined - if (currentFetch?.[DEFAULT_FETCH_MOCK]) { - globalThis.fetch = originalFetch - } - __setLogTestSink(null) - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - if (tempConfigDir) { - await rm(tempConfigDir, { recursive: true, force: true }).catch(() => {}) - } - } finally { - expect(activeIntervals.size).toBe(0) + return { + plugin, + getPlugin, + /** Open /claude the way OpenCode runs the slash command; it renders every section's status. */ + async open(): Promise { + const execute = plugin['command.execute.before'] + if (!execute) throw new Error('Plugin did not install its command hook') + await expect( + execute( + { command: 'claude', arguments: '', sessionID: sessionId }, + { parts: [] }, + ), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + }, + /** Run one /claude menu action through the plugin's RPC server, as the TUI does. */ + async apply( + sectionId: string, + actionId: string, + values?: Record, + ): Promise { + const entry = await discoverPortFile(getRpcDir(directory)) + if (!entry) throw new Error('Plugin RPC server is not running') + const response = await loopbackFetch( + `http://127.0.0.1:${entry.port}/rpc/apply-menu`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${entry.token}`, + }, + body: JSON.stringify({ + command: 'claude', + sectionId, + actionId, + ...(values && { values }), + sessionId, + }), + }, + ) + expect(response.status).toBe(200) + const result: unknown = await response.json() + expect(result).toMatchObject({ command: 'claude', ok: true }) + }, + /** Persisted settings, read back through the public native runtime. */ + async settings(): Promise { + expect(await readLegacyConfig()).toBe(legacyConfig) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + return (await runtime.read()).policyStorage + } finally { + runtime.close() + } + }, } -}) +} test('does not retain a background interval unless the helper opts in', async () => { - await useTempAccountFile(createFallbackStorage()) - timerTracking.reset() - await getPlugin() + const claude = await migratedClaude() + // The helper supplies an interval mock that starts no timer. Construction + // schedules only the fallback-account refresh, so the mock is called once + // while the tracked set of active intervals remains empty. expect(timerTracking.disabledIntervalCalls).toBe(1) expect(activeIntervals.size).toBe(0) await timerTracking.withTrackedInterval(async () => { - await getPlugin({ + await claude.getPlugin({ setInterval: trackedSetInterval, clearInterval: trackedClearInterval, }) @@ -130,26 +292,6 @@ test('does not retain a background interval unless the helper opts in', async () expect(activeIntervals.size).toBe(0) }) -async function executeCommand( - plugin: any, - command: string, - args: string, -): Promise { - await expect( - plugin['command.execute.before']({ - command, - arguments: args, - sessionID: 'ses_test', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') -} - -async function readConfigFile(): Promise { - return JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) -} - function findCommandsLog(message: string): LogTestRecord | undefined { return capturedRecords.find( (r) => @@ -161,37 +303,34 @@ describe('setting-change INFO logs', () => { // -- dump ---------------------------------------------------------------- test('dump on emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-dump', 'on') + const claude = await migratedClaude() + await claude.apply('Diagnostics', 'dump-on') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) const rec = findCommandsLog('dump changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ enabled: true }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.dump?.enabled).toBe(true) }) test('dump off emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-dump', 'off') + const claude = await migratedClaude() + await claude.apply('Diagnostics', 'dump-off') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) const rec = findCommandsLog('dump changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ enabled: false }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.dump?.enabled).toBe(false) }) test('dump status emits no setting-change info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-dump', '') + const claude = await migratedClaude() + await claude.open() expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(0) @@ -200,23 +339,21 @@ describe('setting-change INFO logs', () => { // -- fast mode ----------------------------------------------------------- test('fast on emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-fast', 'on') + const claude = await migratedClaude() + await claude.apply('Extras', 'fast-on') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) const rec = findCommandsLog('fast mode changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ enabled: true }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.claudeFast?.enabled).toBe(true) }) test('fast off emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-fast', 'off') + const claude = await migratedClaude() + await claude.apply('Extras', 'fast-off') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) @@ -228,23 +365,21 @@ describe('setting-change INFO logs', () => { // -- routing ------------------------------------------------------------- test('routing mode change emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-routing', 'fallback-first') + const claude = await migratedClaude() + await claude.apply('Routing', 'routing-mode', { mode: 'fallback-first' }) expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) const rec = findCommandsLog('routing mode changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ mode: 'fallback-first' }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.routing?.mode).toBe('fallback-first') }) test('routing status emits no setting-change info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-routing', '') + const claude = await migratedClaude() + await claude.open() expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(0) @@ -253,75 +388,71 @@ describe('setting-change INFO logs', () => { // -- cache 1h ------------------------------------------------------------ test('cache on emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-cache', 'on') + const claude = await migratedClaude() + await claude.apply('Cache', 'cache-on') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) const rec = findCommandsLog('cache enabled changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ enabled: true }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.claudeCache?.enabled).toBe(true) }) test('cache mode change emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-cache', 'mode hybrid') + const claude = await migratedClaude() + await claude.apply('Cache', 'cache-mode', { mode: 'hybrid' }) expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) const rec = findCommandsLog('cache mode changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ mode: 'hybrid' }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.claudeCache?.mode).toBe('hybrid') }) // -- killswitch ---------------------------------------------------------- test('killswitch on emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-killswitch', 'on') + const claude = await migratedClaude() + await claude.apply('Limits', 'killswitch-on') const rec = findCommandsLog('killswitch changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ enabled: true }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.killswitch?.enabled).toBe(true) }) test('killswitch off emits info log after on', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() + const claude = await migratedClaude() // Turn on first so off is a genuine change - await executeCommand(plugin, 'claude-killswitch', 'on') + await claude.apply('Limits', 'killswitch-on') capturedRecords = [] - await executeCommand(plugin, 'claude-killswitch', 'off') + await claude.apply('Limits', 'killswitch-off') const rec = findCommandsLog('killswitch changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ enabled: false }) }) test('killswitch set emits thresholds info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-killswitch', 'set main:3,8') + const claude = await migratedClaude() + await claude.apply('Limits', 'killswitch-set', { + entries: JSON.stringify([{ account: 'main', fh: 3, sd: 8 }]), + }) const rec = findCommandsLog('killswitch thresholds changed') expect(rec).toBeDefined() expect(rec!.payload?.thresholds).toEqual({ five_hour: 3, seven_day: 8 }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.killswitch?.enabled).toBe(true) expect(raw.killswitch?.main?.five_hour).toBe(3) expect(raw.killswitch?.main?.seven_day).toBe(8) }) test('killswitch status emits no setting-change info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-killswitch', '') + const claude = await migratedClaude() + await claude.open() expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(0) @@ -330,9 +461,11 @@ describe('setting-change INFO logs', () => { // -- cachekeep ----------------------------------------------------------- test('cachekeep window emits info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-cachekeep', '09-17') + const claude = await migratedClaude() + await claude.apply('Cache', 'cachekeep-window', { + startHour: 9, + endHour: 17, + }) expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) @@ -342,17 +475,15 @@ describe('setting-change INFO logs', () => { }) test('cachekeep always emits info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-cachekeep', 'always') + const claude = await migratedClaude() + await claude.apply('Cache', 'cachekeep-always') const rec = findCommandsLog('cachekeep schedule changed') expect(rec?.payload).toEqual({ schedule: 'always' }) }) test('cachekeep off emits info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-cachekeep', 'off') + const claude = await migratedClaude() + await claude.apply('Cache', 'cachekeep-off') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) @@ -362,9 +493,8 @@ describe('setting-change INFO logs', () => { }) test('cachekeep subagents emits info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-cachekeep', 'subagents on') + const claude = await migratedClaude() + await claude.apply('Cache', 'cachekeep-subagents', { enabled: 'on' }) expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) @@ -376,40 +506,36 @@ describe('setting-change INFO logs', () => { // -- logging -------------------------------------------------------------- test('logging level change emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-logging', 'debug') + const claude = await migratedClaude() + await claude.apply('Diagnostics', 'logging-level', { level: 'debug' }) expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) const rec = findCommandsLog('log level changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ level: 'debug' }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.logging?.level).toBe('debug') }) test('logging status emits no setting-change info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-logging', '') + const claude = await migratedClaude() + await claude.open() expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(0) }) test('logging trace level persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-logging', 'trace') - const raw = await readConfigFile() + const claude = await migratedClaude() + await claude.apply('Diagnostics', 'logging-level', { level: 'trace' }) + const raw = await claude.settings() expect(raw.logging?.level).toBe('trace') }) test('logging getLogLevel reflects persisted level after command', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-logging', 'warn') + const claude = await migratedClaude() + await claude.apply('Diagnostics', 'logging-level', { level: 'warn' }) const { getLogLevel: _getLogLevel } = await import( '@cortexkit/anthropic-auth-core' ) @@ -419,10 +545,9 @@ describe('setting-change INFO logs', () => { // -- payload hygiene ----------------------------------------------------- test('INFO log payload does not contain tokens or bearer strings', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() + const claude = await migratedClaude() capturedRecords = [] - await executeCommand(plugin, 'claude-dump', 'on') + await claude.apply('Diagnostics', 'dump-on') const recs = capturedRecords.filter( (r) => r.level === 'info' && r.channel === 'commands', ) @@ -436,29 +561,27 @@ describe('setting-change INFO logs', () => { } }) - // -- prime --------------------------------------------------------------- + // -- quota priming settings ---------------------------------------------- test('prime on emits info log and persists', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-prime', 'on') + const claude = await migratedClaude() + await claude.apply('Extras', 'prime-on') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) const rec = findCommandsLog('prime changed') expect(rec).toBeDefined() expect(rec!.payload).toEqual({ enabled: true }) - const raw = await readConfigFile() + const raw = await claude.settings() expect(raw.prime?.enabled).toBe(true) }) test('prime off after on emits info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() + const claude = await migratedClaude() // Turn on first so off is a genuine change - await executeCommand(plugin, 'claude-prime', 'on') + await claude.apply('Extras', 'prime-on') capturedRecords = [] - await executeCommand(plugin, 'claude-prime', 'off') + await claude.apply('Extras', 'prime-off') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(1) @@ -468,20 +591,18 @@ describe('setting-change INFO logs', () => { }) test('prime status emits no setting-change info log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-prime', '') + const claude = await migratedClaude() + await claude.open() expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(0) }) test('repeated prime on emits only one setting-change log', async () => { - await useTempAccountFile(createFallbackStorage()) - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-prime', 'on') + const claude = await migratedClaude() + await claude.apply('Extras', 'prime-on') capturedRecords = [] - await executeCommand(plugin, 'claude-prime', 'on') + await claude.apply('Extras', 'prime-on') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(0) diff --git a/packages/opencode/src/tests/rpc-multi-project.test.ts b/packages/opencode/src/tests/rpc-multi-project.test.ts index f777f1b1a..dca8078f7 100644 --- a/packages/opencode/src/tests/rpc-multi-project.test.ts +++ b/packages/opencode/src/tests/rpc-multi-project.test.ts @@ -1,91 +1,165 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { describe, expect, mock } from 'bun:test' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createEmptyStorage, QuotaHeaderFeedRegistry, - saveAccounts, } from '@cortexkit/anthropic-auth-core' -import type { Hooks } from '@opencode-ai/plugin' +import type { Hooks, PluginInput } from '@opencode-ai/plugin' +import { createOpencodeClient } from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { AnthropicAuthPlugin } from '../index' import { resetNotificationsForTest } from '../rpc/notifications' import { discoverPortFile } from '../rpc/port-file' import { getRpcDir } from '../rpc/rpc-dir' import type { RpcServerHandle } from '../rpc/rpc-server' +import { drainSidebarWrites } from '../sidebar-state.ts' +import { migrateNativeOpencodeFixture } from './native-fixture.ts' +import { createTimerTracking } from './timer-tracking' type RpcGlobal = typeof globalThis & { __anthropicAuthRpcServers?: Map } +const lifetimes = createTestLifetimeSuite() +const test = lifetimes.test +// getPlugin supplies timer mocks to each project's plugin instance. They +// start no intervals, so background timers cannot outlive these RPC tests. +const { disabledPluginTimerOverrides } = createTimerTracking() + +// Saved before each test and restored after it: every path variable that +// migrateNativeOpencodeFixture returns in its env, plus the profile-hydration +// switch and OPENCODE_AUTH_CONTENT, which this file sets or clears. +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', + 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', +] as const + let testRoot: string -let previousRpcDir: string | undefined -let previousAccountFile: string | undefined -let previousSidebarStateFile: string | undefined -let previousCacheKeepRegistryDir: string | undefined -let previousQuotaFeedDir: string | undefined let startedRpcDirs: Set let createdPlugins: Hooks[] -const disabledPluginRuntimeOverrides = { - setInterval: mock( - () => ({ unref() {} }) as unknown as ReturnType, - ) as unknown as typeof setInterval, - clearInterval: mock(() => {}) as unknown as typeof clearInterval, -} - -function createMockClient(applyMarker?: string) { - return { - auth: { set: mock(() => Promise.resolve()) }, - session: { - promptAsync: mock(() => - applyMarker - ? Promise.reject(new Error(applyMarker)) - : Promise.resolve(), - ), +/** + * Supply one project's plugin with the same input shape as OpenCode, using a + * real SDK client whose HTTP requests are answered inside the test process. + * Record each prompted session ID. A configured prompt failure appears in the + * command's RPC reply, identifying which project's client handled the action. + */ +function createHost(directory: string, rejectPromptsWith?: string) { + const prompts: string[] = [] + const client = createOpencodeClient({ + baseUrl: 'http://opencode.invalid', + fetch: async (request) => { + const path = new URL(request.url).pathname + if (request.method === 'GET' && /^\/session\/[^/]+\/message$/.test(path)) + return Response.json([]) + const prompt = /^\/session\/([^/]+)\/prompt_async$/.exec(path) + if (request.method === 'POST' && prompt) { + prompts.push(decodeURIComponent(prompt[1] ?? '')) + if (rejectPromptsWith) throw new Error(rejectPromptsWith) + return new Response(null, { status: 204 }) + } + return Response.json({ name: 'NotFound' }, { status: 404 }) + }, + }) + const context: PluginInput = { + client, + project: { + id: 'rpc-multi-project-fixture', + worktree: directory, + time: { created: Date.now() }, }, + directory, + worktree: directory, + serverUrl: new URL('http://opencode.invalid'), + experimental_workspace: { register() {} }, + $, } + return { context, prompts } +} + +/** Plugin construction starts a fallback-account refresh and exposes its promise for tests. */ +function fallbackRefreshReady(hooks: Hooks): Promise { + const ready = + '__fallbackRefreshReady' in hooks ? hooks.__fallbackRefreshReady : undefined + if (!(ready instanceof Promise)) + throw new Error('Plugin did not expose its startup fallback refresh') + return ready } async function getPlugin( directory: string, - applyMarker?: string, + host = createHost(directory), ): Promise { - const plugin = AnthropicAuthPlugin as unknown as ( - ctx: Parameters[0], - runtimeOverrides: typeof disabledPluginRuntimeOverrides, - ) => ReturnType startedRpcDirs.add(getRpcDir(directory)) - const hooks = await plugin( - { - // @ts-expect-error: minimal mock for testing - client: createMockClient(applyMarker), - directory, - }, - disabledPluginRuntimeOverrides, + const creation = AnthropicAuthPlugin( + host.context, + disabledPluginTimerOverrides(), ) + lifetimes.trackDetached(creation) + const hooks = await creation createdPlugins.push(hooks) + // Plugin construction starts a background refresh of fallback accounts + // without awaiting it. Track that promise so teardown waits for it before + // deleting the files. + lifetimes.trackDetached(fallbackRefreshReady(hooks)) return hooks } -async function applyViaRpc( +async function postRpc( entry: { port: number; token: string }, - sessionId: string, -) { - const response = await fetch(`http://127.0.0.1:${entry.port}/rpc/apply`, { + method: string, + body: Record, +): Promise { + const response = await fetch(`http://127.0.0.1:${entry.port}/rpc/${method}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${entry.token}`, }, - body: JSON.stringify({ - command: 'claude-start', - arguments: '', - sessionId, - }), + body: JSON.stringify(body), }) expect(response.status).toBe(200) - return (await response.json()) as { text: string } + return response.json() +} + +/** + * Ask one project's RPC server, through the /claude menu action the TUI sends, + * to fire a lane start: a single automated cache-warm message posted to the + * given session, to which no response is expected. + */ +async function fireLaneStartViaRpc( + entry: { port: number; token: string }, + sessionId: string, +): Promise<{ ok: boolean; text: string }> { + const result = await postRpc(entry, 'apply-menu', { + command: 'claude', + sectionId: 'Extras', + actionId: 'start-fire', + sessionId, + }) + if ( + typeof result !== 'object' || + result === null || + !('ok' in result) || + typeof result.ok !== 'boolean' || + !('text' in result) || + typeof result.text !== 'string' + ) + throw new Error('Menu RPC returned no apply result') + return { ok: result.ok, text: result.text } } async function stopRpcServers() { @@ -99,88 +173,85 @@ async function stopRpcServers() { } } -beforeEach(async () => { - testRoot = await mkdtemp(join(tmpdir(), 'aa-rpc-multi-project-')) - startedRpcDirs = new Set() - createdPlugins = [] - previousRpcDir = process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR - previousAccountFile = process.env.OPENCODE_ANTHROPIC_AUTH_FILE - previousSidebarStateFile = - process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE - previousCacheKeepRegistryDir = - process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR - previousQuotaFeedDir = process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR - process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = '.rpc' - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( - testRoot, - 'anthropic-auth.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( - testRoot, - 'sidebar-state.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( - testRoot, - 'cachekeep-registry', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( - testRoot, - 'quota-header-feed', - ) - await stopRpcServers() -}) - -afterEach(async () => { - try { - for (const plugin of createdPlugins.reverse()) await plugin.dispose?.() - for (const rpcDir of startedRpcDirs) { - expect( - (globalThis as RpcGlobal).__anthropicAuthRpcServers?.get(rpcDir), - ).toBeUndefined() - expect(await discoverPortFile(rpcDir)).toBeNull() - } - } finally { - await stopRpcServers() - if (previousRpcDir === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = previousRpcDir - } - if (previousAccountFile === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = previousAccountFile - } - if (previousSidebarStateFile === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = - previousSidebarStateFile - } - if (previousCacheKeepRegistryDir === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = - previousCacheKeepRegistryDir - } - if (previousQuotaFeedDir === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = previousQuotaFeedDir +/** + * Every project directory shares one set of native credential and + * configuration files, created by running the real offline migration on + * synthetic legacy fixtures, as separate OpenCode projects on one machine do. + * + * Lifetime cleanups run in registration order, and the migration fixture + * registers removal of its root only when it is called. The teardown below is + * registered first, so it stops every plugin and RPC server and waits for all + * queued sidebar writes before any fixture root or project directory is + * removed. + */ +async function migratedProjects( + legacyConfig: Record = { ...createEmptyStorage() }, +) { + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const projectsRoot = await mkdtemp(join(tmpdir(), 'aa-rpc-multi-project-')) + const plugins: Hooks[] = [] + const rpcDirs = new Set() + testRoot = projectsRoot + createdPlugins = plugins + startedRpcDirs = rpcDirs + lifetimes.deferCleanup(async () => { + try { + for (const plugin of plugins.reverse()) await plugin.dispose?.() + for (const rpcDir of rpcDirs) { + expect( + (globalThis as RpcGlobal).__anthropicAuthRpcServers?.get(rpcDir), + ).toBeUndefined() + expect(await discoverPortFile(rpcDir)).toBeNull() + } + } finally { + await stopRpcServers() + await drainSidebarWrites() + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await rm(projectsRoot, { recursive: true, force: true }) + resetNotificationsForTest() } - await rm(testRoot, { recursive: true, force: true }) - resetNotificationsForTest() - } -}) + }) + await stopRpcServers() + const root = await mkdtemp(join(tmpdir(), 'aa-rpc-multi-project-pool-')) + // migrateNativeOpencodeFixture registers deletion of the directory only + // after it accepts it. This separate cleanup also removes the directory if + // the fixture fails before accepting it. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig, + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + // A relative RPC directory resolves inside each project directory. The + // fixture's absolute RPC path would put every project's server in one + // directory and hide the per-project separation these tests check. + process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = '.rpc' + // Startup otherwise fetches each OAuth account's profile in the background + // without awaiting it. That request is unrelated to these tests and could + // still be running when the temporary files are removed. + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + delete process.env.OPENCODE_AUTH_CONTENT + return fixture +} describe('RPC server lifecycle', () => { test('dispose stops and removes its server when feed cleanup rejects', async () => { - await saveAccounts({ + // The migrated settings enable the quota header feed, so the plugin builds + // the registry whose disposal is made to fail here. + await migratedProjects({ ...createEmptyStorage(), quotaHeaderFeed: { enabled: true }, }) const originalDispose = QuotaHeaderFeedRegistry.prototype.dispose + let feedDisposals = 0 QuotaHeaderFeedRegistry.prototype.dispose = async () => { + feedDisposals++ throw new Error('feed disposal failed') } try { @@ -192,6 +263,9 @@ describe('RPC server lifecycle', () => { expect(entry).not.toBeNull() await plugin.dispose?.() + // Quota-feed disposal threw as configured. The assertions below prove + // that this failure did not prevent RPC server cleanup. + expect(feedDisposals).toBe(1) expect(await discoverPortFile(rpcDir)).toBeNull() expect( (globalThis as RpcGlobal).__anthropicAuthRpcServers?.get(rpcDir), @@ -205,6 +279,7 @@ describe('RPC server lifecycle', () => { }) test('keeps RPC servers live for distinct project directories', async () => { + await migratedProjects() const directoryA = join(testRoot, 'project-a') const directoryB = join(testRoot, 'project-b') @@ -220,10 +295,13 @@ describe('RPC server lifecycle', () => { }) test('each project RPC server applies through its own plugin instance', async () => { + await migratedProjects() const directoryA = join(testRoot, 'project-a') const directoryB = join(testRoot, 'project-b') - await getPlugin(directoryA, 'applied by project-a') - await getPlugin(directoryB, 'applied by project-b') + const hostA = createHost(directoryA, 'applied by project-a') + const hostB = createHost(directoryB, 'applied by project-b') + await getPlugin(directoryA, hostA) + await getPlugin(directoryB, hostB) const entryA = await discoverPortFile(getRpcDir(directoryA)) const entryB = await discoverPortFile(getRpcDir(directoryB)) @@ -248,15 +326,40 @@ describe('RPC server lifecycle', () => { ), ).toMatchObject({ port: entryB.port, token: entryB.token }) - expect((await applyViaRpc(entryA, 'session-a')).text).toContain( - 'applied by project-a', - ) - expect((await applyViaRpc(entryB, 'session-b')).text).toContain( - 'applied by project-b', - ) + // The retired text apply endpoint only points at /claude; it must not + // reach either project's session client. + expect( + await postRpc(entryA, 'apply', { + command: 'claude-start', + arguments: '', + sessionId: 'session-a', + }), + ).toEqual({ + text: 'Open /claude to manage native Claude settings.', + knobs: {}, + }) + expect(hostA.prompts).toHaveLength(0) + expect(hostB.prompts).toHaveLength(0) + + // Lane start from the /claude menu prompts through the session client + // of the plugin that owns the RPC server, for the requesting session only. + const resultA = await fireLaneStartViaRpc(entryA, 'session-a') + expect(resultA.text).toContain('applied by project-a') + expect(resultA.text).not.toContain('applied by project-b') + expect(hostA.prompts).toHaveLength(1) + expect(hostA.prompts[0]).toBe('session-a') + expect(hostB.prompts).toHaveLength(0) + + const resultB = await fireLaneStartViaRpc(entryB, 'session-b') + expect(resultB.text).toContain('applied by project-b') + expect(resultB.text).not.toContain('applied by project-a') + expect(hostB.prompts).toHaveLength(1) + expect(hostB.prompts[0]).toBe('session-b') + expect(hostA.prompts).toHaveLength(1) }) test('dispose stops its directory while another project remains live', async () => { + await migratedProjects() const directoryA = join(testRoot, 'project-a') const directoryB = join(testRoot, 'project-b') const pluginA = await getPlugin(directoryA) @@ -278,6 +381,7 @@ describe('RPC server lifecycle', () => { }) test('late disposal cannot remove a same-directory successor port file', async () => { + await migratedProjects() const directory = join(testRoot, 'project') const first = await getPlugin(directory) const second = await getPlugin(directory) @@ -302,6 +406,7 @@ describe('RPC server lifecycle', () => { }) test('a dispose whose entry was replaced does not stop the successor server', async () => { + await migratedProjects() const directory = join(testRoot, 'project') const first = await getPlugin(directory) const rpcGlobal = globalThis as RpcGlobal @@ -321,18 +426,21 @@ describe('RPC server lifecycle', () => { await first.dispose?.() - // D2's port-file check would otherwise mask loss of D1. + // Both registry entries use the same port. Port-file checks alone cannot + // detect an unintended call to stop the original server. expect(stopSpy).not.toHaveBeenCalled() expect(rpcGlobal.__anthropicAuthRpcServers?.get(rpcDir)).toBe( successorHandle, ) - // Dispose refused to stop D1 by design; the spy wraps the real stop, so - // invoking it clears the dangling server and its port file before afterEach. + // The registry no longer names the original server, so its plugin must + // leave it running. Stop it explicitly for cleanup; the spy calls the real + // stop function, which also removes its port file. await stopSpy() rpcGlobal.__anthropicAuthRpcServers?.delete(rpcDir) }) test('a disposed project can start a discoverable RPC server again', async () => { + await migratedProjects() const directory = join(testRoot, 'project') const first = await getPlugin(directory) From 6fb39133e69c4561d704ee6eac2341fe18872e7c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:28:26 +0200 Subject: [PATCH 086/169] mason: migrate e2e process fixtures through native offline migration The OpenCode process harness used to hand the host legacy account files and an OPENCODE_AUTH_CONTENT snapshot. The native plugin refuses both. spawnOpencode now writes OpenCode's real auth.json under the child's isolated XDG_DATA_HOME and runs the exported Core runNativeMigration with the child's exact environment before it spawns the host. The migration's process fence fails if the host has already started. For a local main OAuth login, the public account runtime then validates the credential with a synthetic refresh and account lookup, so the host can serve it offline. Vault fixtures discover accounts through the mock Claustrum daemon named in childEnv. - nativeAccounts fixture option: local (default), vault, unmigrated. A deliberately unmigrated fixture may take a real auth.json instead of the snapshot env var. - custody-mode: the positive scoped tests now migrate through vault custody. Retirement deletes the legacy account files, so their storage reads moved to the public native runtime; the wire assertions are unchanged. The legacy handle-only negative used to expect status 'retry'. That status came from a synthetic 503 in the pre-native plugin, which a store that was never migrated can no longer reach. The test now strictly asserts the terminal 'Anthropic account migration is required; run setup' error with no retry and keeps every zero-dispatch, credential-read and enrollment check. This was approved by the parent reviewer and verified on a real OpenCode 1.18.18 host. - haiku55's API-key case supplies the key through auth.json instead of OPENCODE_AUTH_CONTENT. - fixtures/opencode1 pins opencode-ai 1.18.18, the version CI installs. fixtures/opencode1-config-deps pins @opencode-ai/plugin 1.18.18. When ANTHROPIC_AUTH_E2E_OPENCODE_CONFIG_DEPS points at an installed copy, the harness verifies that tree against its lockfile and copies it into each isolated OpenCode config directory, so an offline host gets past its plugin SDK install. - native-process-fixture.test.ts exercises the preparation path with a stand-in host. --- .../opencode1-config-deps/package-lock.json | 401 +++++++++++++++ .../opencode1-config-deps/package.json | 5 + .../fixtures/opencode1/package-lock.json | 190 +++++++ .../e2e-tests/fixtures/opencode1/package.json | 8 + packages/e2e-tests/src/harness.ts | 4 + packages/e2e-tests/src/opencode-runner.ts | 462 +++++++++++++++++- packages/e2e-tests/tests/custody-mode.test.ts | 108 +++- packages/e2e-tests/tests/haiku55.test.ts | 4 +- .../tests/native-process-fixture.test.ts | 330 +++++++++++++ 9 files changed, 1485 insertions(+), 27 deletions(-) create mode 100644 packages/e2e-tests/fixtures/opencode1-config-deps/package-lock.json create mode 100644 packages/e2e-tests/fixtures/opencode1-config-deps/package.json create mode 100644 packages/e2e-tests/fixtures/opencode1/package-lock.json create mode 100644 packages/e2e-tests/fixtures/opencode1/package.json create mode 100644 packages/e2e-tests/tests/native-process-fixture.test.ts diff --git a/packages/e2e-tests/fixtures/opencode1-config-deps/package-lock.json b/packages/e2e-tests/fixtures/opencode1-config-deps/package-lock.json new file mode 100644 index 000000000..233cc0c5b --- /dev/null +++ b/packages/e2e-tests/fixtures/opencode1-config-deps/package-lock.json @@ -0,0 +1,401 @@ +{ + "name": "opencode1-config-deps", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.18.18" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.18.tgz", + "integrity": "sha512-vqQeqJtn9c+J+tIQDzYk88xip/NVNN1hym1ATmckxo6zINHAoXoul4Sw/jgnvL00rLsfAvhja28qax4h3g/5Jg==", + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.18", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.18.tgz", + "integrity": "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/e2e-tests/fixtures/opencode1-config-deps/package.json b/packages/e2e-tests/fixtures/opencode1-config-deps/package.json new file mode 100644 index 000000000..63d15196d --- /dev/null +++ b/packages/e2e-tests/fixtures/opencode1-config-deps/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@opencode-ai/plugin": "1.18.18" + } +} diff --git a/packages/e2e-tests/fixtures/opencode1/package-lock.json b/packages/e2e-tests/fixtures/opencode1/package-lock.json new file mode 100644 index 000000000..8ed397ba8 --- /dev/null +++ b/packages/e2e-tests/fixtures/opencode1/package-lock.json @@ -0,0 +1,190 @@ +{ + "name": "anthropic-auth-e2e-opencode1-host", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "anthropic-auth-e2e-opencode1-host", + "dependencies": { + "opencode-ai": "1.18.18" + } + }, + "node_modules/opencode-ai": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.18.tgz", + "integrity": "sha512-J+5HFq8tf+wPBBpBpMPSNjSytF2/EkNWYfFZh4si1d9auFbQriqDyqZv+vFUsLWERfdMU32Eajwuiq3rKBvZLQ==", + "cpu": [ + "arm64", + "x64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "opencode": "bin/opencode.exe" + }, + "optionalDependencies": { + "opencode-darwin-arm64": "1.18.18", + "opencode-darwin-x64": "1.18.18", + "opencode-darwin-x64-baseline": "1.18.18", + "opencode-linux-arm64": "1.18.18", + "opencode-linux-arm64-musl": "1.18.18", + "opencode-linux-x64": "1.18.18", + "opencode-linux-x64-baseline": "1.18.18", + "opencode-linux-x64-baseline-musl": "1.18.18", + "opencode-linux-x64-musl": "1.18.18", + "opencode-windows-arm64": "1.18.18", + "opencode-windows-x64": "1.18.18", + "opencode-windows-x64-baseline": "1.18.18" + } + }, + "node_modules/opencode-darwin-arm64": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.18.tgz", + "integrity": "sha512-VkG+bz8u8Xqg9NzPK+2/71nEd4DKKlo2NLZurQ1eLAzDnmb1CMYZif/o6Shl8YFuTuYU/30k6yufl4Zr0Ij64g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-darwin-x64": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.18.tgz", + "integrity": "sha512-xox5XJJ1bI5qDEbxWWR9pWY2Gak5IuSfDcObyWunRUiN7J8OiwyazsJs1HGsTSkVhisk8SNbFIr0/0xQGhxSZg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-darwin-x64-baseline": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.18.tgz", + "integrity": "sha512-NYlIeOOxKPrqY6rdVIjQV4h+eE1AFCHzEcoxXhc8zSvqnCVIO0hMtdNm3l3HhrDucWmdh1BsQBUoZlrBOvAl4w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-linux-arm64": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.18.tgz", + "integrity": "sha512-e8D3g0qJEIzawEg2+ygW3vkZjAYL2ssyAx4GbihjwXwZFvlZZy5zRWWzdz5KLBoHSTl0FB73vNtnNeXONyHpVQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-arm64-musl": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.18.tgz", + "integrity": "sha512-Dp3XByFRRZngPAQotNbOwr22HgZej4r9Ck0Iv/rOVU+oO5fLD9YSpHEwx80FTZOZmFmd/vriCdrR/dGdzabICw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.18.tgz", + "integrity": "sha512-WmeUnhljYJ252wywKTiW4bNDzsas2njpjPUEh0jM6HKNI4vFxJtREtzaWViY4AKEAcOkLWT8Ll17ixvcHz3AnA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-baseline": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.18.tgz", + "integrity": "sha512-6GvFarhP0pDiXcE9Au8PWoqb9+ZLBIGKrhZxeVAmOJp/gJ8lLL1Eno8O1qpqRb1KjOfOaOD71jHu9xNaqmjEtw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-baseline-musl": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.18.tgz", + "integrity": "sha512-cXD1gAZ+TTmdE3tnWy5qPstZjrnbPg0rPUwtcHeLaeB+KYxQWEoie1c88oYmZfQ+RWI6+/+LLF0FU29Uyd1Nrw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-musl": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.18.tgz", + "integrity": "sha512-lWiWxotqyVTJYiu2dN90KDNDSrwmyhZk8ajzyfAFjwms2TBVx4L0Ly6gkdhz8nxsxwPRv751W8+yUpzetB8AMA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-windows-arm64": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.18.tgz", + "integrity": "sha512-Fj1LfP3kXUeD34N0FOw6vebS1oK2YmxA2nviAKOAPo8jLFjEgq5djAJ/WroPmS71VOP1EHlKhRshnK3Sx8pI2w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/opencode-windows-x64": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.18.tgz", + "integrity": "sha512-wbsBZsHDgfHaw9zJogFrrSfWpObtXMFjiA6xl6IFyeVGXsdKi6E8AWIMRZUxKVkf9X0P48mYUuaP1EHgmJCbpA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/opencode-windows-x64-baseline": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.18.tgz", + "integrity": "sha512-IeTrXqbIDbXD5VXpeupRa8aD+l4lSNeq02/K0jnaEPvR5adv42tiN1rOENiTaO2uX+d9cQ/csCaJPBFiQvwoRg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + } + } +} diff --git a/packages/e2e-tests/fixtures/opencode1/package.json b/packages/e2e-tests/fixtures/opencode1/package.json new file mode 100644 index 000000000..35228f2a6 --- /dev/null +++ b/packages/e2e-tests/fixtures/opencode1/package.json @@ -0,0 +1,8 @@ +{ + "name": "anthropic-auth-e2e-opencode1-host", + "private": true, + "description": "Pinned OpenCode 1 host for the e2e process tests; matches the version CI installs.", + "dependencies": { + "opencode-ai": "1.18.18" + } +} diff --git a/packages/e2e-tests/src/harness.ts b/packages/e2e-tests/src/harness.ts index 6ace93526..58a98efb4 100644 --- a/packages/e2e-tests/src/harness.ts +++ b/packages/e2e-tests/src/harness.ts @@ -3,6 +3,7 @@ import { MockAnthropicServer, type MockResponse } from './mock-anthropic.ts' import { MockRelayServer } from './mock-relay.ts' import { type IsolatedEnv, + type NativeAccountFixture, type SpawnedOpencode, spawnOpencode, } from './opencode-runner.ts' @@ -49,6 +50,8 @@ export type E2EHarnessOptions = { childTmpDir?: string childEnv?: Record beforeSpawn?: (env: IsolatedEnv) => void | Promise + /** Defaults to `{ kind: 'local' }`; see NativeAccountFixture. */ + nativeAccounts?: NativeAccountFixture } export class E2EHarness { @@ -98,6 +101,7 @@ export class E2EHarness { quotaFeed: options.quotaFeed, childEnv: options.childEnv, beforeSpawn: options.beforeSpawn, + nativeAccounts: options.nativeAccounts, }) const sdk = await import('@opencode-ai/sdk') const client = sdk.createOpencodeClient({ diff --git a/packages/e2e-tests/src/opencode-runner.ts b/packages/e2e-tests/src/opencode-runner.ts index 227c15a7e..b4f8e1a5b 100644 --- a/packages/e2e-tests/src/opencode-runner.ts +++ b/packages/e2e-tests/src/opencode-runner.ts @@ -10,7 +10,9 @@ import { writeFileSync, } from 'node:fs' import { + cp, lstat, + mkdir, readdir, readFile, rename, @@ -19,8 +21,22 @@ import { writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, join, relative, resolve, sep } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' +import { + createNativeAccountRuntime, + custodyTombstoneOAuth, + discoverNativeVaultInventory, + getStickyRoutingStatePath, + type NativeMigrationJournal, + type NativePoolPaths, + type NativeRefreshResult, + type ProviderAccountUuid, + publishNativeVaultRosterSeed, + publishNativeVaultRuntimeSeed, + resolveNativePoolPaths, + runNativeMigration, +} from '@cortexkit/anthropic-auth-core' const REPO_ROOT = resolve(import.meta.dir, '../../..') const PLUGIN_ENTRY = join(REPO_ROOT, 'packages/opencode/src/index.ts') @@ -163,10 +179,53 @@ async function handoffRunPid(tempDir: string, root: string, childPid: number) { return false } +/** + * How the child's Anthropic accounts are prepared before `opencode serve` + * starts. The plugin serves only from a native pool that offline migration + * created; it refuses unmigrated legacy files and OPENCODE_AUTH_CONTENT. + * + * - `local` (the default): OpenCode's own auth.json holds `hostAuth`, by + * default a synthetic Anthropic OAuth login. Migration moves that login + * into the pool. When the pool then has a local main OAuth account, it is + * validated once through the public account runtime, so the host can + * serve it without contacting the real token or account endpoints. + * - `vault`: auth.json holds the inert custody marker, and migration + * discovers the vault accounts through the Claustrum connection and + * enrollment files named in the child environment. The test must write a + * custody legacy config and enrollment file in `beforeSpawn`. + * - `unmigrated`: no migration runs. Without `hostAuth`, the child receives + * the synthetic OAuth login through OPENCODE_AUTH_CONTENT unless + * `childEnv` replaces it. With `hostAuth`, the harness writes it to + * OpenCode's own auth.json and removes OPENCODE_AUTH_CONTENT, so the + * plugin's missing-migration refusal is reached instead of its refusal of + * that variable. Use this only for a test that expects the plugin to refuse. + */ +export type NativeAccountFixture = + | { kind: 'local'; hostAuth?: Record } + | { kind: 'vault' } + | { kind: 'unmigrated'; hostAuth?: Record } + +/** Pool paths and retired journal of a migrated fixture. */ +export type MigratedNativeAccounts = { + paths: NativePoolPaths + journal: NativeMigrationJournal + hostAuthPath: string + routingDestinationPath: string + /** Result of validating the local main OAuth account; absent when the pool has none. */ + mainAdmission?: NativeRefreshResult +} + +/** Synthetic account UUID that the fixture's account lookup reports for the main login. */ +export const FIXTURE_MAIN_ACCOUNT_UUID = '11111111-1111-4111-8111-111111111111' +/** Access token the fixture's token exchange issues; the child serves with this token. */ +export const FIXTURE_ADMITTED_ACCESS_TOKEN = 'test-access-token-admitted' + export type SpawnedOpencode = { url: string port: number env: IsolatedEnv + /** Undefined for an `unmigrated` fixture. */ + native?: MigratedNativeAccounts kill: () => Promise stdout: () => string stderr: () => string @@ -186,6 +245,8 @@ export type SpawnOptions = { childEnv?: Record childTmpDir?: string quotaFeed?: boolean + /** Defaults to `{ kind: 'local' }`. */ + nativeAccounts?: NativeAccountFixture } export function waitForOpencodeListening( @@ -505,6 +566,350 @@ export async function cleanupE2ERun(options: { }) } +type NativeSetupLayout = { + paths: NativePoolPaths + hostAuthPath: string + routingSourcePath: string + routingDestinationPath: string +} + +/** + * The files offline setup would use for this child: the legacy files the + * harness gives the plugin through OPENCODE_ANTHROPIC_AUTH_FILE and + * OPENCODE_ANTHROPIC_AUTH_STATE_FILE, the pool beside them, and OpenCode's + * auth.json under the child's XDG_DATA_HOME. + */ +async function nativeSetupLayout(env: IsolatedEnv): Promise { + const paths = await resolveNativePoolPaths( + join(env.configDir, 'anthropic-auth.json'), + join(env.configDir, 'anthropic-auth-state.json'), + ) + return { + paths, + hostAuthPath: join(env.dataDir, 'opencode', 'auth.json'), + routingSourcePath: getStickyRoutingStatePath(paths.legacyConfig), + routingDestinationPath: getStickyRoutingStatePath(paths.config), + } +} + +async function migrateNativeAccounts(input: { + layout: NativeSetupLayout + childEnv: Record + fixture: Exclude + options: SpawnOptions + hostStarted: () => boolean +}): Promise { + const { layout, fixture } = input + const { paths } = layout + if ( + fixture.kind === 'vault' && + !input.options.childEnv?.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE + ) + // Without an explicit connection file, discovery would follow an + // inherited variable or the default path to a real vault daemon. + throw new Error( + 'A vault fixture needs OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE in childEnv', + ) + const hostAuth = + fixture.kind === 'vault' + ? { anthropic: custodyTombstoneOAuth('anthropic') } + : (fixture.hostAuth ?? { + anthropic: { + type: 'oauth', + access: 'test-access-token', + refresh: 'test-refresh-token', + expires: Date.now() + 60 * 60 * 1000, + }, + }) + mkdirSync(dirname(layout.hostAuthPath), { recursive: true }) + writeFileSync(layout.hostAuthPath, JSON.stringify(hostAuth), { + mode: 0o600, + flag: 'wx', + }) + + // runNativeMigration calls processFence to confirm no host is running + // before it reads the legacy files and again before it commits. The child + // is spawned only after migration returns, so a started host here means + // the harness order is wrong. + let fenceChecks = 0 + const processFence = async () => { + fenceChecks++ + if (input.hostStarted()) + throw new Error('opencode started before its offline migration finished') + } + const migrationEnv = { ...input.childEnv } + const journal = await runNativeMigration({ + paths, + legacyConfigPath: paths.legacyConfig, + legacyStatePath: paths.legacyState, + host: 'opencode', + hostAuthPath: layout.hostAuthPath, + routingSourcePath: layout.routingSourcePath, + routingDestinationPath: layout.routingDestinationPath, + env: migrationEnv, + removePiAnthropicAuth: false, + processFence, + ...(fixture.kind === 'vault' && { + custody: { + discover: () => + discoverNativeVaultInventory({ + paths, + host: 'opencode', + env: migrationEnv, + }), + publishSeed: publishNativeVaultRosterSeed, + publishRuntimeSeed: publishNativeVaultRuntimeSeed, + }, + }), + }) + if (fenceChecks === 0) + throw new Error('Fixture migration did not check its process fence') + if (journal.phase !== 'retired') + throw new Error(`Fixture migration ended in phase ${journal.phase}`) + + const mainAdmission = + fixture.kind === 'local' ? await admitLocalMain(paths) : undefined + return { + paths, + journal, + hostAuthPath: layout.hostAuthPath, + routingDestinationPath: layout.routingDestinationPath, + ...(mainAdmission && { mainAdmission }), + } +} + +/** + * The anthropic-auth plugin serves a local OAuth account only when it holds + * validation evidence for that exact credential version. Its built-in token + * refresh and account lookup call fixed Anthropic URLs that + * ANTHROPIC_BASE_URL does not redirect, so the host cannot produce that + * evidence offline. Before OpenCode is spawned, this function runs the + * public account runtime with a synthetic refresh and account lookup. They + * verify the local OAuth account and store validation for the exact + * credential version they produce. The host then adopts that stored proof + * and serves the credential without contacting the provider. Account + * metadata alone does not count as validation. + */ +async function admitLocalMain( + paths: NativePoolPaths, +): Promise { + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: { + refreshToken: async (request) => { + if (request.refreshToken !== 'test-refresh-token') + throw new Error('Fixture token exchange received an unknown login') + const expiresIn = 8 * 60 * 60 + return { + access: FIXTURE_ADMITTED_ACCESS_TOKEN, + refresh: 'test-refresh-token-admitted', + expires: Date.now() + expiresIn * 1000, + expiresIn, + } + }, + resolveIdentity: async (accessToken) => { + if (accessToken !== FIXTURE_ADMITTED_ACCESS_TOKEN) + throw new Error('Fixture account lookup received an unknown token') + return { + deviceId: 'e2e-fixture-device', + sessionId: 'e2e-fixture-session', + accountUuid: FIXTURE_MAIN_ACCOUNT_UUID as ProviderAccountUuid, + } + }, + }, + }) + try { + const snapshot = await runtime.read() + const main = snapshot.accounts.find((account) => account.id === 'main') + // An API-key activation stays under OpenCode's control and creates no + // pool main account; there is nothing to validate. + if (main?.source !== 'local' || main.type !== 'oauth') return undefined + const result = await runtime.authorizeLocal('main', { intent: 'serve' }) + if (result.status !== 'usable') + throw new Error( + `Fixture main account was not admitted: ${JSON.stringify(result)}`, + ) + return result + } finally { + runtime.close() + } +} + +/** + * Environment variable naming an installed copy of + * fixtures/opencode1-config-deps. OpenCode tries to install its plugin SDK, + * @opencode-ai/plugin, in each of its config directories before it loads + * plugins, which needs registry access. When this variable is set, the + * harness instead fills those isolated directories with the genuine pinned + * packages from that fixture. When it is unset, OpenCode installs the SDK + * itself. + */ +export const OPENCODE_CONFIG_DEPS_ENV = + 'ANTHROPIC_AUTH_E2E_OPENCODE_CONFIG_DEPS' + +type LockEntry = { version?: string; integrity?: string; optional?: boolean } +type LockFile = { + packages?: Record< + string, + LockEntry & { dependencies?: Record } + > +} + +function readJsonFile(path: string): Promise { + return readFile(path, 'utf8').then((text) => JSON.parse(text) as T) +} + +function rootDependencyNames(manifest: Record | undefined) { + const names = new Set() + for (const field of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + ]) { + const value = manifest?.[field] + if (value && typeof value === 'object') + for (const name of Object.keys(value)) names.add(name) + } + return names +} + +/** + * Check that `fixture` holds a complete install of its own lockfile before + * anything is copied. A missing or partial tree must fail here: OpenCode + * skips its own install whenever node_modules exists and the lockfile root + * names every declared dependency, so a broken copy would otherwise surface + * only as a plugin load failure inside the host. + */ +export async function verifyOpencodeConfigDependencies(fixture: string) { + const manifest = await readJsonFile>( + join(fixture, 'package.json'), + ) + const lock = await readJsonFile(join(fixture, 'package-lock.json')) + const installed = await readJsonFile( + join(fixture, 'node_modules', '.package-lock.json'), + ).catch(() => { + throw new Error(`${fixture} has no npm-installed node_modules`) + }) + const declared = rootDependencyNames(manifest) + const locked = rootDependencyNames(lock.packages?.['']) + if (!declared.has('@opencode-ai/plugin')) + throw new Error( + `${fixture}/package.json does not declare @opencode-ai/plugin`, + ) + for (const name of declared) + if (!locked.has(name)) + throw new Error(`${fixture}/package-lock.json root does not lock ${name}`) + const verified: string[] = [] + for (const [key, entry] of Object.entries(lock.packages ?? {})) { + if (!key) continue + const present = installed.packages?.[key] + // npm installs only the optional platform packages that match this + // machine; an absent optional package is expected, a different one is not. + if (!present && entry.optional) continue + if ( + !present || + present.version !== entry.version || + present.integrity !== entry.integrity + ) + throw new Error( + `${fixture}: installed ${key} does not match the committed lockfile`, + ) + const files = await readJsonFile<{ version?: string }>( + join(fixture, key, 'package.json'), + ).catch(() => undefined) + if (files?.version !== entry.version) + throw new Error(`${fixture}: ${key} is not on disk at ${entry.version}`) + verified.push(`${key}@${entry.version}`) + } + const plugin = await readJsonFile<{ + version?: string + exports?: Record + }>(join(fixture, 'node_modules', '@opencode-ai', 'plugin', 'package.json')) + const declaredPlugin = ( + manifest.dependencies as Record | undefined + )?.['@opencode-ai/plugin'] + if (plugin.version !== declaredPlugin) + throw new Error( + `${fixture}: @opencode-ai/plugin ${plugin.version} is installed, ${declaredPlugin} is declared`, + ) + const entry = (plugin.exports?.['.'] as { import?: unknown } | undefined) + ?.import + if ( + typeof entry !== 'string' || + !(await lstat( + join(fixture, 'node_modules', '@opencode-ai', 'plugin', entry), + ).then( + (info) => info.isFile() && info.size > 0, + () => false, + )) + ) + throw new Error(`${fixture}: @opencode-ai/plugin has no entry file`) + return { declared: [...declared], verified } +} + +async function isDirectory(path: string) { + return lstat(path).then( + (info) => info.isDirectory() && !info.isSymbolicLink(), + () => false, + ) +} + +/** + * OpenCode 1.18.18 installs its plugin SDK in each config directory it finds. + * Include the XDG and explicit config directories, HOME/.opencode if it exists, + * and every existing .opencode directory from the project up to the fixture's + * temporary root. Reject paths outside that root before copying dependencies + * so this setup cannot write into the developer's own configuration. + */ +export async function opencodeConfigDependencyTargets( + env: IsolatedEnv, + childEnv: Record, +) { + const candidates = [ + join(childEnv.XDG_CONFIG_HOME ?? '', 'opencode'), + childEnv.OPENCODE_CONFIG_DIR ?? '', + ] + for (let dir = env.workdir; ; dir = dirname(dir)) { + if (await isDirectory(join(dir, '.opencode'))) + candidates.push(join(dir, '.opencode')) + if (resolve(dir) === resolve(env.tempDir) || dirname(dir) === dir) break + } + if (await isDirectory(join(childEnv.HOME ?? '', '.opencode'))) + candidates.push(join(childEnv.HOME ?? '', '.opencode')) + const targets = [...new Set(candidates.map((dir) => resolve(dir)))] + for (const target of targets) { + const inside = relative(resolve(env.tempDir), target) + if (!inside || inside.startsWith('..') || inside.startsWith(sep)) + throw new Error( + `OpenCode config directory ${target} is outside the run's temp root`, + ) + } + return targets +} + +/** Copy the verified dependency fixture into each OpenCode config directory. */ +export async function seedOpencodeConfigDependencies( + env: IsolatedEnv, + childEnv: Record, + fixture: string, +) { + const verified = await verifyOpencodeConfigDependencies(fixture) + const targets = await opencodeConfigDependencyTargets(env, childEnv) + for (const target of targets) { + await mkdir(target, { recursive: true }) + for (const name of ['package.json', 'package-lock.json', 'node_modules']) + await cp(join(fixture, name), join(target, name), { + recursive: true, + errorOnExist: true, + force: false, + verbatimSymlinks: true, + }) + } + return { ...verified, targets } +} + export async function spawnOpencode( options: SpawnOptions, ): Promise { @@ -587,12 +992,66 @@ export async function spawnOpencode( expires: Date.now() + 60 * 60 * 1000, }, }) + const nativeAccounts = options.nativeAccounts ?? { kind: 'local' } + const nativeLayout = + nativeAccounts.kind === 'unmigrated' + ? undefined + : await nativeSetupLayout(env) + if (nativeLayout) { + // Offline setup moves the synthetic OAuth credentials into this test's + // common-auth account pool. OpenCode's auth.json retains only a non-secret + // OAuth activation value, which the host must read from disk. Remove the + // inherited OPENCODE_AUTH_CONTENT override: it supplies an auth snapshot + // instead, and the plugin refuses that credential source. A deliberate + // test override applied below still reaches migration's refusal check. + delete childEnv.OPENCODE_AUTH_CONTENT + // Session-to-account assignments move from the old sidecar directory + // to nativeLayout.routingDestinationPath during migration. Give the + // child that exact destination so it does not reopen the retired file. + childEnv.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE = + nativeLayout.routingDestinationPath + } + if (nativeAccounts.kind === 'unmigrated' && nativeAccounts.hostAuth) + // This fixture must refuse because offline credential migration has not + // run. Read the synthetic auth.json from disk; an environment snapshot + // would trigger a different credential-source refusal first. + delete childEnv.OPENCODE_AUTH_CONTENT childEnv.ANTHROPIC_BASE_URL = options.anthropicBaseURL childEnv.ANTHROPIC_API_KEY = 'test-key-not-real' for (const [key, value] of Object.entries(options.childEnv ?? {})) { if (value === undefined) delete childEnv[key] else childEnv[key] = value } + // Import the synthetic OAuth credentials into the test-only common-auth + // pool before starting OpenCode. Use the final child environment, after + // test overrides, so setup and serving resolve the same account paths + // and forbidden auth snapshots cannot bypass the migration guard. + const native = + nativeLayout && nativeAccounts.kind !== 'unmigrated' + ? await migrateNativeAccounts({ + layout: nativeLayout, + childEnv, + fixture: nativeAccounts, + options, + hostStarted: () => child !== undefined, + }) + : undefined + if (nativeAccounts.kind === 'unmigrated' && nativeAccounts.hostAuth) { + if (childEnv.OPENCODE_AUTH_CONTENT !== undefined) + throw new Error( + 'An unmigrated fixture with hostAuth must not set OPENCODE_AUTH_CONTENT', + ) + const hostAuthPath = join(env.dataDir, 'opencode', 'auth.json') + mkdirSync(dirname(hostAuthPath), { recursive: true }) + writeFileSync(hostAuthPath, JSON.stringify(nativeAccounts.hostAuth), { + mode: 0o600, + flag: 'wx', + }) + } + + const configDependencies = process.env[OPENCODE_CONFIG_DEPS_ENV] + if (configDependencies) + await seedOpencodeConfigDependencies(env, childEnv, configDependencies) child = spawn( 'opencode', @@ -638,6 +1097,7 @@ export async function spawnOpencode( url, port, env, + native, stdout: () => stdout, stderr: () => stderr, kill: async () => { diff --git a/packages/e2e-tests/tests/custody-mode.test.ts b/packages/e2e-tests/tests/custody-mode.test.ts index 9ac82b9d3..06ed99290 100644 --- a/packages/e2e-tests/tests/custody-mode.test.ts +++ b/packages/e2e-tests/tests/custody-mode.test.ts @@ -1,6 +1,6 @@ /// import { afterEach, describe, expect, it } from 'bun:test' -import { custodyTombstoneKey, isOAuthAccount, loadAccounts, setRoutingMode } from '@cortexkit/anthropic-auth-core' +import { createNativeAccountRuntime, custodyTombstoneOAuth, type NativePoolPaths, readNativeMigrationJournal, resolveNativePoolPaths } from '@cortexkit/anthropic-auth-core' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -31,6 +31,37 @@ const credential = (access: string, accountId: string, recordVersion: number): F const mainId = 'oauth:anthropic' const workId = 'oauth:anthropic:work' +// Read migrated account settings and cached quota/profile data through the +// native account runtime, not the retired sidecar files. Reading this status +// must not connect to Claustrum or retrieve an OAuth bearer token. +async function readNativeAccounts(paths: NativePoolPaths) { + const runtime = createNativeAccountRuntime({ paths, host: 'opencode' }) + try { + return await runtime.read() + } finally { + runtime.close() + } +} +async function setNativeRoutingMode(mode: string, paths: NativePoolPaths) { + const runtime = createNativeAccountRuntime({ paths, host: 'opencode' }) + try { + await runtime.updateSettings((settings) => ({ + ...settings, + routing: { ...(settings.routing as Record | undefined), mode }, + })) + } finally { + runtime.close() + } +} +const hasVaultAccount = (snapshot: Awaited>, credentialId: string) => + snapshot.accounts.some((a) => a.source === 'vault' && a.credentialId === credentialId) +async function readIfPresent(path: string) { + return readFile(path, 'utf8').catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return '' + throw error + }) +} + describe('zero-bind scoped custody', () => { it('discovers, authorizes and serves a new account without handles or a restart', async () => { const root = await mkdtemp(join(tmpdir(), 'anthropic-auth-scoped-e2e-')) @@ -41,9 +72,9 @@ describe('zero-bind scoped custody', () => { const daemon = await startFakeClaustrumDaemon({ directory: root, scopedCredentials: credentials }) daemons.push(daemon) harness = await E2EHarness.create({ + nativeAccounts: { kind: 'vault' }, childEnv: { OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile, - OPENCODE_AUTH_CONTENT: JSON.stringify({ anthropic: { type: 'oauth', access: '', refresh: custodyTombstoneKey('anthropic'), expires: 0 } }), }, beforeSpawn: async (env) => { const path = join(env.configDir, 'anthropic-auth.json') @@ -62,32 +93,39 @@ describe('zero-bind scoped custody', () => { expect(daemon.credentialGets).toContain(mainId) expect(daemon.scopedLists).toBeGreaterThan(0) credentials[workId] = credential('scoped-work', 'account-work', 12) - const accountPath = join(harness.opencode.env.configDir, 'anthropic-auth.json') + const pool = harness.opencode.native!.paths await harness.waitFor(async () => { - const storage = await loadAccounts(accountPath) - return storage?.accounts.some((a) => isOAuthAccount(a) && a.claustrumScopedCredentialId === workId) ? true : undefined + const snapshot = await readNativeAccounts(pool) + return hasVaultAccount(snapshot, workId) ? true : undefined }, { timeoutMs: 15_000, label: 'new scoped account persisted' }) - const beforeMode = await loadAccounts(accountPath) - expect(beforeMode?.accounts.some((a) => isOAuthAccount(a) && a.claustrumScopedCredentialId === workId), - JSON.stringify({ rows: beforeMode?.accounts.map((a) => [a.id, isOAuthAccount(a) ? a.claustrumScopedCredentialId : 'api']), - raw: JSON.parse(await readFile(accountPath, 'utf8')).accounts?.map((a: { id: string }) => a.id), + const beforeMode = await readNativeAccounts(pool) + expect(hasVaultAccount(beforeMode, workId), + JSON.stringify({ rows: beforeMode.accounts.map((a) => [a.id, a.source === 'vault' ? a.credentialId : a.type]), + roster: await readIfPresent(pool.roster), scopedLists: daemon.scopedLists, vaultIds: Object.keys(credentials) }), ).toBe(true) - await setRoutingMode('fallback-first', accountPath) - const afterMode = await loadAccounts(accountPath) - expect(afterMode?.accounts.some((a) => isOAuthAccount(a) && a.claustrumScopedCredentialId === workId)).toBe(true) + await setNativeRoutingMode('fallback-first', pool) + const afterMode = await readNativeAccounts(pool) + expect(hasVaultAccount(afterMode, workId)).toBe(true) const second = await harness.createSession() await harness.sendPrompt(second, 'serve new fallback') await harness.waitForSessionText(second, 'new account served') expect(harness.anthropic.requests().at(-1)?.headers.authorization).toBe('Bearer scoped-work') expect(daemon.credentialGets).toContain(workId) expect(harness.anthropic.tokenRequests()).toBe(0) - const state = await readFile(join(harness.opencode.env.configDir, 'anthropic-auth-state.json'), 'utf8') + // Require the account-observation file so a missing file cannot make the + // token-exclusion assertion pass vacuously. Inspect configuration, + // credential and roster files too when present; this vault-only fixture + // does not require a file containing local OAuth credentials. + const state = [ + await readFile(pool.runtime, 'utf8'), + ...(await Promise.all([pool.config, pool.state, pool.roster].map(readIfPresent))), + ].join('\n') expect(state).not.toContain('scoped-main') expect(state).not.toContain('scoped-work') }, 120_000) - it('refuses legacy handle-mode even when a vault daemon has a healthy credential', async () => { + it('refuses a legacy handle-only config with native migration-required authority until explicit setup', async () => { const root = await mkdtemp(join(tmpdir(), 'anthropic-auth-legacy-e2e-')) roots.push(root) const daemon = await startFakeClaustrumDaemon({ directory: root, @@ -95,9 +133,15 @@ describe('zero-bind scoped custody', () => { }) daemons.push(daemon) harness = await E2EHarness.create({ + // Deliberately unmigrated: offline setup refuses this handle-only config + // (custody mode without a primary account). OpenCode's real auth.json + // holds a non-secret OAuth-shaped marker that activates this plugin, and + // there is no completed credential-transfer journal. The plugin must end + // the turn with the exact setup-required error, without a retry, a + // provider or vault request, or an enrollment attempt. + nativeAccounts: { kind: 'unmigrated', hostAuth: { anthropic: custodyTombstoneOAuth('anthropic') } }, childEnv: { OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile, - OPENCODE_AUTH_CONTENT: JSON.stringify({ anthropic: { type: 'oauth', access: '', refresh: custodyTombstoneKey('anthropic'), expires: 0 } }), }, beforeSpawn: async (env) => { await writeFile(join(env.configDir, 'anthropic-auth.json'), JSON.stringify({ version: 1, accounts: [], claustrum: { mode: 'claustrum' } }), { mode: 0o600 }) @@ -106,7 +150,22 @@ describe('zero-bind scoped custody', () => { const session = await harness.createSession() await harness.startPrompt(session, 'must fail before transport') try { - await harness.waitForSessionStatusType(session, 'retry', 15_000) + type SessionMessage = { info: { role?: string; error?: { name?: string; data?: { message?: unknown } } }; parts?: Array<{ type?: string; text?: string }> } + const statuses: Array = [] + const assistants = await harness.waitFor(async () => { + statuses.push((await harness!.client.session.status()).data?.[session]?.type) + const messages = ((await harness!.client.session.messages({ path: { id: session } })).data ?? []) as SessionMessage[] + const replies = messages.filter((message) => message.info.role === 'assistant') + return replies.length > 0 && replies.every((message) => message.info.error) ? replies : undefined + }, { timeoutMs: 15_000, label: 'terminal assistant error' }) + const finalStatus = (await harness.client.session.status()).data?.[session]?.type + expect( + assistants.map((message) => message.info.error?.data?.message), + JSON.stringify({ errors: assistants.map((message) => message.info.error), statuses, finalStatus }), + ).toEqual(assistants.map(() => 'Anthropic account migration is required; run setup')) + expect(statuses).not.toContain('retry') + expect(finalStatus).not.toBe('retry') + expect(assistants.flatMap((message) => message.parts ?? []).filter((part) => part.type === 'text' && part.text)).toEqual([]) expect(harness.anthropic.requests()).toHaveLength(0) expect(daemon.credentialGets).toHaveLength(0) expect(daemon.enrollmentProposals).toHaveLength(0) @@ -114,6 +173,11 @@ describe('zero-bind scoped custody', () => { join(harness.opencode.env.configDir, 'claustrum-enrollment-state.json'), 'utf8', )).rejects.toMatchObject({ code: 'ENOENT' }) + const pool = await resolveNativePoolPaths( + join(harness.opencode.env.configDir, 'anthropic-auth.json'), + join(harness.opencode.env.configDir, 'anthropic-auth-state.json'), + ) + expect(await readNativeMigrationJournal(pool)).toBeUndefined() } finally { await harness.abortSession(session) } @@ -131,16 +195,9 @@ describe('scoped credential rotations in the OpenCode process', () => { }) daemons.push(daemon) harness = await E2EHarness.create({ + nativeAccounts: { kind: 'vault' }, childEnv: { OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile, - OPENCODE_AUTH_CONTENT: JSON.stringify({ - anthropic: { - type: 'oauth', - access: '', - refresh: custodyTombstoneKey('anthropic'), - expires: 0, - }, - }), }, beforeSpawn: async (env) => { await writeFile(join(env.configDir, 'anthropic-auth.json'), JSON.stringify({ @@ -224,6 +281,7 @@ it('late fixture cleanup cannot dispose the next test’s harness, daemon or dir await rm(previousRoot, { recursive: true, force: true }) if (!roots.includes(nextRoot)) await rm(nextRoot, { recursive: true, force: true }) - // The registered afterEach owns the next fake fixture when installed. + // If nextRoot remains registered, afterEach stops its harness and daemon + // and removes that directory after this test. } }) diff --git a/packages/e2e-tests/tests/haiku55.test.ts b/packages/e2e-tests/tests/haiku55.test.ts index ba9c831ec..c094465b0 100644 --- a/packages/e2e-tests/tests/haiku55.test.ts +++ b/packages/e2e-tests/tests/haiku55.test.ts @@ -81,7 +81,9 @@ test('Haiku 5.5 structured output preserves forced choice and reaches its tool', test('OpenCode real accounting changes the Haiku 5.5 rate only above 100K cache-inclusive input', async () => { harness = await E2EHarness.create({ - childEnv: { OPENCODE_AUTH_CONTENT: JSON.stringify({ anthropic: { type: 'api', key: 'synthetic-api-key' } }) }, + // OpenCode keeps its own stored Anthropic API key in auth.json. Migration + // creates no primary OAuth account in the shared credential store from it. + nativeAccounts: { kind: 'local', hostAuth: { anthropic: { type: 'api', key: 'synthetic-api-key' } } }, }) for (const total of [100_000, 100_001]) { harness.script([{ type: 'text', text: 'ok', usage: { input_tokens: total - 10_000, output_tokens: 20, cache_creation_input_tokens: 5000, cache_read_input_tokens: 5000 } }]) diff --git a/packages/e2e-tests/tests/native-process-fixture.test.ts b/packages/e2e-tests/tests/native-process-fixture.test.ts new file mode 100644 index 000000000..736c41116 --- /dev/null +++ b/packages/e2e-tests/tests/native-process-fixture.test.ts @@ -0,0 +1,330 @@ +/// +import { afterEach, describe, expect, it } from 'bun:test' +import { chmod, cp, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { + createNativeAccountRuntime, + isCustodyTombstoneOAuth, + readNativeMigrationJournal, + resolveNativePoolPaths, +} from '@cortexkit/anthropic-auth-core' +import { startFakeClaustrumDaemon } from '../src/mock-claustrum.ts' +import { + FIXTURE_ADMITTED_ACCESS_TOKEN, + type IsolatedEnv, + OPENCODE_CONFIG_DEPS_ENV, + removeE2ETempDir, + type SpawnOptions, + spawnOpencode, +} from '../src/opencode-runner.ts' + +// These checks run the harness's real preparation path, offline migration +// included, up to the host spawn. A stand-in `opencode` executable records the +// environment it was started with and exits, so no OpenCode host runs here and +// these tests say nothing about how a real host serves. +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +async function scratchDir(prefix: string) { + const dir = await mkdtemp(join(tmpdir(), prefix)) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function exists(path: string) { + return lstat(path).then( + () => true, + () => false, + ) +} + +/** + * Run spawnOpencode with a stand-in host and keep its temp directory for + * inspection. Returns the error spawnOpencode rejected with, the isolated + * directories, and the stand-in's recorded environment (undefined when it + * was never started). + */ +async function prepareWithoutHost( + options: Omit, + parentEnv: Record = {}, +) { + const bin = await scratchDir('anthropic-auth-e2e-fake-host-') + const recorder = join(bin, 'opencode') + await writeFile( + recorder, + `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} -e 'require("node:fs").writeFileSync("child-env.json", JSON.stringify(process.env)); process.exit(7)'\n`, + ) + await chmod(recorder, 0o700) + let env: IsolatedEnv | undefined + const savedPath = process.env.PATH + const savedKeep = process.env.ANTHROPIC_AUTH_E2E_KEEP_TMP + process.env.PATH = `${bin}:${savedPath ?? ''}` + process.env.ANTHROPIC_AUTH_E2E_KEEP_TMP = '1' + const savedParent = Object.fromEntries(Object.keys(parentEnv).map((key) => [key, process.env[key]])) + Object.assign(process.env, parentEnv) + let failure: unknown + try { + await spawnOpencode({ + ...options, + anthropicBaseURL: 'http://127.0.0.1:9', + beforeSpawn: async (isolated) => { + env = isolated + cleanups.push(() => removeE2ETempDir(isolated.tempDir)) + await options.beforeSpawn?.(isolated) + }, + }) + } catch (error) { + failure = error + } finally { + process.env.PATH = savedPath + for (const [key, value] of Object.entries(savedParent)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + if (savedKeep === undefined) delete process.env.ANTHROPIC_AUTH_E2E_KEEP_TMP + else process.env.ANTHROPIC_AUTH_E2E_KEEP_TMP = savedKeep + } + if (!env) throw new Error('spawnOpencode did not reach beforeSpawn') + const recorded = await readFile(join(env.workdir, 'child-env.json'), 'utf8').then( + (text) => JSON.parse(text) as Record, + () => undefined, + ) + const paths = await resolveNativePoolPaths( + join(env.configDir, 'anthropic-auth.json'), + join(env.configDir, 'anthropic-auth-state.json'), + ) + const hostAuthPath = join(env.dataDir, 'opencode', 'auth.json') + return { failure, env, recorded, paths, hostAuthPath } +} + +/** A local credential source that fails if the runtime tries to exchange a token or look up an account. */ +function offlineOnly() { + const calls: string[] = [] + return { + calls, + local: { + refreshToken: async () => { + calls.push('refreshToken') + throw new Error('host would need the network to exchange a token') + }, + resolveIdentity: async () => { + calls.push('resolveIdentity') + throw new Error('host would need the network to look up the account') + }, + }, + } +} + +describe('native process fixture preparation', () => { + it('migrates a local login before the host starts and leaves it servable without network', async () => { + const run = await prepareWithoutHost({ + relay: { url: 'http://127.0.0.1:9', token: 'relay-token', transport: 'websocket' }, + quotaFeed: true, + hybridCache: true, + }) + expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') + const child = run.recorded! + expect(child).toBeDefined() + expect(child.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(child.HOME).toBe(run.env.homeDir) + expect(child.USERPROFILE).toBe(run.env.homeDir) + expect(child.XDG_CONFIG_HOME).toBe(run.env.configDir) + expect(child.XDG_DATA_HOME).toBe(run.env.dataDir) + expect(child.OPENCODE_ANTHROPIC_AUTH_FILE).toBe(join(run.env.configDir, 'anthropic-auth.json')) + expect(child.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE).toBe( + join(await realpath(run.env.configDir), 'anthropic-auth-pool-routing-state.json'), + ) + + expect(await readNativeMigrationJournal(run.paths)).toMatchObject({ phase: 'retired' }) + // Retirement removed the legacy config. OpenCode's auth.json keeps only a + // non-secret marker that activates the plugin; the real access and + // refresh tokens moved to the shared credential store. + expect(await exists(run.paths.legacyConfig)).toBe(false) + const hostAuth = await readFile(run.hostAuthPath, 'utf8') + expect(isCustodyTombstoneOAuth(JSON.parse(hostAuth).anthropic, 'anthropic')).toBe(true) + expect(hostAuth).not.toContain('test-access-token') + + // A runtime that cannot reach any provider stands in for the host: the + // main account must be usable from the validation recorded before spawn. + const offline = offlineOnly() + const runtime = createNativeAccountRuntime({ paths: run.paths, host: 'opencode', local: offline.local }) + try { + const snapshot = await runtime.read() + expect(snapshot.mode).toBe('local') + expect(snapshot.accounts.map(({ id, type, source }) => ({ id, type, source }))).toEqual([ + { id: 'main', type: 'oauth', source: 'local' }, + ]) + const admitted = await runtime.authorizeLocal('main', { intent: 'serve' }) + expect(admitted).toMatchObject({ status: 'usable', access: FIXTURE_ADMITTED_ACCESS_TOKEN }) + expect(offline.calls).toEqual([]) + expect(await runtime.getRelayConfig()).toMatchObject({ + url: 'http://127.0.0.1:9', + token: 'relay-token', + transport: 'websocket', + }) + } finally { + runtime.close() + } + }) + + it('keeps an OpenCode API-key activation under host control', async () => { + const run = await prepareWithoutHost({ + nativeAccounts: { kind: 'local', hostAuth: { anthropic: { type: 'api', key: 'synthetic-api-key' } } }, + }) + expect(run.recorded?.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(await readNativeMigrationJournal(run.paths)).toMatchObject({ phase: 'retired' }) + expect(JSON.parse(await readFile(run.hostAuthPath, 'utf8')).anthropic).toEqual({ + type: 'api', + key: 'synthetic-api-key', + }) + const runtime = createNativeAccountRuntime({ paths: run.paths, host: 'opencode' }) + try { + expect((await runtime.read()).accounts).toEqual([]) + } finally { + runtime.close() + } + }) + + it('refuses to migrate, and never starts the host, while OPENCODE_AUTH_CONTENT is set', async () => { + const run = await prepareWithoutHost({ + childEnv: { + OPENCODE_AUTH_CONTENT: JSON.stringify({ anthropic: { type: 'api', key: 'synthetic-api-key' } }), + }, + }) + expect(String(run.failure)).toContain('OPENCODE_AUTH_CONTENT') + expect(run.recorded).toBeUndefined() + expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() + expect(await exists(run.paths.config)).toBe(false) + expect(await exists(run.paths.legacyConfig)).toBe(true) + }) + + it('leaves a deliberately unmigrated fixture on its legacy files', async () => { + const run = await prepareWithoutHost({ nativeAccounts: { kind: 'unmigrated' } }) + expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') + expect(JSON.parse(run.recorded!.OPENCODE_AUTH_CONTENT!).anthropic).toMatchObject({ + type: 'oauth', + access: 'test-access-token', + }) + expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() + expect(await exists(run.paths.legacyConfig)).toBe(true) + expect(await exists(run.hostAuthPath)).toBe(false) + }) + + it('gives a deliberately unmigrated fixture a real auth.json instead of OPENCODE_AUTH_CONTENT', async () => { + const run = await prepareWithoutHost({ + nativeAccounts: { kind: 'unmigrated', hostAuth: { anthropic: { type: 'oauth', access: '', refresh: 'claustrum-tombstone:v1:anthropic', expires: 0 } } }, + }) + expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') + expect(run.recorded?.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(isCustodyTombstoneOAuth(JSON.parse(await readFile(run.hostAuthPath, 'utf8')).anthropic, 'anthropic')).toBe(true) + expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() + expect(await exists(run.paths.legacyConfig)).toBe(true) + }) + + it('discovers vault accounts through the mock daemon named in the child environment', async () => { + const daemonDir = await scratchDir('anthropic-auth-e2e-fixture-vault-') + const daemon = await startFakeClaustrumDaemon({ + directory: daemonDir, + scopedCredentials: { + 'oauth:anthropic': { + payload: 'scoped-main', + account_id: 'account-main', + record_version: 1, + expires_at_ms: Date.now() + 3_600_000, + }, + }, + }) + cleanups.push(() => daemon.stop()) + const run = await prepareWithoutHost({ + nativeAccounts: { kind: 'vault' }, + childEnv: { OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile }, + beforeSpawn: async (env) => { + await writeFile(join(env.configDir, 'anthropic-auth.json'), JSON.stringify({ + version: 1, accounts: [], quota: { enabled: false }, + claustrum: { mode: 'claustrum', scopedRoster: true, primaryAccount: { credentialId: 'oauth:anthropic', accountId: 'account-main', state: 'active' } }, + }), { mode: 0o600 }) + await writeFile(join(env.configDir, 'claustrum-enrollment.json'), JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), { mode: 0o600 }) + }, + }) + expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') + expect(run.recorded?.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(await readNativeMigrationJournal(run.paths)).toMatchObject({ phase: 'retired' }) + expect(daemon.scopedLists).toBeGreaterThan(0) + expect(daemon.credentialGets).toEqual([]) + expect(isCustodyTombstoneOAuth(JSON.parse(await readFile(run.hostAuthPath, 'utf8')).anthropic, 'anthropic')).toBe(true) + const roster = await readFile(run.paths.roster, 'utf8') + expect(roster).toContain('oauth:anthropic') + expect(roster).not.toContain('scoped-main') + const runtime = createNativeAccountRuntime({ paths: run.paths, host: 'opencode' }) + try { + const snapshot = await runtime.read() + expect(snapshot.mode).toBe('claustrum') + expect(snapshot.accounts.find((account) => account.id === 'main')).toMatchObject({ + source: 'vault', + credentialId: 'oauth:anthropic', + }) + } finally { + runtime.close() + } + }) + + it('refuses a vault fixture whose daemon connection is not named explicitly', async () => { + const run = await prepareWithoutHost({ nativeAccounts: { kind: 'vault' } }) + expect(String(run.failure)).toContain('OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE') + expect(run.recorded).toBeUndefined() + expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() + }) +}) + +const configDepsFixture = resolve(import.meta.dir, '../fixtures/opencode1-config-deps') +// The committed fixture holds only package.json and package-lock.json; a +// runner provisions node_modules from that lockfile. Without it, the copy +// cannot be checked here, and the test reports itself as skipped. +const configDepsInstalled = await lstat(join(configDepsFixture, 'node_modules', '.package-lock.json')).then( + () => true, + () => false, +) + +/** The check OpenCode 1.18.18 makes before skipping its own install (npm.ts Npm.install). */ +async function opencodeWouldSkipInstall(dir: string) { + if (!(await exists(join(dir, 'node_modules')))) return false + const manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) + const lock = JSON.parse(await readFile(join(dir, 'package-lock.json'), 'utf8')) + const names = (value: Record | undefined) => + ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].flatMap((field) => + Object.keys((value?.[field] as Record | undefined) ?? {}), + ) + const locked = new Set(names(lock.packages?.[''])) + return [...names(manifest), '@opencode-ai/plugin'].every((name) => locked.has(name)) +} + +describe('OpenCode config-directory dependency fixture', () => { + it.skipIf(!configDepsInstalled)('copies the installed plugin dependency tree into every OpenCode config directory', async () => { + const run = await prepareWithoutHost({}, { [OPENCODE_CONFIG_DEPS_ENV]: configDepsFixture }) + expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') + const fixtureLock = JSON.parse(await readFile(join(configDepsFixture, 'package-lock.json'), 'utf8')) + for (const dir of [run.env.configDir, join(run.env.configDir, 'opencode')]) { + expect(await opencodeWouldSkipInstall(dir)).toBe(true) + expect(JSON.parse(await readFile(join(dir, 'package-lock.json'), 'utf8'))).toEqual(fixtureLock) + const plugin = JSON.parse(await readFile(join(dir, 'node_modules', '@opencode-ai', 'plugin', 'package.json'), 'utf8')) + expect(plugin.version).toBe('1.18.18') + expect((await lstat(join(dir, 'node_modules', '@opencode-ai', 'plugin', plugin.exports['.'].import))).size).toBeGreaterThan(0) + expect(JSON.parse(await readFile(join(dir, 'node_modules', '@opencode-ai', 'sdk', 'package.json'), 'utf8')).version).toBe('1.18.18') + } + }) + + it('refuses an uninstalled dependency fixture before the host starts', async () => { + const incomplete = await scratchDir('anthropic-auth-e2e-config-deps-') + for (const name of ['package.json', 'package-lock.json']) + await cp(join(configDepsFixture, name), join(incomplete, name)) + await mkdir(join(incomplete, 'node_modules')) + const run = await prepareWithoutHost({}, { [OPENCODE_CONFIG_DEPS_ENV]: incomplete }) + expect(String(run.failure)).toContain('has no npm-installed node_modules') + expect(run.recorded).toBeUndefined() + for (const dir of [run.env.configDir, join(run.env.configDir, 'opencode')]) + expect(await exists(join(dir, 'node_modules'))).toBe(false) + }) +}) From a55d43f545afdf1c4046254b3cf363477bb3628c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:18:02 +0200 Subject: [PATCH 087/169] mason: complete offline native vault custody activation Adds the journal v4 activation record and a single serving predicate that refuses while a vault switch is requested or pending. Adds the offline crash-forward controller that switches a retired local pool (or a requested first migration) to Claustrum vault custody, holding five leases through the mode rename and the authority rename, and wires explicit per-host setup activation. Version expectations in the existing native tests move from 3 to 4 for the clean v4 journal cutover. --- packages/core/src/index.ts | 20 +- .../core/src/native-custody-activation.ts | 1204 ++++++++++++++++ packages/core/src/native-migration.ts | 24 +- packages/core/src/pool-authority.ts | 357 ++++- .../native-custody-activation-crash-child.ts | 50 + .../tests/native-custody-activation.test.ts | 1238 +++++++++++++++++ .../native-local-credential-service.test.ts | 3 +- .../core/src/tests/pool-authority.test.ts | 208 ++- packages/opencode/src/index.ts | 6 +- packages/opencode/src/setup/command.test.ts | 116 ++ packages/opencode/src/setup/command.ts | 100 +- .../src/setup/native-activation.test.ts | 241 ++++ .../opencode/src/setup/native-activation.ts | 172 +++ .../src/setup/native-enrollment.test.ts | 70 +- .../opencode/src/setup/native-enrollment.ts | 5 + .../opencode/src/tests/native-fixture.test.ts | 2 +- .../opencode/src/tests/native-serving.test.ts | 2 +- packages/pi/src/native.ts | 3 +- packages/pi/src/tests/native-serving.test.ts | 2 +- 19 files changed, 3746 insertions(+), 77 deletions(-) create mode 100644 packages/core/src/native-custody-activation.ts create mode 100644 packages/core/src/tests/native-custody-activation-crash-child.ts create mode 100644 packages/core/src/tests/native-custody-activation.test.ts create mode 100644 packages/opencode/src/setup/command.test.ts create mode 100644 packages/opencode/src/setup/native-activation.test.ts create mode 100644 packages/opencode/src/setup/native-activation.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d03b69c2b..c628f15c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,6 +54,21 @@ export type { NativeCustodyRetry, } from './native-custody.ts' export { createNativeCustody } from './native-custody.ts' +export type { + NativeCustodyActivationErrorCode, + NativeCustodyActivationHooks, + NativeCustodyActivationOptions, + NativeCustodyActivationResult, +} from './native-custody-activation.ts' +export { + NativeCustodyActivationError, + runNativeCustodyActivation, +} from './native-custody-activation.ts' +export { + inspectNativeHostAuthEntry, + NativeHostAuthError, + requireNoSupervisedAuthContentSnapshot, +} from './native-host-auth.ts' export type { NativeLocalAttributedFailure, NativeLocalCredentialService, @@ -167,7 +182,10 @@ export { export * from './oauth-profile.ts' export * from './pkce.ts' export type { NativeMigrationJournal } from './pool-authority.ts' -export { readNativeMigrationJournal } from './pool-authority.ts' +export { + nativeMigrationAuthorityPhase, + readNativeMigrationJournal, +} from './pool-authority.ts' export type { NativeLocalPoolBinding } from './pool-binding.ts' export { captureNativeLocalPoolBinding } from './pool-binding.ts' export * from './pool-paths.ts' diff --git a/packages/core/src/native-custody-activation.ts b/packages/core/src/native-custody-activation.ts new file mode 100644 index 000000000..6c7520fde --- /dev/null +++ b/packages/core/src/native-custody-activation.ts @@ -0,0 +1,1204 @@ +import { + mutateVaultRoster, + readVaultRoster, + type VaultRosterFile, +} from '@cortexkit/common-auth/claustrum' +import { withLock } from '@cortexkit/common-auth/fs' +import { + POOL_LOCK_DEFAULTS, + PoolOperationError, + type PoolRow, +} from '@cortexkit/common-auth/store' + +import { custodyTombstoneOAuth } from './claustrum.ts' +import { readNativeMigrationSource } from './migration-source.ts' +import { NATIVE_PRIMARY_CREDENTIAL_ID } from './native-account-view.ts' +import type { NativeCustodyInventory } from './native-custody.ts' +import { + inspectNativeHostAuthEntry, + requireNoSupervisedAuthContentSnapshot, +} from './native-host-auth.ts' +import { + type NativeHostAuthWriteHooks, + writeNativeHostAuth, +} from './native-host-auth-write.ts' +import { importedRuntimeProjection } from './native-migration.ts' +import { + captureNativeMigrationPreparedProof, + type NativeMigrationRowProof, + nativeMigrationCanonicalJson, + nativeMigrationCredentialDigest, + nativeMigrationDigest, + requireNativeMigrationPreparedProof, +} from './native-migration-proof.ts' +import { nativeQuotaCodec } from './native-quota-codec.ts' +import { + buildNativeVaultRosterSeed, + type NativeRosterSeedInput, + validateNativeVaultRosterSeed, +} from './native-roster-seed.ts' +import { + decodeNativeRuntime, + type NativeRuntimeEntry, + type NativeRuntimeState, + readNativeRuntime, +} from './native-runtime.ts' +import { publishNativeVaultRuntimeSeed } from './native-vault-runtime.ts' +import { + advanceNativeCustodyActivation, + type NativeCustodyActivationPlan, + type NativeCustodyActivationRoster, + type NativeMigrationJournal, + readNativeMigrationJournal, + recordNativeCustodyActivation, +} from './pool-authority.ts' +import type { NativePoolPaths } from './pool-paths.ts' +import { createNativePoolStore, nativePoolStoreLocks } from './pool-store.ts' + +export type NativeCustodyActivationErrorCode = + | 'migration-required' + | 'invalid-state' + | 'primary-missing' + | 'primary-mismatch' + | 'primary-unverified' + | 'primary-conflict' + | 'row-unverified' + | 'unenrolled-local-row' + | 'vault-ambiguous' + | 'roster-conflict' + | 'runtime-conflict' + | 'settings-conflict' + | 'credential-changed' + | 'host-auth-changed' + | 'consent-required' + | 'migration-busy' + | 'ownership-lost' + | 'auth-content-refused' + | 'process-fence-refused' + | 'migration-proof-changed' + +export class NativeCustodyActivationError extends Error { + constructor(public readonly code: NativeCustodyActivationErrorCode) { + super(`Native vault activation: ${code}`) + this.name = 'NativeCustodyActivationError' + } +} + +export interface NativeCustodyActivationOptions { + paths: NativePoolPaths + host: 'opencode' | 'pi' + env: NodeJS.ProcessEnv + /** Rejects unless every selected host is stopped and inspection succeeded. */ + processFence: () => Promise + /** + * The user's explicit consent to delete a stored Pi Anthropic OAuth entry. + * It never authorizes deleting a Pi API key, which always refuses. + */ + removePiAnthropicAuth: boolean + /** + * Lists the vault accounts this host's enrollment token may read; called + * only while preparing the activation. + */ + discover: () => Promise +} + +export interface NativeCustodyActivationHooks { + /** + * Test hook for simulated crashes and lease loss. Receives a label naming + * the activation step about to run or just finished, never account data. + */ + onStep?: (step: string) => Promise + hostWrite?: NativeHostAuthWriteHooks + store?: Pick< + Parameters[0], + 'onStep' | 'onLockStep' + > +} + +export type NativeCustodyActivationResult = + | { status: 'already-vault'; journal: NativeMigrationJournal } + | { status: 'committed'; journal: NativeMigrationJournal } + +type Lease = { assertOwned: () => Promise } + +function refuse(code: NativeCustodyActivationErrorCode): never { + throw new NativeCustodyActivationError(code) +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function sameJson(left: unknown, right: unknown): boolean { + return ( + nativeMigrationCanonicalJson(left) === nativeMigrationCanonicalJson(right) + ) +} + +/** + * Proof rows of the current pool. Refuses a row caught between the two file + * writes of another operation (torn), a row whose credential stamp does not + * match its credential (unbound), and an invalid row. + */ +function proofRows(rows: readonly PoolRow[]): NativeMigrationRowProof[] { + try { + return captureNativeMigrationPreparedProof(rows, null).rows + } catch { + return refuse('credential-changed') + } +} + +/** + * The closed, secret-free roster projection the activation compares. The + * roster seed this activation (or a first migration) publishes never contains + * aliases, email, quota, stale markers or rejected records, so any of them + * means vault discovery or another writer changed the roster. + */ +function rosterProjection( + file: VaultRosterFile | undefined, +): NativeCustodyActivationRoster | undefined { + if (!file) return undefined + if ( + file.version !== 1 || + file.complete !== true || + typeof file.view !== 'string' || + !file.view || + (file.rejected !== undefined && file.rejected.length > 0) + ) + refuse('roster-conflict') + const rows = file.rows.map((row) => { + if ( + row.credentialType !== 'oauth' || + !row.accountIdentity || + row.aliases !== undefined || + row.email !== undefined || + row.orgName !== undefined || + row.quota !== undefined || + row.stale !== undefined || + row.unclaimed !== undefined + ) + refuse('roster-conflict') + return { + routeId: row.routeId, + credentialId: row.credentialId, + credentialType: 'oauth' as const, + accountIdentity: row.accountIdentity, + state: row.state, + label: row.label, + enabled: row.enabled, + addedAt: row.addedAt, + } + }) + return { + version: 1, + view: file.view, + complete: true, + rows, + declined: file.declined.map((entry) => + entry.accountIdentity + ? { + credentialId: entry.credentialId, + accountIdentity: entry.accountIdentity, + } + : { credentialId: entry.credentialId }, + ), + } +} + +async function readRoster( + paths: NativePoolPaths, +): Promise { + // Reject a roster owned by another user or replaced by a symbolic link + // before the public reader parses it. + const source = await readNativeMigrationSource('state', paths.roster) + if ( + source.metadata && + process.getuid && + source.metadata.uid !== process.getuid() + ) + refuse('roster-conflict') + if (!source.data) return undefined + const file = await readVaultRoster(paths.roster) + if (!file) refuse('roster-conflict') + return rosterProjection(file) +} + +async function readRuntime( + paths: NativePoolPaths, +): Promise { + const read = await readNativeRuntime(paths.runtime, paths.storageId) + if (read.status !== 'ready') refuse('runtime-conflict') + return read.state +} + +function hostEntry( + host: 'opencode' | 'pi', + data: unknown, +): ReturnType { + try { + return inspectNativeHostAuthEntry(host, data) + } catch { + return refuse('host-auth-changed') + } +} + +async function readHostEntry(host: 'opencode' | 'pi', path: string) { + const source = await readNativeMigrationSource('hostAuth', path) + if ( + source.metadata && + process.getuid && + source.metadata.uid !== process.getuid() + ) + refuse('host-auth-changed') + return hostEntry(host, source.data) +} + +const TOMBSTONE_DIGEST = inspectNativeHostAuthEntry('opencode', { + anthropic: custodyTombstoneOAuth('anthropic'), +}).digest + +/** + * Runtime fields that describe the Claude account rather than one credential. + * They follow the account to its vault route. A local re-login of the same + * account, which starts a new credential epoch, keeps exactly this set too. + */ +const ACCOUNT_RUNTIME_FIELDS = [ + 'prime', + 'profile', + 'lastUsed', + 'lastQuotaRefreshError', + 'quotaErrorClearedAt', + 'quotaErrorGeneration', +] as const + +function vaultSettings( + settings: Record, + primary: NativeCustodyActivationPlan['primary'], +): Record { + const custody = isRecord(settings.claustrum) ? settings.claustrum : {} + return { + ...settings, + claustrum: { + ...custody, + mode: 'claustrum', + primaryAccount: { + credentialId: primary.credentialId, + accountId: primary.accountIdentity, + }, + }, + } +} + +function sameCustodyBinding( + entry: NativeRuntimeEntry, + paths: NativePoolPaths, + routeId: string, + row: { credentialId: string; accountIdentity: string }, +): boolean { + const binding = entry.binding + return ( + binding.kind === 'custody' && + binding.storageId === paths.storageId && + binding.routeId === routeId && + binding.credentialId === row.credentialId && + binding.accountIdentity === row.accountIdentity + ) +} + +/** + * Rebind runtime metadata from local pool rows to their vault routes. An entry + * moves only from the exact local binding of the verified row (same row id, + * credential epoch and account UUID) to the vault row for that same UUID; + * anything else about that row is dropped, never attributed elsewhere. + * Existing vault bindings must already match the prepared roster. + */ +function targetRuntime( + paths: NativePoolPaths, + source: NativeRuntimeState, + rows: readonly PoolRow[], + removeIds: readonly string[], + roster: NativeCustodyActivationRoster, + primary: NativeCustodyActivationPlan['primary'], +): NativeRuntimeState { + const next: Record = Object.create(null) + const routes = new Set([...removeIds, primary.routeId]) + for (const [id, entry] of Object.entries(source.accounts)) { + if (entry.binding.kind !== 'custody') { + next[id] = entry + continue + } + const row = roster.rows.find((item) => item.routeId === id) + if (!row || !sameCustodyBinding(entry, paths, id, row)) + refuse('runtime-conflict') + next[id] = entry + } + for (const id of routes) { + const existing = source.accounts[id] + const row = rows.find((item) => item.id === id) + if (!row) { + // Only a main route with no local row (fresh install, or a host-managed + // API key) reaches here; it has no local metadata to move. + if (id !== primary.routeId || removeIds.includes(id)) + refuse('runtime-conflict') + if (existing && existing.binding.kind !== 'custody') + refuse('runtime-conflict') + continue + } + if (existing?.binding.kind === 'custody') continue + const vault = roster.rows.find((item) => item.routeId === id) + if ( + !vault || + row.credential?.type !== 'oauth' || + !row.identity || + vault.accountIdentity !== row.identity || + row.credentialEpoch === undefined + ) + refuse('runtime-conflict') + const binding = existing?.binding + const exact = + binding?.kind === 'local' && + binding.storageId === paths.storageId && + binding.rowId === row.id && + binding.credentialEpoch === row.credentialEpoch && + binding.identity === row.identity + const entry: Record = { + binding: { + kind: 'custody', + storageId: paths.storageId, + routeId: id, + credentialId: vault.credentialId, + accountIdentity: row.identity, + // No vault token version produced this metadata; zero never + // authorizes a request or names a rejected token. + recordVersion: 0, + }, + } + if (exact && existing) + for (const key of ACCOUNT_RUNTIME_FIELDS) + if (existing[key] !== undefined) entry[key] = existing[key] + if (row.quota !== undefined) entry.quota = row.quota + next[id] = entry as unknown as NativeRuntimeEntry + } + try { + return decodeNativeRuntime( + { + version: 1, + storageId: paths.storageId, + accounts: next, + ...(source.relay ? { relay: source.relay } : {}), + }, + paths.storageId, + ) + } catch { + return refuse('runtime-conflict') + } +} + +interface Captured { + rows: PoolRow[] + settings: Record + runtime: NativeRuntimeState +} + +async function capturePool( + paths: NativePoolPaths, + store: ReturnType, +): Promise { + const read = await store.read() + const settings = await store.readSettings() + if (read.status !== 'ready' || settings.status !== 'ready') + refuse('invalid-state') + return { + rows: read.rows, + settings: settings.settings, + runtime: await readRuntime(paths), + } +} + +function expectedHostAuth( + options: NativeCustodyActivationOptions, + entry: ReturnType, +): string { + if (options.host === 'opencode') { + // A stock OpenCode API key stays under host control; an inert marker is + // kept. A real OAuth login would be a second credential authority. + if (entry.kind === 'api' || entry.kind === 'activation') return entry.digest + if (entry.kind === 'absent') return TOMBSTONE_DIGEST + return refuse('host-auth-changed') + } + if (entry.kind === 'absent') return 'absent' + // A Pi API key would take precedence over the vault primary; deleting it is + // never implied by consent to remove a stored OAuth login. + if (entry.kind === 'api_key') refuse('primary-conflict') + if (!options.removePiAnthropicAuth) refuse('consent-required') + return 'absent' +} + +async function preparePlan( + options: NativeCustodyActivationOptions, + journal: NativeMigrationJournal, + store: ReturnType, +): Promise { + const { paths } = options + const pool = await capturePool(paths, store) + const main = pool.settings.mainAccountId + if (typeof main !== 'string' || !main) refuse('invalid-state') + const custody = isRecord(pool.settings.claustrum) + ? pool.settings.claustrum + : {} + const host = await readHostEntry(options.host, journal.hostAuthPath) + const hostAuth = { + source: host.digest, + expected: expectedHostAuth(options, host), + } + proofRows(pool.rows) + const inventory = await options.discover() + const designated = inventory.credentials.find( + (item) => item.credentialId === NATIVE_PRIMARY_CREDENTIAL_ID, + ) + if (designated?.credentialType !== 'oauth' || !designated.accountIdentity) + refuse('primary-missing') + const primary = { + routeId: main, + credentialId: designated.credentialId, + accountIdentity: designated.accountIdentity, + } + const local = pool.rows.find((row) => row.id === main) + if (local) { + if (local.credential?.type !== 'oauth') refuse('primary-conflict') + if (!local.identity) refuse('primary-unverified') + // An already-bound main keeps its account; the vault cannot swap it. + if (local.identity !== primary.accountIdentity) refuse('primary-mismatch') + } + const oauth = pool.rows.filter((row) => row.credential?.type === 'oauth') + const removeIds = oauth.map((row) => row.id).sort() + const existing = await readRoster(paths) + let roster: NativeCustodyActivationRoster + let targetSettings: Record + if (custody.mode === 'claustrum') { + // A first migration of legacy vault custody already published the roster + // and runtime bindings; the activation verifies and reuses them as-is. + const pin = isRecord(custody.primaryAccount) ? custody.primaryAccount : {} + if ( + oauth.length > 0 || + pin.credentialId !== primary.credentialId || + pin.accountId !== primary.accountIdentity + ) + refuse('primary-mismatch') + if (!existing) refuse('roster-conflict') + roster = existing + targetSettings = pool.settings + } else { + const legacyRows = oauth + .filter((row) => row.id !== main) + .map((row) => { + if (!row.identity) refuse('row-unverified') + const candidates = inventory.credentials.filter( + (item) => + item.credentialType === 'oauth' && + item.accountIdentity === row.identity && + item.credentialId !== primary.credentialId, + ) + if (row.identity === primary.accountIdentity) refuse('primary-conflict') + if (candidates.length === 0) refuse('unenrolled-local-row') + // No source rule designates one of several logins for a non-primary + // account, so the activation refuses rather than guess. + if (candidates.length > 1) refuse('vault-ambiguous') + return { + routeId: row.id, + scopedCredentialId: candidates[0]?.credentialId, + anthropicAccountUuid: row.identity, + ...(row.label !== undefined ? { label: row.label } : {}), + ...(row.addedAt !== undefined ? { addedAt: row.addedAt } : {}), + enabled: row.enabled, + } + }) + const disabled = Array.isArray(custody.disabledAccountIdentities) + ? custody.disabledAccountIdentities.filter( + (item): item is string => typeof item === 'string', + ) + : [] + const seedInput: NativeRosterSeedInput = { + inventory, + primary: { + credentialId: primary.credentialId, + accountIdentity: primary.accountIdentity, + }, + primaryRouteId: main, + legacyRows, + disabledAccountIdentities: + local && !local.enabled && !disabled.includes(primary.accountIdentity) + ? [...disabled, primary.accountIdentity] + : disabled, + reservedRouteIds: pool.rows + .filter((row) => row.credential?.type === 'api') + .map((row) => row.id), + } + let seed: VaultRosterFile + try { + seed = buildNativeVaultRosterSeed(seedInput) + if (existing) { + const file = await readVaultRoster(paths.roster) + if (!file) refuse('roster-conflict') + validateNativeVaultRosterSeed(file, seedInput) + } + } catch (error) { + if (error instanceof NativeCustodyActivationError) throw error + return refuse( + isRecord(error) && error.code === 'unenrolled-local-row' + ? 'unenrolled-local-row' + : 'roster-conflict', + ) + } + roster = + existing ?? (rosterProjection(seed) as NativeCustodyActivationRoster) + targetSettings = vaultSettings(pool.settings, primary) + } + const target = targetRuntime( + paths, + pool.runtime, + pool.rows, + removeIds, + roster, + primary, + ) + let localProof: NativeCustodyActivationPlan['localProof'] + try { + localProof = captureNativeMigrationPreparedProof( + pool.rows, + await importedRuntimeProjection(paths, store, pool.rows), + ) + } catch { + return refuse('credential-changed') + } + return { + kind: 'activation', + phase: 'prepared', + primary, + roster, + localProof, + removeIds, + settings: { + source: nativeMigrationDigest(pool.settings), + target: nativeMigrationDigest(targetSettings), + }, + runtime: { + source: nativeMigrationDigest(pool.runtime), + target: nativeMigrationDigest(target), + }, + hostAuth, + } +} + +/** + * Classify the whole pool against the prepared proof. Every row must be a + * proof row with the exact same credential, epoch and identity; a kept row + * (API key) must still be present. Returns the replaced OAuth rows that are + * still present. An added, re-added or changed row refuses. + */ +function classifyRows( + plan: NativeCustodyActivationPlan, + rows: readonly PoolRow[], +): Set { + const expected = new Map(plan.localProof.rows.map((row) => [row.id, row])) + const present = new Set() + for (const row of proofRows(rows)) { + const proof = expected.get(row.id) + if (!proof || !sameJson(proof, row)) refuse('credential-changed') + present.add(row.id) + } + for (const proof of plan.localProof.rows) + if (!plan.removeIds.includes(proof.id) && !present.has(proof.id)) + refuse('credential-changed') + return new Set(plan.removeIds.filter((id) => present.has(id))) +} + +function mapError(error: unknown): never { + if (error instanceof NativeCustodyActivationError) throw error + // The settings write failed and releasing its extra leases also failed: + // report the write's failure, which is the one that decides the outcome. + if (error instanceof AggregateError) return mapError(error.errors[0]) + if (error instanceof PoolOperationError) { + if (error.cause instanceof NativeCustodyActivationError) throw error.cause + if (error.kind === 'lock-ownership') refuse('ownership-lost') + if (error.kind === 'lock-contention') refuse('migration-busy') + if ( + error.kind === 'attribution' || + error.kind === 'row-protected' || + error.kind === 'unbound-credential' + ) + refuse('credential-changed') + refuse('invalid-state') + } + if (error instanceof Error) { + if (error.name === 'LockContentionError') refuse('migration-busy') + if (error.name === 'LockOwnershipError') refuse('ownership-lost') + if (error.name === 'NativeHostAuthError') + refuse( + (error as { code?: unknown }).code === 'supervised-auth-snapshot' + ? 'auth-content-refused' + : 'host-auth-changed', + ) + if (error.name === 'NativeHostAuthWriteError') { + const code = (error as { code?: unknown }).code + refuse( + code === 'consent-required' + ? 'consent-required' + : code === 'process-fence-refused' + ? 'process-fence-refused' + : 'host-auth-changed', + ) + } + if (error.name === 'NativeRuntimeError') refuse('runtime-conflict') + if (error.name === 'NativeAuthorityError') refuse('invalid-state') + if (error.name === 'NativeMigrationSourceError') refuse('invalid-state') + } + // Never forward an unknown error: it may carry a caller's or vault's data. + return refuse('invalid-state') +} + +/** + * Hold the pool-config, pool-state, vault-roster, native-runtime and routing + * leases, in that order, through one body. The roster lease is held by a + * read-only mutateVaultRoster scope that returns no new roster. No store + * operation runs inside, so no pool lock is taken twice. + */ +async function holdAll( + paths: NativePoolPaths, + routingPath: string, + body: (leases: Lease[]) => Promise, +): Promise { + const [config, state] = nativePoolStoreLocks(paths) + return withLock( + config.path, + { ...POOL_LOCK_DEFAULTS, name: config.name, renew: true }, + (configLease) => + withLock( + state.path, + { ...POOL_LOCK_DEFAULTS, name: state.name, renew: true }, + (stateLease) => + mutateVaultRoster(paths.roster, async (_current, rosterLease) => { + const result = await withLock( + paths.runtime, + { name: 'native-runtime', ...POOL_LOCK_DEFAULTS, renew: true }, + (runtimeLease) => + withLock( + routingPath, + { + name: 'write', + ttlMs: 30_000, + timeoutMs: 15_000, + renew: true, + }, + (routingLease) => + body([ + configLease, + stateLease, + rosterLease, + runtimeLease, + routingLease, + ]), + ), + ) + return { result } + }), + ), + ) +} + +/** Offline, crash-forward switch of a retired local pool to vault custody. */ +export async function runNativeCustodyActivation( + options: NativeCustodyActivationOptions, + hooks: NativeCustodyActivationHooks = {}, +): Promise { + const paths = Object.freeze({ ...options.paths }) + const input: NativeCustodyActivationOptions = { + ...options, + paths, + env: { ...options.env }, + } + try { + return await withLock( + paths.journal, + { + name: 'native-migration', + ttlMs: 30_000, + timeoutMs: 15_000, + renew: true, + }, + async (lease) => { + const step = async (label: string) => hooks.onStep?.(label) + const fence = async () => { + requireNoSupervisedAuthContentSnapshot(input.env) + try { + await input.processFence() + } catch { + refuse('process-fence-refused') + } + try { + await lease.assertOwned() + } catch { + refuse('ownership-lost') + } + } + await fence() + let journal = await readNativeMigrationJournal(paths) + if (!journal) return refuse('migration-required') + if (journal.host !== input.host || journal.phase !== 'retired') + refuse('invalid-state') + const hostAuthPath = journal.hostAuthPath + const routingPath = journal.routingPaths.destination + // The check every store write runs before its rename. While the + // prepared phase writes, it also requires the source settings. + let writeFence = fence + const store = createNativePoolStore({ + paths, + quota: nativeQuotaCodec, + onLockStep: hooks.store?.onLockStep, + onStep: async (write, info) => { + await hooks.store?.onStep?.(write, info) + if (write.startsWith('before-')) await writeFence() + }, + }) + if (journal.activation === null) { + const settings = await store.readSettings() + if (settings.status !== 'ready') refuse('invalid-state') + const custody = settings.settings.claustrum + if (isRecord(custody) && custody.mode === 'claustrum') + return { status: 'already-vault', journal } + } + if ( + journal.activation?.kind === 'activation' && + journal.activation.phase === 'committed' + ) + return { status: 'committed', journal } + + const requireHost = async (digest: string) => { + const entry = await readHostEntry(input.host, hostAuthPath) + if (entry.digest !== digest) refuse('host-auth-changed') + } + const requireRoster = async ( + plan: NativeCustodyActivationPlan, + allowAbsent: boolean, + ) => { + const roster = await readRoster(paths) + if ( + roster === undefined ? !allowAbsent : !sameJson(roster, plan.roster) + ) + refuse('roster-conflict') + } + const runtimeDigest = async () => + nativeMigrationDigest(await readRuntime(paths)) + const settingsDigest = async () => { + const read = await store.readSettings() + if (read.status !== 'ready') refuse('invalid-state') + return nativeMigrationDigest(read.settings) + } + + if (journal.activation?.kind !== 'activation') { + // When setup asked for vault custody before the first migration, + // serving has been refused since that migration. The pool, + // runtime, settings and roster must still be exactly what the + // migration proved (its prepared proof covers the published vault + // roster). This is checked before planning, again before the plan + // is recorded and inside that journal write, so a change made while + // the vault is being listed cannot become the plan's baseline. + const migrationProof = + journal.activation?.kind === 'requested' + ? journal.preparedProof + : undefined + const requireMigrationProof = async () => { + if (migrationProof === undefined) return + const rows = (await capturePool(paths, store)).rows + try { + requireNativeMigrationPreparedProof( + migrationProof, + rows, + await importedRuntimeProjection(paths, store, rows), + ) + } catch { + refuse('migration-proof-changed') + } + } + await requireMigrationProof() + const plan = await preparePlan(input, journal, store) + // The plan's own proof must be the migration's proof itself. + if ( + migrationProof !== undefined && + !sameJson(plan.localProof, migrationProof) + ) + refuse('migration-proof-changed') + // Record the plan only while the pool rows, settings, runtime, + // roster and host auth it was computed from are still unchanged. + const requirePrepared = async () => { + await requireMigrationProof() + const rows = (await capturePool(paths, store)).rows + try { + requireNativeMigrationPreparedProof( + plan.localProof, + rows, + await importedRuntimeProjection(paths, store, rows), + ) + } catch { + refuse('credential-changed') + } + if ((await settingsDigest()) !== plan.settings.source) + refuse('settings-conflict') + if ((await runtimeDigest()) !== plan.runtime.source) + refuse('runtime-conflict') + await requireRoster(plan, true) + await requireHost(plan.hostAuth.source) + } + await requirePrepared() + await step('before:prepare') + journal = await recordNativeCustodyActivation(paths, plan, { + assertOwned: async () => { + await requirePrepared() + await fence() + }, + onWriteStep: async (write) => step(`${write}:journal:prepared`), + }) + await step('after:prepare') + } + const plan = journal.activation + if (plan?.kind !== 'activation') return refuse('invalid-state') + // Before any further write: the host's Anthropic entry must still be + // the recorded one or the planned one. A changed login is never + // adopted or deleted, whatever consent the original run had. + const host = await readHostEntry(input.host, hostAuthPath) + if ( + host.digest !== plan.hostAuth.source && + host.digest !== plan.hostAuth.expected + ) + refuse('host-auth-changed') + // While prepared, every write also requires the settings the plan + // was computed from; a changed preference refuses before the write. + const preparedFence = async () => { + if ((await settingsDigest()) !== plan.settings.source) + refuse('settings-conflict') + await fence() + } + + // The finished state: replaced rows gone, kept rows unchanged, runtime + // rebound, roster and host auth as planned. Settings are the source + // until the mode switch and the target after it, so callers pass one. + const requirePublished = async (settings: string) => { + if (classifyRows(plan, (await capturePool(paths, store)).rows).size) + refuse('credential-changed') + if ((await runtimeDigest()) !== plan.runtime.target) + refuse('runtime-conflict') + await requireRoster(plan, false) + await requireHost(plan.hostAuth.expected) + if ((await settingsDigest()) !== settings) refuse('settings-conflict') + } + + if (plan.phase === 'prepared') { + // Classify the whole prepared state before resuming any write: + // source settings; runtime at source (then every replaced row is + // still present) or already at target; only proof rows in the + // pool; the roster absent or exactly the prepared one. + if ((await settingsDigest()) !== plan.settings.source) + refuse('settings-conflict') + const remaining = classifyRows( + plan, + (await capturePool(paths, store)).rows, + ) + const runtimeNow = await runtimeDigest() + if ( + runtimeNow !== plan.runtime.target && + (runtimeNow !== plan.runtime.source || + remaining.size !== plan.removeIds.length) + ) + refuse('runtime-conflict') + await requireRoster(plan, true) + writeFence = preparedFence + + // Publish the prepared secret-free roster, or accept an equal one. + await step('before:roster') + await mutateVaultRoster( + paths.roster, + async (current, rosterLease) => { + const projected = rosterProjection(current) + if (projected) { + if (!sameJson(projected, plan.roster)) refuse('roster-conflict') + return { result: undefined } + } + classifyRows(plan, (await capturePool(paths, store)).rows) + await preparedFence() + await rosterLease.assertOwned() + return { + next: structuredClone(plan.roster) as VaultRosterFile, + result: undefined, + } + }, + { beforePublish: preparedFence }, + ) + await step('after:roster') + + // Rebind runtime metadata from local rows to vault routes before + // any row is removed: the target is computed from those rows. + const current = await runtimeDigest() + if (current !== plan.runtime.target) { + if (current !== plan.runtime.source) refuse('runtime-conflict') + const pool = await capturePool(paths, store) + if (classifyRows(plan, pool.rows).size !== plan.removeIds.length) + refuse('runtime-conflict') + const target = targetRuntime( + paths, + pool.runtime, + pool.rows, + plan.removeIds, + plan.roster, + plan.primary, + ) + if (nativeMigrationDigest(target) !== plan.runtime.target) + refuse('runtime-conflict') + await step('before:runtime') + await preparedFence() + await publishNativeVaultRuntimeSeed(paths, target, { + assertOwned: async () => { + if ((await runtimeDigest()) !== plan.runtime.source) + refuse('runtime-conflict') + const rows = (await capturePool(paths, store)).rows + if (classifyRows(plan, rows).size !== plan.removeIds.length) + refuse('runtime-conflict') + await requireRoster(plan, false) + await preparedFence() + }, + }) + await step('after:runtime') + } + + // Remove each replaced local OAuth row with an attributed removal, + // which refuses unless the row still holds the recorded credential + // epoch and account identity. + for (const [index, id] of plan.removeIds.entries()) { + const proof = plan.localProof.rows.find((row) => row.id === id) + if (!proof) refuse('invalid-state') + const present = classifyRows( + plan, + (await capturePool(paths, store)).rows, + ).has(id) + await step(`before:remove:${index}`) + await preparedFence() + try { + await store.remove(id, { + attribution: { + credentialEpoch: proof.credentialEpoch, + ...(proof.identity !== null + ? { identity: proof.identity } + : {}), + }, + // A credential replacement advances the row generation, but + // ordinary token refresh keeps it. Require the recorded + // credential digest as well to detect changed tokens. If an + // interrupted removal leaves only credential state, + // common-auth validates that state's binding record against + // the attributed generation and account identity. + protect: (_id, view) => { + if (!view.row) return undefined + try { + return view.row.credential && + nativeMigrationCredentialDigest(view.row.credential) === + proof.credentialDigest + ? undefined + : 'credential-changed' + } catch { + return 'credential-changed' + } + }, + }) + } catch (error) { + // Ignore unknown-row only when this row was already absent + // before removal; an earlier run may have completed it. + // Rethrow every other removal failure. + if ( + present || + !(error instanceof PoolOperationError) || + error.kind !== 'unknown-row' + ) + throw error + } + await step(`after:remove:${index}`) + } + + // Update the host Anthropic entry to the recorded target: OpenCode + // retains a non-secret OAuth value that activates this plugin, or + // its existing stock API key. Pi removes only the consented OAuth + // entry. + const entry = await readHostEntry(input.host, hostAuthPath) + if (entry.digest !== plan.hostAuth.expected) { + if (entry.digest !== plan.hostAuth.source) + refuse('host-auth-changed') + await step('before:host') + await preparedFence() + await writeNativeHostAuth( + { + host: input.host, + authPath: hostAuthPath, + sourceDigest: plan.hostAuth.source, + expectedDigest: plan.hostAuth.expected, + env: input.env, + removePiAnthropicAuth: input.removePiAnthropicAuth, + processFence: preparedFence, + }, + hooks.hostWrite, + ) + await step('after:host') + } + + await requirePublished(plan.settings.source) + journal = await advanceNativeCustodyActivation( + paths, + 'prepared', + 'published', + { + assertOwned: async () => { + await requirePublished(plan.settings.source) + await fence() + }, + onWriteStep: async (write) => step(`${write}:journal:published`), + }, + ) + writeFence = fence + } + + if ((await settingsDigest()) !== plan.settings.target) + await commitMode(paths, routingPath, plan, { + step, + fence, + requirePublished, + hooks, + }) + + // Allow outbound credential use only after the committed journal + // replaces the pending one. Recheck the completed custody state while + // all five locks remain held, before that final rename. + journal = await holdAll(paths, routingPath, async (leases) => { + await requirePublished(plan.settings.target) + return advanceNativeCustodyActivation( + paths, + 'published', + 'committed', + { + assertOwned: async () => { + await step('before:authority-rename') + await requirePublished(plan.settings.target) + await fence() + for (const held of leases) await held.assertOwned() + }, + onWriteStep: async (write) => step(`${write}:journal:committed`), + }, + ) + }) + return { status: 'committed', journal } + }, + ) + } catch (error) { + return mapError(error) + } +} + +interface ModeContext { + step: (label: string) => Promise + fence: () => Promise + requirePublished: (settings: string) => Promise + hooks: NativeCustodyActivationHooks +} + +/** + * Switch the settings to vault mode in one settings write that holds all five + * leases through its rename. The store takes pool-config and pool-state; the + * mutator opens a read-only roster scope that then takes native-runtime and + * routing and waits on a gate. The store's before-config-write step runs the + * caller hook and every check first and asserts the three extra leases last, + * immediately before the store's own lock assertion and the rename. The gate + * opens and the scope is joined on every outcome. + */ +async function commitMode( + paths: NativePoolPaths, + routingPath: string, + plan: NativeCustodyActivationPlan, + context: ModeContext, +): Promise { + let openGate: () => void = () => {} + const gate = new Promise((resolve) => { + openGate = resolve + }) + let leases: Lease[] | undefined + let scope: Promise | undefined + // A store instance used for this one settings write, so its step hook + // runs only for this write. + const writer = createNativePoolStore({ + paths, + quota: nativeQuotaCodec, + onLockStep: context.hooks.store?.onLockStep, + onStep: async (write, info) => { + await context.hooks.store?.onStep?.(write, info) + if (info.operation !== 'updateSettings') return + if (write !== 'before-config-write') return + await context.step('before:mode-rename') + // The settings on disk are still the source until this rename. + await context.requirePublished(plan.settings.source) + await context.fence() + if (!leases) refuse('ownership-lost') + for (const held of leases) await held.assertOwned() + }, + }) + let failure: { error: unknown } | undefined + try { + await writer.updateSettings(async (settings) => { + const digest = nativeMigrationDigest(settings) + if (digest === plan.settings.target) return undefined + if (digest !== plan.settings.source) refuse('settings-conflict') + const next = vaultSettings(settings, plan.primary) + if (nativeMigrationDigest(next) !== plan.settings.target) + refuse('settings-conflict') + leases = await new Promise((ready, failed) => { + scope = mutateVaultRoster( + paths.roster, + async (_current, rosterLease) => { + const result = await withLock( + paths.runtime, + { name: 'native-runtime', ...POOL_LOCK_DEFAULTS, renew: true }, + (runtimeLease) => + withLock( + routingPath, + { + name: 'write', + ttlMs: 30_000, + timeoutMs: 15_000, + renew: true, + }, + async (routingLease) => { + ready([rosterLease, runtimeLease, routingLease]) + await gate + }, + ), + ) + return { result } + }, + ) + // A scope that ends before it is ready fails the settings write with + // its own error; once ready, this rejection handler is a no-op. + scope.catch(failed) + }) + return next + }) + } catch (error) { + failure = { error } + } finally { + openGate() + } + const [drained] = await Promise.allSettled([scope ?? Promise.resolve()]) + if (failure) { + if (drained.status === 'rejected' && leases) + throw new AggregateError( + [failure.error, drained.reason], + 'Native vault mode write failed', + ) + throw failure.error + } + if (drained.status === 'rejected') throw drained.reason + await context.step('after:mode-rename') +} diff --git a/packages/core/src/native-migration.ts b/packages/core/src/native-migration.ts index 8ce2495d5..5921c78f9 100644 --- a/packages/core/src/native-migration.ts +++ b/packages/core/src/native-migration.ts @@ -110,6 +110,12 @@ export interface NativeMigrationOptions { removePiAnthropicAuth: boolean custody?: NativeMigrationCustody preflight?: NativeMigrationPreflight + /** + * Offline setup sets this when the user explicitly chose vault custody. It + * applies only when no journal exists yet; it keeps the pool unauthorized + * after this migration commits until runNativeCustodyActivation commits. + */ + requestVaultActivation?: boolean } /** Result of read-only checks for the selected storage and host. Contains no account data or credential digests. */ @@ -398,7 +404,11 @@ async function runtimeState( return result.state } -async function importedRuntimeProjection( +/** + * The public, secret-free state a prepared proof binds besides the rows: + * runtime metadata, settings, account flags and quota, and the vault roster. + */ +export async function importedRuntimeProjection( paths: NativePoolPaths, store: ReturnType, rows: PoolRow[], @@ -656,13 +666,18 @@ export async function runNativeMigration( ) }, } + // Once a switch to vault custody is requested or prepared, the + // activation controller owns the host auth file. Repairing it here + // could delete a credential that controller must refuse to adopt. if (journal?.phase === 'retired') { - await repair(operation, assertMigration, hooks) + if (journal.activation === null) + await repair(operation, assertMigration, hooks) return journal } if (journal?.phase === 'committed') { journal = await retire(operation, journal, assertMigration, hooks) - await repair(operation, assertMigration, hooks) + if (journal.activation === null) + await repair(operation, assertMigration, hooks) return journal } if (!journal) { @@ -685,6 +700,9 @@ export async function runNativeMigration( source: captured.routingSourcePath, destination: captured.routingDestinationPath, }, + ...(operation.requestVaultActivation + ? { activation: 'requested' as const } + : {}), }, journalHooks, ) diff --git a/packages/core/src/pool-authority.ts b/packages/core/src/pool-authority.ts index 14036f3fc..2998335f9 100644 --- a/packages/core/src/pool-authority.ts +++ b/packages/core/src/pool-authority.ts @@ -38,6 +38,12 @@ export interface NativeMigrationInput { sources: NativeMigrationSources routingPaths: NativeMigrationRoutingPaths hostAuthPath: string + /** + * Set by offline setup when the user explicitly chose vault custody before + * the first migration. The pool then stays unauthorized after the migration + * commits, until the vault activation itself commits. + */ + activation?: 'requested' } export interface NativeMigrationExpectations { @@ -46,8 +52,68 @@ export interface NativeMigrationExpectations { preparedProof: NativeMigrationPreparedProof } +/** One vault account row of the secret-free roster prepared for activation. */ +export interface NativeCustodyActivationRosterRow { + routeId: string + credentialId: string + credentialType: 'oauth' + accountIdentity: string + state: string + label: string + enabled: boolean + addedAt: number +} + +/** The complete vault roster the activation publishes or reuses, as prepared. */ +export interface NativeCustodyActivationRoster { + version: 1 + view: string + complete: true + rows: NativeCustodyActivationRosterRow[] + declined: { credentialId: string; accountIdentity?: string }[] +} + +export type NativeCustodyActivationPhase = + | 'prepared' + | 'published' + | 'committed' + +/** + * The offline switch of a migrated pool from local to vault custody. Every + * field is fixed when the activation is prepared, so a resumed run checks the + * files against these values and never against what it finds on disk later. + * Holds no credential: only public descriptor digests and vault ids. + */ +export interface NativeCustodyActivationPlan { + kind: 'activation' + phase: NativeCustodyActivationPhase + /** The vault's designated primary login, bound to the pool's main route. */ + primary: { routeId: string; credentialId: string; accountIdentity: string } + roster: NativeCustodyActivationRoster + /** + * Every credential row captured before this custody switch, including local + * OAuth and API-key accounts, with a digest of the imported runtime state. + */ + localProof: NativeMigrationPreparedProof + /** + * IDs of local OAuth credential rows to remove. Keep every other row + * recorded in localProof. + */ + removeIds: string[] + /** Digests of the canonical pool settings before and after the mode switch. */ + settings: { source: string; target: string } + /** Digests of the canonical native runtime state before and after rebinding. */ + runtime: { source: string; target: string } + /** Anthropic host-auth entry digests (or 'absent') before and after activation. */ + hostAuth: { source: string; expected: string } +} + +export type NativeCustodyActivation = + | { kind: 'requested' } + | NativeCustodyActivationPlan + export interface NativeMigrationJournal { - version: 3 + version: 4 storageId: string host: 'opencode' | 'pi' phase: NativeMigrationPhase @@ -57,6 +123,11 @@ export interface NativeMigrationJournal { expectedHostAuth: string expectedRouting: string | null preparedProof: NativeMigrationPreparedProof | null + /** + * Null unless the offline setup command requested or began switching + * credential authority to Claustrum. + */ + activation: NativeCustodyActivation | null } export class NativeAuthorityError extends Error { @@ -162,6 +233,170 @@ function isRoutingPaths(value: unknown): value is NativeMigrationRoutingPaths { ) } +function isText(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function decodeRoster(value: unknown): NativeCustodyActivationRoster { + if ( + !isRecord(value) || + !hasKeys(value, ['version', 'view', 'complete', 'rows', 'declined']) || + value.version !== 1 || + !isText(value.view) || + value.complete !== true || + !Array.isArray(value.rows) || + !Array.isArray(value.declined) + ) + throw new NativeAuthorityError('invalid-journal') + const rows = value.rows.map((row): NativeCustodyActivationRosterRow => { + if ( + !isRecord(row) || + !hasKeys(row, [ + 'routeId', + 'credentialId', + 'credentialType', + 'accountIdentity', + 'state', + 'label', + 'enabled', + 'addedAt', + ]) || + !isText(row.routeId) || + !isText(row.credentialId) || + row.credentialType !== 'oauth' || + !isText(row.accountIdentity) || + !isText(row.state) || + typeof row.label !== 'string' || + typeof row.enabled !== 'boolean' || + typeof row.addedAt !== 'number' || + !Number.isFinite(row.addedAt) + ) + throw new NativeAuthorityError('invalid-journal') + return { + routeId: row.routeId, + credentialId: row.credentialId, + credentialType: 'oauth', + accountIdentity: row.accountIdentity, + state: row.state, + label: row.label, + enabled: row.enabled, + addedAt: row.addedAt, + } + }) + const declined = value.declined.map((entry) => { + if ( + !isRecord(entry) || + !isText(entry.credentialId) || + !( + hasKeys(entry, ['credentialId']) || + (hasKeys(entry, ['credentialId', 'accountIdentity']) && + isText(entry.accountIdentity)) + ) + ) + throw new NativeAuthorityError('invalid-journal') + return isText(entry.accountIdentity) + ? { + credentialId: entry.credentialId, + accountIdentity: entry.accountIdentity, + } + : { credentialId: entry.credentialId } + }) + if (new Set(rows.map((row) => row.routeId)).size !== rows.length) + throw new NativeAuthorityError('invalid-journal') + return { version: 1, view: value.view, complete: true, rows, declined } +} + +function decodeDigestPair( + value: unknown, + keys: readonly [K, K], + valid: (item: unknown) => boolean, +): Record { + if ( + !isRecord(value) || + !hasKeys(value, [...keys]) || + !keys.every((key) => valid(value[key])) + ) + throw new NativeAuthorityError('invalid-journal') + return Object.fromEntries(keys.map((key) => [key, value[key]])) as Record< + K, + string + > +} + +const ACTIVATION_PHASES = ['prepared', 'published', 'committed'] as const + +function decodeActivation( + value: unknown, + phase: NativeMigrationPhase, +): NativeCustodyActivation | null { + if (value === null) return null + if (!isRecord(value)) throw new NativeAuthorityError('invalid-journal') + if (value.kind === 'requested' && hasKeys(value, ['kind'])) + return { kind: 'requested' } + if ( + value.kind !== 'activation' || + // A switch is prepared only on a migration that already finished. + phase !== 'retired' || + !hasKeys(value, [ + 'kind', + 'phase', + 'primary', + 'roster', + 'localProof', + 'removeIds', + 'settings', + 'runtime', + 'hostAuth', + ]) || + !ACTIVATION_PHASES.some((item) => item === value.phase) || + !isRecord(value.primary) || + !hasKeys(value.primary, ['routeId', 'credentialId', 'accountIdentity']) || + !isText(value.primary.routeId) || + !isText(value.primary.credentialId) || + !isText(value.primary.accountIdentity) || + !Array.isArray(value.removeIds) || + !value.removeIds.every(isText) + ) + throw new NativeAuthorityError('invalid-journal') + const primary = { + routeId: value.primary.routeId, + credentialId: value.primary.credentialId, + accountIdentity: value.primary.accountIdentity, + } + const roster = decodeRoster(value.roster) + const localProof = decodePreparedProof(value.localProof) + const removeIds = [...value.removeIds] as string[] + const proofIds = new Set(localProof.rows.map((row) => row.id)) + if ( + removeIds.some( + (id, index) => + !proofIds.has(id) || (index > 0 && (removeIds[index - 1] ?? '') >= id), + ) || + !roster.rows.some( + (row) => + row.routeId === primary.routeId && + row.credentialId === primary.credentialId && + row.accountIdentity === primary.accountIdentity, + ) + ) + throw new NativeAuthorityError('invalid-journal') + return { + kind: 'activation', + phase: value.phase as NativeCustodyActivationPhase, + primary, + roster, + localProof, + removeIds, + settings: decodeDigestPair(value.settings, ['source', 'target'], isDigest), + runtime: decodeDigestPair(value.runtime, ['source', 'target'], isDigest), + hostAuth: decodeDigestPair( + value.hostAuth, + ['source', 'expected'], + isHostEntry, + ), + } +} + function decodeJournal( input: unknown, storageId: string, @@ -180,8 +415,9 @@ function decodeJournal( 'expectedHostAuth', 'expectedRouting', 'preparedProof', + 'activation', ]) || - value.version !== 3 || + value.version !== 4 || value.storageId !== storageId || (value.host !== 'opencode' && value.host !== 'pi') || !isPhase(value.phase) || @@ -211,7 +447,7 @@ function decodeJournal( throw new NativeAuthorityError('invalid-journal') } return { - version: 3, + version: 4, storageId, host: value.host, phase: value.phase, @@ -229,6 +465,7 @@ function decodeJournal( expectedHostAuth: value.expectedHostAuth, expectedRouting: value.expectedRouting, preparedProof, + activation: decodeActivation(value.activation, value.phase), } } @@ -250,13 +487,33 @@ export async function readNativeMigrationJournal( } } +/** + * The migration phase that authorizes serving, or undefined while the + * migration or a requested switch to vault custody is unfinished. Every reader + * that admits requests from the journal uses this one predicate. + */ +export function nativeMigrationAuthorityPhase( + journal: NativeMigrationJournal | undefined, +): 'committed' | 'retired' | undefined { + if (!journal) return undefined + if (journal.phase !== 'committed' && journal.phase !== 'retired') + return undefined + if ( + journal.activation !== null && + (journal.activation.kind !== 'activation' || + journal.activation.phase !== 'committed') + ) + return undefined + return journal.phase +} + /** An empty or readable store is not proof that the offline authority flip completed. */ export async function requireNativePoolAuthority( paths: NativePoolPaths, ): Promise { const journal = await readNativeMigrationJournal(paths) if (!journal) throw new NativeAuthorityError('migration-required') - if (journal.phase !== 'committed' && journal.phase !== 'retired') { + if (!nativeMigrationAuthorityPhase(journal)) { throw new NativeAuthorityError('migration-incomplete') } } @@ -271,6 +528,12 @@ interface JournalHooks { ) => Promise } +function deepFreeze(value: unknown): void { + if (value === null || typeof value !== 'object') return + for (const item of Object.values(value)) deepFreeze(item) + Object.freeze(value) +} + async function writeJournal( paths: NativePoolPaths, journal: NativeMigrationJournal, @@ -287,6 +550,7 @@ async function writeJournal( } Object.freeze(snapshot.sources) Object.freeze(snapshot.routingPaths) + deepFreeze(snapshot.activation) Object.freeze(snapshot) await writeJsonAtomic(paths.journal, snapshot, { beforeRename: async () => { @@ -311,7 +575,7 @@ export async function beginNativeMigration( } const initial = decodeJournal( { - version: 3, + version: 4, storageId: paths.storageId, host: input.host, phase: 'building', @@ -321,6 +585,12 @@ export async function beginNativeMigration( expectedHostAuth: 'unprepared', expectedRouting: 'unprepared', preparedProof: null, + activation: + input.activation === undefined + ? null + : input.activation === 'requested' + ? { kind: 'requested' } + : 'invalid', }, paths.storageId, ) @@ -345,7 +615,8 @@ export async function beginNativeMigration( current.routingPaths.source !== initial.routingPaths.source || current.routingPaths.destination !== initial.routingPaths.destination || - current.hostAuthPath !== initial.hostAuthPath + current.hostAuthPath !== initial.hostAuthPath || + (current.activation === null) !== (initial.activation === null) ) { throw new NativeAuthorityError('journal-conflict') } @@ -465,3 +736,77 @@ export async function advanceNativeMigration( }, ) } + +function journalLock(hooks: JournalHooks) { + return { + name: 'migration-journal', + ttlMs: 30_000, + timeoutMs: 15_000, + ...hooks.lockOptions, + renew: true, + } +} + +/** + * Record the prepared switch to vault custody on a retired migration. From + * this write until the activation commits, the pool authorizes no serving. + * Retrying with the identical plan returns the journal; any other plan is a + * conflict, so a resumed setup can never replace what it recorded. + */ +export async function recordNativeCustodyActivation( + paths: NativePoolPaths, + plan: NativeCustodyActivationPlan, + hooks: JournalHooks = {}, +): Promise { + const captured = captureJournalJson(plan) + if (!isRecord(captured) || captured.phase !== 'prepared') + throw new NativeAuthorityError('journal-conflict') + const decoded = decodeActivation(captured, 'retired') + if (decoded?.kind !== 'activation') + throw new NativeAuthorityError('invalid-journal') + return withLock(paths.journal, journalLock(hooks), async (lock) => { + const current = await readNativeMigrationJournal(paths) + if (!current) throw new NativeAuthorityError('migration-required') + if (current.phase !== 'retired') + throw new NativeAuthorityError('journal-conflict') + if (current.activation?.kind === 'activation') { + if ( + nativeMigrationCanonicalJson({ ...current.activation, phase: '' }) !== + nativeMigrationCanonicalJson({ ...decoded, phase: '' }) + ) + throw new NativeAuthorityError('journal-conflict') + return current + } + const updated = { ...current, activation: decoded } + await writeJournal(paths, updated, () => lock.assertOwned(), hooks) + return updated + }) +} + +/** Advance a recorded activation one phase; never backwards or across a phase. */ +export async function advanceNativeCustodyActivation( + paths: NativePoolPaths, + expected: NativeCustodyActivationPhase, + next: NativeCustodyActivationPhase, + hooks: JournalHooks = {}, +): Promise { + if ( + ACTIVATION_PHASES.indexOf(next) !== + ACTIVATION_PHASES.indexOf(expected) + 1 || + ACTIVATION_PHASES.indexOf(expected) < 0 + ) + throw new NativeAuthorityError('journal-conflict') + return withLock(paths.journal, journalLock(hooks), async (lock) => { + const current = await readNativeMigrationJournal(paths) + if (!current) throw new NativeAuthorityError('migration-required') + const activation = current.activation + if (activation?.kind !== 'activation') + throw new NativeAuthorityError('journal-conflict') + if (activation.phase === next) return current + if (activation.phase !== expected) + throw new NativeAuthorityError('journal-conflict') + const updated = { ...current, activation: { ...activation, phase: next } } + await writeJournal(paths, updated, () => lock.assertOwned(), hooks) + return updated + }) +} diff --git a/packages/core/src/tests/native-custody-activation-crash-child.ts b/packages/core/src/tests/native-custody-activation-crash-child.ts new file mode 100644 index 000000000..989df07e7 --- /dev/null +++ b/packages/core/src/tests/native-custody-activation-crash-child.ts @@ -0,0 +1,50 @@ +/** + * Child process for real crash proofs of the vault activation controller. + * argv: + * + * Exits with 19 at the named point, leaving its leases and files exactly as a + * killed setup process would. Uses synthetic files and inventory only. + */ +import { resolveNativePoolPaths } from '../pool-paths.ts' + +const [configPath, statePath, host, inventoryJson, kind, point] = + process.argv.slice(2) +if ( + !configPath || + !statePath || + (host !== 'opencode' && host !== 'pi') || + !inventoryJson || + (kind !== 'step' && kind !== 'store') || + !point +) + throw new Error('Invalid activation crash fixture arguments') + +const { runNativeCustodyActivation } = await import( + '../native-custody-activation.ts' +) +const paths = await resolveNativePoolPaths(configPath, statePath) +const inventory = JSON.parse(inventoryJson) +await runNativeCustodyActivation( + { + paths, + host, + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: true, + discover: async () => inventory, + }, + { + onStep: async (step) => { + if (kind === 'step' && step === point) process.exit(19) + }, + store: { + // `store` points are `:`, e.g. the gap between a + // removal's config write and its state write. + onStep: async (step, info) => { + if (kind === 'store' && `${step}:${info.operation}` === point) + process.exit(19) + }, + }, + }, +) +throw new Error('Expected activation crash fixture exit') diff --git a/packages/core/src/tests/native-custody-activation.test.ts b/packages/core/src/tests/native-custody-activation.test.ts new file mode 100644 index 000000000..151df8a2a --- /dev/null +++ b/packages/core/src/tests/native-custody-activation.test.ts @@ -0,0 +1,1238 @@ +import { expect } from 'bun:test' +import { + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + mutateVaultRoster, + readVaultRoster, +} from '@cortexkit/common-auth/claustrum' +import { lockPathFor, withLock } from '@cortexkit/common-auth/fs' + +import { custodyTombstoneOAuth } from '../claustrum.ts' +import type { NativeCustodyInventory } from '../native-custody.ts' +import { + type NativeCustodyActivationHooks, + type NativeCustodyActivationOptions, + runNativeCustodyActivation, +} from '../native-custody-activation.ts' +import { + type NativeMigrationOptions, + runNativeMigration, +} from '../native-migration.ts' +import { nativeQuotaCodec } from '../native-quota-codec.ts' +import { readNativeRuntime, updateNativeRuntime } from '../native-runtime.ts' +import { + publishNativeVaultRosterSeed, + publishNativeVaultRuntimeSeed, +} from '../native-vault-runtime.ts' +import { + nativeMigrationAuthorityPhase, + readNativeMigrationJournal, + requireNativePoolAuthority, +} from '../pool-authority.ts' +import { type NativePoolPaths, resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore } from '../pool-store.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() + +const MAIN_IDENTITY = 'account-main-uuid' +const WORK_IDENTITY = 'account-work-uuid' +const MAIN_TOKEN = { + type: 'oauth' as const, + access: 'synthetic-local-access-main', + refresh: 'synthetic-local-refresh-main', + expires: 2000, +} +const WORK_TOKEN = { + type: 'oauth' as const, + access: 'synthetic-local-access-work', + refresh: 'synthetic-local-refresh-work', + expires: 3000, +} +const API_KEY = { + type: 'api' as const, + apiKey: 'synthetic-local-api-key', + baseURL: 'https://synthetic.invalid', + authHeader: 'x-api-key' as const, +} +const OTHER_PROVIDER = { type: 'api', key: 'synthetic-unrelated-provider' } +const ROUTING_BYTES = `${JSON.stringify( + { version: 1, updatedAt: 10, assignments: {} }, + null, + 2, +)}\n` +const SECRETS = [ + MAIN_TOKEN.access, + MAIN_TOKEN.refresh, + WORK_TOKEN.access, + WORK_TOKEN.refresh, + API_KEY.apiKey, +] + +function inventory( + credentials: NativeCustodyInventory['credentials'] = [ + { + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: MAIN_IDENTITY, + state: 'active', + }, + { + credentialId: 'oauth:anthropic:work', + credentialType: 'oauth', + accountIdentity: WORK_IDENTITY, + state: 'active', + }, + ], +): NativeCustodyInventory { + return { view: 'synthetic-view-1', skipped: [], credentials } +} + +interface Fixture { + root: string + paths: NativePoolPaths + migration: NativeMigrationOptions + options: NativeCustodyActivationOptions + store: ReturnType + main: string + inventory: { value: NativeCustodyInventory } +} + +/** + * Run the real offline migration on an empty install (no legacy files), then + * give the retired local pool synthetic accounts through the public store, as + * a local login would. Nothing here touches a live host, daemon or vault. + */ +async function fixture( + input: { + host?: 'opencode' | 'pi' + accounts?: boolean + requestVaultActivation?: boolean + removePiAnthropicAuth?: boolean + } = {}, +): Promise { + const host = input.host ?? 'opencode' + const created = await mkdtemp(join(tmpdir(), 'native-custody-activation-')) + deferCleanup(() => rm(created, { recursive: true, force: true })) + const root = await realpath(created) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + const hostAuthPath = join(root, 'host-auth.json') + await writeFile(hostAuthPath, JSON.stringify({ other: OTHER_PROVIDER }), { + mode: 0o600, + }) + const migration: NativeMigrationOptions = { + paths, + host, + hostAuthPath, + routingSourcePath: join(root, 'legacy-routing.json'), + routingDestinationPath: join(root, 'native-routing.json'), + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: true, + ...(input.requestVaultActivation ? { requestVaultActivation: true } : {}), + } + expect((await runNativeMigration(migration)).phase).toBe('retired') + await writeFile(migration.routingDestinationPath, ROUTING_BYTES, { + mode: 0o600, + }) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + const settings = await store.readSettings() + if (settings.status !== 'ready') throw new Error('fixture settings') + const main = settings.settings.mainAccountId as string + if (input.accounts !== false) { + await store.add({ + id: main, + credential: MAIN_TOKEN, + identity: MAIN_IDENTITY, + }) + await store.add({ + id: 'work-route', + credential: WORK_TOKEN, + identity: WORK_IDENTITY, + label: 'Work', + }) + await store.add({ id: 'api-route', credential: API_KEY, label: 'API' }) + await store.updateSettings((current) => { + current.routing = { mode: 'sticky' } + }) + const rows = await store.read() + if (rows.status !== 'ready') throw new Error('fixture rows') + const epoch = (id: string) => + rows.rows.find((row) => row.id === id)?.credentialEpoch ?? 0 + await updateNativeRuntime(paths.runtime, paths.storageId, (current) => { + current.accounts[main] = { + binding: { + kind: 'local', + storageId: paths.storageId, + rowId: main, + credentialEpoch: epoch(main), + identity: MAIN_IDENTITY, + }, + lastUsed: 111, + lastRefreshedAt: 5, + } + // Metadata bound to a different account UUID than the row's must be + // dropped, not moved to the vault route for this row's account. + current.accounts['work-route'] = { + binding: { + kind: 'local', + storageId: paths.storageId, + rowId: 'work-route', + credentialEpoch: epoch('work-route'), + identity: 'some-other-account-uuid', + }, + lastUsed: 222, + } + current.accounts['api-route'] = { + binding: { + kind: 'local', + storageId: paths.storageId, + rowId: 'api-route', + credentialEpoch: epoch('api-route'), + }, + lastUsed: 333, + } + return current + }) + } + const vault = { value: inventory() } + return { + root, + paths, + migration, + store, + main, + inventory: vault, + options: { + paths, + host, + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: input.removePiAnthropicAuth ?? false, + discover: async () => vault.value, + }, + } +} + +async function files(f: Fixture): Promise> { + const read = async (path: string) => { + try { + return await readFile(path, 'utf8') + } catch (error) { + if ((error as { code?: unknown }).code === 'ENOENT') return null + throw error + } + } + return { + config: await read(f.paths.config), + state: await read(f.paths.state), + runtime: await read(f.paths.runtime), + roster: await read(f.paths.roster), + journal: await read(f.paths.journal), + host: await read(f.migration.hostAuthPath), + routing: await read(f.migration.routingDestinationPath), + } +} + +async function interrupted( + f: Fixture, + point: string, + extra: NativeCustodyActivationHooks = {}, +): Promise { + await expect( + runNativeCustodyActivation(f.options, { + ...extra, + onStep: async (step) => { + if (step === point) throw new Error('synthetic interruption') + }, + }), + ).rejects.toMatchObject({ code: 'invalid-state' }) +} + +async function expectPending(paths: NativePoolPaths): Promise { + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) +} + +/** The committed vault state every successful or resumed activation reaches. */ +async function expectVault(f: Fixture): Promise { + await requireNativePoolAuthority(f.paths) + const journal = await readNativeMigrationJournal(f.paths) + expect(journal?.activation).toMatchObject({ phase: 'committed' }) + expect(nativeMigrationAuthorityPhase(journal)).toBe('retired') + const rows = await f.store.read() + expect(rows).toMatchObject({ status: 'ready' }) + if (rows.status !== 'ready') return + expect(rows.rows.map((row) => row.id)).toEqual(['api-route']) + expect(rows.rows[0]?.credential).toMatchObject(API_KEY) + const settings = await f.store.readSettings() + expect(settings).toMatchObject({ + settings: { + mainAccountId: f.main, + routing: { mode: 'sticky' }, + claustrum: { + mode: 'claustrum', + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: MAIN_IDENTITY, + }, + disabledAccountIdentities: [], + }, + }, + }) + const roster = await readVaultRoster(f.paths.roster) + expect(roster?.rows).toEqual([ + { + routeId: f.main, + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: MAIN_IDENTITY, + state: 'active', + label: f.main, + enabled: true, + addedAt: 0, + }, + { + routeId: 'work-route', + credentialId: 'oauth:anthropic:work', + credentialType: 'oauth', + accountIdentity: WORK_IDENTITY, + state: 'active', + label: 'Work', + enabled: true, + addedAt: expect.any(Number), + }, + ]) + const runtime = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (runtime.status !== 'ready') throw new Error('runtime missing') + expect(runtime.state.accounts[f.main]).toEqual({ + binding: { + kind: 'custody', + storageId: f.paths.storageId, + routeId: f.main, + credentialId: 'oauth:anthropic', + accountIdentity: MAIN_IDENTITY, + recordVersion: 0, + }, + lastUsed: 111, + }) + expect(runtime.state.accounts['work-route']).toEqual({ + binding: { + kind: 'custody', + storageId: f.paths.storageId, + routeId: 'work-route', + credentialId: 'oauth:anthropic:work', + accountIdentity: WORK_IDENTITY, + recordVersion: 0, + }, + }) + expect(runtime.state.accounts['api-route']).toMatchObject({ lastUsed: 333 }) + const host = JSON.parse(await readFile(f.migration.hostAuthPath, 'utf8')) + expect(host.other).toEqual(OTHER_PROVIDER) + if (f.options.host === 'opencode') + expect(host.anthropic).toEqual(custodyTombstoneOAuth('anthropic')) + else expect(host.anthropic).toBeUndefined() + expect(await readFile(f.migration.routingDestinationPath, 'utf8')).toBe( + ROUTING_BYTES, + ) + const journalText = await readFile(f.paths.journal, 'utf8') + for (const secret of SECRETS) expect(journalText).not.toContain(secret) +} + +test('a retired local pool switches to vault custody with one credential authority', async () => { + const f = await fixture() + await requireNativePoolAuthority(f.paths) + const result = await runNativeCustodyActivation(f.options) + expect(result.status).toBe('committed') + await expectVault(f) + // A rerun of a committed activation reads and writes nothing it owns. + const before = await files(f) + expect((await runNativeCustodyActivation(f.options)).status).toBe('committed') + expect(await files(f)).toEqual(before) +}) + +test('a fresh install with a vault request stays unauthorized until activation commits', async () => { + const f = await fixture({ accounts: false, requestVaultActivation: true }) + const journal = await readNativeMigrationJournal(f.paths) + expect(journal).toMatchObject({ + phase: 'retired', + activation: { kind: 'requested' }, + }) + await expectPending(f.paths) + // Rerunning the migration neither admits serving nor repairs host auth. + const before = await files(f) + await runNativeMigration(f.migration) + expect(await files(f)).toEqual(before) + await expectPending(f.paths) + expect((await runNativeCustodyActivation(f.options)).status).toBe('committed') + await requireNativePoolAuthority(f.paths) + expect((await readVaultRoster(f.paths.roster))?.rows).toEqual([ + { + routeId: f.main, + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: MAIN_IDENTITY, + state: 'active', + label: f.main, + enabled: true, + addedAt: 0, + }, + ]) + expect(await f.store.readSettings()).toMatchObject({ + settings: { + mainAccountId: f.main, + claustrum: { + mode: 'claustrum', + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: MAIN_IDENTITY, + }, + }, + }, + }) + expect( + JSON.parse(await readFile(f.migration.hostAuthPath, 'utf8')).anthropic, + ).toEqual(custodyTombstoneOAuth('anthropic')) +}) + +/** + * The real first migration of a legacy vault-custody config with a vault + * request, using the real roster and runtime publishers. The pool is left + * retired with the activation requested and serving refused. + */ +async function legacyVaultFixture() { + const created = await mkdtemp(join(tmpdir(), 'native-custody-legacy-')) + deferCleanup(() => rm(created, { recursive: true, force: true })) + const root = await realpath(created) + const paths = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + await writeFile( + paths.legacyConfig, + JSON.stringify({ + mainAccountId: 'primary-route', + claustrum: { + mode: 'claustrum', + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: MAIN_IDENTITY, + }, + }, + accounts: [ + { + id: 'vault-work', + type: 'oauth', + refresh: '', + enabled: true, + label: 'Work', + claustrumScopedCredentialId: 'oauth:anthropic:work', + anthropicAccountUuid: WORK_IDENTITY, + }, + { + id: 'api-route', + type: 'api', + apiKey: API_KEY.apiKey, + baseURL: API_KEY.baseURL, + }, + ], + }), + { mode: 0o600 }, + ) + const hostAuthPath = join(root, 'host-auth.json') + const vault = inventory() + const migration: NativeMigrationOptions = { + paths, + host: 'opencode', + hostAuthPath, + routingSourcePath: join(root, 'legacy-routing.json'), + routingDestinationPath: join(root, 'native-routing.json'), + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: false, + requestVaultActivation: true, + custody: { + discover: async () => vault, + publishSeed: publishNativeVaultRosterSeed, + publishRuntimeSeed: publishNativeVaultRuntimeSeed, + }, + } + expect((await runNativeMigration(migration)).phase).toBe('retired') + await expectPending(paths) + const read = async () => + Promise.all( + [ + paths.config, + paths.state, + paths.runtime, + paths.roster, + hostAuthPath, + paths.journal, + ].map((path) => readFile(path, 'utf8')), + ) + const options: NativeCustodyActivationOptions = { + paths, + host: 'opencode', + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: false, + discover: async () => vault, + } + return { paths, read, options } +} + +test('a first migration of legacy vault custody is verified and reused without rewriting it', async () => { + const { paths, read, options } = await legacyVaultFixture() + const before = await read() + expect((await runNativeCustodyActivation(options)).status).toBe('committed') + await requireNativePoolAuthority(paths) + // Pool, runtime, roster and host bytes are untouched; only the journal + // records the committed activation. + const after = await read() + expect(after.slice(0, 5)).toEqual(before.slice(0, 5)) + expect(after[5]).not.toBe(before[5]) +}) + +test('a first legacy vault activation refuses a roster changed after the migration proved it', async () => { + type Edit = (row: { label: string; enabled: boolean }) => void + const edits: Array<[string, Edit]> = [ + [ + 'label', + (row) => { + row.label = 'Renamed after migration' + }, + ], + [ + 'enabled', + (row) => { + row.enabled = false + }, + ], + ] + for (const [name, edit] of edits) { + const { paths, read, options } = await legacyVaultFixture() + // A public roster edit, as vault discovery or a menu action would make. + await mutateVaultRoster(paths.roster, (current) => { + if (!current) throw new Error('roster missing') + const next = structuredClone(current) + const row = next.rows.find((item) => item.routeId === 'vault-work') + if (!row) throw new Error('fallback row missing') + edit(row) + return { next, result: undefined } + }) + const before = await read() + await expect( + runNativeCustodyActivation(options), + name, + ).rejects.toMatchObject({ code: 'migration-proof-changed' }) + expect(await read(), name).toEqual(before) + await expectPending(paths) + } +}) + +test('a first legacy vault activation refuses a roster changed while vault accounts are listed or the plan is recorded', async () => { + const cases = [ + ['discover', 'label'], + ['discover', 'enabled'], + ['journal-write', 'label'], + ] as const + for (const [when, field] of cases) { + const name = `${when} ${field}` + const { paths, read, options } = await legacyVaultFixture() + let edited: string[] | undefined + const edit = async () => { + await mutateVaultRoster(paths.roster, (current) => { + if (!current) throw new Error('roster missing') + const next = structuredClone(current) + const row = next.rows.find((item) => item.routeId === 'vault-work') + if (!row) throw new Error('fallback row missing') + if (field === 'label') row.label = 'Renamed during activation' + else row.enabled = false + return { next, result: undefined } + }) + edited = await read() + } + const result = await runNativeCustodyActivation( + { + ...options, + // The vault listing itself is unchanged; only the roster file is + // edited while it runs, after the activation has started. + discover: async () => { + if (when === 'discover') await edit() + return options.discover() + }, + }, + { + // Inside the journal write that would record the plan, before its + // rename: the last check before the plan becomes durable. + onStep: async (step) => { + if ( + when === 'journal-write' && + step === 'before-write:journal:prepared' + ) + await edit() + }, + }, + ).then( + (value) => value, + (error: unknown) => error, + ) + if (!edited) throw new Error(`${name}: edit point was not reached`) + // Nothing after the external edit: no journal record, pool, runtime, + // roster or host write by the activation. + expect(await read(), name).toEqual(edited) + expect(result, name).toMatchObject({ code: 'migration-proof-changed' }) + await expectPending(paths) + } +}) + +test('the designated primary is required and an already-bound main never adopts another account', async () => { + const cases: Array<[string, NativeCustodyInventory['credentials']]> = [ + [ + 'primary-missing', + [ + { + credentialId: 'oauth:anthropic:work', + credentialType: 'oauth', + accountIdentity: WORK_IDENTITY, + state: 'active', + }, + ], + ], + [ + 'primary-mismatch', + [ + { + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: 'a-different-account-uuid', + state: 'active', + }, + { + credentialId: 'oauth:anthropic:work', + credentialType: 'oauth', + accountIdentity: WORK_IDENTITY, + state: 'active', + }, + ], + ], + [ + 'unenrolled-local-row', + [ + { + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: MAIN_IDENTITY, + state: 'active', + }, + ], + ], + [ + 'vault-ambiguous', + [ + { + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: MAIN_IDENTITY, + state: 'active', + }, + { + credentialId: 'oauth:anthropic:work', + credentialType: 'oauth', + accountIdentity: WORK_IDENTITY, + state: 'active', + }, + { + credentialId: 'oauth:anthropic:work-2', + credentialType: 'oauth', + accountIdentity: WORK_IDENTITY, + state: 'active', + }, + ], + ], + ] + for (const [code, credentials] of cases) { + const f = await fixture() + f.inventory.value = inventory(credentials) + const before = await files(f) + await expect(runNativeCustodyActivation(f.options)).rejects.toMatchObject({ + code, + }) + expect(await files(f)).toEqual(before) + await requireNativePoolAuthority(f.paths) + } +}) + +test('environment, process and consent fences refuse before any write', async () => { + const f = await fixture() + const before = await files(f) + await expect( + runNativeCustodyActivation({ + ...f.options, + env: { OPENCODE_AUTH_CONTENT: '{}' }, + }), + ).rejects.toMatchObject({ code: 'auth-content-refused' }) + await expect( + runNativeCustodyActivation({ + ...f.options, + processFence: async () => { + throw new Error('synthetic running host') + }, + }), + ).rejects.toMatchObject({ code: 'process-fence-refused' }) + expect(await files(f)).toEqual(before) + + // The fence runs immediately before each write: a host that starts later + // stops the activation at that write, and a rerun resumes it. + let calls = 0 + const g = await fixture() + await runNativeCustodyActivation({ + ...g.options, + processFence: async () => { + calls++ + }, + }) + const total = calls + expect(total).toBeGreaterThan(10) + for (let failAt = 2; failAt <= total; failAt += Math.ceil(total / 6)) { + const h = await fixture() + calls = 0 + await expect( + runNativeCustodyActivation({ + ...h.options, + processFence: async () => { + calls++ + if (calls === failAt) throw new Error('synthetic host started') + }, + }), + ).rejects.toMatchObject({ code: 'process-fence-refused' }) + const journal = await readNativeMigrationJournal(h.paths) + if (journal?.activation) await expectPending(h.paths) + expect((await runNativeCustodyActivation(h.options)).status).toBe( + 'committed', + ) + await expectVault(h) + } +}) + +const STEPS = [ + 'before:prepare', + 'before-write:journal:prepared', + 'after-write:journal:prepared', + 'after:prepare', + 'before:roster', + 'after:roster', + 'before:runtime', + 'after:runtime', + 'before:remove:0', + 'after:remove:0', + 'before:remove:1', + 'after:remove:1', + 'before-write:journal:published', + 'after-write:journal:published', + 'before:mode-rename', + 'after:mode-rename', + 'before:authority-rename', + 'before-write:journal:committed', +] + +test('an interruption at every boundary leaves serving refused and resumes to the same vault state', async () => { + for (const point of STEPS) { + const f = await fixture() + await interrupted(f, point) + const journal = await readNativeMigrationJournal(f.paths) + // Before the prepared journal write nothing is recorded or changed. + if (journal?.activation === null) await requireNativePoolAuthority(f.paths) + else await expectPending(f.paths) + expect((await runNativeCustodyActivation(f.options)).status, point).toBe( + 'committed', + ) + await expectVault(f) + } +}) + +test('an interrupted removal between its config and state writes completes through the attributed fence', async () => { + const f = await fixture() + await interrupted(f, 'never', { + store: { + onStep: async (step, info) => { + if (step === 'after-config-write' && info.operation === 'remove') + throw new Error('synthetic interruption') + }, + }, + }) + await expectPending(f.paths) + const rows = await f.store.read() + expect(rows.status === 'ready' && rows.rows.map((row) => row.id)).toEqual([ + 'work-route', + 'api-route', + ]) + expect((await runNativeCustodyActivation(f.options)).status).toBe('committed') + await expectVault(f) +}) + +/** Add a pool row from a separate Bun process through the public store. */ +async function addInAnotherProcess( + paths: NativePoolPaths, + row: { id: string; credential: unknown; identity: string }, +): Promise { + const module = (name: string) => + JSON.stringify(new URL(name, import.meta.url).href) + const script = ` + const { createNativePoolStore } = await import(${module('../pool-store.ts')}) + const { nativeQuotaCodec } = await import(${module('../native-quota-codec.ts')}) + await createNativePoolStore({ + paths: ${JSON.stringify(paths)}, + quota: nativeQuotaCodec, + }).add(${JSON.stringify(row)}) + ` + const child = Bun.spawn([process.execPath, '-e', script], { + stdout: 'pipe', + stderr: 'pipe', + }) + const exited = child.exited + const stdout = new Response(child.stdout).text() + const stderr = new Response(child.stderr).text() + deferCleanup(async () => { + child.kill() + await Promise.allSettled([exited, stdout, stderr]) + }) + const code = await exited + const [, errors] = await Promise.all([stdout, stderr]) + expect(code, errors).toBe(0) +} + +test('resume refuses a removed id re-added at a new epoch, a changed token and an added row', async () => { + const mutations: Array<[string, (f: Fixture) => Promise]> = [ + [ + 'after:remove:0', + async (f) => { + const removed = [f.main, 'work-route'].sort()[0] as string + // A store refuses to reuse an id it removed itself in this process, + // so another process re-adds it, as a later local login would. + await addInAnotherProcess(f.paths, { + id: removed, + credential: removed === f.main ? MAIN_TOKEN : WORK_TOKEN, + identity: removed === f.main ? MAIN_IDENTITY : WORK_IDENTITY, + }) + }, + ], + [ + 'after:roster', + async (f) => { + await f.store.rotate('work-route', { + ...WORK_TOKEN, + access: 'synthetic-rotated-access', + }) + }, + ], + [ + 'after:prepare', + async (f) => { + await f.store.add({ + id: 'added-api', + credential: { ...API_KEY, apiKey: 'synthetic-added-key' }, + }) + }, + ], + ] + for (const [point, mutate] of mutations) { + const f = await fixture() + await interrupted(f, point) + await mutate(f) + const before = await files(f) + await expect(runNativeCustodyActivation(f.options)).rejects.toMatchObject({ + code: 'credential-changed', + }) + expect(await files(f)).toEqual(before) + await expectPending(f.paths) + } +}) + +async function changeRoutingPreference(f: Fixture): Promise { + // A separate store instance stands in for a user editing preferences. + await createNativePoolStore({ + paths: f.paths, + quota: nativeQuotaCodec, + }).updateSettings((settings) => { + settings.routing = { mode: 'round-robin' } + }) +} + +test('resume refuses changed preferences, runtime metadata and roster before any forward write', async () => { + const mutations: Array<[string, string, (f: Fixture) => Promise]> = [ + ['after:prepare', 'settings-conflict', changeRoutingPreference], + ['after:roster', 'settings-conflict', changeRoutingPreference], + ['after:runtime', 'settings-conflict', changeRoutingPreference], + ['after:remove:0', 'settings-conflict', changeRoutingPreference], + [ + 'after:prepare', + 'runtime-conflict', + async (f) => { + await updateNativeRuntime( + f.paths.runtime, + f.paths.storageId, + (state) => { + const entry = state.accounts['api-route'] + if (entry) entry.lastUsed = 999 + return state + }, + ) + }, + ], + [ + 'after:roster', + 'roster-conflict', + async (f) => { + const roster = JSON.parse(await readFile(f.paths.roster, 'utf8')) + roster.rows[1].label = 'Renamed' + await writeFile(f.paths.roster, JSON.stringify(roster), { mode: 0o600 }) + }, + ], + ] + for (const [point, code, mutate] of mutations) { + const f = await fixture() + await interrupted(f, point) + await mutate(f) + // Roster, runtime, pool rows and config, host entry and journal bytes. + const before = await files(f) + await expect( + runNativeCustodyActivation(f.options), + `${point} ${code}`, + ).rejects.toMatchObject({ code }) + expect(await files(f), `${point} ${code}`).toEqual(before) + await expectPending(f.paths) + } +}) + +test('a preference changed during a run refuses before the next roster, runtime or removal write', async () => { + for (const point of ['before:roster', 'before:runtime', 'before:remove:0']) { + const f = await fixture() + let before: Record | undefined + await expect( + runNativeCustodyActivation(f.options, { + onStep: async (step) => { + if (step !== point) return + await changeRoutingPreference(f) + before = await files(f) + }, + }), + point, + ).rejects.toMatchObject({ code: 'settings-conflict' }) + if (!before) throw new Error(`step ${point} was not reached`) + expect(await files(f), point).toEqual(before) + await expectPending(f.paths) + } +}) + +test('Pi stored OAuth is removed only with consent, a Pi API key always refuses, and a changed token never resumes', async () => { + const login = { + type: 'oauth', + access: 'synthetic-pi-access', + refresh: 'synthetic-pi-refresh', + expires: 5000, + } + const withEntry = async ( + entry: unknown, + removePiAnthropicAuth: boolean, + ): Promise => { + const f = await fixture({ host: 'pi', removePiAnthropicAuth }) + await writeFile( + f.migration.hostAuthPath, + JSON.stringify({ anthropic: entry, other: OTHER_PROVIDER }), + { mode: 0o600 }, + ) + return f + } + const refused = await withEntry(login, false) + let before = await files(refused) + await expect( + runNativeCustodyActivation(refused.options), + ).rejects.toMatchObject({ code: 'consent-required' }) + expect(await files(refused)).toEqual(before) + + const key = await withEntry( + { type: 'api_key', key: 'synthetic-pi-api-key' }, + true, + ) + before = await files(key) + await expect(runNativeCustodyActivation(key.options)).rejects.toMatchObject({ + code: 'primary-conflict', + }) + expect(await files(key)).toEqual(before) + + const consented = await withEntry(login, true) + expect((await runNativeCustodyActivation(consented.options)).status).toBe( + 'committed', + ) + await expectVault(consented) + + const changed = await withEntry(login, true) + await interrupted(changed, 'after:prepare') + await writeFile( + changed.migration.hostAuthPath, + JSON.stringify({ + anthropic: { ...login, access: 'synthetic-pi-relogin' }, + other: OTHER_PROVIDER, + }), + { mode: 0o600 }, + ) + before = await files(changed) + await expect( + runNativeCustodyActivation(changed.options), + ).rejects.toMatchObject({ code: 'host-auth-changed' }) + expect(await files(changed)).toEqual(before) + await expectPending(changed.paths) +}) + +test('an OpenCode stock API key stays under host control and a real host login refuses', async () => { + const api = await fixture() + const stock = { type: 'api', key: 'synthetic-opencode-api-key' } + await writeFile( + api.migration.hostAuthPath, + JSON.stringify({ anthropic: stock, other: OTHER_PROVIDER }), + { mode: 0o600 }, + ) + expect((await runNativeCustodyActivation(api.options)).status).toBe( + 'committed', + ) + expect( + JSON.parse(await readFile(api.migration.hostAuthPath, 'utf8')).anthropic, + ).toEqual(stock) + + const login = await fixture() + await writeFile( + login.migration.hostAuthPath, + JSON.stringify({ anthropic: { ...MAIN_TOKEN }, other: OTHER_PROVIDER }), + { mode: 0o600 }, + ) + const before = await files(login) + await expect(runNativeCustodyActivation(login.options)).rejects.toMatchObject( + { code: 'host-auth-changed' }, + ) + expect(await files(login)).toEqual(before) +}) + +/** + * Take a lease away from its holder the way an expired lease is lost: remove + * its lock file and let a public contender acquire it. The contender holds it + * until released, so the original holder can only find out by asserting. + */ +async function steal(target: string, name: string, ttlMs = 30_000) { + await rm(lockPathFor(target, name), { force: true }) + let release: () => void = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + let acquired: () => void = () => {} + const ready = new Promise((resolve) => { + acquired = resolve + }) + const held = withLock( + target, + { name, ttlMs, timeoutMs: 1_000, renew: true }, + async () => { + acquired() + await released + }, + ) + await ready + return async () => { + release() + await held + } +} + +/** + * Run an activation that hands a lease to a contender at `point`. The + * contender is released as soon as the run settles, or earlier at `releaseAt` + * if the run gets that far, and always in `finally`, so a run that wrongly + * keeps going reaches its postcondition assertions instead of waiting on the + * contender until the test deadline. + */ +async function runWithStolenLease( + f: Fixture, + point: string, + releaseAt: string, + take: () => Promise<() => Promise>, +): Promise { + let free: (() => Promise) | undefined + const release = async () => { + const pending = free + free = undefined + await pending?.() + } + try { + return await runNativeCustodyActivation(f.options, { + onStep: async (step) => { + if (step === point) free = await take() + else if (step === releaseAt) await release() + }, + }).then( + (result) => result, + (error: unknown) => error, + ) + } finally { + await release() + } +} + +for (const lease of ['runtime', 'routing'] as const) { + test(`losing the ${lease} lease immediately before the mode rename refuses with settings unchanged`, async () => { + const f = await fixture() + const outcome = await runWithStolenLease( + f, + 'before:mode-rename', + 'after:mode-rename', + () => + lease === 'runtime' + ? steal(f.paths.runtime, 'native-runtime') + : steal(f.migration.routingDestinationPath, 'write'), + ) + expect(await f.store.readSettings()).toMatchObject({ + settings: { claustrum: { mode: 'local' } }, + }) + expect(await readNativeMigrationJournal(f.paths)).toMatchObject({ + activation: { phase: 'published' }, + }) + expect(outcome).toMatchObject({ code: 'ownership-lost' }) + await expectPending(f.paths) + expect((await runNativeCustodyActivation(f.options)).status).toBe( + 'committed', + ) + await expectVault(f) + }) +} + +test('loss or drift between the mode rename and the authority rename leaves the activation published', async () => { + const lossCases: Array< + [string, (f: Fixture) => Promise<() => Promise>] + > = [ + ['runtime', (f) => steal(f.paths.runtime, 'native-runtime')], + ['routing', (f) => steal(f.migration.routingDestinationPath, 'write')], + ['pool-config', (f) => steal(f.paths.config, 'pool-config')], + ] + for (const [name, take] of lossCases) { + const f = await fixture() + const outcome = await runWithStolenLease( + f, + 'before:authority-rename', + 'after-write:journal:committed', + () => take(f), + ) + expect( + (await readNativeMigrationJournal(f.paths))?.activation, + name, + ).toMatchObject({ phase: 'published' }) + expect(outcome, name).toMatchObject({ code: 'ownership-lost' }) + await expectPending(f.paths) + expect((await runNativeCustodyActivation(f.options)).status).toBe( + 'committed', + ) + } + const drift = await fixture() + await expect( + runNativeCustodyActivation(drift.options, { + onStep: async (step) => { + if (step !== 'before:authority-rename') return + await writeFile( + drift.migration.hostAuthPath, + JSON.stringify({ anthropic: MAIN_TOKEN, other: OTHER_PROVIDER }), + { mode: 0o600 }, + ) + }, + }), + ).rejects.toMatchObject({ code: 'host-auth-changed' }) + expect(await readNativeMigrationJournal(drift.paths)).toMatchObject({ + activation: { phase: 'published' }, + }) + await expectPending(drift.paths) +}) + +async function expireAbandonedLocks(root: string): Promise { + for (const name of await readdir(root)) { + if (!name.endsWith('.lock')) continue + const path = join(root, name) + const value: unknown = JSON.parse(await readFile(path, 'utf8')) + if ( + !value || + typeof value !== 'object' || + !('ownerId' in value) || + !('expiresAt' in value) + ) + continue + const inode = (await stat(path)).ino + // The child process is dead. Expire only its own lock marker, keeping the + // owner and inode, so a successor reclaims it without waiting out the TTL. + await writeFile(path, JSON.stringify({ ...value, expiresAt: 0 })) + expect((await stat(path)).ino).toBe(inode) + } +} + +const CHILD_POINTS: Array<['step' | 'store', string]> = [ + ['step', 'after-write:journal:prepared'], + ['step', 'after:runtime'], + ['store', 'after-config-write:remove'], + ['step', 'after:remove:1'], + ['step', 'before:mode-rename'], + ['store', 'after-config-write:updateSettings'], + ['step', 'before:authority-rename'], + ['step', 'before-write:journal:committed'], +] + +for (const [kind, point] of CHILD_POINTS) { + test(`a setup process killed at ${point} leaves serving refused and a successor completes`, async () => { + const f = await fixture() + const child = Bun.spawn( + [ + process.execPath, + fileURLToPath( + new URL( + './native-custody-activation-crash-child.ts', + import.meta.url, + ), + ), + f.paths.legacyConfig, + f.paths.legacyState, + 'opencode', + JSON.stringify(f.inventory.value), + kind, + point, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ) + // Each stream is read exactly once. If the body fails, cleanup cancels + // the owned child first, then drains its exit status and both streams + // before the fixture directory is removed. + const exited = child.exited + const stdout = new Response(child.stdout).text() + const stderr = new Response(child.stderr).text() + deferCleanup(async () => { + child.kill() + await Promise.allSettled([exited, stdout, stderr]) + }) + const code = await exited + const [, errors] = await Promise.all([stdout, stderr]) + expect(code, errors).toBe(19) + await expectPending(f.paths) + await expireAbandonedLocks(f.root) + expect((await runNativeCustodyActivation(f.options)).status).toBe( + 'committed', + ) + await expectVault(f) + }) +} diff --git a/packages/core/src/tests/native-local-credential-service.test.ts b/packages/core/src/tests/native-local-credential-service.test.ts index a18189295..5d1b2dcd7 100644 --- a/packages/core/src/tests/native-local-credential-service.test.ts +++ b/packages/core/src/tests/native-local-credential-service.test.ts @@ -112,7 +112,7 @@ async function writeJournal(paths: NativePoolPaths, authority: Authority) { authority === 'invalid' ? '{"version":2' : JSON.stringify({ - version: 3, + version: 4, storageId: paths.storageId, ...migrationInput(paths), phase: committed ? 'committed' : 'building', @@ -121,6 +121,7 @@ async function writeJournal(paths: NativePoolPaths, authority: Authority) { preparedProof: committed ? { version: 1, rows: [], runtimeDigest: 'f'.repeat(64) } : null, + activation: null, }), { mode: 0o600 }, ) diff --git a/packages/core/src/tests/pool-authority.test.ts b/packages/core/src/tests/pool-authority.test.ts index 5de47e0cc..74ccdf6a0 100644 --- a/packages/core/src/tests/pool-authority.test.ts +++ b/packages/core/src/tests/pool-authority.test.ts @@ -14,11 +14,15 @@ import { fileURLToPath } from 'node:url' import { inspectNativeHostAuthEntry } from '../native-host-auth.ts' import { + advanceNativeCustodyActivation, advanceNativeMigration, beginNativeMigration, + type NativeCustodyActivationPlan, type NativeMigrationInput, type NativeMigrationPhase, + nativeMigrationAuthorityPhase, readNativeMigrationJournal, + recordNativeCustodyActivation, recordNativeMigrationExpectations, requireNativePoolAuthority, } from '../pool-authority.ts' @@ -236,13 +240,13 @@ test('concurrent starts publish one journal without rewriting its baselines', as ) }) -test('v3 persists ten fields including an initially absent prepared proof', async () => { +test('v4 persists eleven fields including an initially absent prepared proof and activation', async () => { const paths = await fixture() const input = captureInput(paths) const journal = await beginNativeMigration(paths, input) const text = await readFile(paths.journal, 'utf8') expect(JSON.parse(text)).toEqual({ - version: 3, + version: 4, storageId: paths.storageId, host: 'opencode', phase: 'building', @@ -257,8 +261,9 @@ test('v3 persists ten fields including an initially absent prepared proof', asyn expectedHostAuth: 'unprepared', expectedRouting: 'unprepared', preparedProof: null, + activation: null, }) - expect(Object.keys(journal)).toHaveLength(10) + expect(Object.keys(journal)).toHaveLength(11) expect(Object.keys(journal.sources).sort()).toEqual([ 'config', 'hostAuth', @@ -306,7 +311,7 @@ test('canonical Anthropic entry digests persist without any credential strings', expect(text).not.toContain(secret) }) -test('v1 journals refuse unchanged rather than supplying v3 defaults', async () => { +test('v1 journals refuse unchanged rather than supplying v4 defaults', async () => { const paths = await fixture() const input = captureInput(paths) const text = JSON.stringify({ @@ -960,7 +965,7 @@ for (const point of ['before-write', 'after-write'] as const) { const text = await readFile(paths.journal, 'utf8') const raw = JSON.parse(text) - expect(Object.keys(raw)).toHaveLength(10) + expect(Object.keys(raw)).toHaveLength(11) expect(raw.phase).toBe('verified') expect([raw.expectedHostAuth, raw.expectedRouting]).toEqual( point === 'before-write' @@ -1076,3 +1081,196 @@ test('expired expectation publisher refuses without changing either verified exp await recordNativeMigrationExpectations(paths, expectations), ).toMatchObject({ phase: 'verified', ...expectations }) }) + +async function retiredJournal( + paths: NativePoolPaths, + activation?: 'requested', +): Promise { + await beginNativeMigration(paths, { + ...captureInput(paths), + ...(activation ? { activation } : {}), + }) + await advanceNativeMigration(paths, 'building', 'verified') + await recordNativeMigrationExpectations(paths, expectations) + await advanceNativeMigration(paths, 'verified', 'activation-installed') + await advanceNativeMigration(paths, 'activation-installed', 'committed') + await advanceNativeMigration(paths, 'committed', 'retired') +} + +function activationPlan(): NativeCustodyActivationPlan { + return { + kind: 'activation', + phase: 'prepared', + primary: { + routeId: 'main-route', + credentialId: 'oauth:anthropic', + accountIdentity: 'account-main', + }, + roster: { + version: 1, + view: 'view-1', + complete: true, + rows: [ + { + routeId: 'main-route', + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: 'account-main', + state: 'active', + label: 'main-route', + enabled: true, + addedAt: 0, + }, + ], + declined: [], + }, + localProof: { + version: 1, + rows: [ + { + id: 'main-route', + credentialEpoch: 1, + identity: 'account-main', + stamp: 'bound', + credentialDigest: '1'.repeat(64), + }, + ], + runtimeDigest: '2'.repeat(64), + }, + removeIds: ['main-route'], + settings: { source: '3'.repeat(64), target: '4'.repeat(64) }, + runtime: { source: '5'.repeat(64), target: '6'.repeat(64) }, + hostAuth: { source: 'absent', expected: '7'.repeat(64) }, + } +} + +test('older v3 native journals fail closed unchanged', async () => { + const paths = await fixture() + await retiredJournal(paths) + const current = JSON.parse(await readFile(paths.journal, 'utf8')) + const { activation: _activation, ...v3 } = { ...current, version: 3 } + const text = JSON.stringify(v3) + await writeFile(paths.journal, text, { mode: 0o600 }) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + for (const value of [ + { ...current, version: 3 }, + { ...v3, version: 4 }, + ]) { + await writeFile(paths.journal, JSON.stringify(value), { mode: 0o600 }) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + } +}) + +test('a requested vault activation keeps a retired migration unauthorized', async () => { + const paths = await fixture() + await retiredJournal(paths, 'requested') + const journal = await readNativeMigrationJournal(paths) + expect(journal?.phase).toBe('retired') + expect(journal?.activation).toEqual({ kind: 'requested' }) + expect(nativeMigrationAuthorityPhase(journal)).toBeUndefined() + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + // Resuming a begin with a different activation request is a conflict. + await expect( + beginNativeMigration(paths, captureInput(paths)), + ).rejects.toMatchObject({ code: 'journal-conflict' }) +}) + +test('activation phases block authority until committed and only move forward', async () => { + const paths = await fixture() + await retiredJournal(paths) + await requireNativePoolAuthority(paths) + const plan = activationPlan() + await recordNativeCustodyActivation(paths, plan) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + const prepared = await readFile(paths.journal, 'utf8') + // An identical retry writes nothing; any other plan conflicts unchanged. + await recordNativeCustodyActivation(paths, plan) + expect(await readFile(paths.journal, 'utf8')).toBe(prepared) + await expect( + recordNativeCustodyActivation(paths, { + ...plan, + removeIds: [], + }), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + await expect( + advanceNativeCustodyActivation(paths, 'prepared', 'committed'), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(prepared) + await advanceNativeCustodyActivation(paths, 'prepared', 'published') + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'migration-incomplete', + }) + await expect( + advanceNativeCustodyActivation(paths, 'published', 'prepared'), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + await advanceNativeCustodyActivation(paths, 'published', 'committed') + await requireNativePoolAuthority(paths) + const committed = await readNativeMigrationJournal(paths) + expect(nativeMigrationAuthorityPhase(committed)).toBe('retired') + expect(committed?.activation).toMatchObject({ phase: 'committed' }) + // The completed migration history is kept, never reset. + expect(committed?.preparedProof).toEqual(preparedProof) + expect(committed?.expectedHostAuth).toBe(expectations.expectedHostAuth) +}) + +test('activation is recorded only on a retired migration', async () => { + const paths = await fixture() + await beginNativeMigration(paths, captureInput(paths)) + const before = await readFile(paths.journal, 'utf8') + await expect( + recordNativeCustodyActivation(paths, activationPlan()), + ).rejects.toMatchObject({ code: 'journal-conflict' }) + expect(await readFile(paths.journal, 'utf8')).toBe(before) +}) + +test('closed activation decoder rejects incoherent or extra fields unchanged', async () => { + const paths = await fixture() + await retiredJournal(paths) + await recordNativeCustodyActivation(paths, activationPlan()) + const journal = JSON.parse(await readFile(paths.journal, 'utf8')) + const plan = journal.activation + const invalid = [ + { ...journal, phase: 'committed' }, + { ...journal, activation: { ...plan, token: 'x' } }, + { ...journal, activation: { ...plan, phase: 'requested' } }, + { ...journal, activation: { ...plan, removeIds: ['unknown-row'] } }, + { ...journal, activation: { kind: 'requested', extra: true } }, + { + ...journal, + activation: { + ...plan, + primary: { ...plan.primary, credentialId: 'oauth:anthropic:other' }, + }, + }, + { + ...journal, + activation: { + ...plan, + roster: { + ...plan.roster, + rows: [{ ...plan.roster.rows[0], access: 'synthetic-access' }], + }, + }, + }, + { + ...journal, + activation: { ...plan, hostAuth: { ...plan.hostAuth, expected: 'x' } }, + }, + ] + for (const value of invalid) { + const text = JSON.stringify(value) + await writeFile(paths.journal, text, { mode: 0o600 }) + await expect(requireNativePoolAuthority(paths)).rejects.toMatchObject({ + code: 'invalid-journal', + }) + expect(await readFile(paths.journal, 'utf8')).toBe(text) + } +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 52e4848bf..58d08e3f2 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -123,6 +123,7 @@ import { type NativeLocalCredentialValidation as NativeKnownCredentialSubject, type NativeMenuDispatch, type NativeUiOptions, + nativeMigrationAuthorityPhase, normalizeQuotaHeaders, type OAuthAccount, type OAuthQuotaSnapshot, @@ -5192,10 +5193,7 @@ const anthropicAuthPlugin = async ( main, fallbacks: fallbackDimensions.fallbacks, evidence: mainEvidence, - authority: - journal?.phase === 'committed' || journal?.phase === 'retired' - ? journal.phase - : undefined, + authority: nativeMigrationAuthorityPhase(journal), }) } catch (error) { if (!(error instanceof CustodyStateMismatchError)) throw error diff --git a/packages/opencode/src/setup/command.test.ts b/packages/opencode/src/setup/command.test.ts new file mode 100644 index 000000000..f1819b377 --- /dev/null +++ b/packages/opencode/src/setup/command.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { runSetupCommand, type SetupCommandOptions } from './command.ts' +import type { HarnessKind, SetupDetection } from './types.ts' + +function detection(piLocalAuth = false): SetupDetection { + return { + opencode: { + kind: 'opencode', + installed: true, + version: '1.18.31', + configPath: '/synthetic/opencode.jsonc', + pluginInstalled: true, + }, + pi: { + kind: 'pi', + installed: true, + version: '0.86.1', + configPath: '/synthetic/pi', + pluginInstalled: true, + hasLocalAuth: piLocalAuth, + }, + claustrum: { + ckInstalled: true, + ckVersion: '0.20.8', + daemonRunning: true, + connectionPath: '/synthetic/subc-connection.json', + }, + } +} + +/** + * Drive the real command with injected detection, process fence, runner and + * per-host activation. Host config writes land in a private synthetic home. + */ +async function run( + argv: string[], + input: { + piLocalAuth?: boolean + fail?: HarnessKind + } = {}, +) { + const root = await mkdtemp(join(tmpdir(), 'setup-command-vault-')) + try { + const env: NodeJS.ProcessEnv = { + HOME: root, + XDG_CONFIG_HOME: join(root, '.config'), + XDG_DATA_HOME: join(root, '.local', 'share'), + PI_CODING_AGENT_DIR: join(root, '.pi', 'agent'), + } + await mkdir(join(root, '.config', 'opencode'), { recursive: true }) + await writeFile( + join(root, '.config', 'opencode', 'opencode.jsonc'), + '{\n "plugin": []\n}\n', + ) + const activations: Array<{ host: HarnessKind; consent: boolean }> = [] + const options: SetupCommandOptions = { + env, + fence: { listRunningHosts: async () => [] }, + runner: { + run: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + }, + detect: async () => detection(input.piLocalAuth), + activate: async (host, options) => { + activations.push({ host, consent: options.removePiAnthropicAuth }) + if (host === input.fail) throw new Error('synthetic activation refusal') + return 'committed' + }, + } + const code = await runSetupCommand(argv, options) + return { code, activations } + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +test('--yes alone never switches custody even when the vault daemon is available', async () => { + const result = await run(['--yes']) + expect(result.code).toBe(0) + expect(result.activations).toEqual([]) +}) + +test('--claustrum activates each selected host on its own and stops at the first failure', async () => { + expect(await run(['--yes', '--claustrum'])).toEqual({ + code: 0, + activations: [ + { host: 'opencode', consent: false }, + { host: 'pi', consent: false }, + ], + }) + expect(await run(['--yes', '--claustrum'], { fail: 'opencode' })).toEqual({ + code: 1, + activations: [{ host: 'opencode', consent: false }], + }) +}) + +test('a stored Pi login is removed only with the explicit --remove-pi-auth flag', async () => { + expect(await run(['--yes', '--claustrum'], { piLocalAuth: true })).toEqual({ + code: 1, + activations: [], + }) + expect( + await run(['--yes', '--claustrum', '--remove-pi-auth'], { + piLocalAuth: true, + }), + ).toEqual({ + code: 0, + activations: [ + { host: 'opencode', consent: false }, + { host: 'pi', consent: true }, + ], + }) +}) diff --git a/packages/opencode/src/setup/command.ts b/packages/opencode/src/setup/command.ts index 8e4b1632e..42ffacd45 100644 --- a/packages/opencode/src/setup/command.ts +++ b/packages/opencode/src/setup/command.ts @@ -8,16 +8,15 @@ import { outro, spinner, } from '@clack/prompts' -import { writeOpenCodeTombstoneAuth } from './activation.ts' -import { setupClaustrumForHost } from './claustrum.ts' import { defaultCommandRunner } from './command-runner.ts' import { detectAll } from './detect.ts' +import { activateNativeVaultForHost } from './native-activation.ts' import { ensureOpenCodePluginConfig, ensureOpenCodeTuiConfig, } from './opencode-config.ts' -import { getOpenCodeAuthPath, getSetupPackageVersion } from './paths.ts' -import { cleanPiLocalAnthropicAuth, installPiExtension } from './pi.ts' +import { getSetupPackageVersion } from './paths.ts' +import { installPiExtension } from './pi.ts' import { assertHostsStopped, defaultProcessFence, @@ -30,6 +29,9 @@ export interface SetupCommandOptions { fence?: ProcessFence env?: NodeJS.ProcessEnv isInteractive?: boolean + /** Test seams for host detection and per-host vault activation. */ + detect?: typeof detectAll + activate?: typeof activateNativeVaultForHost } export async function runSetupCommand( @@ -39,18 +41,20 @@ export async function runSetupCommand( const env = options.env ?? process.env const runner = options.runner ?? defaultCommandRunner const fence = options.fence ?? defaultProcessFence + const activate = options.activate ?? activateNativeVaultForHost const nonInteractive = argv.includes('--yes') || argv.includes('-y') const dryRun = argv.includes('--dry-run') const explicitNoClaustrum = argv.includes('--no-claustrum') const explicitClaustrum = argv.includes('--claustrum') + const explicitRemovePiAuth = argv.includes('--remove-pi-auth') intro('CortexKit Anthropic Auth Setup') // 1. Detect environment const s = spinner() s.start('Detecting installed environments…') - const detection = await detectAll(env, runner) + const detection = await (options.detect ?? detectAll)(env, runner) s.stop('Environment detection complete.') const opencodeStatus = detection.opencode.installed @@ -129,16 +133,15 @@ export async function runSetupCommand( const claustrumAvailable = detection.claustrum.daemonRunning && detection.claustrum.ckInstalled + // Vault custody is explicit only: --yes alone never switches custody. if (claustrumAvailable && !explicitNoClaustrum) { if (explicitClaustrum) { useClaustrum = true - } else if (nonInteractive) { - useClaustrum = true - } else { + } else if (!nonInteractive) { const claustrumChoice = await confirm({ message: - 'Claustrum daemon is running. Use Claustrum custody to manage Anthropic accounts securely in the vault?', - initialValue: true, + 'Claustrum daemon is running. Switch Anthropic accounts to Claustrum vault custody? Local OAuth logins are removed once their vault accounts are verified.', + initialValue: false, }) if (isCancel(claustrumChoice)) { @@ -158,6 +161,14 @@ export async function runSetupCommand( detection.pi.hasLocalAuth ) { if (nonInteractive) { + // Deleting a stored login needs its own flag; --yes is not consent. + if (!explicitRemovePiAuth) { + log.error( + 'Pi has a stored Anthropic login. Rerun with --remove-pi-auth to remove it for vault custody.', + ) + outro('Setup aborted.') + return 1 + } removePiLocalAuth = true } else { log.warn( @@ -166,7 +177,7 @@ export async function runSetupCommand( const consent = await confirm({ message: 'Remove local Pi Anthropic OAuth credential so Claustrum custody can serve requests?', - initialValue: true, + initialValue: false, }) if (isCancel(consent) || !consent) { @@ -204,6 +215,30 @@ export async function runSetupCommand( const version = await getSetupPackageVersion() + // Each host is enrolled and activated on its own. A failure stops setup + // before the next host; a host already switched stays switched. + const activateHost = async (host: HarnessKind, label: string) => { + s.start(`Switching ${label} to Claustrum vault custody…`) + try { + const outcome = await activate(host, { + env, + runner, + fence, + removePiAnthropicAuth: host === 'pi' && removePiLocalAuth, + }) + s.stop( + outcome === 'already-vault' + ? `${label} already uses Claustrum vault custody.` + : `${label} now uses Claustrum vault custody.`, + ) + return true + } catch (error: unknown) { + s.stop(`${label} vault custody setup failed.`) + log.error(error instanceof Error ? error.message : String(error)) + return false + } + } + // 6. Execute OpenCode setup if (selectedHosts.includes('opencode')) { s.start('Configuring OpenCode plugin and TUI sidebar…') @@ -211,32 +246,12 @@ export async function runSetupCommand( await ensureOpenCodePluginConfig(version, env) await ensureOpenCodeTuiConfig(version, env) s.stop('OpenCode configuration updated.') - - if (useClaustrum) { - s.start('Enrolling OpenCode into Claustrum custody…') - const result = await setupClaustrumForHost('opencode', { - runner, - env, - }) - if (!result.ok) { - s.stop('Claustrum enrollment failed.') - log.error(result.message) - return 1 - } - s.stop(`OpenCode enrolled into Claustrum: ${result.message}`) - - s.start('Installing Anthropic activation tombstone…') - await writeOpenCodeTombstoneAuth({ - authPath: getOpenCodeAuthPath(env), - fence, - }) - s.stop('Anthropic activation tombstone installed.') - } } catch (error: unknown) { s.stop('OpenCode setup failed.') log.error(error instanceof Error ? error.message : String(error)) return 1 } + if (useClaustrum && !(await activateHost('opencode', 'OpenCode'))) return 1 } // 7. Execute Pi setup @@ -250,31 +265,12 @@ export async function runSetupCommand( return 1 } s.stop('Pi Anthropic extension installed.') - - if (useClaustrum) { - if (removePiLocalAuth) { - s.start('Removing local Pi Anthropic OAuth credential…') - await cleanPiLocalAnthropicAuth(env) - s.stop('Local Pi Anthropic OAuth credential removed.') - } - - s.start('Enrolling Pi into Claustrum custody…') - const result = await setupClaustrumForHost('pi', { - runner, - env, - }) - if (!result.ok) { - s.stop('Pi Claustrum enrollment failed.') - log.error(result.message) - return 1 - } - s.stop(`Pi enrolled into Claustrum: ${result.message}`) - } } catch (error: unknown) { s.stop('Pi setup failed.') log.error(error instanceof Error ? error.message : String(error)) return 1 } + if (useClaustrum && !(await activateHost('pi', 'Pi'))) return 1 } // 8. Next steps note diff --git a/packages/opencode/src/setup/native-activation.test.ts b/packages/opencode/src/setup/native-activation.test.ts new file mode 100644 index 000000000..8a00f8b2c --- /dev/null +++ b/packages/opencode/src/setup/native-activation.test.ts @@ -0,0 +1,241 @@ +import { expect, test } from 'bun:test' +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + custodyTombstoneOAuth, + type NativeCustodyInventory, + nativeMigrationAuthorityPhase, + readNativeMigrationJournal, + runNativeMigration, +} from '@cortexkit/anthropic-auth-core' +import { readVaultRoster } from '@cortexkit/common-auth/claustrum' + +import { activateNativeVaultForHost } from './native-activation.ts' +import { resolveNativeSetupPaths } from './native-paths.ts' +import type { CommandRunner, HarnessKind, ProcessFence } from './types.ts' + +const MAIN_IDENTITY = 'account-main-uuid' +const OTHER_PROVIDER = { type: 'api', key: 'synthetic-unrelated-provider' } +const INVENTORY: NativeCustodyInventory = { + view: 'synthetic-view', + skipped: [], + credentials: [ + { + credentialId: 'oauth:anthropic', + credentialType: 'oauth', + accountIdentity: MAIN_IDENTITY, + state: 'active', + }, + ], +} + +/** + * A private synthetic home: host config, enrollment and pool files all live in + * it. The process fence, ck runner, enrollment client and vault inventory are + * injected, so no live host, daemon or vault is used. + */ +async function fixture(host: HarnessKind, running: string[] = []) { + const root = await mkdtemp(join(tmpdir(), 'native-setup-activation-')) + const env: NodeJS.ProcessEnv = { + HOME: root, + XDG_CONFIG_HOME: join(root, '.config'), + XDG_DATA_HOME: join(root, '.local', 'share'), + XDG_STATE_HOME: join(root, '.local', 'state'), + PI_CODING_AGENT_DIR: join(root, '.pi', 'agent'), + } + const paths = await resolveNativeSetupPaths(host, env) + const calls: string[] = [] + let approved = false + const runner: CommandRunner = { + run: async (_command, args) => { + calls.push(args.slice(0, 3).join(' ')) + if (args.includes('approve')) approved = true + return { exitCode: 0, stdout: '', stderr: '' } + }, + } + const fence: ProcessFence = { + listRunningHosts: async () => + running.map((command, index) => ({ pid: 100 + index, command })), + } + const options = { + env, + runner, + fence, + removePiAnthropicAuth: false, + paths, + discover: async () => INVENTORY, + enrollment: { + paths: { + tokenPath: join(root, 'enrollment', `${host}.json`), + statePath: join(root, 'enrollment', `${host}-state.json`), + }, + client: { + enrollPropose: async () => { + calls.push('propose') + return { requestId: 'synthetic-request-id' } + }, + enrollPoll: async () => { + calls.push('poll') + return approved + ? { + status: 'approved' as const, + name: `anthropic-auth-${host}`, + token: 'c'.repeat(64), + tokenGeneration: 1, + } + : { status: 'pending' as const } + }, + }, + }, + } + return { + root, + paths, + calls, + options, + cleanup: () => rm(root, { recursive: true, force: true }), + } +} + +async function writeHostAuth(path: string, anthropic?: unknown) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile( + path, + JSON.stringify({ + ...(anthropic ? { anthropic } : {}), + other: OTHER_PROVIDER, + }), + { mode: 0o600 }, + ) +} + +test('a fresh OpenCode install is enrolled, migrated with a vault request and activated', async () => { + const f = await fixture('opencode') + try { + await writeHostAuth(f.paths.hostAuthPath) + expect(await activateNativeVaultForHost('opencode', f.options)).toBe( + 'committed', + ) + expect(f.calls).toEqual([ + 'propose', + 'poll', + 'auth enroll approve', + 'poll', + 'auth grant --principal', + ]) + const journal = await readNativeMigrationJournal(f.paths.paths) + expect(nativeMigrationAuthorityPhase(journal)).toBe('retired') + expect(journal?.activation).toMatchObject({ + kind: 'activation', + phase: 'committed', + }) + expect((await readVaultRoster(f.paths.paths.roster))?.rows).toMatchObject([ + { credentialId: 'oauth:anthropic', accountIdentity: MAIN_IDENTITY }, + ]) + expect(JSON.parse(await readFile(f.paths.hostAuthPath, 'utf8'))).toEqual({ + anthropic: custodyTombstoneOAuth('anthropic'), + other: OTHER_PROVIDER, + }) + } finally { + await f.cleanup() + } +}) + +test('a retired local pool is activated without rerunning its migration', async () => { + const f = await fixture('opencode') + try { + await writeHostAuth(f.paths.hostAuthPath) + await runNativeMigration({ + paths: f.paths.paths, + legacyConfigPath: f.paths.legacyConfigPath, + legacyStatePath: f.paths.legacyStatePath, + host: 'opencode', + hostAuthPath: f.paths.hostAuthPath, + routingSourcePath: f.paths.routingSourcePath, + routingDestinationPath: f.paths.routingDestinationPath, + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: false, + }) + expect( + nativeMigrationAuthorityPhase( + await readNativeMigrationJournal(f.paths.paths), + ), + ).toBe('retired') + expect(await activateNativeVaultForHost('opencode', f.options)).toBe( + 'committed', + ) + expect( + (await readNativeMigrationJournal(f.paths.paths))?.activation, + ).toMatchObject({ phase: 'committed' }) + } finally { + await f.cleanup() + } +}) + +test('environment, running-host and Pi consent fences refuse before enrollment or any file write', async () => { + const cases: Array<{ + host: HarnessKind + running?: string[] + env?: NodeJS.ProcessEnv + anthropic?: unknown + consent?: boolean + error: Record + }> = [ + { + host: 'opencode', + env: { OPENCODE_AUTH_CONTENT: '{}' }, + error: { code: 'auth-content-refused' }, + }, + { + host: 'opencode', + running: ['opencode serve'], + error: { name: 'ProcessFenceViolationError' }, + }, + { + host: 'pi', + anthropic: { type: 'api_key', key: 'synthetic-pi-api-key' }, + consent: true, + error: { code: 'primary-conflict' }, + }, + { + host: 'pi', + anthropic: { + type: 'oauth', + access: 'synthetic-pi-access', + refresh: 'synthetic-pi-refresh', + expires: 1, + }, + error: { code: 'consent-required' }, + }, + ] + for (const item of cases) { + const f = await fixture(item.host, item.running) + try { + await writeHostAuth(f.paths.hostAuthPath, item.anthropic) + const host = await readFile(f.paths.hostAuthPath, 'utf8') + await expect( + activateNativeVaultForHost(item.host, { + ...f.options, + env: { ...f.options.env, ...item.env }, + removePiAnthropicAuth: item.consent ?? false, + }), + ).rejects.toMatchObject(item.error) + expect(f.calls).toEqual([]) + expect(await readFile(f.paths.hostAuthPath, 'utf8')).toBe(host) + expect(await readNativeMigrationJournal(f.paths.paths)).toBeUndefined() + const entries = await readdir(f.root) + expect(entries.includes('enrollment')).toBe(false) + } finally { + await f.cleanup() + } + } +}) diff --git a/packages/opencode/src/setup/native-activation.ts b/packages/opencode/src/setup/native-activation.ts new file mode 100644 index 000000000..fdebfe006 --- /dev/null +++ b/packages/opencode/src/setup/native-activation.ts @@ -0,0 +1,172 @@ +import { readFile } from 'node:fs/promises' +import { + discoverNativeVaultInventory, + inspectNativeHostAuthEntry, + type NativeCustodyActivationHooks, + type NativeCustodyInventory, + publishNativeVaultRosterSeed, + publishNativeVaultRuntimeSeed, + readNativeMigrationJournal, + requireNoSupervisedAuthContentSnapshot, + runNativeCustodyActivation, + runNativeMigration, +} from '@cortexkit/anthropic-auth-core' + +import { + enrollNativeVaultForHost, + type NativeVaultEnrollmentOptions, +} from './native-enrollment.ts' +import { + type NativeSetupPaths, + resolveNativeSetupPaths, +} from './native-paths.ts' +import { assertHostsStopped } from './process-fence.ts' +import type { CommandRunner, HarnessKind, ProcessFence } from './types.ts' + +export class NativeVaultActivationError extends Error { + constructor( + public readonly code: + | 'auth-content-refused' + | 'host-auth-invalid' + | 'primary-conflict' + | 'consent-required', + ) { + super( + { + 'auth-content-refused': + 'Run setup without OPENCODE_AUTH_CONTENT in the environment.', + 'host-auth-invalid': 'The host auth file could not be read safely.', + 'primary-conflict': + 'Pi has a stored Anthropic API key, which would take precedence over the vault account. Remove it in Pi, then rerun setup.', + 'consent-required': + 'Pi has a stored Anthropic OAuth login. Rerun setup and agree to remove it so the vault account serves requests.', + }[code], + ) + this.name = 'NativeVaultActivationError' + } +} + +export interface NativeVaultActivationOptions { + env: NodeJS.ProcessEnv + runner: CommandRunner + fence: ProcessFence + /** The user's explicit consent to delete Pi's stored Anthropic OAuth login. */ + removePiAnthropicAuth: boolean + /** + * Tests override the host paths, enrollment client and vault listing; + * production resolves them from the host's environment. + */ + paths?: NativeSetupPaths + enrollment?: Pick< + NativeVaultEnrollmentOptions, + 'paths' | 'client' | 'ckBinary' | 'connectionFile' + > + discover?: () => Promise + hooks?: NativeCustodyActivationHooks +} + +async function readHostAuth(path: string): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) + } catch (error) { + if ((error as { code?: unknown }).code === 'ENOENT') return undefined + throw new NativeVaultActivationError('host-auth-invalid') + } +} + +/** + * Explicit offline switch of one host to vault custody: enroll this host, + * finish (or start) its migration, then activate vault custody. Each host is + * enrolled and activated on its own; nothing here runs at host startup. + */ +export async function activateNativeVaultForHost( + host: HarnessKind, + options: NativeVaultActivationOptions, +): Promise<'committed' | 'already-vault'> { + const env = { ...options.env } + // Checked before every write below: OPENCODE_AUTH_CONTENT (an auth snapshot + // a supervising OpenCode passes down) must be unset, and no host may run. + const processFence = async () => { + try { + requireNoSupervisedAuthContentSnapshot(env) + } catch { + throw new NativeVaultActivationError('auth-content-refused') + } + await assertHostsStopped(options.fence) + } + await processFence() + const plan = options.paths ?? (await resolveNativeSetupPaths(host, env)) + let entry: ReturnType + try { + entry = inspectNativeHostAuthEntry( + host, + await readHostAuth(plan.hostAuthPath), + ) + } catch { + throw new NativeVaultActivationError('host-auth-invalid') + } + if (host === 'pi') { + // Consent to remove an OAuth login never covers a Pi API key. + if (entry.kind === 'api_key') + throw new NativeVaultActivationError('primary-conflict') + if (entry.kind !== 'absent' && !options.removePiAnthropicAuth) + throw new NativeVaultActivationError('consent-required') + } + await enrollNativeVaultForHost(host, { + env, + runner: options.runner, + processFence, + ...options.enrollment, + }) + const discover = + options.discover ?? + (() => + discoverNativeVaultInventory({ + paths: plan.paths, + host, + env: + host === 'pi' + ? { + ...env, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: + env.PI_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE, + } + : env, + })) + const journal = await readNativeMigrationJournal(plan.paths) + // A retired (fully finished) migration is not rerun, because rerunning it + // would repair host auth that the activation now checks. Otherwise finish + // or start the migration; a first migration records that vault custody was + // requested, which keeps serving refused until the activation commits. + if (journal?.phase !== 'retired') + await runNativeMigration({ + paths: plan.paths, + legacyConfigPath: plan.legacyConfigPath, + legacyStatePath: plan.legacyStatePath, + host, + hostAuthPath: plan.hostAuthPath, + routingSourcePath: plan.routingSourcePath, + routingDestinationPath: plan.routingDestinationPath, + env, + processFence, + removePiAnthropicAuth: options.removePiAnthropicAuth, + custody: { + discover, + publishSeed: publishNativeVaultRosterSeed, + publishRuntimeSeed: publishNativeVaultRuntimeSeed, + }, + ...(journal ? {} : { requestVaultActivation: true }), + }) + const result = await runNativeCustodyActivation( + { + paths: plan.paths, + host, + env, + processFence, + removePiAnthropicAuth: options.removePiAnthropicAuth, + discover, + }, + options.hooks, + ) + return result.status +} diff --git a/packages/opencode/src/setup/native-enrollment.test.ts b/packages/opencode/src/setup/native-enrollment.test.ts index 25b7f1b1c..5cecdab68 100644 --- a/packages/opencode/src/setup/native-enrollment.test.ts +++ b/packages/opencode/src/setup/native-enrollment.test.ts @@ -1,5 +1,12 @@ import { expect, test } from 'bun:test' -import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises' +import { + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -162,3 +169,64 @@ test('approval and grant failures retain fixed errors without runner stderr or e expect(String(caught)).not.toContain('b'.repeat(64)) }) }) + +test('a terminal enrollment is reset without proposing; only a later explicit setup run proposes again', async () => { + await fixture(async (root) => { + const paths = { + tokenPath: join(root, 'enrollment.json'), + statePath: join(root, 'enrollment-state.json'), + } + await writeFile( + paths.statePath, + JSON.stringify({ + version: 1, + phase: 'blocked', + proposedName: 'anthropic-auth-pi', + errorCode: 'superseded', + updatedAt: 1, + }), + { mode: 0o600 }, + ) + const calls: string[] = [] + const client = { + enrollPropose: async () => { + calls.push('propose') + return { requestId: 'synthetic-request-id' } + }, + enrollPoll: async () => { + calls.push('poll') + return { status: 'pending' as const } + }, + } + const runner: CommandRunner = { + run: async (_command, args) => { + calls.push(args.slice(0, 3).join(' ')) + return { exitCode: 0, stdout: '', stderr: '' } + }, + } + await expect( + enrollNativeVaultForHost('pi', { + paths, + env: {}, + runner, + processFence: async () => {}, + client, + }), + ).rejects.toMatchObject({ code: 'enrollment-reset' }) + expect(calls).toEqual([]) + await expect(stat(paths.tokenPath)).rejects.toMatchObject({ + code: 'ENOENT', + }) + // The next explicit run starts a new request from the reset state. + await expect( + enrollNativeVaultForHost('pi', { + paths, + env: {}, + runner, + processFence: async () => {}, + client, + }), + ).rejects.toMatchObject({ code: 'enrollment-refused' }) + expect(calls.slice(0, 2)).toEqual(['propose', 'poll']) + }) +}) diff --git a/packages/opencode/src/setup/native-enrollment.ts b/packages/opencode/src/setup/native-enrollment.ts index 400ec0bf9..4434730ea 100644 --- a/packages/opencode/src/setup/native-enrollment.ts +++ b/packages/opencode/src/setup/native-enrollment.ts @@ -32,6 +32,7 @@ export class NativeVaultEnrollmentError extends Error { constructor( public readonly code: | 'enrollment-refused' + | 'enrollment-reset' | 'approval-refused' | 'permission-refused', ) { @@ -108,6 +109,10 @@ export async function enrollNativeVaultForHost( await processFence() if ((await manager.resetTerminal()) !== 'reset') throw new NativeVaultEnrollmentError('enrollment-refused') + // Clearing a refused or superseded request is the whole effect of + // this run. A new request is proposed only when the user runs setup + // again, so a stale ceremony never turns into a fresh one unasked. + throw new NativeVaultEnrollmentError('enrollment-reset') } await processFence() status = await manager.reconcile() diff --git a/packages/opencode/src/tests/native-fixture.test.ts b/packages/opencode/src/tests/native-fixture.test.ts index 1119cfd1a..1841cdddc 100644 --- a/packages/opencode/src/tests/native-fixture.test.ts +++ b/packages/opencode/src/tests/native-fixture.test.ts @@ -263,7 +263,7 @@ test('local legacy accounts commit through the real migration and keep unrelated expect(requests).toEqual([]) expect(calls).toEqual(['trackDetached', 'deferCleanup']) - expect(fixture.journal).toMatchObject({ phase: 'retired', version: 3 }) + expect(fixture.journal).toMatchObject({ phase: 'retired', version: 4 }) expect(await readNativeMigrationJournal(fixture.paths)).toEqual( fixture.journal, ) diff --git a/packages/opencode/src/tests/native-serving.test.ts b/packages/opencode/src/tests/native-serving.test.ts index 8c40455f2..35c4b83a0 100644 --- a/packages/opencode/src/tests/native-serving.test.ts +++ b/packages/opencode/src/tests/native-serving.test.ts @@ -560,7 +560,7 @@ async function migrateServingFixture( : {}), }) expect(journal.phase).toBe('retired') - expect(journal.version).toBe(3) + expect(journal.version).toBe(4) expect(JSON.parse(await readFile(hostAuthPath, 'utf8')).other).toEqual({ type: 'api', key: 'synthetic-unrelated-provider', diff --git a/packages/pi/src/native.ts b/packages/pi/src/native.ts index 9cfcaa2a1..1f5342977 100644 --- a/packages/pi/src/native.ts +++ b/packages/pi/src/native.ts @@ -12,6 +12,7 @@ import { type NativeLocalExternalPolicy, type NativePoolPaths, type NativeRefreshResult, + nativeMigrationAuthorityPhase, quotaSnapshotHasStandardWindows, quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, @@ -128,7 +129,7 @@ export class PiNativeRuntime { const journal = await readNativeMigrationJournal(await this.paths) if ( !journal || - (journal.phase !== 'committed' && journal.phase !== 'retired') || + !nativeMigrationAuthorityPhase(journal) || !journal.routingPaths.destination ) throw new Error('Pi native routing requires committed offline migration') diff --git a/packages/pi/src/tests/native-serving.test.ts b/packages/pi/src/tests/native-serving.test.ts index 208165388..fb975fad1 100644 --- a/packages/pi/src/tests/native-serving.test.ts +++ b/packages/pi/src/tests/native-serving.test.ts @@ -298,7 +298,7 @@ test('offline controller imports synthetic Pi host OAuth with consent before nat fences++ }, }) - expect(journal.version).toBe(3) + expect(journal.version).toBe(4) expect(journal.phase).toBe('retired') expect(fences).toBeGreaterThan(0) expect(JSON.parse(await readFile(hostAuthPath, 'utf8'))).toEqual({ From afc92058e43b2432b03377fdfb432a524f592094 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:03:04 +0200 Subject: [PATCH 088/169] mason: restore native profile status hydration and cache settings shape The unified /claude menu now fetches missing or stale OAuth profiles for the account and quota sections. Each display waits at most three seconds and never waits on the profile save. A request that reached the network is not repeated this boot unless its profile is stale. Profiles are shown only for the account identity they were read for, and never after their save was refused. Core adds fetchProfileForDisplay to the native account and vault runtimes. It returns a metadata-only profile copy as soon as the validated response arrives, plus a persisted promise that settles 'saved' or 'failed' and never rejects. The save goes through the same fenced publication fetchProfile uses, and fetchProfile itself is unchanged. Menu cache-on/off store the effective strategy, so an empty store becomes { enabled, mode: 'explicit' }. cachekeep-always removes startHour/endHour. Both match the standalone cache commands. --- packages/core/src/native-account-runtime.ts | 47 +- packages/core/src/native-vault-runtime.ts | 92 +- .../src/tests/native-status-profile.test.ts | 338 +++++++ packages/opencode/src/index.ts | 324 +++++-- .../src/tests/native-menu-settings.test.ts | 253 ++++++ .../src/tests/native-status-profile.test.ts | 845 ++++++++++++++++++ 6 files changed, 1805 insertions(+), 94 deletions(-) create mode 100644 packages/core/src/tests/native-status-profile.test.ts create mode 100644 packages/opencode/src/tests/native-menu-settings.test.ts create mode 100644 packages/opencode/src/tests/native-status-profile.test.ts diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index 87c087688..39ad072cd 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -54,8 +54,10 @@ import { } from './native-runtime.ts' import { acquireNativeVaultRuntime, + type NativeDisplayProfile, type NativeVaultRuntime, type NativeVaultRuntimeOptions, + nativeDisplayProfile, } from './native-vault-runtime.ts' import { fetchOAuthAccountProfile } from './oauth-profile.ts' import { requireNativePoolAuthority } from './pool-authority.ts' @@ -201,6 +203,12 @@ export interface NativeAccountRuntime { fetchImpl?: typeof fetch, signal?: AbortSignal, ): Promise + /** Like fetchProfile, but returns before the profile is saved; see NativeDisplayProfile. */ + fetchProfileForDisplay( + routeId: string, + fetchImpl?: typeof fetch, + signal?: AbortSignal, + ): Promise close(): void } @@ -1085,24 +1093,22 @@ export function createNativeAccountRuntime( async fetchProfile(routeId, fetchImpl = fetch, signal) { if ((await runtime.read()).mode === 'claustrum') return vault.fetchProfile(routeId, fetchImpl, signal) - const result = await fetchReading( - routeId, - fetchImpl, - signal, - (attempt, transport) => - fetchOAuthAccountProfile({ - accessToken: attempt.access, - accountIdentity: attempt.binding.identity, - fetchImpl: transport, - now, - signal, - }), - ) + const result = await readLocalProfile(routeId, fetchImpl, signal) await runtime.publishLocal(result.attempt.subject, { profile: result.value, }) return result.value }, + async fetchProfileForDisplay(routeId, fetchImpl = fetch, signal) { + if ((await runtime.read()).mode === 'claustrum') + return vault.fetchProfileForDisplay(routeId, fetchImpl, signal) + const result = await readLocalProfile(routeId, fetchImpl, signal) + return nativeDisplayProfile(result.value, () => + runtime.publishLocal(result.attempt.subject, { + profile: result.value, + }), + ) + }, close() { if (!shutdown.signal.aborted) { shutdown.abort(new NativeCustodyError('closed')) @@ -1110,6 +1116,21 @@ export function createNativeAccountRuntime( } }, } + function readLocalProfile( + id: string, + fetchImpl: typeof fetch, + signal: AbortSignal | undefined, + ) { + return fetchReading(id, fetchImpl, signal, (attempt, transport) => + fetchOAuthAccountProfile({ + accessToken: attempt.access, + accountIdentity: attempt.binding.identity, + fetchImpl: transport, + now, + signal, + }), + ) + } async function fetchReading( id: string, fetchImpl: typeof fetch, diff --git a/packages/core/src/native-vault-runtime.ts b/packages/core/src/native-vault-runtime.ts index ea859ca77..815409bab 100644 --- a/packages/core/src/native-vault-runtime.ts +++ b/packages/core/src/native-vault-runtime.ts @@ -73,6 +73,55 @@ export type NativeScoped401Site = | 'prime' | 'quota-profile' +export type NativeProfilePersistence = 'saved' | 'failed' + +/** The profile fields a display may receive; never the legacy token fingerprint. */ +export type NativeDisplayProfileMetadata = Pick< + OAuthAccountProfile, + 'tier' | 'orgType' | 'checkedAt' | 'accountIdentity' | 'providerAccountUuid' +> + +/** + * A profile read for display, returned as soon as the validated profile + * response arrives. `profile` is a copy of the profile's metadata fields only; + * it carries no credential, receipt or subject. `persisted` follows the same + * fenced publication that fetchProfile awaits, resolves 'saved' or 'failed', + * and never rejects, so a caller may leave it unawaited. + */ +export interface NativeDisplayProfile { + profile: NativeDisplayProfileMetadata + persisted: Promise +} + +/** Build a NativeDisplayProfile from a validated profile and its publication. */ +export function nativeDisplayProfile( + profile: OAuthAccountProfile, + publish: () => Promise, +): NativeDisplayProfile { + return { + // Copy named fields rather than spreading, so nothing else stored on a + // profile, such as a legacy token fingerprint, reaches a display. + profile: { + tier: profile.tier, + orgType: profile.orgType, + checkedAt: profile.checkedAt, + ...(profile.accountIdentity !== undefined && { + accountIdentity: profile.accountIdentity, + }), + ...(profile.providerAccountUuid !== undefined && { + providerAccountUuid: profile.providerAccountUuid, + }), + }, + // A refused fence and a thrown write both mean the profile was not saved. + persisted: Promise.resolve() + .then(publish) + .then( + (saved): NativeProfilePersistence => (saved ? 'saved' : 'failed'), + (): NativeProfilePersistence => 'failed', + ), + } +} + export interface NativeVaultRuntime { read(): Promise refresh(signal?: AbortSignal): Promise @@ -111,6 +160,12 @@ export interface NativeVaultRuntime { fetchImpl?: typeof fetch, signal?: AbortSignal, ): Promise + /** Like fetchProfile, but returns before the profile is saved; see NativeDisplayProfile. */ + fetchProfileForDisplay( + routeId: string, + fetchImpl?: typeof fetch, + signal?: AbortSignal, + ): Promise setEnabled(routeId: string, enabled: boolean): Promise resetBackoff( receipt: NativeCustodyReceipt, @@ -512,22 +567,16 @@ export function createNativeVaultRuntime( return quota }, async fetchProfile(routeId, fetchImpl = fetch, signal) { - const result = await fetchReading( - routeId, - fetchImpl, - signal, - (receipt, transport) => - fetchOAuthAccountProfile({ - accessToken: receipt.accessToken, - accountIdentity: receipt.assertedAccountIdentity, - fetchImpl: transport, - now: options.now, - signal, - }), - ) + const result = await readProfile(routeId, fetchImpl, signal) await runtime.publish(result.receipt, { profile: result.value }) return result.value }, + async fetchProfileForDisplay(routeId, fetchImpl = fetch, signal) { + const result = await readProfile(routeId, fetchImpl, signal) + return nativeDisplayProfile(result.value, () => + runtime.publish(result.receipt, { profile: result.value }), + ) + }, async setEnabled(routeId, enabled) { await requireActive() const roster = await readVaultRoster(paths.roster) @@ -713,6 +762,21 @@ export function createNativeVaultRuntime( check(combined) return { value, receipt: served } } + function readProfile( + routeId: string, + fetchImpl: typeof fetch, + signal: AbortSignal | undefined, + ) { + return fetchReading(routeId, fetchImpl, signal, (receipt, transport) => + fetchOAuthAccountProfile({ + accessToken: receipt.accessToken, + accountIdentity: receipt.assertedAccountIdentity, + fetchImpl: transport, + now: options.now, + signal, + }), + ) + } return runtime } @@ -770,6 +834,8 @@ export function acquireNativeVaultRuntime( use().fetchQuota(id, transport, signal), fetchProfile: (id, transport, signal) => use().fetchProfile(id, transport, signal), + fetchProfileForDisplay: (id, transport, signal) => + use().fetchProfileForDisplay(id, transport, signal), setEnabled: (id, enabled) => use().setEnabled(id, enabled), resetBackoff: (receipt, kind) => use().resetBackoff(receipt, kind), getOrCreateAuthLineage: (receipt) => use().getOrCreateAuthLineage(receipt), diff --git a/packages/core/src/tests/native-status-profile.test.ts b/packages/core/src/tests/native-status-profile.test.ts new file mode 100644 index 000000000..555335dcf --- /dev/null +++ b/packages/core/src/tests/native-status-profile.test.ts @@ -0,0 +1,338 @@ +import { expect } from 'bun:test' +import { readFileSync } from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { projectVaultRoster } from '@cortexkit/common-auth/claustrum' +import { withLock } from '@cortexkit/common-auth/fs' +import { POOL_LOCK_DEFAULTS } from '@cortexkit/common-auth/store' +import type { ProviderAccountUuid } from '../claude-code.ts' +import { createNativeAccountRuntime } from '../native-account-runtime.ts' +import type { NativeCustodyClient } from '../native-custody.ts' +import { nativeQuotaCodec } from '../native-quota-codec.ts' +import { createNativeVaultRuntime } from '../native-vault-runtime.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createNativePoolStore, nativePoolStoreLocks } from '../pool-store.ts' +import { initializeNativeTestAuthority } from './native-authority-fixture.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +// Account and quota displays may show a fetched profile before it is saved. +// fetchProfileForDisplay returns a metadata-only copy as soon as the profile +// response is validated; its `persisted` promise follows the same fenced save +// that fetchProfile still waits for, and reports a failed save without +// rejecting. + +const { test, deferCleanup, gate, trackDetached } = createTestLifetimeSuite() +const uuid = '11111111-2222-4333-8444-555555555555' as ProviderAccountUuid +const now = 1_000_000 +const tier = 'default_claude_max_5x' + +async function fixtureRoot(name: string) { + const parent = new URL( + `../../../../node_modules/.cache/${name}/`, + import.meta.url, + ).pathname + await mkdir(parent, { recursive: true }) + const root = await mkdtemp(join(parent, 'fixture-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + return resolveNativePoolPaths(join(root, 'anthropic-auth.json')) +} + +async function localFixture() { + const paths = await fixtureRoot('native-status-profile-local') + await initializeNativeTestAuthority(paths) + const store = createNativePoolStore({ + paths, + quota: nativeQuotaCodec, + now: () => now, + }) + await store.initialize() + await store.add({ + id: 'imported-main', + identity: uuid, + credential: { + type: 'oauth', + access: 'synthetic-access', + refresh: 'synthetic-refresh', + expires: now + 3_600_000, + }, + }) + await store.updateSettings((settings) => { + settings.mainAccountId = 'imported-main' + }) + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: { + now: () => now, + resolveIdentity: async () => ({ + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: uuid, + }), + refreshToken: async () => { + throw new Error('This test never refreshes a credential') + }, + }, + }) + deferCleanup(() => runtime.close()) + return { paths, runtime, store } +} + +async function vaultFixture() { + const paths = await fixtureRoot('native-status-profile-vault') + await initializeNativeTestAuthority(paths) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + await store.initialize() + const credentialId = 'oauth:anthropic' + const projected = projectVaultRoster( + undefined, + { + view: 'initial', + credentials: [ + { + credentialId, + credentialType: 'oauth', + accountIdentity: uuid, + state: 'active', + }, + ], + skipped: [], + }, + { now }, + ) + const primaryRouteId = projected.rows[0]?.routeId + if (!primaryRouteId) throw new Error('Missing fixture primary route') + await store.updateSettings((settings) => { + settings.mainAccountId = primaryRouteId + settings.claustrum = { + mode: 'claustrum', + primaryAccount: { credentialId, accountId: uuid, state: 'active' }, + } + }) + const client: NativeCustodyClient = { + listScoped: async () => ({ + view: 'view-1', + rows: [ + { + id: credentialId, + accountId: uuid, + credentialType: 'oauth', + categories: ['anthropic-native'], + refreshAdapter: 'anthropic', + serves: ['anthropic'], + providerIds: [], + state: 'active', + operations: ['read'], + recordVersion: 7, + createdAtMs: null, + }, + ], + }), + getScoped: async (input) => ({ + credentialId: input.credentialId, + accountId: uuid, + material: 'synthetic-bearer-7', + recordVersion: 7, + expiresAtMs: now + 3_600_000, + }), + reportAuthFailureScoped: async () => {}, + close: () => {}, + } + const runtime = createNativeVaultRuntime({ + paths, + host: 'opencode', + now: () => now, + connect: async () => client, + readToken: async () => ({ token: '01'.repeat(32), token_generation: 1 }), + }) + deferCleanup(() => runtime.close()) + return { paths, runtime } +} + +function profileTransport( + requests: string[], + beforeResponse: () => Promise = async () => {}, +): typeof fetch { + return Object.assign( + async (input: Parameters[0]) => { + requests.push(String(input)) + await beforeResponse() + return Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: tier, + }, + }) + }, + { preconnect: fetch.preconnect }, + ) +} + +function savedRuntimeText(path: string) { + try { + return readFileSync(path, 'utf8') + } catch { + return '' + } +} + +const displayProfile = { + tier, + orgType: 'claude_max', + checkedAt: now, + accountIdentity: uuid, +} + +/** + * Take the pool-config lock that every local metadata write needs and keep it + * until `release` resolves. Resolves `held` once the lock is owned. + */ +function holdPoolConfigLock( + paths: Awaited>, + release: Promise, +) { + const [config] = nativePoolStoreLocks(paths) + let acquired!: () => void + const held = new Promise((resolve) => { + acquired = resolve + }) + const work = withLock( + config.path, + { ...POOL_LOCK_DEFAULTS, name: config.name }, + async () => { + acquired() + await release + }, + ) + trackDetached(work) + return { held, work } +} + +test('local display profile returns while its save is held, then saves through the fence', async () => { + const f = await localFixture() + const requests: string[] = [] + const release = gate() + let hold: ReturnType | undefined + const display = await f.runtime.fetchProfileForDisplay( + 'main', + profileTransport(requests, async () => { + hold = holdPoolConfigLock(f.paths, release.wait) + await hold.held + }), + ) + let outcome: string | undefined + const persisted = display.persisted.then((value) => { + outcome = value + return value + }) + expect(requests).toEqual(['https://api.anthropic.com/api/oauth/profile']) + // Exactly the allowlisted metadata: no bearer, receipt, subject or token + // fingerprint. + expect(display.profile).toEqual(displayProfile) + expect(Object.keys(display).sort()).toEqual(['persisted', 'profile']) + expect(outcome).toBeUndefined() + expect(savedRuntimeText(f.paths.runtime)).not.toContain(tier) + release.open() + await hold?.work + expect(await persisted).toBe('saved') + const read = await f.runtime.read() + expect(read.accounts.find((row) => row.id === 'main')?.profile?.tier).toBe( + tier, + ) +}) + +test('vault display profile returns metadata only and saves through the fence', async () => { + const f = await vaultFixture() + const requests: string[] = [] + const display = await f.runtime.fetchProfileForDisplay( + 'main', + profileTransport(requests), + ) + expect(requests).toHaveLength(1) + expect(display.profile).toEqual(displayProfile) + expect(JSON.stringify(display.profile)).not.toContain('synthetic-bearer') + expect(await display.persisted).toBe('saved') + expect(savedRuntimeText(f.paths.runtime)).toContain(tier) +}) + +test('a save refused by the credential fence resolves failed and stores nothing', async () => { + const f = await localFixture() + const display = await f.runtime.fetchProfileForDisplay( + 'main', + // The account is removed while its profile request is in flight, so the + // save no longer matches the credential the profile was read with. + profileTransport([], async () => { + await f.store.remove('imported-main') + }), + ) + expect(display.profile).toEqual(displayProfile) + expect(await display.persisted).toBe('failed') + expect(savedRuntimeText(f.paths.runtime)).not.toContain(tier) +}) + +test('a save that throws resolves failed without an unhandled rejection in a fresh process', async () => { + const f = await localFixture() + const script = join(dirname(f.paths.journal), 'persist-failure.ts') + const coreSource = new URL('../', import.meta.url).pathname + await writeFile( + script, + `import { rmSync } from 'node:fs' +import { createNativeAccountRuntime } from ${JSON.stringify(`${coreSource}native-account-runtime.ts`)} +import { resolveNativePoolPaths } from ${JSON.stringify(`${coreSource}pool-paths.ts`)} +const paths = await resolveNativePoolPaths(${JSON.stringify(f.paths.legacyConfig)}) +const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: { + now: () => ${now}, + resolveIdentity: async () => ({ deviceId: 'd', sessionId: 's', accountUuid: ${JSON.stringify(uuid)} }), + refreshToken: async () => { throw new Error('no refresh') }, + }, +}) +const transport = Object.assign(async () => { + // Removing the authority journal makes the following save throw. + rmSync(paths.journal) + return Response.json({ organization: { organization_type: 'claude_max', rate_limit_tier: ${JSON.stringify(tier)} } }) +}, { preconnect: fetch.preconnect }) +const display = await runtime.fetchProfileForDisplay('main', transport) +console.log('profile', display.profile.tier) +console.log('persisted', await display.persisted) +// Let any stray rejection reach the unhandled-rejection check before exit. +await new Promise((resolve) => setImmediate(resolve)) +await new Promise((resolve) => setImmediate(resolve)) +runtime.close() +`, + ) + const child = Bun.spawn([process.execPath, script], { + stdout: 'pipe', + stderr: 'pipe', + }) + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + expect({ code, stdout, stderr }).toEqual({ + code: 0, + stdout: `profile ${tier}\npersisted failed\n`, + stderr: '', + }) + expect(savedRuntimeText(f.paths.runtime)).not.toContain(tier) +}) + +test('missing native authority refuses before any profile request', async () => { + const f = await localFixture() + await rm(f.paths.journal, { force: true }) + const requests: string[] = [] + await expect( + f.runtime.fetchProfileForDisplay('main', profileTransport(requests)), + ).rejects.toThrow() + expect(requests).toEqual([]) +}) + +test('fetchProfile keeps waiting for the save before it returns', async () => { + const f = await localFixture() + const profile = await f.runtime.fetchProfile('main', profileTransport([])) + expect(profile.tier).toBe(tier) + expect(savedRuntimeText(f.paths.runtime)).toContain(tier) +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 4ba1064c4..8c745aa02 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -62,7 +62,6 @@ import { executeRoutingCommand, FALLBACK_BACKGROUND_TICK_MS, fallbackAccountUuidForLineage, - type fetchOAuthAccountProfile, formatEnrollmentStatus, formatOAuthAccountTier, formatQuotaBackoffMessage, @@ -117,6 +116,7 @@ import { mergeAnthropicBetas, mergeHeaderQuotaForPersistence, type NativeAccountRuntimeOptions, + type NativeAccountSnapshot, type NativeAccountView, type NativeCustodyClient, type NativeCustodyReceipt, @@ -125,8 +125,10 @@ import { type NativeUiOptions, normalizeQuotaHeaders, type OAuthAccount, + type OAuthAccountProfile, type OAuthQuotaSnapshot, oauthProfileIsFresh, + oauthProfileMatchesIdentity, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, PrimeManager, type PrimeManagerOptions, @@ -1238,15 +1240,51 @@ const anthropicAuthPlugin = async ( }) return loadAccounts() } - const setCache1hPersistentEnabled = (enabled: boolean) => - updateNativeSection('claudeCache', { enabled }) + function plainSettingsSection(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} + } + // Turning the cache on or off also records the effective strategy, as the + // standalone cache command always did: a pool without a stored mode is + // saved as { enabled, mode: 'explicit' } instead of leaving mode implicit. + const setCache1hPersistentEnabled = async (enabled: boolean) => { + await nativeAccounts.updateSettings((settings) => ({ + ...settings, + claudeCache: { + ...plainSettingsSection(settings.claudeCache), + enabled, + mode: getCache1hPersistentMode({ + ...settings, + version: 1, + accounts: [], + }), + }, + })) + return loadAccounts() + } const setCache1hPersistentMode = ( mode: NonNullable['mode'], ) => updateNativeSection('claudeCache', { mode }) const setCacheKeepPersistentEnabled = (enabled: boolean) => updateNativeSection('cacheKeep', { enabled }) - const setCacheKeepPersistentAlways = () => - updateNativeSection('cacheKeep', { enabled: true, always: true }) + // An always-on schedule has no hour bounds. Leaving a previous window's + // startHour/endHour behind would revive that window as soon as anything + // reads the hours without first checking `always`. + const setCacheKeepPersistentAlways = async () => { + await nativeAccounts.updateSettings((settings) => { + const { + startHour: _startHour, + endHour: _endHour, + ...rest + } = plainSettingsSection(settings.cacheKeep) + return { + ...settings, + cacheKeep: { ...rest, enabled: true, always: true }, + } + }) + return loadAccounts() + } const setCacheKeepPersistentWindow = (startHour: number, endHour: number) => updateNativeSection('cacheKeep', { enabled: true, @@ -1676,87 +1714,231 @@ const anthropicAuthPlugin = async ( initialStorage?.quotaHeaderFeed?.enabled === true ? new QuotaHeaderFeedRegistry() : null - const profileHydrationAttempts = new Map< - string, - Promise> | undefined> - >() + // Profiles fetched by this plugin instance, keyed by route and account + // identity. A profile request that reached the network is not repeated in + // this boot: a failed request stays failed, and a fetched profile is reused + // until it goes stale, even if saving it is slow or fails. Attempts refused + // before any request (missing native authority, unavailable route) leave no + // entry, so a later display can try again. + interface ProfileHydration { + /** Settles when the profile response arrives or the attempt fails, never on the save. */ + fetched: Promise + /** + * The display bound the attempt started under. Once it has expired, a + * later display does not wait on the same attempt again. + */ + deadline?: AbortSignal + profile?: OAuthAccountProfile + persistence?: 'pending' | 'saved' | 'failed' + } + const profileHydrations = new Map() + // Saving a fetched profile continues after the display has used it. Dispose + // aborts unfinished profile requests and waits for these saves, so an + // instance never leaves a metadata write running after it is torn down. + const profilePublications = new Set>() + const profileHydrationShutdown = new AbortController() + + function profileHydrationKey(id: string, accountIdentity?: string) { + return JSON.stringify([id, accountIdentity ?? null]) + } - async function hydrateProfileOnce( - accountId: string, - accountIdentity: string | undefined, - _accessToken: string, - _providerAccountUuid?: ProviderAccountUuid, - signal?: AbortSignal, + /** A fetched profile may be displayed while fresh unless its persistence failed. */ + function displayableProfile( + hydration: ProfileHydration | undefined, + now: number, ) { - if (signal?.aborted) return undefined + return hydration?.profile && + hydration.persistence !== 'failed' && + oauthProfileIsFresh(hydration.profile, now) + ? hydration.profile + : undefined + } + + function startProfileHydration( + account: NativeAccountView, + key: string, + signal?: AbortSignal, + ): ProfileHydration { assertNativeEnvironment() - const attemptKey = `${accountIdentity ?? accountId}` - let attempt = profileHydrationAttempts.get(attemptKey) - if (!attempt) { - const fetchAttempt: Promise< - Awaited> | undefined - > = nativeAccounts - .fetchProfile(accountId, profileFetch, signal) - .catch((error: unknown) => { + let requested = false + const transport: typeof fetch = Object.assign( + ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requested = true + return profileFetch(input, init) + }, + { preconnect: profileFetch.preconnect }, + ) + const hydration: ProfileHydration = { + fetched: Promise.resolve(), + ...(signal && { deadline: signal }), + } + hydration.fetched = nativeAccounts + .fetchProfileForDisplay( + account.id, + transport, + AbortSignal.any([ + profileHydrationShutdown.signal, + ...(signal ? [signal] : []), + ]), + ) + .then( + ({ profile, persisted }) => { + // Keep a profile only for the account it was read for. A route + // whose identity was not yet known accepts the identity this read + // reports; the display checks it against the route again later. + if ( + account.accountIdentity === undefined || + profile.accountIdentity === account.accountIdentity + ) { + hydration.profile = profile + hydration.persistence = 'pending' + } + const saving = persisted.then((outcome) => { + hydration.persistence = outcome + if (outcome === 'failed') + logger.debug('quota', 'failed to save account profile', { + account: account.id, + }) + }) + profilePublications.add(saving) + void saving.then(() => profilePublications.delete(saving)) + }, + (error: unknown) => { logger.debug('quota', 'failed to hydrate account profile', { - account: accountId, + account: account.id, error: error instanceof Error ? error.message : String(error), }) - return undefined - }) - .finally(() => { - if (profileHydrationAttempts.get(attemptKey) === fetchAttempt) { - profileHydrationAttempts.delete(attemptKey) - } - }) - attempt = fetchAttempt - profileHydrationAttempts.set(attemptKey, attempt) - } - if (!signal) return attempt - return new Promise>((resolve) => { - let settled = false - const finish = (profile: Awaited) => { - if (settled) return - settled = true - signal.removeEventListener('abort', onAbort) - resolve(profile) + if (!requested && profileHydrations.get(key) === hydration) + profileHydrations.delete(key) + }, + ) + profilePublications.add(hydration.fetched) + void hydration.fetched.then(() => + profilePublications.delete(hydration.fetched), + ) + return hydration + } + + /** + * Wait for an attempt's profile response, but no longer than this display's + * `signal` or the bound the attempt itself started under. + */ + function waitForProfile( + hydration: ProfileHydration, + signal?: AbortSignal, + ): Promise { + const bounds = [signal, hydration.deadline].filter( + (bound): bound is AbortSignal => bound !== undefined, + ) + if (!bounds.length) return hydration.fetched + const bound = AbortSignal.any(bounds) + if (bound.aborted) return Promise.resolve() + return new Promise((resolve) => { + const finish = () => { + bound.removeEventListener('abort', finish) + resolve() } - const onAbort = () => finish(undefined) - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) onAbort() - void attempt.then(finish) + bound.addEventListener('abort', finish, { once: true }) + void hydration.fetched.then(finish) }) } - async function ensureProfilesForQuotaDisplay( - storage: AccountStorage, - _mainAccessToken?: string, + function storedProfileIsFresh(account: NativeAccountView, now: number) { + return ( + oauthProfileIsFresh(account.profile, now) && + oauthProfileMatchesIdentity(account.profile, account.accountIdentity) + ) + } + + /** + * Read native account metadata for the account and quota displays, first + * fetching missing or stale OAuth profiles. Waiting stops when `signal` + * aborts; a failed fetch or a refused save leaves the stored profile in + * place, and the display never waits for a fetched profile to be saved. + */ + async function readNativeSnapshotForDisplay( signal?: AbortSignal, - _mainProviderAccountUuid?: ProviderAccountUuid, - ): Promise { - if (process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION === '1') - return storage + ): Promise { const snapshot = await nativeAccounts.read() + if (process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION === '1') + return snapshot + const now = Date.now() + const waits: Promise[] = [] + // Attempts made for routes whose account identity was not yet known. + const unidentified = new Map() for (const account of snapshot.accounts) { - if (signal?.aborted) break if ( account.type !== 'oauth' || !account.enabled || - oauthProfileIsFresh(account.profile, Date.now()) + storedProfileIsFresh(account, now) ) continue - await hydrateProfileOnce( - account.id, - account.accountIdentity, - '', - asProviderAccountUuid(account.accountIdentity), - signal, + const key = profileHydrationKey(account.id, account.accountIdentity) + let hydration = profileHydrations.get(key) + if ( + !hydration || + (hydration.profile && !oauthProfileIsFresh(hydration.profile, now)) + ) { + if (signal?.aborted || profileHydrationShutdown.signal.aborted) continue + hydration = startProfileHydration(account, key, signal) + profileHydrations.set(key, hydration) + } + if (account.accountIdentity === undefined) + unidentified.set(account.id, hydration) + waits.push(waitForProfile(hydration, signal)) + } + if (!waits.length) return snapshot + await Promise.all(waits) + // An account can be replaced or signed in again while profiles load. + // Reread the accounts and attach each fetched profile only to the route + // that still has the identity the profile was read for. + const current = await nativeAccounts.read() + const later = Date.now() + for (const account of current.accounts) { + if (account.type !== 'oauth') continue + const key = profileHydrationKey(account.id, account.accountIdentity) + // Authorizing a route records its account identity, so a route that + // was unidentified when its attempt began is usually identified now. + // File the attempt under the identified route as well; otherwise the + // next display would repeat the request or miss the profile. + const earlier = unidentified.get(account.id) + if ( + earlier && + account.accountIdentity !== undefined && + !profileHydrations.has(key) && + (!earlier.profile || + earlier.profile.accountIdentity === account.accountIdentity) ) + profileHydrations.set(key, earlier) + if (storedProfileIsFresh(account, later)) continue + const profile = displayableProfile(profileHydrations.get(key), later) + if (!profile || profile.accountIdentity !== account.accountIdentity) + continue + account.profile = profile + if (account.id === 'main') { + if (current.policyStorage.main) + current.policyStorage.main.profile = profile + } else { + for (const stored of current.policyStorage.accounts) + if (stored.id === account.id && isOAuthAccount(stored)) + stored.profile = profile + } } - // Profile polling saved the tier and organization for the account it read. - // Reload current account metadata so replacing this route cannot display - // the profile fetched for its previous credentials. - return (await nativeAccounts.read()).policyStorage + return current + } + + async function ensureProfilesForQuotaDisplay( + storage: AccountStorage, + _mainAccessToken?: string, + signal?: AbortSignal, + _mainProviderAccountUuid?: ProviderAccountUuid, + ): Promise { + if (process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION === '1') + return storage + return (await readNativeSnapshotForDisplay(signal)).policyStorage } const warnedQuotaNormalizeErrors = new Set() @@ -2986,6 +3168,8 @@ const anthropicAuthPlugin = async ( let rpcServerAdoption: RpcServerAdoption | null = null const dispose: NonNullable = async () => { + profileHydrationShutdown.abort() + while (profilePublications.size) await Promise.all([...profilePublications]) try { closeOpenCodeScopedRuntime(accountStoragePath) } catch (error) { @@ -3345,7 +3529,9 @@ const anthropicAuthPlugin = async ( } await refreshFallbackQuotas(true) } - const snapshot = await nativeAccounts.read() + const snapshot = await readNativeSnapshotForDisplay( + AbortSignal.timeout(3_000), + ) quotaManager.updateStorage(snapshot.policyStorage) quotaManager.seedMainFromStorage(snapshot.policyStorage, mainQuotaAccountId) quotaManager.seedFallbacksFromAccounts( @@ -4620,7 +4806,9 @@ const anthropicAuthPlugin = async ( invocation, ) => { if (command === 'account') { - const snapshot = await nativeAccounts.read() + const snapshot = await readNativeSnapshotForDisplay( + AbortSignal.timeout(3_000), + ) const enrollment = snapshot.mode === 'claustrum' ? await readClaustrumEnrollmentStatus( diff --git a/packages/opencode/src/tests/native-menu-settings.test.ts b/packages/opencode/src/tests/native-menu-settings.test.ts new file mode 100644 index 000000000..018f4fbc5 --- /dev/null +++ b/packages/opencode/src/tests/native-menu-settings.test.ts @@ -0,0 +1,253 @@ +import { expect } from 'bun:test' +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + __setLogTestSink, + createNativeAccountRuntime, + type LogTestRecord, + type NativePoolPaths, + type ProviderAccountUuid, + resetClaudeCodeIdentityCachesForTest, +} from '@cortexkit/anthropic-auth-core' +import type { CommandApplyResult } from '@cortexkit/common-auth/commands' +import { createOpencodeClient } from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { AnthropicAuthPlugin } from '../index.ts' +import { createRpcClient } from '../rpc/rpc-client.ts' +import { getRpcDir } from '../rpc/rpc-dir.ts' +import { migrateNativeOpencodeFixture } from './native-fixture.ts' + +// The unified /claude menu's cache actions must store the same settings the +// standalone cache commands did, not just report the same effect: turning the +// cache on records its strategy, and an always-on keep-warm schedule has no +// hour window. These tests apply menu actions to the real plugin over its +// menu IPC and read the pool config file the settings are stored in. + +const { test, deferCleanup, trackDetached } = createTestLifetimeSuite() + +type PluginHooks = Awaited> +const mainIdentity = '11111111-1111-4111-8111-111111111111' +const sessionId = 'synthetic-session' +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_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_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', + 'CLAUDE_CONFIG_DIR', +] as const + +const nativeLocal = { + resolveIdentity: async () => ({ + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: mainIdentity as ProviderAccountUuid, + }), + refreshToken: async () => { + throw new Error('Menu settings tests never refresh a credential') + }, +} + +/** + * Migrate a local native pool, store `settings` in it, then start the plugin. + * Cleanups run in registration order, so the plugin is disposed and process + * state restored before the fixture removes its root. + */ +async function start(settings: Record) { + const savedFetch = globalThis.fetch + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const logs: LogTestRecord[] = [] + const plugins: PluginHooks[] = [] + deferCleanup(async () => { + try { + for (const plugin of plugins.splice(0)) await plugin.dispose?.() + } finally { + __setLogTestSink(null) + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + const root = await mkdtemp(join(tmpdir(), 'oc-menu-settings-')) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: { deferCleanup, trackDetached }, + legacyConfig: { version: 1, accounts: [] }, + hostAuth: { + anthropic: { + type: 'oauth', + access: 'synthetic-main-access', + refresh: 'synthetic-main-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + }, + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + // These tests are about settings; the menu's profile display is covered + // separately and would otherwise need a profile endpoint. + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + delete process.env.OPENCODE_AUTH_CONTENT + resetClaudeCodeIdentityCachesForTest() + const seeding = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + local: nativeLocal, + }) + try { + await seeding.updateSettings((current) => ({ ...current, ...settings })) + } finally { + seeding.close() + } + const unexpected: string[] = [] + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + // The menu IPC client talks to the plugin over loopback. + if (url.hostname === '127.0.0.1') return savedFetch(input, init) + unexpected.push(url.href) + return new Response('not found', { status: 404 }) + }, + { preconnect: savedFetch.preconnect }, + ) + __setLogTestSink((record) => { + logs.push(record) + }) + const creation = AnthropicAuthPlugin( + { + client: createOpencodeClient({ + baseUrl: 'http://127.0.0.1:9', + fetch: async () => new Response(null, { status: 204 }), + }), + project: { + id: 'native-menu-settings', + worktree: fixture.root, + time: { created: Date.now() }, + }, + directory: fixture.root, + worktree: fixture.root, + serverUrl: new URL('http://127.0.0.1:9'), + experimental_workspace: { register() {} }, + $, + }, + { + scopedRosterPollIntervalMs: 0, + cacheKeepAggregateRefreshIntervalMs: 0, + nativeLocal, + }, + ) + trackDetached(creation) + plugins.push(await creation) + const client = createRpcClient(getRpcDir(fixture.root), process.pid) + return { + paths: fixture.paths, + logs, + unexpected, + apply(actionId: string): Promise { + return client.applyMenu({ + command: 'claude', + sectionId: 'Cache', + actionId, + sessionId, + }) + }, + } +} + +/** The settings stored in the pool config file. */ +async function storedSettings(paths: NativePoolPaths) { + const config: unknown = JSON.parse(await readFile(paths.config, 'utf8')) + if (!config || typeof config !== 'object') + throw new Error('Pool config is not an object') + return config as Record +} + +function commandLogs(logs: LogTestRecord[]) { + return logs + .filter((record) => record.channel === 'commands') + .map(({ level, message, payload }) => ({ level, message, payload })) +} + +const unrelated = { + dump: { enabled: true }, + claudeFast: { enabled: true }, + routing: { mode: 'round-robin' }, + logging: { level: 'info' }, +} + +test('cache-on stores the explicit strategy when none was stored', async () => { + const s = await start({ ...unrelated, claudeCache: { enabled: false } }) + const result = await s.apply('cache-on') + expect(result.ok).toBe(true) + const stored = await storedSettings(s.paths) + expect(stored.claudeCache).toEqual({ enabled: true, mode: 'explicit' }) + expect(stored).toMatchObject(unrelated) + expect(commandLogs(s.logs)).toEqual([ + { + level: 'info', + message: 'cache enabled changed', + payload: { enabled: true }, + }, + ]) + expect(s.unexpected).toEqual([]) +}) + +test('cache-on and cache-off keep a strategy that was already stored', async () => { + const s = await start({ + ...unrelated, + claudeCache: { enabled: false, mode: 'hybrid' }, + }) + expect((await s.apply('cache-on')).ok).toBe(true) + expect((await storedSettings(s.paths)).claudeCache).toEqual({ + enabled: true, + mode: 'hybrid', + }) + expect((await s.apply('cache-off')).ok).toBe(true) + const stored = await storedSettings(s.paths) + expect(stored.claudeCache).toEqual({ enabled: false, mode: 'hybrid' }) + expect(stored).toMatchObject(unrelated) +}) + +test('cachekeep-always removes the stored hour window and keeps other keep-warm settings', async () => { + const s = await start({ + ...unrelated, + cacheKeep: { + enabled: false, + always: false, + startHour: 9, + endHour: 17, + subagents: true, + }, + }) + const result = await s.apply('cachekeep-always') + expect(result.ok).toBe(true) + const stored = await storedSettings(s.paths) + expect(stored.cacheKeep).toEqual({ + enabled: true, + always: true, + subagents: true, + }) + expect(stored).toMatchObject(unrelated) + expect(commandLogs(s.logs)).toEqual([ + { + level: 'info', + message: 'cachekeep schedule changed', + payload: { schedule: 'always' }, + }, + ]) + expect(s.unexpected).toEqual([]) +}) diff --git a/packages/opencode/src/tests/native-status-profile.test.ts b/packages/opencode/src/tests/native-status-profile.test.ts new file mode 100644 index 000000000..605f0dfa3 --- /dev/null +++ b/packages/opencode/src/tests/native-status-profile.test.ts @@ -0,0 +1,845 @@ +import { expect } from 'bun:test' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + __setLogTestSink, + createNativeAccountRuntime, + custodyTombstoneOAuth, + getLogLevel, + type NativeAccountRuntimeOptions, + type NativeCustodyClient, + type NativePoolPaths, + nativePoolStoreLocks, + type ProviderAccountUuid, + resetClaudeCodeIdentityCachesForTest, + setLogLevel, +} from '@cortexkit/anthropic-auth-core' +import { withLock } from '@cortexkit/common-auth/fs' +import { POOL_LOCK_DEFAULTS } from '@cortexkit/common-auth/store' +import { createOpencodeClient } from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { AnthropicAuthPlugin } from '../index.ts' +import { + migrateNativeOpencodeFixture, + type NativeOpencodeFixtureInput, +} from './native-fixture.ts' + +// The unified /claude menu fetches missing or stale OAuth profiles before it +// shows the account and quota sections. These tests run the real plugin on a +// migrated native pool and never call auth.loader: the menu must work before +// OpenCode first asks the plugin for credentials. Profile HTTP goes through a +// replaced global fetch, installed before the plugin captures it. + +const { test, deferCleanup, trackDetached, gate } = createTestLifetimeSuite() + +type PluginHooks = Awaited> +const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile' +const DAY = 24 * 60 * 60 * 1000 +const mainIdentity = '11111111-1111-4111-8111-111111111111' +const fallbackIdentity = '22222222-2222-4222-8222-222222222222' +const otherIdentity = '33333333-3333-4333-8333-333333333333' +/** Which account each synthetic bearer belongs to. */ +const bearerIdentity: Record = { + 'synthetic-main-access': mainIdentity, + 'synthetic-main-rotated': mainIdentity, + 'synthetic-fallback-access': fallbackIdentity, + 'synthetic-fallback-rotated': fallbackIdentity, + 'synthetic-fallback-rotated-again': fallbackIdentity, + 'synthetic-other-access': otherIdentity, + 'synthetic-other-rotated': otherIdentity, + 'synthetic-vault-main-bearer': mainIdentity, + 'synthetic-vault-fallback-bearer': fallbackIdentity, +} +/** The access token a synthetic refresh credential is exchanged for. */ +const rotatedAccess: Record = { + 'synthetic-main-refresh': 'synthetic-main-rotated', + 'synthetic-fallback-refresh': 'synthetic-fallback-rotated', + 'synthetic-fallback-refresh-again': 'synthetic-fallback-rotated-again', + 'synthetic-other-refresh': 'synthetic-other-rotated', +} +/** The tier the profile endpoint reports for each account. */ +const accountTier: Record = { + [mainIdentity]: 'default_claude_max_20x', + [fallbackIdentity]: 'default_claude_max_5x', + [otherIdentity]: 'default_claude_max_10x', +} +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_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_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', + 'CLAUDE_CONFIG_DIR', +] as const + +function profileResponse(tier: string, orgType = 'claude_max') { + return Response.json({ + organization: { organization_type: orgType, rate_limit_tier: tier }, + }) +} + +/** Local credential checks resolve identity from the synthetic bearer, without HTTP. */ +function nativeLocal( + now?: () => number, +): NonNullable { + return { + ...(now && { now }), + resolveIdentity: async (accessToken) => { + const identity = bearerIdentity[accessToken] + if (!identity) throw new Error('Unknown synthetic bearer') + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: identity as ProviderAccountUuid, + } + }, + refreshToken: async (input) => { + const rotated = rotatedAccess[input.refreshToken] + if (!rotated) throw new Error('Unknown synthetic refresh credential') + return { + access: rotated, + refresh: `${input.refreshToken}-next`, + expires: Date.now() + DAY, + expiresIn: DAY / 1000, + } + }, + } +} + +function localLegacyConfig() { + return { + version: 1, + refresh: { enabled: false }, + quota: { enabled: false }, + accounts: [ + { + id: 'fallback-route', + type: 'oauth', + enabled: true, + access: 'synthetic-fallback-access', + refresh: 'synthetic-fallback-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + { + id: 'api-route', + type: 'api', + apiKey: 'synthetic-api-route-key', + baseURL: 'https://synthetic.invalid', + }, + ], + } +} + +function localHostAuth() { + return { + anthropic: { + type: 'oauth', + access: 'synthetic-main-access', + refresh: 'synthetic-main-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + } +} + +/** + * The profile endpoint and nothing else. Every profile request is recorded by + * the account its bearer belongs to (rotation may have replaced the original + * access token); `respond` may replace or delay the answer for an account. + */ +function createNetwork() { + const network = { + profileBearers: [] as string[], + profileAccounts: [] as string[], + unexpected: [] as string[], + respond: undefined as + | ((account: string) => Promise) + | undefined, + } + const saved = globalThis.fetch + const fetchImpl = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const url = input instanceof Request ? input.url : String(input) + if (url !== PROFILE_URL) { + network.unexpected.push(url) + return new Response('not found', { status: 404 }) + } + const bearer = ( + new Headers(init?.headers).get('authorization') ?? '' + ).replace(/^Bearer /, '') + const account = bearerIdentity[bearer] ?? `unknown:${bearer}` + network.profileBearers.push(bearer) + network.profileAccounts.push(account) + const replaced = await network.respond?.(account) + if (replaced) return replaced + const tier = accountTier[account] + return tier + ? profileResponse(tier) + : new Response('unknown bearer', { status: 401 }) + }, + { preconnect: saved.preconnect }, + ) + return { network, fetchImpl } +} + +interface Started { + plugin: PluginHooks + paths: NativePoolPaths + root: string + network: ReturnType['network'] + /** Opens /claude without a TUI and returns the text the plugin replied with. */ + readMenu(): Promise + /** Dispose the plugin, which also waits for its outstanding profile saves. */ + dispose(): Promise +} + +/** + * Migrate a native pool, then start the plugin on it with the synthetic + * network. Cleanups run in registration order, so the plugin is disposed and + * the process state restored before the fixture removes its root. + */ +async function start( + input: Pick< + NativeOpencodeFixtureInput, + 'legacyConfig' | 'legacyState' | 'hostAuth' | 'custody' + >, + options: { + claustrumScopedConnect?: () => Promise + beforePlugin?: (paths: NativePoolPaths) => Promise + } = {}, +): Promise { + const savedFetch = globalThis.fetch + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const plugins: PluginHooks[] = [] + deferCleanup(async () => { + try { + for (const plugin of plugins.splice(0)) await plugin.dispose?.() + } finally { + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + const root = await mkdtemp(join(tmpdir(), 'oc-status-profile-')) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: { deferCleanup, trackDetached }, + ...input, + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + delete process.env.OPENCODE_AUTH_CONTENT + resetClaudeCodeIdentityCachesForTest() + await options.beforePlugin?.(fixture.paths) + const { network, fetchImpl } = createNetwork() + globalThis.fetch = fetchImpl + const replies: string[] = [] + const client = createOpencodeClient({ + baseUrl: 'http://127.0.0.1:9', + // Stands in for the OpenCode server: records ignored replies and reports + // an empty message history. + fetch: async (request: Request) => { + if (request.method === 'GET') return Response.json([]) + const body: unknown = await request.json().catch(() => undefined) + const parts = + body && typeof body === 'object' && 'parts' in body + ? body.parts + : undefined + if (Array.isArray(parts)) + for (const part of parts) + if (part && typeof part === 'object' && 'text' in part) + replies.push(String(part.text)) + return new Response(null, { status: 204 }) + }, + }) + const creation = AnthropicAuthPlugin( + { + client, + project: { + id: 'native-status-profile', + worktree: fixture.root, + time: { created: Date.now() }, + }, + directory: fixture.root, + worktree: fixture.root, + serverUrl: new URL('http://127.0.0.1:9'), + experimental_workspace: { register() {} }, + $, + }, + { + scopedRosterPollIntervalMs: 0, + cacheKeepAggregateRefreshIntervalMs: 0, + nativeLocal: nativeLocal(), + ...(options.claustrumScopedConnect && { + claustrumScopedConnect: options.claustrumScopedConnect, + }), + }, + ) + trackDetached(creation) + const plugin = await creation + plugins.push(plugin) + return { + plugin, + paths: fixture.paths, + root: fixture.root, + network, + async readMenu() { + const before = replies.length + const handled = plugin['command.execute.before']?.( + { command: 'claude', arguments: '', sessionID: 'synthetic-session' }, + { parts: [] }, + ) + await expect(handled).rejects.toThrow() + return replies.slice(before).join('\n') + }, + async dispose() { + const index = plugins.indexOf(plugin) + if (index >= 0) plugins.splice(index, 1) + await plugin.dispose?.() + }, + } +} + +/** The /claude menu's own section headings, in order. */ +const menuSections = [ + 'Accounts', + 'Quota', + 'Routing', + 'Limits', + 'Cache', + 'Diagnostics', + 'Extras', +] + +/** One menu section's text, up to the next menu section heading. */ +function section(text: string, title: string) { + const start = text.indexOf(`\n## ${title}\n`) + if (start < 0) throw new Error(`Menu has no ${title} section:\n${text}`) + const ends = menuSections + .map((next) => text.indexOf(`\n## ${next}\n`, start + 1)) + .filter((index) => index > start) + return text.slice(start, ends.length ? Math.min(...ends) : undefined) +} + +/** The account-section line for one route. */ +function accountLine(text: string, marker: string) { + const line = section(text, 'Accounts') + .split('\n') + .find((candidate) => candidate.includes(marker)) + if (!line) throw new Error(`No account line for ${marker}:\n${text}`) + return line +} + +/** Saved profile tiers by route, read through a separate native runtime. */ +async function storedTiers( + paths: NativePoolPaths, +): Promise> { + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: nativeLocal(), + }) + try { + const snapshot = await runtime.read() + return Object.fromEntries( + snapshot.accounts + .filter((account) => account.type === 'oauth') + .map((account) => [account.id, account.profile?.tier]), + ) + } finally { + runtime.close() + } +} + +/** Save a profile for one route through the real runtime, as of `at`. */ +async function seedProfile( + paths: NativePoolPaths, + routeId: string, + tier: string, + at: number, +) { + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: nativeLocal(() => at), + }) + try { + await runtime.fetchProfile( + routeId, + Object.assign(async () => profileResponse(tier), { + preconnect: globalThis.fetch.preconnect, + }), + ) + } finally { + runtime.close() + } +} + +/** + * Take the pool-config lock that every local metadata save needs and keep it + * until `release` resolves. Resolves `held` once the lock is owned. + */ +function holdPoolConfigLock(paths: NativePoolPaths, release: Promise) { + const [config] = nativePoolStoreLocks(paths) + let acquired!: () => void + const held = new Promise((resolve) => { + acquired = resolve + }) + const work = withLock( + config.path, + { ...POOL_LOCK_DEFAULTS, name: config.name }, + async () => { + acquired() + await release + }, + ) + trackDetached(work) + return { held, work } +} + +test('menu shows fetched local profiles while their saves are still held', async () => { + const s = await start({ + legacyConfig: localLegacyConfig(), + hostAuth: localHostAuth(), + }) + const release = gate() + let hold: ReturnType | undefined + let bothArrived!: () => void + const arrived = new Promise((resolve) => { + bothArrived = resolve + }) + // Teardown opens the gate, so a request can never stay parked past the test. + void release.wait.then(bothArrived) + s.network.respond = async () => { + // Answer only once both accounts' requests have arrived, and only while + // holding the lock every local save needs: neither fetched profile can be + // saved until the test releases it. + if (s.network.profileAccounts.length === 2) { + hold = holdPoolConfigLock(s.paths, release.wait) + await hold.held + bothArrived() + } + await arrived + return undefined + } + const started = performance.now() + const text = await s.readMenu() + // Each display waits at most three seconds for profiles. Finishing well + // inside that bound shows the display used the fetched profiles without + // waiting for their saves; waiting on the held saves would only end at the + // bound, with the same text. + expect(performance.now() - started).toBeLessThan(3_000) + expect(accountLine(text, '[main]')).toContain('Max 20x') + expect(accountLine(text, 'fallback-route')).toContain('Max 5x') + expect(section(text, 'Quota')).toContain('Max 20x') + expect(section(text, 'Quota')).toContain('Max 5x') + // Each account was asked once with its own credential; the API route is not + // an OAuth profile subject. + expect(s.network.profileAccounts.sort()).toEqual( + [mainIdentity, fallbackIdentity].sort(), + ) + expect(s.network.profileBearers).not.toContain('synthetic-api-route-key') + expect(s.network.unexpected).toEqual([]) + const runtimeText = await readFile(s.paths.runtime, 'utf8') + expect(runtimeText).not.toContain(accountTier[mainIdentity]) + expect(runtimeText).not.toContain(accountTier[fallbackIdentity]) + release.open() + await hold?.work + await s.dispose() + expect(await storedTiers(s.paths)).toEqual({ + main: accountTier[mainIdentity], + 'fallback-route': accountTier[fallbackIdentity], + }) +}) + +test('a failed profile request renders saved data silently and is not repeated this boot', async () => { + const s = await start({ + legacyConfig: localLegacyConfig(), + hostAuth: localHostAuth(), + }) + s.network.respond = async (account) => + account === fallbackIdentity + ? new Response('synthetic outage', { status: 503 }) + : undefined + const first = await s.readMenu() + expect(accountLine(first, '[main]')).toContain('Max 20x') + expect(accountLine(first, 'fallback-route')).not.toContain('Max') + expect(first).not.toContain('503') + expect(first).not.toContain('profile check failed') + expect(first).not.toContain('synthetic outage') + const second = await s.readMenu() + expect(accountLine(second, 'fallback-route')).not.toContain('Max') + expect(s.network.profileAccounts.sort()).toEqual( + [mainIdentity, fallbackIdentity].sort(), + ) + await s.dispose() + expect(await storedTiers(s.paths)).toEqual({ + main: accountTier[mainIdentity], + 'fallback-route': undefined, + }) +}) + +test('a stale saved profile is refreshed once and a fresh one is reused per boot', async () => { + const s = await start( + { legacyConfig: localLegacyConfig(), hostAuth: localHostAuth() }, + { + beforePlugin: async (paths) => { + // Older than the seven-day profile lifetime. + await seedProfile( + paths, + 'fallback-route', + 'default_claude_max_10x', + Date.now() - 8 * DAY, + ) + await seedProfile( + paths, + 'main', + accountTier[mainIdentity] ?? '', + Date.now(), + ) + }, + }, + ) + const first = await s.readMenu() + expect(accountLine(first, '[main]')).toContain('Max 20x') + expect(accountLine(first, 'fallback-route')).toContain('Max 5x') + expect(s.network.profileAccounts).toEqual([fallbackIdentity]) + const second = await s.readMenu() + expect(accountLine(second, 'fallback-route')).toContain('Max 5x') + expect(s.network.profileAccounts).toEqual([fallbackIdentity]) + await s.dispose() + expect(await storedTiers(s.paths)).toEqual({ + main: accountTier[mainIdentity], + 'fallback-route': accountTier[fallbackIdentity], + }) +}) + +test('a fresh saved profile survives access rotation without a new profile request', async () => { + const s = await start( + { legacyConfig: localLegacyConfig(), hostAuth: localHostAuth() }, + { + beforePlugin: async (paths) => { + await seedProfile( + paths, + 'main', + accountTier[mainIdentity] ?? '', + Date.now(), + ) + await seedProfile( + paths, + 'fallback-route', + accountTier[fallbackIdentity] ?? '', + Date.now(), + ) + // A later authorization finds the fallback access expired and + // rotates it for the same account. + const later = Date.now() + 9 * 60 * 60_000 + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: nativeLocal(() => later), + }) + try { + const rotated = await runtime.authorizeLocal('fallback-route') + expect(rotated.status).toBe('usable') + } finally { + runtime.close() + } + expect(await readFile(paths.state, 'utf8')).toContain( + 'synthetic-fallback-rotated', + ) + }, + }, + ) + const text = await s.readMenu() + expect(accountLine(text, 'fallback-route')).toContain('Max 5x') + expect(accountLine(text, '[main]')).toContain('Max 20x') + expect(s.network.profileBearers).toEqual([]) +}) + +test('a profile read for a replaced account is not shown or saved for its successor', async () => { + const s = await start({ + legacyConfig: localLegacyConfig(), + hostAuth: localHostAuth(), + }) + const release = gate() + let hold: ReturnType | undefined + let bothArrived!: () => void + const arrived = new Promise((resolve) => { + bothArrived = resolve + }) + void release.wait.then(bothArrived) + s.network.respond = async (account) => { + if (account === fallbackIdentity) { + // The route signs in to a different account while its profile request + // is in flight, and that account is identified before the display + // reads the route again. + const runtime = createNativeAccountRuntime({ + paths: s.paths, + host: 'opencode', + local: nativeLocal(), + }) + try { + await runtime.loginOAuth({ + routeId: 'fallback-route', + credential: { + access: 'synthetic-other-access', + refresh: 'synthetic-other-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + replace: true, + }) + expect((await runtime.authorizeLocal('fallback-route')).status).toBe( + 'usable', + ) + } finally { + runtime.close() + } + } + // Hold the lock every local profile save needs while the menu renders. + // The previous account's profile save for this route is refused at once + // anyway, because it no longer matches the route's credential, so the + // test checks the outcome (that profile never appears on the replacement + // account) rather than which of the display's guards stopped it. + if (s.network.profileAccounts.length === 2 && !hold) { + hold = holdPoolConfigLock(s.paths, release.wait) + await hold.held + bothArrived() + } + await arrived + return undefined + } + const text = await s.readMenu() + expect(accountLine(text, 'fallback-route')).not.toContain('Max') + expect(accountLine(text, '[main]')).toContain('Max 20x') + release.open() + await hold?.work + await s.dispose() + // Only the new account's own profile, fetched by a later display, is saved + // for the route; the old account's profile was refused. + expect((await storedTiers(s.paths))['fallback-route']).toBe( + accountTier[otherIdentity], + ) +}) +test('vault accounts show fetched profiles through vault-issued credentials', async () => { + const row = (id: string, accountId: string) => ({ + id, + accountId, + credentialType: 'oauth', + state: 'active', + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + refreshAdapter: 'anthropic', + recordVersion: 1, + operations: ['read', 'invalidate'], + createdAtMs: 1, + }) + const material: Record = { + 'oauth:anthropic': 'synthetic-vault-main-bearer', + 'oauth:secondary': 'synthetic-vault-fallback-bearer', + } + const client: NativeCustodyClient = { + async listScoped() { + return { + view: 'synthetic-native-view', + rows: [ + row('oauth:anthropic', mainIdentity), + row('oauth:secondary', fallbackIdentity), + ], + } + }, + async getScoped(input) { + return { + credentialId: input.credentialId, + accountId: + input.credentialId === 'oauth:anthropic' + ? mainIdentity + : fallbackIdentity, + material: material[input.credentialId] ?? '', + recordVersion: 1, + expiresAtMs: Date.now() + 60 * 60_000, + } + }, + async reportAuthFailureScoped() {}, + close() {}, + } + const s = await start( + { + legacyConfig: { + version: 1, + mainAccountId: 'primary-route', + refresh: { enabled: false }, + quota: { enabled: false }, + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: mainIdentity, + state: 'active', + }, + }, + accounts: [ + { + id: 'fallback-route', + type: 'oauth', + enabled: true, + refresh: '', + claustrumScopedCredentialId: 'oauth:secondary', + anthropicAccountUuid: fallbackIdentity, + }, + ], + }, + hostAuth: { anthropic: custodyTombstoneOAuth('anthropic') }, + custody: { + connect: async () => client, + enrollment: { token: 'ab'.repeat(32), token_generation: 1 }, + }, + }, + { claustrumScopedConnect: async () => client }, + ) + const text = await s.readMenu() + expect(accountLine(text, '[main]')).toContain('Max 20x') + expect(accountLine(text, '[fallback]')).toContain('Max 5x') + expect(s.network.profileBearers.sort()).toEqual([ + 'synthetic-vault-fallback-bearer', + 'synthetic-vault-main-bearer', + ]) + await s.dispose() + expect(Object.values(await storedTiers(s.paths)).sort()).toEqual( + [accountTier[mainIdentity], accountTier[fallbackIdentity]].sort(), + ) +}) + +test('missing native authority refuses status without any profile request', async () => { + const s = await start({ + legacyConfig: localLegacyConfig(), + hostAuth: localHostAuth(), + }) + await rm(s.paths.journal, { force: true }) + const text = await s.readMenu() + expect(section(text, 'Accounts')).not.toContain('Max') + expect(s.network.profileBearers).toEqual([]) +}) + +test('a profile whose save was refused is not shown once the refusal is known', async () => { + const savedLevel = getLogLevel() + let saveRefused!: () => void + const refused = new Promise((resolve) => { + saveRefused = resolve + }) + // The deferred refusal gate: `refused` resolves when the plugin logs that + // the fallback profile save failed. Teardown opens `teardown` before it + // joins the test body, so a body still waiting for a refusal that never + // came is released instead of hanging teardown. + const teardown = gate() + deferCleanup(() => { + __setLogTestSink(null) + setLogLevel(savedLevel) + }) + __setLogTestSink((record) => { + if ( + record.message === 'failed to save account profile' && + record.payload?.account === 'fallback-route' + ) + saveRefused() + }) + const s = await start({ + legacyConfig: localLegacyConfig(), + hostAuth: localHostAuth(), + }) + // The plugin applies its stored log level during startup; set this test's + // diagnostic level afterwards. + setLogLevel('debug') + s.network.respond = async (account) => { + if (account !== fallbackIdentity) return undefined + // The same account signs in again while its profile request is in + // flight. The new credential keeps the account identity but replaces the + // credential the profile was read with, so the save is refused. + const runtime = createNativeAccountRuntime({ + paths: s.paths, + host: 'opencode', + local: nativeLocal(), + }) + try { + await runtime.loginOAuth({ + routeId: 'fallback-route', + credential: { + access: 'synthetic-fallback-rotated', + refresh: 'synthetic-fallback-refresh-again', + expires: Date.now() + 8 * 60 * 60_000, + }, + replace: true, + }) + // Authorizing the new credential records its (unchanged) account + // identity, so the route the display reads still names the account + // the profile was read for: only the refused save can hide it. + expect((await runtime.authorizeLocal('fallback-route')).status).toBe( + 'usable', + ) + } finally { + runtime.close() + } + return undefined + } + await s.readMenu() + await Promise.race([refused, teardown.wait]) + const text = await s.readMenu() + expect(accountLine(text, 'fallback-route')).not.toContain('Max') + expect(accountLine(text, '[main]')).toContain('Max 20x') + // The refused profile is not requested again in this boot. + expect(s.network.profileAccounts.sort()).toEqual( + [mainIdentity, fallbackIdentity].sort(), + ) + await s.dispose() + expect((await storedTiers(s.paths))['fallback-route']).toBeUndefined() +}) + +test('a profile request that throws secret-bearing text leaks it to no log, reply or status', async () => { + // Not shaped like a bearer, API key or JWT, so the logger's own value + // redaction cannot hide a leak: only the code under test can keep it out. + const marker = 'synthetic-leak-marker-5f1c' + const savedLevel = getLogLevel() + const records: unknown[] = [] + deferCleanup(() => { + __setLogTestSink(null) + setLogLevel(savedLevel) + }) + __setLogTestSink((record) => { + records.push(record) + }) + const s = await start({ + legacyConfig: localLegacyConfig(), + hostAuth: localHostAuth(), + }) + // The plugin applies its stored log level during startup; set this test's + // diagnostic level afterwards so debug records are captured. + setLogLevel('debug') + s.network.respond = async (account) => { + if (account === fallbackIdentity) + throw new Error(`profile transport failed near ${marker}`) + return undefined + } + const text = await s.readMenu() + expect(accountLine(text, '[main]')).toContain('Max 20x') + expect(accountLine(text, 'fallback-route')).not.toContain('Max') + expect(s.network.profileAccounts).toContain(fallbackIdentity) + expect(text).not.toContain(marker) + expect(JSON.stringify(records)).not.toContain(marker) + expect(JSON.stringify(records)).toContain('failed to hydrate account profile') +}) From f40de332036b314a28d34ec254e0bbf77a1298c3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:12:56 +0200 Subject: [PATCH 089/169] Preserve ordered last-main attempts and refresh-backoff quota inventory Keep the legacy 429 contract for a complete sticky pool during main refresh backoff. Under OAIAUTH's ordered-routing ruling, defer primary authorization until eligible fallbacks are exhausted; explicit killswitch limits still refuse locally. --- packages/opencode/src/index.ts | 95 ++++++++++++++++--- .../opencode/src/tests/native-serving.test.ts | 19 ++-- 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 7320d3c59..930c4fd22 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1392,12 +1392,20 @@ const anthropicAuthPlugin = async ( ) } } + class NativeCredentialUnavailableError extends Error { + constructor( + readonly account: NativeAccountView, + status: string, + ) { + super(`Native OAuth authorization refused: ${status}`) + } + } async function authorizeOAuth( routeId: string, signal?: AbortSignal, rejectedAccessToken?: string, modelId?: string, - intent: 'serve' | 'refresh' = 'serve', + intent: 'serve' | 'refresh' | 'last-main' = 'serve', ): Promise { assertNativeEnvironment() signal?.throwIfAborted() @@ -1412,6 +1420,13 @@ const anthropicAuthPlugin = async ( view: NativeAccountView, storage: AccountStorage, ) => { + // Ordered routing can try main after eligible fallbacks are exhausted. + // A cached scoped limit must not remove that final provider attempt; + // explicit killswitch limits still block it before dispatch. + const lastMainAttempt = + intent === 'last-main' && + routeId === 'main' && + getRoutingMode(storage) !== 'sticky-balanced' if ( modelId && ((isKillswitchEnabled(storage) && @@ -1421,7 +1436,8 @@ const anthropicAuthPlugin = async ( routeId === 'main' ? undefined : routeId, modelId, )) || - !quotaSnapshotPassesModelScope(view.quota, modelId)) + (!lastMainAttempt && + !quotaSnapshotPassesModelScope(view.quota, modelId))) ) throw new NativeModelPolicyError(view) } @@ -1439,11 +1455,16 @@ const anthropicAuthPlugin = async ( const authorization = await nativeAccounts.authorizeLocal(routeId, { signal, rejectedAccessToken, - intent: rejectedAccessToken ? 'refresh' : intent, + intent: rejectedAccessToken + ? 'refresh' + : intent === 'last-main' + ? 'serve' + : intent, }) if (authorization.status !== 'usable') - throw new Error( - `Native OAuth authorization refused: ${authorization.status}`, + throw new NativeCredentialUnavailableError( + account, + authorization.status, ) result = { accessToken: authorization.access, @@ -5266,6 +5287,7 @@ const anthropicAuthPlugin = async ( nativeLocalSource: undefined, nativeScopedAttempt: undefined, modelDenied: false, + credentialUnavailable: false, } if (!isCustodyTombstoneOAuth(currentActivation, 'anthropic')) { throw new Error( @@ -5274,6 +5296,7 @@ const anthropicAuthPlugin = async ( } let credential: NativeOAuthAuthorization | undefined let denied: NativeAccountView | undefined + let unavailable: NativeAccountView | undefined try { credential = await authorizeOAuth( 'main', @@ -5302,8 +5325,14 @@ const anthropicAuthPlugin = async ( } catch (retryError) { if (retryError instanceof NativeModelPolicyError) denied = retryError.account + else if ( + retryError instanceof NativeCredentialUnavailableError + ) + unavailable = retryError.account } } + } else if (error instanceof NativeCredentialUnavailableError) { + unavailable = error.account } } signal?.throwIfAborted() @@ -5313,8 +5342,11 @@ const anthropicAuthPlugin = async ( expires: credential?.expires ?? 0, refresh: undefined, nativeAccountIdentity: - credential?.accountIdentity ?? denied?.accountIdentity, + credential?.accountIdentity ?? + denied?.accountIdentity ?? + unavailable?.accountIdentity, modelDenied: Boolean(denied), + credentialUnavailable: Boolean(unavailable), nativeLocalSource: credential?.localSource, nativeScopedAttempt: credential?.scopedAttempt, } @@ -5916,6 +5948,7 @@ const anthropicAuthPlugin = async ( init?.signal ?? undefined, undefined, modelForIdentity, + oauthAccountId === 'main' ? 'last-main' : 'serve', ) accessToken = nativeAuthorization.accessToken if ( @@ -6130,6 +6163,7 @@ const anthropicAuthPlugin = async ( init?.signal ?? undefined, rejectedAccessToken, modelForIdentity, + oauthAccountId === 'main' ? 'last-main' : 'serve', ) if ( current.accountIdentity !== requestAuthority.accountIdentity || @@ -7428,9 +7462,13 @@ const anthropicAuthPlugin = async ( mainServedAccessToken = auth.access mainProviderAccountUuid = resolution.providerAccountUuid } - } else if (auth.modelDenied && requestMainProviderUuid) { - // The saved account UUID identifies which quota to inspect; - // it does not authorize credentials for this denied model. + } else if ( + (auth.modelDenied || auth.credentialUnavailable) && + requestMainProviderUuid + ) { + // A known account keeps its quota in the routing inventory + // even when model policy or credential backoff blocks serving. + // This metadata does not authorize a model request. // An API fallback still requires its separate primary-account // credential check and fresh account-wide exhaustion proof. await reconcileMainQuotaAccountIdentity( @@ -7567,7 +7605,8 @@ const anthropicAuthPlugin = async ( storage, mainAccessToken: auth.access, mainRefreshToken: auth.refresh, - mainPolicyDenied: auth.modelDenied, + mainPolicyDenied: + auth.modelDenied || auth.credentialUnavailable, requestedModelId: routingModelId, mainQuotaIdentity: requestMainQuotaIdentity, }) @@ -7858,7 +7897,8 @@ const anthropicAuthPlugin = async ( storage: stickyRoutes.storage, mainAccessToken: auth.access, mainRefreshToken: auth.refresh, - mainPolicyDenied: auth.modelDenied, + mainPolicyDenied: + auth.modelDenied || auth.credentialUnavailable, requestedModelId: routingModelId, mainQuotaIdentity: requestMainQuotaIdentity, }) @@ -8490,7 +8530,7 @@ const anthropicAuthPlugin = async ( init, fallbackAccounts, storage, - deniedResponse, + undefined, trace, { onSuccess: (account) => @@ -8501,7 +8541,36 @@ const anthropicAuthPlugin = async ( mainQuotaIdentity: requestMainQuotaIdentity, }, ) - return wrapResponse(response ?? deniedResponse) + if (response) return wrapResponse(response) + const lastMainAttempt = + getRoutingMode(storage) !== 'sticky-balanced' && + quotaSnapshotModelScopeIsExhausted( + mainQuota, + credentialModelId, + ) && + quotaSnapshotPassesPolicy(mainQuota, storage) && + killswitchPassesPolicy( + mainQuota, + storage, + undefined, + credentialModelId, + ) + if (!lastMainAttempt) return wrapResponse(deniedResponse) + return wrapResponse( + await sendWithAccessToken( + input, + init, + '', + trace, + 'main', + storage, + 'main', + undefined, + fableRequest, + laneStartRequest, + requestMainQuotaIdentity, + ), + ) } const mainResponse = await sendWithAccessToken( input, diff --git a/packages/opencode/src/tests/native-serving.test.ts b/packages/opencode/src/tests/native-serving.test.ts index c68a74463..9f949bc64 100644 --- a/packages/opencode/src/tests/native-serving.test.ts +++ b/packages/opencode/src/tests/native-serving.test.ts @@ -997,7 +997,7 @@ test('native Prime performs one fresh usage poll before one minimal Haiku send', }) for (const fallback of [false, true]) { - test(`model-only primary denial ${fallback ? 'preserves eligible OAuth fallback' : 'returns local 429'} without primary token authorization`, async () => { + test(`model-scoped primary exhaustion ${fallback ? 'uses eligible OAuth fallback without primary authorization' : 'retains the ordered last-main attempt'}`, async () => { const fixture = await migrateServingFixture('claustrum', fallback) const runtime = createNativeAccountRuntime({ paths: fixture.paths, @@ -1043,16 +1043,19 @@ for (const fallback of [false, true]) { claustrumScopedConnect: async () => fixture.scopedClient, }) const response = await sendNative(plugin, 'claude-fable-5-1') - expect(response.status).toBe(fallback ? 200 : 429) - expect(fixture.authorizedGets).not.toContain('oauth:anthropic') + expect(response.status).toBe(200) + if (fallback) + expect(fixture.authorizedGets).not.toContain('oauth:anthropic') + else expect(fixture.authorizedGets).toContain('oauth:anthropic') const sent = fixture.records.filter((record) => record.url.includes('/v1/messages'), ) - expect(sent).toHaveLength(fallback ? 1 : 0) - if (fallback) - expect(sent[0]?.authorization).toBe( - 'Bearer sk-ant-oat01-vault-fallback-v1', - ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe( + fallback + ? 'Bearer sk-ant-oat01-vault-fallback-v1' + : 'Bearer sk-ant-oat01-vault-main-v1', + ) expect( fixture.records.some((record) => record.url.includes('/oauth/token')), ).toBe(false) From 27c5aa641bf64d8f4cffe6fd9749ac8d1a817d5a Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:34:09 +0200 Subject: [PATCH 090/169] Honor proactive refresh backoff and fence stale credential errors --- packages/core/src/native-account-view.ts | 14 ++++- .../src/tests/native-account-view.test.ts | 53 ++++++++++++++++++- packages/opencode/src/index.ts | 11 ++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts index c77730ea6..d8918a6d8 100644 --- a/packages/core/src/native-account-view.ts +++ b/packages/core/src/native-account-view.ts @@ -1,5 +1,5 @@ import { types } from 'node:util' -import type { PoolRow } from '@cortexkit/common-auth/store' +import { fingerprintOf, type PoolRow } from '@cortexkit/common-auth/store' import { type AccountOperationError, @@ -491,6 +491,16 @@ export function projectNativeAccountViews(input: { const stored = input.runtime?.accounts[row.id] const entry = binding && sameLocal(stored, binding) ? stored : undefined const id = row.id === settings.mainAccountId ? 'main' : row.id + const localMetadata = metadata(entry) + // A refresh-token rotation can keep the account and epoch unchanged. + // An error from the previous token must not exclude the new credential. + if ( + entry?.lastRefreshError?.credentialFingerprint && + (!row.credential || + entry.lastRefreshError.credentialFingerprint !== + fingerprintOf(row.credential)) + ) + delete localMetadata.lastRefreshError accounts.push({ id, type: row.type, @@ -500,7 +510,7 @@ export function projectNativeAccountViews(input: { addedAt: row.addedAt, accountIdentity: row.identity, binding, - ...metadata(entry), + ...localMetadata, ...(row.type === 'oauth' && row.quota !== undefined ? { quota: fromNativeQuotaMap(row.quota) } : {}), diff --git a/packages/core/src/tests/native-account-view.test.ts b/packages/core/src/tests/native-account-view.test.ts index f82e9e68b..d0b1ddeab 100644 --- a/packages/core/src/tests/native-account-view.test.ts +++ b/packages/core/src/tests/native-account-view.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test' -import type { PoolRow } from '@cortexkit/common-auth/store' +import { fingerprintOf, type PoolRow } from '@cortexkit/common-auth/store' import { applyNativeMetadataPatch, nativeAccountReadError, @@ -185,3 +185,54 @@ test('native lastUsed publication is monotonic for older asynchronous observatio .lastUsed, ).toBe(200) }) + +for (const lineage of ['current', 'previous', 'unbound'] as const) { + test(`local account projection keeps only current or unbound refresh errors (${lineage})`, () => { + if (!row.credential) throw new Error('Expected an OAuth fixture') + const credentialEpoch = row.credentialEpoch + if (credentialEpoch === undefined) + throw new Error('Expected a bound fixture') + const currentFingerprint = fingerprintOf(row.credential) + const state: NativeRuntimeState = { + version: 1, + storageId: paths.storageId, + accounts: { + [row.id]: { + binding: { + kind: 'local', + storageId: paths.storageId, + rowId: row.id, + credentialEpoch, + identity: uuid, + }, + lastRefreshError: { + message: 'Synthetic refresh rejection', + checkedAt: 200, + nextRetryAt: 10000, + retryCount: 1, + accountIdentity: uuid, + permanent: true, + ...(lineage === 'unbound' + ? {} + : { + credentialFingerprint: + lineage === 'current' ? currentFingerprint : 'b'.repeat(64), + }), + }, + lastUsed: 100, + }, + }, + } + const snapshot = projectNativeAccountViews({ + paths, + rows: [row], + settings: { mainAccountId: row.id }, + runtime: state, + }) + expect(snapshot.accounts[0]?.lastUsed).toBe(100) + if (lineage === 'previous') + expect(snapshot.accounts[0]?.lastRefreshError).toBeUndefined() + else expect(snapshot.accounts[0]?.lastRefreshError?.permanent).toBe(true) + expect(JSON.stringify(snapshot)).not.toContain(currentFingerprint) + }) +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 930c4fd22..ecb442cd3 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -2304,6 +2304,17 @@ const anthropicAuthPlugin = async ( continue try { const credential = await authorizeOAuth(account.id) + // Proactive refresh must honor an existing account backoff even when + // the still-valid access token enters the refresh-before-expiry window. + if ( + refreshBackoffActive( + account.lastRefreshError, + account.accountIdentity, + Date.now(), + undefined, + ) + ) + continue if ( credential.localSource !== 'rotated' && credential.localSource !== 'adopted' && From 416c337e131864a5a7f3c6bca5dfad7b61b7f1ae Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:49:20 +0200 Subject: [PATCH 091/169] Refresh stale model-scoped quota before migrating ordered requests --- packages/opencode/src/index.ts | 28 +++++++++++++++---- .../opencode/src/tests/native-serving.test.ts | 15 ++++++---- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ecb442cd3..5f7c7e1dd 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -78,8 +78,10 @@ import { getHostClaustrumEnrollmentPaths, getKillswitchConfig, getPersistedLogLevel, + getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getRoutingMode, + getScopedQuotaWindowForModel, getStickyRoutingStatePath, getThinkingPrefixMismatchBehavior, type IdentityState, @@ -1386,7 +1388,10 @@ const anthropicAuthPlugin = async ( return getClaudeCodeIdentityForVerifiedAccount(accountUuid, accountUuid) } class NativeModelPolicyError extends Error { - constructor(readonly account: NativeAccountView) { + constructor( + readonly account: NativeAccountView, + readonly checkIntervalMs: number, + ) { super( 'OAuth account cannot serve the requested model under current quota policy', ) @@ -1439,7 +1444,7 @@ const anthropicAuthPlugin = async ( (!lastMainAttempt && !quotaSnapshotPassesModelScope(view.quota, modelId))) ) - throw new NativeModelPolicyError(view) + throw new NativeModelPolicyError(view, getQuotaCheckIntervalMs(storage)) } checkModelPolicy(account, snapshot.policyStorage) let result: NativeOAuthAuthorization @@ -5318,11 +5323,22 @@ const anthropicAuthPlugin = async ( } catch (error) { if (error instanceof NativeModelPolicyError) { denied = error.account - // A missing usage snapshot may itself trigger the killswitch. - // Its neutral usage poll is not permission to send this model. + // Recheck a stale limit for this model before moving the request + // to another account. A neutral usage poll grants no permission + // to send the model; authorization checks the new reading again. + const scopedWindow = getScopedQuotaWindowForModel( + denied.quota, + modelId, + ) + const scopedStale = + scopedWindow !== undefined && + (!Number.isFinite(scopedWindow.checkedAt) || + (scopedWindow.checkedAt ?? 0) + error.checkIntervalMs <= + Date.now()) if ( - !quotaSnapshotHasStandardWindows(denied.quota) && - !quotaSnapshotModelScopeIsExhausted(denied.quota, modelId) + scopedStale || + (!quotaSnapshotHasStandardWindows(denied.quota) && + !quotaSnapshotModelScopeIsExhausted(denied.quota, modelId)) ) { try { await nativeAccounts.fetchQuota('main', undefined, signal) diff --git a/packages/opencode/src/tests/native-serving.test.ts b/packages/opencode/src/tests/native-serving.test.ts index 9f949bc64..1be88d0b9 100644 --- a/packages/opencode/src/tests/native-serving.test.ts +++ b/packages/opencode/src/tests/native-serving.test.ts @@ -996,8 +996,13 @@ test('native Prime performs one fresh usage poll before one minimal Haiku send', prime.stop() }) -for (const fallback of [false, true]) { - test(`model-scoped primary exhaustion ${fallback ? 'uses eligible OAuth fallback without primary authorization' : 'retains the ordered last-main attempt'}`, async () => { +for (const [fallback, scopedAge] of [ + [false, 0], + [true, 0], + [false, 60 * 60_000], + [true, 60 * 60_000], +] as const) { + test(`model-scoped primary exhaustion (${scopedAge ? 'stale' : 'fresh'}, fallback=${fallback}) preserves ordered admission`, async () => { const fixture = await migrateServingFixture('claustrum', fallback) const runtime = createNativeAccountRuntime({ paths: fixture.paths, @@ -1028,7 +1033,7 @@ for (const fallback of [false, true]) { modelName: 'Fable', remainingPercent: 0, usedPercent: 100, - checkedAt: Date.now(), + checkedAt: Date.now() - scopedAge, resetsAt: new Date(Date.now() + 24 * 60 * 60_000).toISOString(), }, ], @@ -1044,7 +1049,7 @@ for (const fallback of [false, true]) { }) const response = await sendNative(plugin, 'claude-fable-5-1') expect(response.status).toBe(200) - if (fallback) + if (fallback && scopedAge === 0) expect(fixture.authorizedGets).not.toContain('oauth:anthropic') else expect(fixture.authorizedGets).toContain('oauth:anthropic') const sent = fixture.records.filter((record) => @@ -1052,7 +1057,7 @@ for (const fallback of [false, true]) { ) expect(sent).toHaveLength(1) expect(sent[0]?.authorization).toBe( - fallback + fallback && scopedAge === 0 ? 'Bearer sk-ant-oat01-vault-fallback-v1' : 'Bearer sk-ant-oat01-vault-main-v1', ) From 2023e24dfb058178e8c60c9dc165ef0a4c0ff4c7 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:14:01 +0200 Subject: [PATCH 092/169] Reject quota observations from another or unidentified native account --- packages/core/src/native-account-view.ts | 15 +++++++-- .../src/tests/native-account-view.test.ts | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts index d8918a6d8..693e86f5b 100644 --- a/packages/core/src/native-account-view.ts +++ b/packages/core/src/native-account-view.ts @@ -491,6 +491,17 @@ export function projectNativeAccountViews(input: { const stored = input.runtime?.accounts[row.id] const entry = binding && sameLocal(stored, binding) ? stored : undefined const id = row.id === settings.mainAccountId ? 'main' : row.id + const observedQuota = + row.type === 'oauth' && row.quota !== undefined + ? fromNativeQuotaMap(row.quota) + : undefined + // Learning an account identity does not retag older quota observations. + // Only a reading explicitly owned by the current account can guide routing. + const quota = + row.identity !== undefined && + observedQuota?.accountIdentity === row.identity + ? observedQuota + : undefined const localMetadata = metadata(entry) // A refresh-token rotation can keep the account and epoch unchanged. // An error from the previous token must not exclude the new credential. @@ -511,9 +522,7 @@ export function projectNativeAccountViews(input: { accountIdentity: row.identity, binding, ...localMetadata, - ...(row.type === 'oauth' && row.quota !== undefined - ? { quota: fromNativeQuotaMap(row.quota) } - : {}), + ...(quota ? { quota } : {}), ...(row.credential?.type === 'api' ? { baseURL: row.credential.baseURL, diff --git a/packages/core/src/tests/native-account-view.test.ts b/packages/core/src/tests/native-account-view.test.ts index d0b1ddeab..67448f7c5 100644 --- a/packages/core/src/tests/native-account-view.test.ts +++ b/packages/core/src/tests/native-account-view.test.ts @@ -6,6 +6,7 @@ import { nativeQuotaFailure, projectNativeAccountViews, } from '../native-account-view.ts' +import { toNativeQuotaMap } from '../native-quota-codec.ts' import type { NativeRuntimeState } from '../native-runtime.ts' import type { NativePoolPaths } from '../pool-paths.ts' @@ -236,3 +237,34 @@ for (const lineage of ['current', 'previous', 'unbound'] as const) { expect(JSON.stringify(snapshot)).not.toContain(currentFingerprint) }) } + +for (const quotaIdentity of [ + uuid, + undefined, + '99999999-2222-4333-8444-555555555555', +]) { + test(`local quota projection rejects readings from another or unknown account (${quotaIdentity ?? 'unbound'})`, () => { + const quota = toNativeQuotaMap({ + ...(quotaIdentity === undefined + ? {} + : { accountIdentity: quotaIdentity }), + checkedAt: 200, + five_hour: { usedPercent: 25, remainingPercent: 75, checkedAt: 200 }, + seven_day: { usedPercent: 30, remainingPercent: 70, checkedAt: 200 }, + }) + const snapshot = projectNativeAccountViews({ + paths, + rows: [{ ...row, quota }], + settings: { mainAccountId: row.id }, + }) + if (quotaIdentity === uuid) { + expect(snapshot.accounts[0]?.quota?.five_hour?.usedPercent).toBe(25) + expect(snapshot.policyStorage.quota?.mainQuota?.accountIdentity).toBe( + uuid, + ) + } else { + expect(snapshot.accounts[0]?.quota).toBeUndefined() + expect(snapshot.policyStorage.quota?.mainQuota).toBeUndefined() + } + }) +} From a71bf832aac2c03ce027ca6c25d7423409548748 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:18:36 +0200 Subject: [PATCH 093/169] Defer fallback-first main credentials and display verified native quotas --- packages/opencode/src/index.ts | 63 ++++++-- .../opencode/src/tests/native-serving.test.ts | 142 ++++++++++-------- 2 files changed, 127 insertions(+), 78 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5f7c7e1dd..576fa0db5 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1411,10 +1411,11 @@ const anthropicAuthPlugin = async ( rejectedAccessToken?: string, modelId?: string, intent: 'serve' | 'refresh' | 'last-main' = 'serve', + accountSnapshot?: NativeAccountSnapshot, ): Promise { assertNativeEnvironment() signal?.throwIfAborted() - const snapshot = await nativeAccounts.read() + const snapshot = accountSnapshot ?? (await nativeAccounts.read()) const account = snapshot.accounts.find( (candidate) => candidate.id === routeId, ) @@ -3351,17 +3352,14 @@ const anthropicAuthPlugin = async ( id: account.id, label: account.label, tierLabel: formatOAuthAccountTier(account.profile), - // Token-aware read: local access or a live vault-served tombstone - // qualifies the lineage-bound cache read. If a fallback account was - // re-logged with the same id/label, an old in-memory quota snapshot - // must not be shown as the new account's quota; vault-served - // tombstones have no local access by design, but their live binding - // proves the snapshot belongs to the currently served account. + // Native account reads verify the quota's current account binding. + // Local and vault projections both omit bearer material, so an + // empty access field must not hide their verified quota readings. quota: options.skipFallbackQuotaSeed ? null - : account.access || vaultServed - ? (quotaManager.getFallback(account.id, account)?.quota ?? null) - : null, + : (quotaManager.getFallback(account.id, account)?.quota ?? + account.quota ?? + null), // A fallback with a permanently-dead refresh token (400 invalid_grant) // is dropped by getUsableFallbackAccounts and silently degrades to // main — surface it as "needs re-login". Only flag truly-dead tokens @@ -5291,7 +5289,11 @@ const anthropicAuthPlugin = async ( } } const journal = await readNativeMigrationJournal(nativePaths) - const getAuth = async (modelId?: string, signal?: AbortSignal) => { + const getAuth = async ( + modelId?: string, + signal?: AbortSignal, + deferCredential = false, + ) => { const currentActivation = await hostGetAuth() if (currentActivation.type !== 'oauth') return { @@ -5304,12 +5306,35 @@ const anthropicAuthPlugin = async ( nativeScopedAttempt: undefined, modelDenied: false, credentialUnavailable: false, + deferred: false, } if (!isCustodyTombstoneOAuth(currentActivation, 'anthropic')) { throw new Error( 'Native OAuth requires inert OpenCode activation; run offline setup', ) } + const primarySnapshot = await nativeAccounts.read() + if ( + deferCredential && + getRoutingMode(primarySnapshot.policyStorage) === 'fallback-first' + ) { + const primary = primarySnapshot.accounts.find( + (account) => account.id === 'main', + ) + signal?.throwIfAborted() + return { + type: 'oauth', + access: '', + expires: 0, + refresh: undefined, + nativeAccountIdentity: primary?.accountIdentity, + nativeLocalSource: undefined, + nativeScopedAttempt: undefined, + modelDenied: false, + credentialUnavailable: false, + deferred: true, + } + } let credential: NativeOAuthAuthorization | undefined let denied: NativeAccountView | undefined let unavailable: NativeAccountView | undefined @@ -5319,6 +5344,8 @@ const anthropicAuthPlugin = async ( signal, undefined, modelId, + 'serve', + primarySnapshot, ) } catch (error) { if (error instanceof NativeModelPolicyError) { @@ -5374,6 +5401,7 @@ const anthropicAuthPlugin = async ( unavailable?.accountIdentity, modelDenied: Boolean(denied), credentialUnavailable: Boolean(unavailable), + deferred: false, nativeLocalSource: credential?.localSource, nativeScopedAttempt: credential?.scopedAttempt, } @@ -7438,6 +7466,7 @@ const anthropicAuthPlugin = async ( const auth = await getAuth( credentialModelId, init?.signal ?? undefined, + true, ) trace.mark('get_auth', { ms: roundMs(nowMs() - authStart), @@ -7490,7 +7519,9 @@ const anthropicAuthPlugin = async ( mainProviderAccountUuid = resolution.providerAccountUuid } } else if ( - (auth.modelDenied || auth.credentialUnavailable) && + (auth.modelDenied || + auth.credentialUnavailable || + auth.deferred) && requestMainProviderUuid ) { // A known account keeps its quota in the routing inventory @@ -8064,6 +8095,14 @@ const anthropicAuthPlugin = async ( } } + // Fallback-first reached main only after its fallback attempts. + // Resolve current access now; absence from the earlier metadata + // view is not evidence that the stored token needs refreshing. + if (auth.deferred) + Object.assign( + auth, + await getAuth(credentialModelId, init?.signal ?? undefined), + ) if ( !auth.modelDenied && (!auth.access || !auth.expires || auth.expires < Date.now()) diff --git a/packages/opencode/src/tests/native-serving.test.ts b/packages/opencode/src/tests/native-serving.test.ts index 1be88d0b9..e99821a62 100644 --- a/packages/opencode/src/tests/native-serving.test.ts +++ b/packages/opencode/src/tests/native-serving.test.ts @@ -996,75 +996,85 @@ test('native Prime performs one fresh usage poll before one minimal Haiku send', prime.stop() }) -for (const [fallback, scopedAge] of [ - [false, 0], - [true, 0], - [false, 60 * 60_000], - [true, 60 * 60_000], -] as const) { - test(`model-scoped primary exhaustion (${scopedAge ? 'stale' : 'fresh'}, fallback=${fallback}) preserves ordered admission`, async () => { - const fixture = await migrateServingFixture('claustrum', fallback) - const runtime = createNativeAccountRuntime({ - paths: fixture.paths, - host: 'opencode', - vault: { connect: async () => fixture.scopedClient }, - }) - try { - const receipt = await runtime.authorizeVault('main') - expect( - await runtime.vault.publish(receipt, { - quota: { - accountIdentity: mainIdentity, - checkedAt: Date.now(), - five_hour: { - remainingPercent: 90, - usedPercent: 10, - checkedAt: Date.now(), - }, - seven_day: { - remainingPercent: 90, - usedPercent: 10, +for (const mode of ['main-first', 'fallback-first'] as const) { + for (const [fallback, scopedAge] of [ + [false, 0], + [true, 0], + [false, 60 * 60_000], + [true, 60 * 60_000], + ] as const) { + test(`model-scoped primary exhaustion (${scopedAge ? 'stale' : 'fresh'}, fallback=${fallback}, mode=${mode}) preserves ordered admission`, async () => { + const fixture = await migrateServingFixture('claustrum', fallback) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + vault: { connect: async () => fixture.scopedClient }, + }) + try { + await runtime.updateSettings((settings) => ({ + ...settings, + routing: { mode }, + })) + const receipt = await runtime.authorizeVault('main') + expect( + await runtime.vault.publish(receipt, { + quota: { + accountIdentity: mainIdentity, checkedAt: Date.now(), - }, - scoped: [ - { - id: 'claude-weekly-scoped-fable', - title: 'Fable only', - modelName: 'Fable', - remainingPercent: 0, - usedPercent: 100, - checkedAt: Date.now() - scopedAge, - resetsAt: new Date(Date.now() + 24 * 60 * 60_000).toISOString(), + five_hour: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), }, - ], - }, - }), - ).toBe(true) - } finally { - runtime.close() - } - fixture.authorizedGets.length = 0 - const plugin = await createPlugin({ - claustrumScopedConnect: async () => fixture.scopedClient, + seven_day: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + remainingPercent: 0, + usedPercent: 100, + checkedAt: Date.now() - scopedAge, + resetsAt: new Date( + Date.now() + 24 * 60 * 60_000, + ).toISOString(), + }, + ], + }, + }), + ).toBe(true) + } finally { + runtime.close() + } + fixture.authorizedGets.length = 0 + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + const response = await sendNative(plugin, 'claude-fable-5-1') + expect(response.status).toBe(200) + const usesFallback = + fallback && (mode === 'fallback-first' || scopedAge === 0) + if (usesFallback) + expect(fixture.authorizedGets).not.toContain('oauth:anthropic') + else expect(fixture.authorizedGets).toContain('oauth:anthropic') + const sent = fixture.records.filter((record) => + record.url.includes('/v1/messages'), + ) + expect(sent).toHaveLength(1) + expect(sent[0]?.authorization).toBe( + usesFallback + ? 'Bearer sk-ant-oat01-vault-fallback-v1' + : 'Bearer sk-ant-oat01-vault-main-v1', + ) + expect( + fixture.records.some((record) => record.url.includes('/oauth/token')), + ).toBe(false) }) - const response = await sendNative(plugin, 'claude-fable-5-1') - expect(response.status).toBe(200) - if (fallback && scopedAge === 0) - expect(fixture.authorizedGets).not.toContain('oauth:anthropic') - else expect(fixture.authorizedGets).toContain('oauth:anthropic') - const sent = fixture.records.filter((record) => - record.url.includes('/v1/messages'), - ) - expect(sent).toHaveLength(1) - expect(sent[0]?.authorization).toBe( - fallback && scopedAge === 0 - ? 'Bearer sk-ant-oat01-vault-fallback-v1' - : 'Bearer sk-ant-oat01-vault-main-v1', - ) - expect( - fixture.records.some((record) => record.url.includes('/oauth/token')), - ).toBe(false) - }) + } } test('OAuth SDK callback commits native credentials and returns only inert activation', async () => { From 615744dcaed312cbf6c5efd928736afac777a9d7 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:31:12 +0200 Subject: [PATCH 094/169] Convert central plugin fixtures to committed native account authority --- packages/opencode/src/tests/index.test.ts | 11150 +++++++++++--------- 1 file changed, 6243 insertions(+), 4907 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 51221d34a..3c0bceda0 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -2,11 +2,11 @@ import { afterAll, afterEach, beforeEach, + test as bunTest, describe, expect, mock, spyOn, - test, } from 'bun:test' import { randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' @@ -28,7 +28,11 @@ import { buildPrimeRequestBody, buildRefreshOperationError, ClaudeOAuthRefreshError, + type CommandApplyRequest, + type CommandApplyResult, CustodyTombstoneRefreshError, + createNativeAccountRuntime, + createNativePoolStore, custodyTombstoneOAuth, extractBillingHeaderCCH, FallbackAccountManager, @@ -36,9 +40,11 @@ import { getClaudeCodeIdentity, getOrCreatePrimeAuthLineageId, hashRefreshToken, + isCustodyTombstoneOAuth, isOAuthAccount, type LogTestRecord, loadAccounts, + type NativeCustodyClient, type OAuthAccount, type OAuthQuotaSnapshot, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, @@ -54,6 +60,8 @@ import { TRAILING_ASSISTANT_HISTORY_MESSAGE, tokenFingerprint, } from '@cortexkit/anthropic-auth-core' +import { nativeQuotaCodec } from '../../../core/src/native-quota-codec.ts' +import { TestLifetime } from '../../../core/src/tests/test-lifetime.ts' import { EFFORT_MARKER_PREFIX } from '../effort-history' import { AnthropicAuthPlugin } from '../index' import { LANE_START_REQUEST_HEADER, LANE_START_TEXT } from '../lane-start' @@ -62,6 +70,7 @@ import { resetNotificationsForTest, } from '../rpc/notifications' import { COMMAND_MODAL_NAMES } from '../rpc/protocol' +import { createRpcClient } from '../rpc/rpc-client' import { SERVER_FALLBACK_SIGNATURE_PREFIX, SERVER_SIDE_FALLBACK_BETA, @@ -76,6 +85,10 @@ import { setSidebarState, } from '../sidebar-state' import { rewriteRequestBody } from '../transform.ts' +import { + migrateNativeOpencodeFixture, + type NativeOpencodeFixture, +} from './native-fixture.ts' import { extractUrl, installDefaultFetchMock, @@ -122,12 +135,105 @@ const tempConfigDirs = new Set() const allTempConfigDirs = new Set() const fallbackRefreshes = new Set>() +// Environment variables a test sets to point the plugin at its migrated +// account pool; each test's cleanup puts back their previous values. +const nativeEnvKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', + 'OPENCODE_AUTH_CONTENT', +] as const + +/** + * One lifetime per test. It is created before the describe-level beforeEach + * hooks run, so a pool migrated in a hook and one migrated in the body share + * it. Teardown waits for the whole body and registered work, disposes the + * plugins it created, then removes the directories it migrated. + */ +let testLifetime: TestLifetime | undefined +/** The pool the current test migrated most recently, if any. */ +let migratedPool: MigratedPool | undefined +const livePlugins = new Set<{ dispose?: () => unknown }>() + +type MigratedPool = NativeOpencodeFixture & { + /** The Anthropic OAuth credential transferred from OpenCode's auth.json to the shared credential pool. */ + mainHostAuth: { access: string; refresh: string; expires: number } +} + +function bodyLifetime(): TestLifetime { + if (!testLifetime) throw new Error('Test fixture has no body lifetime') + return testLifetime +} + +type TestBody = () => unknown + +/** Bun's test, with the body run inside this test's fixture lifetime. */ +function test(name: string, body: TestBody, timeout?: number) { + bunTest(name, () => bodyLifetime().runBody(body), timeout) +} +test.serial = (name: string, body: TestBody, timeout?: number) => { + bunTest.serial(name, () => bodyLifetime().runBody(body), timeout) +} +test.each = + (cases: readonly T[]) => + (name: string, body: (row: T) => unknown, timeout?: number) => { + bunTest.each(cases as T[])( + name, + (row: T) => bodyLifetime().runBody(() => body(row)), + timeout, + ) + } + beforeEach(() => { installDefaultFetchMock() + const lifetime = new TestLifetime() + testLifetime = lifetime + const savedEnv = new Map( + nativeEnvKeys.map((key) => [key, process.env[key]] as const), + ) + // Registered first, so it runs before any migrated directory is removed: + // plugins stop and pending sidebar writes land while their files exist. + lifetime.deferCleanup(async () => { + const plugins = [...livePlugins] + livePlugins.clear() + const results = await Promise.allSettled( + plugins.map((plugin) => Promise.resolve().then(() => plugin.dispose?.())), + ) + try { + await drainSidebarWrites() + } finally { + migratedPool = undefined + poolLogins.length = 0 + successorAccess.clear() + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Plugin disposal failed', + ) + }) }) afterEach(async () => { - await cleanupTempConfigDirs() + const lifetime = testLifetime + testLifetime = undefined + try { + await cleanupTempConfigDirs() + } finally { + await lifetime?.finish() + } }) afterAll(async () => { @@ -149,9 +255,9 @@ async function cleanupTempConfigDirs(drainTimeoutMs = 4_000) { ]) if (refreshesSettled) fallbackRefreshes.clear() await drainSidebarWrites() - const directories = [...tempConfigDirs, tempConfigDir].filter( - (directory): directory is string => Boolean(directory), - ) + // tempConfigDir is a migrated root; the test's lifetime removes it after + // disposing the plugins that use it. + const directories = [...tempConfigDirs] tempConfigDirs.clear() tempConfigDir = undefined await Promise.all( @@ -169,6 +275,47 @@ function deferred() { return { promise, resolve } } +/** + * A signal a test's mock raises when the plugin reaches a point the body + * waits for. Teardown raises it as well, so a body still waiting for a point + * the plugin never reached resumes and finishes (failing on its assertions) + * instead of holding its plugin and every later test's teardown. + */ +function lifetimeSignal() { + const gate = bodyLifetime().gate() + return { raised: gate.wait, raise: gate.open } +} + +/** + * A provider response the test releases when it chooses. If the test ends + * first, teardown rejects it with an AbortError, so plugin work waiting on + * this response settles before the plugin is disposed and its files removed. + */ +function heldResponse() { + const released = bodyLifetime().gate() + let response: Response | undefined + const held = released.wait.then( + () => + response ?? + Promise.reject( + new DOMException( + 'The test ended before releasing this response', + 'AbortError', + ), + ), + ) + // A test that fails before its mock hands this response to the plugin + // must not also report the teardown rejection as an unhandled error. + held.catch(() => {}) + return { + response: held, + release(value: Response) { + response = value + released.open() + }, + } +} + async function withDeadlockGuard( promise: Promise, ms: number, @@ -312,7 +459,7 @@ function createFallbackStorage( { id: 'fallback-1', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: Date.now() + 5 * 60 * 60 * 1000, quota: { @@ -333,6 +480,95 @@ function createFallbackStorage( } } +/** Default Anthropic account UUID for the main OAuth credential in these fixtures. */ +const syntheticMainAccountUuid = + '00000000-0000-4000-8000-000000000001' as ProviderAccountUuid + +/** + * Record in a legacy store which Claude account the main login belongs to: a + * main profile observed for that login's access token. Migration then gives + * the pool's main login a known account, which the native runtime confirms + * with bootstrap alone instead of first exchanging the refresh token. + */ +function bindMainAccount( + storage: AccountStorage, + access = 'sk-ant-oat01-main-access', + identity: ProviderAccountUuid = syntheticMainAccountUuid, +): AccountStorage { + storage.main = { + type: 'opencode', + provider: 'anthropic', + ...storage.main, + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: Date.now(), + ...storage.main?.profile, + providerAccountUuid: identity, + tokenFingerprint: tokenFingerprint(access), + }, + } + return storage +} + +/** + * Name the Claude account behind a legacy store's main quota snapshot, the + * only form of it that migration imports. Migration keeps a main quota only + * when the store recorded which account produced it: a main profile observed + * for the migrated access token, and a snapshot carrying that account. A + * snapshot keyed by nothing or by the slot label "main" is dropped, and + * quota-based routing then waits for a fresh snapshot instead. + */ +function bindMainQuotaToAccount( + storage: AccountStorage, + access = 'sk-ant-oat01-main-access', + identity: ProviderAccountUuid = syntheticMainAccountUuid, +): AccountStorage { + const quota = storage.quota?.mainQuota + if (!storage.quota || !quota) + throw new Error('This store has no main quota to bind') + bindMainAccount(storage, access, identity) + storage.quota.mainQuota = { ...quota, accountIdentity: identity } + storage.quota.mainQuotaToken = tokenFingerprint(access) + return storage +} + +/** The Claude account a synthetic fallback login at `index` belongs to. */ +function syntheticFallbackAccountUuid(index: number) { + return `00000000-0000-4000-8000-${String(index + 2).padStart(12, '0')}` as ProviderAccountUuid +} + +/** + * Give a legacy store the account identities migration needs to carry its + * quota into the native pool: the main login's account (see + * bindMainQuotaToAccount, or bindMainAccount when there is no main quota), + * and a Claude account UUID for each OAuth fallback that has none. Quota + * snapshots and errors a fallback recorded under its slot id, or under no + * account, are re-keyed to that UUID, as the legacy writer would have keyed + * them had it known the account. + */ +function bindPoolAccounts(storage: AccountStorage): AccountStorage { + if (storage.quota?.mainQuota) bindMainQuotaToAccount(storage) + else bindMainAccount(storage) + storage.accounts.forEach((account, index) => { + if (account.type !== 'oauth') return + const uuid = + account.anthropicAccountUuid ?? syntheticFallbackAccountUuid(index) + account.anthropicAccountUuid = uuid + const rebind = (value: T) => + value.accountIdentity === undefined || + value.accountIdentity === account.id + ? { ...value, accountIdentity: uuid } + : value + if (account.quota) account.quota = rebind(account.quota) + if (account.lastRefreshError) + account.lastRefreshError = rebind(account.lastRefreshError) + if (account.lastQuotaRefreshError) + account.lastQuotaRefreshError = rebind(account.lastQuotaRefreshError) + }) + return storage +} + async function runStickyDnsBackoffScenario(options: { tokenExpiresAt: number refreshErrorCheckedAt: number @@ -367,93 +603,130 @@ async function runStickyDnsBackoffScenario(options: { }, ], }) + // Quota and refresh failures are tied to Claude account UUIDs, the only + // identities migration carries into the native pool. + const fallbackAccount = '00000000-0000-4000-8000-0000000d0e5f' await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'sticky-balanced' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: fableQuota(0, now), - mainQuotaCheckedAt: now, - mainQuotaToken: tokenFingerprint('main-access'), - }, - accounts: [ - { - id: 'dns-backed-off', - type: 'oauth', - access: 'initial-fallback-access', - refresh: 'fallback-refresh', - expires: options.tokenExpiresAt, - lastRefreshError: { - message: 'getaddrinfo ENOTFOUND platform.claude.com', - checkedAt: options.refreshErrorCheckedAt, - nextRetryAt: options.refreshErrorNextRetryAt, - retryCount: options.refreshErrorRetryCount, - accountIdentity: 'dns-backed-off', - permanent: false, - }, - quota: fableQuota(64, cachedAt), + bindMainQuotaToAccount( + createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: fableQuota(0, now), + mainQuotaCheckedAt: now, }, - ], - }), + accounts: [ + { + id: 'dns-backed-off', + type: 'oauth', + access: 'sk-ant-oat01-initial-fallback-access', + refresh: 'fallback-refresh', + expires: options.tokenExpiresAt, + anthropicAccountUuid: fallbackAccount as ProviderAccountUuid, + lastRefreshError: { + message: 'getaddrinfo ENOTFOUND platform.claude.com', + checkedAt: options.refreshErrorCheckedAt, + nextRetryAt: options.refreshErrorNextRetryAt, + retryCount: options.refreshErrorRetryCount, + accountIdentity: fallbackAccount, + permanent: false, + }, + quota: { + ...fableQuota(64, cachedAt), + accountIdentity: fallbackAccount, + }, + }, + ], + }), + ), ) + const pool = migratedPool + if (!pool) throw new Error('DNS routing fixture did not migrate') + const accountRuntime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + const row = (await accountRuntime.read()).accounts.find( + (account) => account.id === 'dns-backed-off', + ) + expect(row?.binding?.identity).toBe(fallbackAccount) + const stored = await createNativePoolStore({ + paths: pool.paths, + quota: nativeQuotaCodec, + }).read() + const credential = + stored.status === 'ready' + ? stored.rows.find((entry) => entry.id === row?.binding?.rowId) + ?.credential + : undefined + if (credential?.type !== 'oauth') + throw new Error('DNS fixture has no bound OAuth credential') + expect(credential.expires).toBe(options.tokenExpiresAt) + } finally { + accountRuntime.close() + } const tokenRequests: string[] = [] const messageAuthorizations: string[] = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - const authorization = new Headers(init?.headers).get('authorization') ?? '' - if (url.includes('/v1/oauth/token')) { - tokenRequests.push(url) - return Promise.resolve( - Response.json({ - access_token: 'refreshed-fallback-access', - refresh_token: 'refreshed-fallback-refresh', - expires_in: 8 * 60 * 60, - }), - ) - } - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - Response.json({ - five_hour: { - utilization: 4, - resets_at: new Date(now + 60 * 60_000).toISOString(), - }, - seven_day: { - utilization: 18, - resets_at: new Date(now + 3 * 24 * 60 * 60_000).toISOString(), - }, - limits: [ - { - kind: 'weekly_scoped', - group: 'weekly', - percent: 36, + globalThis.fetch = mock( + withMainAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + if (url.includes('/v1/oauth/token')) { + tokenRequests.push(url) + return Promise.resolve( + Response.json({ + access_token: 'sk-ant-oat01-refreshed-fallback-access', + refresh_token: 'refreshed-fallback-refresh', + expires_in: 8 * 60 * 60, + }), + ) + } + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { + utilization: 4, + resets_at: new Date(now + 60 * 60_000).toISOString(), + }, + seven_day: { + utilization: 18, resets_at: new Date(now + 3 * 24 * 60 * 60_000).toISOString(), - scope: { model: { display_name: 'Fable' } }, }, - ], - }), - ) - } - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ oauth_account: { account_uuid: 'dns-backed-off' } }), - ) - } - if (url.includes('/v1/messages')) { - messageAuthorizations.push(authorization) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 36, + resets_at: new Date(now + 3 * 24 * 60 * 60_000).toISOString(), + scope: { model: { display_name: 'Fable' } }, + }, + ], + }), + ) + } + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve( + Response.json({ oauth_account: { account_uuid: fallbackAccount } }), + ) + } + if (url.includes('/v1/messages')) { + messageAuthorizations.push(authorization) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: now + 8 * 60 * 60_000, }), @@ -482,22 +755,23 @@ async function expectUnknownQuotaAdmissionBlocked(storage: AccountStorage) { retryCount: 1, }, } + // Import the configured usage-API failure into shared account storage. + // Migration retires the old state file; later writes use shared storage. await useTempAccountFile(storage) - await saveAccountState(storage, process.env.OPENCODE_ANTHROPIC_AUTH_FILE, { - mainQuota: true, - }) - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) - return Promise.reject(new Error('quota source unavailable')) - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) + return Promise.reject(new Error('quota source unavailable')) + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -507,132 +781,940 @@ async function expectUnknownQuotaAdmissionBlocked(storage: AccountStorage) { expect(response.status).toBe(429) } -async function useTempAccountFile(storage: AccountStorage) { - if (tempConfigDir) { - await drainSidebarWrites() - await rm(tempConfigDir, { recursive: true, force: true }) - } - tempConfigDir = await mkdtemp(join(tmpdir(), 'anthropic-plugin-test-')) - tempConfigDirs.add(tempConfigDir) - allTempConfigDirs.add(tempConfigDir) - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( - tempConfigDir, - 'anthropic-auth.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( - tempConfigDir, - 'sidebar-state.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( - tempConfigDir, - 'opencode-enrollment.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( - tempConfigDir, - 'cachekeep-registry', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( - tempConfigDir, - 'quota-header-feed', - ) - await saveAccounts(storage) - if (storage.main?.profile) { - await saveAccountState(storage, process.env.OPENCODE_ANTHROPIC_AUTH_FILE, { - mainProfile: true, - }) +/** + * Main OAuth credential stored in the fixture's OpenCode auth.json. + * The Anthropic OAuth access-token prefix enables the bootstrap request that + * verifies its account UUID. Arbitrary token strings must not gain access. + */ +function syntheticMainHostAuth() { + return { + access: 'sk-ant-oat01-main-access', + refresh: 'main-refresh', + expires: Date.now() + 8 * 60 * 60_000, } } -function restoreProcessTestFiles() { - const testDir = process.env.OPENCODE_ANTHROPIC_AUTH_TEST_DIR - if (!testDir) return - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( - testDir, - 'anthropic-auth.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( - testDir, - 'sidebar-state.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( - testDir, - 'opencode-enrollment.json', - ) - process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( - testDir, - 'cachekeep-registry', - ) -} - -async function waitForSidebarState( - predicate: (state: Awaited>) => boolean, -) { - for (let attempt = 0; attempt < 50; attempt++) { - const state = await getSidebarState() - if (predicate(state)) return state - await Bun.sleep(10) +/** + * Serialize old-installation data with saveAccounts and saveAccountState. + * Return the config and credential-state text for offline migration. + */ +async function legacyAccountFiles(storage: AccountStorage) { + const scratch = await mkdtemp(join(tmpdir(), 'anthropic-plugin-legacy-')) + const configPath = join(scratch, 'anthropic-auth.json') + const statePath = join(scratch, 'anthropic-auth-state.json') + const savedStatePath = process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = statePath + try { + await saveAccounts(storage, configPath) + if (storage.main?.profile) + await saveAccountState(storage, configPath, { mainProfile: true }) + return { + config: await readFile(configPath, 'utf8'), + state: await readFile(statePath, 'utf8').catch(() => undefined), + } + } finally { + if (savedStatePath === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE + else process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = savedStatePath + await rm(scratch, { recursive: true, force: true }) } - const state = await getSidebarState() - throw new Error(`Sidebar state did not match: ${JSON.stringify(state)}`) } -async function waitForAccountStorage( - predicate: (storage: Awaited>) => boolean, +/** + * Run offline migration to transfer the fixture's old account files and + * OpenCode OAuth credential into shared storage. Construct plugins afterwards + * so their startup reads see the committed authority journal and pool. + */ +async function useTempAccountFile( + storage: AccountStorage, + mainHostAuth = syntheticMainHostAuth(), ) { - for (let attempt = 0; attempt < 100; attempt++) { - const storage = await loadAccounts() - if (predicate(storage)) return storage - await Bun.sleep(10) - } - const storage = await loadAccounts() - throw new Error(`Account storage did not match: ${JSON.stringify(storage)}`) + const lifetime = bodyLifetime() + const root = await mkdtemp(join(tmpdir(), 'anthropic-plugin-test-')) + // Register removal before migration can reject the directory. Cleanup + // otherwise would not know about a directory created by failed setup. + lifetime.deferCleanup(() => rm(root, { recursive: true, force: true })) + const legacy = await legacyAccountFiles(storage) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime, + legacyConfig: legacy.config, + ...(legacy.state !== undefined && { legacyState: legacy.state }), + hostAuth: { anthropic: { type: 'oauth', ...mainHostAuth } }, + }) + migratedPool = { ...fixture, mainHostAuth } + registerPoolLogins(storage, mainHostAuth) + // TestLifetime removes this directory after disposing its plugin instances + // and joining their asynchronous work. + tempConfigDir = fixture.root + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + delete process.env.OPENCODE_AUTH_CONTENT } -async function seedSidebarRouting( - activeId: string, - route: string, - lastUpdated: number, +/** + * Credentials and account UUIDs accepted by this test's OAuth server mocks. + * Unknown credentials must not receive a successful bootstrap or exchange. + */ +type PoolLogin = { access: string; refresh: string; identity: string } +const poolLogins: PoolLogin[] = [] + +function registerPoolLogins( + storage: AccountStorage, + main: { access: string; refresh: string }, ) { - await setSidebarState({ - ...(await getSidebarState()), - activeId, - route, - lastUpdated, + poolLogins.length = 0 + successorAccess.clear() + const identityFor = (index: number) => + `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}` + // Migration learns the main account UUID from its token-bound profile. + // The bootstrap mock must return that same UUID for the migrated credential. + poolLogins.push({ + ...main, + identity: storage.main?.profile?.providerAccountUuid ?? identityFor(0), + }) + storage.accounts.forEach((account, index) => { + if (account.type !== 'oauth' || !account.access || !account.refresh) return + poolLogins.push({ + access: account.access, + refresh: account.refresh, + identity: account.anthropicAccountUuid ?? identityFor(index + 1), + }) }) } -async function waitForMockCall(fn: { mock?: { calls: unknown[] } }) { - for (let attempt = 0; attempt < 50; attempt++) { - if ((fn.mock?.calls.length ?? 0) > 0) return - await Bun.sleep(10) - } +async function requestText( + input: Parameters[0], + init?: RequestInit, +): Promise { + if (typeof init?.body === 'string') return init.body + return input instanceof Request ? input.clone().text() : '' } /** - * Set up the common test scaffolding for concurrent refresh tests: - * mocks setTimeout to be synchronous and creates a plugin loader - * with an already-expired OAuth token. + * Synthetic Anthropic OAuth answers a migrated pool needs before it serves. + * The native runtime checks the account behind a local OAuth login before + * serving it. When the pool does not yet know that account, it exchanges the + * login's refresh token first and reads the account of the new access token + * (bootstrap); when it does, bootstrap alone confirms it. For a login this + * test migrated, the token endpoint returns the same access and refresh tokens + * with a fresh expiry, and bootstrap names the login's account; every other + * request is left to the test's own mock. */ -async function setupExpiredTokenLoader() { - const setTimeoutMock = mock((handler: () => unknown) => { - handler() - return 0 as unknown as ReturnType - }) as unknown as typeof setTimeout +async function nativeAdmissionAnswer( + input: Parameters[0], + init?: RequestInit, +): Promise { + return ( + (await nativeTokenAnswer(input, init)) ?? + (await nativeBootstrapAnswer(input, init)) + ) +} - const mockClient = createMockClient() - const plugin = await getPlugin(mockClient, undefined, { - setTimeout: setTimeoutMock, +/** The token-endpoint half of nativeAdmissionAnswer. */ +async function nativeTokenAnswer( + input: Parameters[0], + init?: RequestInit, + logins: readonly PoolLogin[] = poolLogins, +): Promise { + if (!extractUrl(input).includes('/v1/oauth/token')) return undefined + let refresh: unknown + try { + refresh = JSON.parse(await requestText(input, init)).refresh_token + } catch { + return undefined + } + const login = logins.find((entry) => entry.refresh === refresh) + if (!login) return undefined + return Response.json({ + access_token: login.access, + refresh_token: login.refresh, + expires_in: 8 * 60 * 60, + }) +} + +/** + * Like withNativeAdmission, but for a test whose own mock answers bootstrap + * (it checks which account bootstrap reports): only the token endpoint is + * answered here. + */ +function withNativeTokenExchange( + handler: (input: Input, init: Init) => unknown, +): (input: Input, init: Init) => Promise { + return async (input, init) => + (await nativeTokenAnswer( + input as Parameters[0], + init as RequestInit | undefined, + )) ?? handler(input, init) +} + +/** + * Later access tokens of an account already in this test's pool, mapped to + * that account: tokens the test's own token endpoint issues on refresh, or a + * re-login of the same account. The native runtime reads the account behind + * every new access token before it serves it, so bootstrap must name the + * same account for these as for the login they replace. + */ +const successorAccess = new Map() + +/** The Claude account the main login of this test's pool belongs to. */ +function poolMainIdentity(): string { + const main = poolLogins[0] + if (!main) throw new Error('This test has no migrated local main login') + return main.identity +} + +/** Record that `accesses` are later tokens of the pool's main account. */ +function mainAccountIssues(...accesses: string[]) { + const identity = poolMainIdentity() + for (const access of accesses) successorAccess.set(access, identity) +} + +/** + * Record that `accesses` are later tokens of the account whose migrated + * login has the access token `current`. + */ +function loginIssues(current: string, ...accesses: string[]) { + const login = poolLogins.find((entry) => entry.access === current) + if (!login) throw new Error(`No migrated login has access token ${current}`) + for (const access of accesses) successorAccess.set(access, login.identity) +} + +/** + * Bootstrap's answer for an access token of this test's pool, or for a + * later token of one of its accounts; undefined for anything else, which the + * test's own mock then answers. + */ +async function nativeBootstrapAnswer( + input: Parameters[0], + init?: RequestInit, + logins: readonly PoolLogin[] = poolLogins, +): Promise { + if (!extractUrl(input).includes('/api/claude_cli/bootstrap')) return undefined + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ) + const bearer = headers.get('authorization')?.replace(/^Bearer /, '') + const known = new Set(logins.map((entry) => entry.identity)) + const successor = + bearer === undefined ? undefined : successorAccess.get(bearer) + const identity = + logins.find((entry) => entry.access === bearer)?.identity ?? + (successor !== undefined && known.has(successor) ? successor : undefined) + if (!identity) return undefined + return Response.json({ oauth_account: { account_uuid: identity } }) +} + +/** Answer OAuth exchange and account-bootstrap requests; pass other requests to the test. */ +function withNativeAdmission( + handler: (input: Input, init: Init) => unknown, +): (input: Input, init: Init) => Promise { + return async (input, init) => + (await nativeAdmissionAnswer( + input as Parameters[0], + init as RequestInit | undefined, + )) ?? handler(input, init) +} + +/** + * Answer exchange and bootstrap requests for the primary account only. + * Let the test's handler control these endpoints for its fallback accounts. + */ +function withMainAdmission( + handler: (input: Input, init: Init) => unknown, +): (input: Input, init: Init) => Promise { + return async (input, init) => { + const main = poolLogins.slice(0, 1) + const request = input as Parameters[0] + const options = init as RequestInit | undefined + return ( + (await nativeTokenAnswer(request, options, main)) ?? + (await nativeBootstrapAnswer(request, options, main)) ?? + handler(input, init) + ) + } +} + +/** + * Answer account-bootstrap requests, but leave OAuth exchange requests to + * the test's handler so it can count refreshes or return rotated tokens. + */ +function withNativeBootstrap( + handler: (input: Input, init: Init) => unknown, +): (input: Input, init: Init) => Promise { + return async (input, init) => + (await nativeBootstrapAnswer( + input as Parameters[0], + init as RequestInit | undefined, + )) ?? handler(input, init) +} + +/** + * Replace the primary OAuth credential through a separate account runtime. + * With sameAccount=true, register the new access token under the existing + * account UUID; otherwise the test supplies its own bootstrap response. + * The tested plugin must discover the replacement from shared storage. + */ +async function replacePoolMainLogin( + credential: { access: string; refresh: string; expires: number }, + options: { sameAccount: boolean } = { sameAccount: true }, +) { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + if (options.sameAccount) mainAccountIssues(credential.access) + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + await runtime.loginOAuth({ routeId: 'main', replace: true, credential }) + } finally { + runtime.close() + } +} + +/** + * Refresh the pool's main login in another plugin process; see + * refreshPoolLoginElsewhere. + */ +async function refreshPoolMainElsewhere(successor: { + access: string + refresh: string + expires: number +}) { + mainAccountIssues(successor.access) + await refreshPoolLoginElsewhere('main', successor) +} + +/** + * Rotate the chosen account's tokens using a separate account runtime. + * Register the returned access token with loginIssues or mainAccountIssues + * before calling this helper, so bootstrap confirms its account UUID. + * The injected exchange returns successor directly; the tested plugin's + * exchange mock must see no request for this externally completed rotation. + */ +async function refreshPoolLoginElsewhere( + routeId: string, + successor: { access: string; refresh: string; expires: number }, +) { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + local: { + refreshToken: async () => ({ + ...successor, + expiresIn: Math.round((successor.expires - Date.now()) / 1000), + }), + }, + }) + try { + const result = await runtime.authorizeLocal(routeId, { intent: 'refresh' }) + if (result.status !== 'usable' || result.access !== successor.access) + throw new Error( + `Refresh of ${routeId} elsewhere did not complete: ${result.status} ${'reason' in result ? result.reason : ''}`, + ) + } finally { + runtime.close() + } +} + +/** + * Commit the caller's settings update using a separate account runtime. + * The tested plugin must discover the change by rereading shared storage. + */ +async function updatePoolSettings( + mutator: (settings: Record) => Record, +) { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + await runtime.updateSettings(mutator) + } finally { + runtime.close() + } +} + +/** + * Add an alternative OAuth account through a separate account runtime. + * Its tokens belong only in shared storage, never in OpenCode's auth.json. + */ +async function addPoolOAuthAccount( + routeId: string, + credential: { access: string; refresh: string; expires: number }, +) { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', }) + try { + await runtime.loginOAuth({ routeId, credential, replace: false }) + } finally { + runtime.close() + } +} + +/** + * Commit relay configuration using the account runtime's updateRelay API. + * This keeps the relay secret outside the public settings projection. + */ +async function updatePoolRelay( + patch: Parameters< + ReturnType['updateRelay'] + >[0], +) { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + await runtime.updateRelay(patch) + } finally { + runtime.close() + } +} + +/** + * Publish a usage reading from a separate account runtime, tagged with the + * account UUID verified for the credential that authorized the reading. + */ +function publishPoolQuota(routeId: string, quota: OAuthQuotaSnapshot) { + return publishPoolMetadata(routeId, (identity) => ({ + quota: { ...quota, accountIdentity: identity }, + })) +} + +/** + * Authorize the account using a separate runtime, then publish quota or error + * metadata for that exact credential version. A concurrent credential change + * must make publication refuse rather than attach metadata to the new login. + */ +async function publishPoolMetadata( + routeId: string, + patch: ( + identity: string, + ) => Parameters< + ReturnType['publishLocal'] + >[1], +) { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + const authorization = await runtime.authorizeLocal(routeId) + if (authorization.status !== 'usable') + throw new Error(`Pool ${routeId} login is not usable`) + const identity = authorization.binding.identity + if (identity === undefined) + throw new Error(`Pool ${routeId} login has no confirmed account`) + const published = await runtime.publishLocal( + authorization.subject, + patch(identity), + ) + if (!published) throw new Error(`Metadata for ${routeId} was not published`) + } finally { + runtime.close() + } +} + +/** + * Check that OpenCode's auth store still holds only the placeholder login + * migration wrote for Anthropic: no access or refresh token reached it. + */ +async function expectHostActivationNonSecret(...tokens: string[]) { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + const text = await readFile(pool.hostAuthPath, 'utf8') + expect(isCustodyTombstoneOAuth(JSON.parse(text).anthropic, 'anthropic')).toBe( + true, + ) + for (const token of tokens) expect(text).not.toContain(token) +} + +/** + * The access token the pool would hand a new caller for its main login, read + * through a separate native runtime the way another plugin process would. + */ +async function poolMainAccess(): Promise { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + const result = await runtime.authorizeLocal('main') + if (result.status !== 'usable') + throw new Error(`Pool main login is not usable: ${result.status}`) + return result.access + } finally { + runtime.close() + } +} + +/** + * Return the non-secret activation marker written to OpenCode's auth.json. + * Reject fixture credentials that differ from those imported by migration; + * the host loader cannot supply an unrelated login as pool authority. + */ +async function migratedHostAuth( + pool: MigratedPool, + testAuth: () => Promise>, +) { + const supplied = await testAuth() + if ( + supplied.type !== 'oauth' || + isCustodyTombstoneOAuth(supplied, 'anthropic') + ) + return supplied + const migrated = pool.mainHostAuth + if ( + supplied.access !== migrated.access || + supplied.refresh !== migrated.refresh || + (typeof supplied.expires === 'number' && supplied.expires <= Date.now()) !== + migrated.expires <= Date.now() + ) + throw new Error( + `Test host OAuth ${String(supplied.access)} is not the login migrated into this pool (${migrated.access})`, + ) + return JSON.parse(await readFile(pool.hostAuthPath, 'utf8')).anthropic +} + +const syntheticMainIdentity = '11111111-1111-4111-8111-111111111111' + +/** A synthetic vault whose listing holds only the main account's credential + * row; any attempt to read that credential's token makes the test fail. */ +function mainOnlyVault(): NativeCustodyClient { + return { + async listScoped() { + return { + view: 'synthetic-native-view', + rows: [ + { + id: 'oauth:anthropic', + accountId: syntheticMainIdentity, + credentialType: 'oauth', + state: 'active', + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + refreshAdapter: 'anthropic', + recordVersion: 1, + operations: ['read', 'invalidate'], + createdAtMs: 1, + }, + ], + } + }, + async getScoped() { + throw new Error('Status tests must not read vault credential material') + }, + async reportAuthFailureScoped() { + throw new Error('Status tests must not report vault failures') + }, + close() {}, + } +} + +/** + * Run setup migration for accounts whose credentials remain in Claustrum. + * The fixture's enrollment token permits listing vault accounts for this host. + * Plugin startup then reads the committed, credential-free account inventory. + */ +async function useVaultAccountFile( + storage: AccountStorage, + client: NativeCustodyClient, +) { + const lifetime = bodyLifetime() + const root = await mkdtemp(join(tmpdir(), 'anthropic-plugin-vault-')) + lifetime.deferCleanup(() => rm(root, { recursive: true, force: true })) + const legacy = await legacyAccountFiles(storage) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime, + legacyConfig: legacy.config, + ...(legacy.state !== undefined && { legacyState: legacy.state }), + hostAuth: { anthropic: custodyTombstoneOAuth('anthropic') }, + custody: { + connect: async () => client, + enrollment: { token: 'ab'.repeat(32), token_generation: 1 }, + }, + }) + poolLogins.length = 0 + // In vault custody auth.json never held the main tokens; it already holds + // the placeholder login, so there is no local main login to compare. + migratedPool = { + ...fixture, + mainHostAuth: { access: '', refresh: '', expires: 0 }, + } + tempConfigDir = fixture.root + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + delete process.env.OPENCODE_AUTH_CONTENT + return fixture +} + +/** + * Keep the old account files without running migration. Tests can then + * verify that plugin startup neither adopts their credentials nor rewrites them. + */ +async function useUnmigratedAccountFile(storage: AccountStorage) { + const root = await mkdtemp(join(tmpdir(), 'anthropic-plugin-unmigrated-')) + bodyLifetime().deferCleanup(() => rm(root, { recursive: true, force: true })) + migratedPool = undefined + poolLogins.length = 0 + tempConfigDir = root + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join(root, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = join( + root, + 'anthropic-auth-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( + root, + 'sidebar-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + root, + 'opencode-enrollment.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = join( + root, + 'missing-connection.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( + root, + 'cachekeep-registry', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( + root, + 'quota-header-feed', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = join(root, 'rpc') + await saveAccounts(storage) +} + +// The fetch installed by the test preload (setup.ts), which only allows +// 127.0.0.1 requests; captured before any test mock replaces it. +const loopbackFetch = globalThis.fetch + +/** + * Records without Anthropic's sk-ant-oat access-token prefix must refuse + * before sending a model request, even if bootstrap supplies an account UUID. + * The error directs API-key users to OpenCode's stock Anthropic authentication. + */ +async function expectNonOatMainRefused(access: string) { + await useTempAccountFile( + createFallbackStorage({ accounts: [], quotaHeaderFeed: { enabled: true } }), + { access, refresh: `refresh-${access}`, expires: Date.now() + 100_000 }, + ) + const modelRequests: string[] = [] + globalThis.fetch = mock( + withNativeTokenExchange((input: Parameters[0]) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: syntheticMainAccountUuid }, + }), + ) + if (url.includes('/v1/messages')) modelRequests.push(url) + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch + const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ - type: 'oauth', - access: 'expired-token', - refresh: 'old-refresh', - expires: Date.now() - 1000, + type: 'oauth' as const, + access, + refresh: `refresh-${access}`, + expires: Date.now() + 100_000, }), { models: {} }, ) + await expect( + result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), + }), + ).rejects.toThrow(/API key/i) + expect(modelRequests).toEqual([]) + expect(await readFeedEntries()).toEqual([]) +} + +/** + * Fail if `value` (stored account metadata, a recorded error) carries any + * part of an OAuth token or API key this test's pool knows: an access or + * refresh token of a migrated login or of a later login of its accounts, or + * any `sk-ant-` token prefix. + */ +function expectNoCredentialFragment(value: unknown) { + const text = JSON.stringify(value ?? null) + expect(text).not.toContain('sk-ant-') + for (const login of poolLogins) { + expect(text).not.toContain(login.access) + expect(text).not.toContain(login.refresh) + } + for (const access of successorAccess.keys()) + expect(text).not.toContain(access) +} + +/** Settings as the native runtime reads them back from the migrated pool. */ +async function readNativeSettings(): Promise> { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + return (await runtime.read()).settings + } finally { + runtime.close() + } +} + +/** + * Per-account runtime state as the native pool records it, in the shape of + * the retired legacy state file: `main` and `accounts[routeId]`, each with + * its account identity, quota, profile, prime counters and recorded errors. The main + * account's quota error keeps its legacy name, `lastQuotaApiError`. Native + * reads carry no credential material. + */ +async function readNativeRuntimeState() { + const pool = migratedPool + if (!pool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + const snapshot = await runtime.read() + const project = (account: (typeof snapshot.accounts)[number]) => ({ + accountIdentity: account.accountIdentity, + quota: account.quota, + profile: account.profile, + prime: account.prime, + lastRefreshError: account.lastRefreshError, + lastQuotaRefreshError: account.lastQuotaRefreshError, + }) + const main = snapshot.accounts.find((account) => account.id === 'main') + return { + main: main && { + ...project(main), + lastQuotaApiError: main.lastQuotaRefreshError, + }, + accounts: Object.fromEntries( + snapshot.accounts + .filter((account) => account.id !== 'main') + .map((account) => [account.id, project(account)]), + ), + } + } finally { + runtime.close() + } +} + +/** The latest ignored reply text sent to a session without a connected TUI. */ +function latestIgnoredReply(client: ReturnType) { + const calls = ( + client.session.promptAsync as unknown as { + mock: { + calls: Array< + [{ body: { parts: Array<{ text: string; ignored?: boolean }> } }] + > + } + } + ).mock.calls + const part = calls.at(-1)?.[0]?.body.parts[0] + expect(part?.ignored).toBe(true) + return part?.text ?? '' +} + +/** Open /claude for a TUI-connected session and return the menu payload. */ +async function openClaudeMenu(plugin: any, sessionId: string) { + // The TUI polls its session's notifications; the plugin treats a recent + // poll as a connected TUI, so draining here makes the session interactive. + drainNotifications(0, sessionId) + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude', + arguments: '', + sessionID: sessionId, + }), + ) + const notice = drainNotifications(0, sessionId).at(-1) + if (notice?.type !== 'open-menu') + throw new Error('Expected the /claude menu notification') + drainNotifications(notice.id, sessionId) + return notice.payload +} + +/** + * Open /claude, then submit one menu action through the plugin's RPC server + * the way the TUI renderer does. The plugin starts that server only when it + * is given a project directory, so create it with the migrated root. + */ +async function applyMenuAction( + plugin: any, + sessionId: string, + request: Omit, +): Promise { + await openClaudeMenu(plugin, sessionId) + const rpcDir = process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR + if (!rpcDir) throw new Error('Missing isolated RPC directory') + // Provider mocks reject loopback URLs; only the RPC request goes to the + // preload's loopback-only fetch. + const providerFetch = globalThis.fetch + globalThis.fetch = Object.assign( + (input: Parameters[0], init?: RequestInit) => + new URL(input instanceof Request ? input.url : String(input)).hostname === + '127.0.0.1' + ? loopbackFetch(input, init) + : providerFetch(input, init), + { preconnect: providerFetch.preconnect }, + ) as typeof fetch + try { + return await createRpcClient(rpcDir, process.pid).applyMenu({ + command: 'claude', + sessionId, + ...request, + }) + } finally { + globalThis.fetch = providerFetch + } +} + +function restoreProcessTestFiles() { + const testDir = process.env.OPENCODE_ANTHROPIC_AUTH_TEST_DIR + if (!testDir) return + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( + testDir, + 'anthropic-auth.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( + testDir, + 'sidebar-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + testDir, + 'opencode-enrollment.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( + testDir, + 'cachekeep-registry', + ) +} + +async function waitForSidebarState( + predicate: (state: Awaited>) => boolean, +) { + for (let attempt = 0; attempt < 50; attempt++) { + const state = await getSidebarState() + if (predicate(state)) return state + await Bun.sleep(10) + } + const state = await getSidebarState() + throw new Error(`Sidebar state did not match: ${JSON.stringify(state)}`) +} + +/** + * The account storage a plugin process sees. For a migrated pool this is the + * native runtime's AccountStorage-shaped projection (settings, account + * metadata and quota, never credential material), the same view the plugin + * reads; migration retires the legacy files. Without a migrated pool it is + * the unmigrated legacy store. + */ +async function readAccountStorage(): Promise { + const pool = migratedPool + if (!pool) return loadAccounts() + const runtime = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + return (await runtime.read()).policyStorage + } finally { + runtime.close() + } +} + +async function waitForAccountStorage( + predicate: (storage: Awaited>) => boolean, +) { + for (let attempt = 0; attempt < 100; attempt++) { + const storage = await readAccountStorage() + if (predicate(storage)) return storage + await Bun.sleep(10) + } + const storage = await readAccountStorage() + throw new Error(`Account storage did not match: ${JSON.stringify(storage)}`) +} + +async function seedSidebarRouting( + activeId: string, + route: string, + lastUpdated: number, +) { + await setSidebarState({ + ...(await getSidebarState()), + activeId, + route, + lastUpdated, + }) +} + +async function waitForMockCall(fn: { mock?: { calls: unknown[] } }) { + for (let attempt = 0; attempt < 50; attempt++) { + if ((fn.mock?.calls.length ?? 0) > 0) return + await Bun.sleep(10) + } +} + +/** + * Set up the common test scaffolding for concurrent refresh tests: + * mocks setTimeout to be synchronous and creates a plugin loader + * whose migrated main login has an already-expired access token. + * The tokens the test's token endpoint issues are later tokens of that + * same account. Quota routing is off, so every request is actually sent + * with the refreshed token rather than answered locally for unknown quota. + */ +async function setupExpiredTokenLoader(...issuedAccess: string[]) { + const expiredLogin = { + access: 'sk-ant-oat01-expired-token', + refresh: 'old-refresh', + expires: Date.now() - 1000, + } + await useTempAccountFile( + createFallbackStorage({ accounts: [], quota: { enabled: false } }), + expiredLogin, + ) + mainAccountIssues(...issuedAccess) + const setTimeoutMock = mock((handler: () => unknown) => { + handler() + return 0 as unknown as ReturnType + }) as unknown as typeof setTimeout + + const mockClient = createMockClient() + const plugin = await getPlugin(mockClient, undefined, { + setTimeout: setTimeoutMock, + }) + const result = await plugin.auth.loader( + () => Promise.resolve({ type: 'oauth', ...expiredLogin }), + { models: {} }, + ) return { mockClient, result } } @@ -651,6 +1733,7 @@ type PluginRuntimeOverrides = Partial<{ setInterval: typeof globalThis.setInterval clearInterval: typeof globalThis.clearInterval scopedRosterPollIntervalMs: number + claustrumScopedConnect: () => Promise cacheKeepAggregateRefreshIntervalMs: number }> @@ -678,7 +1761,7 @@ async function getPlugin( runtimeOverrides: PluginRuntimeOverrides = {}, ) { const defaultTimerOverrides = disabledPluginRuntimeOverrides() - const plugin = (await ( + const creation = ( AnthropicAuthPlugin as unknown as ( ctx: Parameters[0], runtimeOverrides?: PluginRuntimeOverrides, @@ -694,145 +1777,107 @@ async function getPlugin( ...pluginRuntimeOverrides, ...runtimeOverrides, }, - )) as any + ) + testLifetime?.trackDetached(creation) + const plugin = (await creation) as any + livePlugins.add(plugin) if (plugin.__fallbackRefreshReady) { fallbackRefreshes.add(plugin.__fallbackRefreshReady) } + const pool = migratedPool + const loader = plugin.auth?.loader + if (pool && loader) { + // After migration, OpenCode's auth store answers with the inert + // activation, not the test's literal OAuth tokens. + plugin.auth.loader = ( + hostGetAuth: () => Promise>, + provider: unknown, + ) => loader(() => migratedHostAuth(pool, hostGetAuth), provider) + } return plugin } describe('scoped enrollment is explicit', () => { test('account dialog reports approved enrollment without false setup guidance for a vault-served main', async () => { - await useTempAccountFile( + const vault = mainOnlyVault() + const fixture = await useVaultAccountFile( createFallbackStorage({ accounts: [], + mainAccountId: 'primary-route', claustrum: { mode: 'claustrum', scopedRoster: true, primaryAccount: { credentialId: 'oauth:anthropic', - accountId: - '11111111-1111-4111-8111-111111111111' as ProviderAccountUuid, + accountId: syntheticMainIdentity as ProviderAccountUuid, state: 'active', }, }, }), - ) - const enrollmentFile = - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE - if (!enrollmentFile) throw new Error('Missing isolated enrollment path') - await writeFile( - enrollmentFile, - JSON.stringify({ - token: 'ab'.repeat(32), - token_generation: 1, - }), - { mode: 0o600 }, - ) - const previousConnection = - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = join( - tempConfigDir ?? '', - 'missing-connection.json', + vault, ) const sessionId = `ses_approved_vault_account_dialog_${randomUUID()}` - drainNotifications(0, sessionId) - const plugin = await getPlugin() - let notificationId = 0 - try { - await expect( - plugin['command.execute.before']({ - command: 'claude-account', - arguments: '', - sessionID: sessionId, - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - const notices = drainNotifications(0, sessionId) - notificationId = notices.at(-1)?.id ?? 0 - const notice = notices.at(-1) - if (notice?.type !== 'open-dialog') - throw new Error('Expected a legacy account dialog notification') - const accountDialog = notice.payload - expect(accountDialog?.command).toBe('claude-account') - expect(accountDialog?.knobs?.accounts).toContainEqual( - expect.objectContaining({ - id: 'main', - vaultServed: true, - }), - ) - const status = String(accountDialog?.knobs?.enrollmentStatus) - expect(status).toContain('approved as enrolled:anthropic-auth-opencode') - expect(status).not.toContain('scoped serving not active yet') - expect(status).not.toContain('Quit the host and run') - } finally { - await plugin.dispose?.() - if (notificationId > 0) drainNotifications(notificationId, sessionId) - if (previousConnection === undefined) - delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE - else - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = - previousConnection - } + const plugin = await getPlugin(undefined, fixture.root, { + claustrumScopedConnect: async () => vault, + }) + const menu = await openClaudeMenu(plugin, sessionId) + const status = ( + menu.menu.sections.find((section) => section.id === 'Accounts')?.lines ?? + [] + ).join('\n') + // The main account's status line names the vault as its source. + expect(status).toContain('[main] enabled — vault') + expect(status).toContain('approved as enrolled:anthropic-auth-opencode') + expect(status).not.toContain('scoped serving not active yet') + expect(status).not.toContain('Quit the host and run') }) test('boot and account status are read-only for an unscoped Claustrum configuration', async () => { - await useTempAccountFile( + // A Claustrum configuration without a scoped account roster cannot migrate. + // Startup and status reads must not propose or persist enrollment state. + await useUnmigratedAccountFile( createFallbackStorage({ accounts: [], claustrum: { mode: 'claustrum' } }), ) const enrollmentFile = process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE if (!enrollmentFile) throw new Error('Missing isolated enrollment path') const stateFile = enrollmentFile.replace(/\.json$/, '-state.json') - const previousConnection = - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = join( - tempConfigDir ?? '', - 'missing-connection.json', + const plugin = await getPlugin(createMockClient()) + await openClaudeMenu(plugin, 'ses_read_only') + await expect(readFile(stateFile, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }) + // Also reject the enrollment-starting call in source: detached enrollment + // work could begin after the file-absence assertion has already passed. + const pluginSource = await readFile( + join(import.meta.dir, '..', 'index.ts'), + 'utf8', ) - const client = createMockClient() - const plugin = await getPlugin(client) - try { - await expect( - plugin['command.execute.before']({ - command: 'claude-account', - arguments: '', - sessionID: 'ses_read_only', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - await expect(readFile(stateFile, 'utf8')).rejects.toMatchObject({ - code: 'ENOENT', - }) - // Pin the structural boundary as well: the old fire-and-forget adoption - // can outlive an otherwise successful boot/status assertion. - const pluginSource = await readFile( - join(import.meta.dir, '..', 'index.ts'), - 'utf8', - ) - expect(pluginSource.includes('adoptClaustrumEnrollment')).toBe(false) - } finally { - await plugin.dispose?.() - if (previousConnection === undefined) - delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE - else - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = - previousConnection - } + expect(pluginSource.includes('adoptClaustrumEnrollment')).toBe(false) }) test('enrollment-reset only clears terminal state; setup owns the next proposal', async () => { - await useTempAccountFile( - createFallbackStorage({ accounts: [], claustrum: { mode: 'claustrum' } }), - ) - const enrollmentFile = - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE - if (!enrollmentFile) throw new Error('Missing isolated enrollment path') - const stateFile = enrollmentFile.replace(/\.json$/, '-state.json') - const previousConnection = - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = join( - tempConfigDir ?? '', - 'missing-connection.json', + const vault = mainOnlyVault() + const fixture = await useVaultAccountFile( + createFallbackStorage({ + accounts: [], + mainAccountId: 'primary-route', + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: syntheticMainIdentity as ProviderAccountUuid, + state: 'active', + }, + }, + }), + vault, ) + // Represent lost host enrollment by removing its consumer token. Keep a + // terminal refusal record so reset clears that record without proposing. + await rm(fixture.enrollmentPath) + const stateFile = fixture.enrollmentPath.replace(/\.json$/, '-state.json') await writeFile( stateFile, JSON.stringify({ @@ -844,33 +1889,20 @@ describe('scoped enrollment is explicit', () => { }), { mode: 0o600 }, ) - const client = createMockClient() - const plugin = await getPlugin(client) - try { - await expect( - plugin['command.execute.before']({ - command: 'claude-account', - arguments: 'enrollment-reset', - sessionID: 'ses_reset_only', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - await expect(readFile(stateFile, 'utf8')).rejects.toMatchObject({ - code: 'ENOENT', - }) - const text = (client.session.promptAsync as any).mock.calls.at(-1)?.[0] - ?.body.parts[0]?.text - expect(text).toContain('setup') - } finally { - await plugin.dispose?.() - if (previousConnection === undefined) - delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE - else - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE = - previousConnection - } + const plugin = await getPlugin(createMockClient(), fixture.root, { + claustrumScopedConnect: async () => vault, + }) + const result = await applyMenuAction(plugin, 'ses_reset_only', { + sectionId: 'Accounts', + actionId: 'enrollment-reset', + confirmed: true, + }) + await expect(readFile(stateFile, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }) + expect(result.text).toContain('setup') }) }) - describe('desktop notice identity (#230)', () => { test('orders repeated notices before the same assistant with bounded IDs', async () => { const plugin = await getPlugin() @@ -940,7 +1972,7 @@ describe('sidebar needsReauth (dead-fallback indicator)', () => { { id: 'fallback-1', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh, expires: now + 5 * 60 * 60 * 1000, lastRefreshError: error, @@ -956,7 +1988,7 @@ describe('sidebar needsReauth (dead-fallback indicator)', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -997,7 +2029,7 @@ describe('sidebar needsReauth (dead-fallback indicator)', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -1020,7 +2052,7 @@ describe('fallback quota persistence ordering', () => { { id: 'fallback-late-error', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: successAt + 5 * 60 * 60 * 1000, quota: { @@ -1050,7 +2082,7 @@ describe('fallback quota persistence ordering', () => { }) await plugin.dispose?.() - const saved = await loadAccounts() + const saved = await readAccountStorage() const savedAccount = saved?.accounts[0] expect(savedAccount).toBeDefined() if (!savedAccount || !isOAuthAccount(savedAccount)) { @@ -1119,9 +2151,11 @@ async function loadMainAndFetch( requestHeaders?: Record, ) { await useTempAccountFile(storage) - globalThis.fetch = mock(() => - Promise.resolve( - typeof response === 'function' ? response() : response.clone(), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve( + typeof response === 'function' ? response() : response.clone(), + ), ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -1129,7 +2163,7 @@ async function loadMainAndFetch( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -1153,59 +2187,9 @@ describe('quota header feed integration', () => { test.serial( 'non-OAT compatibility identity stays a local quota key without a provider uuid', async () => { - await useTempAccountFile( - createFallbackStorage({ - quota: { enabled: true, failClosedOnUnknownQuota: false }, - quotaHeaderFeed: { enabled: true }, - accounts: [], - }), - ) - let bootstrapRequests = 0 - globalThis.fetch = mock( - (input: Parameters[0]) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) bootstrapRequests += 1 - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { - 'anthropic-ratelimit-unified-5h-utilization': '0.25', - 'anthropic-ratelimit-unified-5h-reset': '1800000000', - }, - }), - ) - }, - ) as unknown as typeof fetch - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'local-adapter-access', - refresh: 'local-adapter-refresh', - expires: Date.now() + 100_000, - }), - { models: {} }, - ) - const response = await result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), - }) - await response.text() - const entries = await waitForFeedEntries( - (candidate) => candidate.length === 1, - 'the local compatibility entry', - ) - const persisted = await loadAccounts() - expect(bootstrapRequests).toBe(0) - expect(persisted?.mainAccountId).toBeDefined() - expect((entries[0] as any)?.anthropic_account_uuid).toBeNull() - const resolution = await plugin.__resolveMainQuotaIdentityForTest( - 'local-adapter-access', - ) - expect(resolution.providerAccountUuid).toBeUndefined() - expect(resolution.state).toBe('na') - await plugin.dispose?.() + // OAuth records without Anthropic's access-token prefix are no longer served natively: the request is + // refused with guidance and nothing reaches the model or the quota feed. + await expectNonOatMainRefused('local-adapter-access') }, ) @@ -1246,32 +2230,42 @@ describe('quota header feed integration', () => { ], }), ) - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve(Response.json(bootstrapResponse)) - } - if (url.includes('/v1/messages')) { - const authorization = new Headers(init?.headers).get('authorization') - if (authorization === `Bearer ${fallbackAccess}`) { - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, - }), - ) - } - return Promise.resolve(new Response(null, { status: 429 })) - } - return Promise.resolve(Response.json({})) - }) as unknown as typeof fetch + // Answer token exchanges and the primary account's bootstrap normally. + // Let this test control the fallback credential's bootstrap response. + globalThis.fetch = mock( + withNativeTokenExchange( + withMainAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve(Response.json(bootstrapResponse)) + } + if (url.includes('/v1/messages')) { + const authorization = new Headers(init?.headers).get( + 'authorization', + ) + if (authorization === `Bearer ${fallbackAccess}`) { + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.25', + }, + }), + ) + } + return Promise.resolve(new Response(null, { status: 429 })) + } + return Promise.resolve(Response.json({})) + }), + ), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -1314,104 +2308,9 @@ describe('quota header feed integration', () => { }) test('non-oat main feed leaves provider UUID null while retaining its quota slot', async () => { - const originalNow = Date.now - let clock = 1_000_000 - Date.now = () => clock - try { - await useTempAccountFile({ - ...createFallbackStorage({ - quotaHeaderFeed: { enabled: true }, - mainAccountId: 'main-fixed-id', - }), - accounts: [], - }) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('{}', { - status: 200, - headers: { - 'anthropic-ratelimit-unified-5h-utilization': '0.25', - 'anthropic-ratelimit-unified-5h-reset': '1800000000', - }, - }), - ), - ) as unknown as typeof fetch - - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'main-access', - refresh: 'main-refresh', - expires: Date.now() + 100000, - }), - { models: {} }, - ) - const response = await result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), - }) - await response.text() - clock += 1_000 - - const feedDirectory = process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR! - const [feedEntry] = await waitForFeedEntries( - (entries) => entries.length === 1, - 'one published entry', - ) - const files = (await readdir(feedDirectory)).filter((name) => - name.endsWith('.json'), - ) - expect(files).toHaveLength(1) - const record = JSON.parse( - await readFile(join(feedDirectory, files[0]!), 'utf8'), - ) - const raw = await readFile(join(feedDirectory, files[0]!), 'utf8') - const published = Object.values(record.entries)[0] as any - expect(published).toEqual( - expect.objectContaining({ - identity_source: 'account_ref', - account_ref: 'main-fixed-id', - anthropic_account_uuid: null, - configured_account_count: 1, - observed_at_ms: 1_000_000, - quota: expect.objectContaining({ - five_hour: expect.objectContaining({ checkedAt: 1_000_000 }), - }), - }), - ) - expect(record.lease_horizon_ms).toBe(180_000) - expect(published).not.toHaveProperty('credential_id') - for (const secret of [ - 'main-access', - 'main-refresh', - 'test-access-token', - 'test-refresh-token', - 'authorization', - 'anthropic-ratelimit-unified-5h-utilization', - 'claude-sonnet-4-5', - 'vault', - 'credential_id', - ]) { - expect(raw).not.toContain(secret) - } - expect(raw).not.toContain('"credential_id":"main"') - expect(raw).not.toContain('"account_ref":"main"') - expect(feedEntry).toEqual( - expect.objectContaining({ - account_ref: 'main-fixed-id', - anthropic_account_uuid: null, - }), - ) - const persisted = await waitForAccountStorage( - (storage) => - storage?.quota?.mainQuota?.accountIdentity === 'main-fixed-id', - ) - expect(persisted?.quota?.mainQuota?.accountIdentity).toBe('main-fixed-id') - } finally { - Date.now = originalNow - } + // OAuth records without Anthropic's access-token prefix are no longer served natively: the request is + // refused with guidance and nothing reaches the model or the quota feed. + await expectNonOatMainRefused('main-access') }) test('oat main feed preserves its bootstrapped Anthropic account UUID', async () => { @@ -1423,23 +2322,30 @@ describe('quota header feed integration', () => { quotaHeaderFeed: { enabled: true }, accounts: [], }), + { + access: 'sk-ant-oat-main-feed', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }, ) - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { + globalThis.fetch = mock( + withNativeTokenExchange((input: any) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: providerAccountUuid }, + }), + ) + } return Promise.resolve( - Response.json({ - oauth_account: { account_uuid: providerAccountUuid }, + new Response('{}', { + status: 200, + headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, }), ) - } - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, - }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( @@ -1462,141 +2368,29 @@ describe('quota header feed integration', () => { ), 'a bootstrapped main UUID feed entry', ) + // The native feed keys the main account by its stable Claude account + // UUID; the configured slot id stays a local name only. expect(published).toEqual( expect.objectContaining({ - account_ref: mainAccountId, + account_ref: providerAccountUuid, anthropic_account_uuid: providerAccountUuid, }), ) + expect((published as { account_ref?: unknown }).account_ref).not.toBe( + mainAccountId, + ) }) test('main feed drops a cached oat UUID after the current credential becomes non-oat', async () => { - const mainAccountId = 'main-oat-to-non-oat-slot' - const providerAccountUuid = '44444444-4444-4444-4444-444444444444' - let liveAccess = 'sk-ant-oat-main-feed-before-rotation' - await useTempAccountFile( - createFallbackStorage({ - mainAccountId, - quotaHeaderFeed: { enabled: true }, - accounts: [], - }), - ) - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ - oauth_account: { account_uuid: providerAccountUuid }, - }), - ) - } - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, - }), - ) - }) as unknown as typeof fetch - - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: liveAccess, - refresh: `refresh-${liveAccess}`, - expires: Date.now() + 100_000, - }), - { models: {} }, - ) - await (await result.fetch(MESSAGES_URL, EMPTY_POST)).text() - await waitForFeedEntries( - (entries) => - entries.some( - (entry: any) => entry.anthropic_account_uuid === providerAccountUuid, - ), - 'a bootstrapped main UUID feed entry', - ) - - liveAccess = 'main-feed-non-oat-after-rotation' - await (await result.fetch(MESSAGES_URL, EMPTY_POST)).text() - - const feedEntries = await waitForFeedEntries( - (entries) => - entries.some( - (entry: any) => - entry.account_ref === mainAccountId && - entry.anthropic_account_uuid === null, - ), - 'a non-oat main feed entry without a provider UUID', - ) - const published = feedEntries.find( - (entry: any) => - entry.account_ref === mainAccountId && - entry.anthropic_account_uuid === null, - ) - expect(published).toEqual( - expect.objectContaining({ - account_ref: mainAccountId, - anthropic_account_uuid: null, - }), - ) + // OAuth records without Anthropic's access-token prefix are no longer served natively: the request is + // refused with guidance and nothing reaches the model or the quota feed. + await expectNonOatMainRefused('main-feed-non-oat-after-rotation') }) test('fresh non-oat main install persists a quota slot and publishes no provider UUID', async () => { - await useTempAccountFile({ - version: 1, - main: { type: 'opencode', provider: 'anthropic' }, - quotaHeaderFeed: { enabled: true }, - accounts: [], - }) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('{}', { - status: 200, - headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, - }), - ), - ) as unknown as typeof fetch - - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: 'fresh-main-non-oat', - refresh: 'main-refresh', - expires: Date.now() + 100_000, - }), - { models: {} }, - ) - const response = await result.fetch(MESSAGES_URL, EMPTY_POST) - await response.text() - - const persisted = await waitForAccountStorage( - (storage) => - storage?.mainAccountId !== undefined && - storage.quota?.mainQuota?.accountIdentity === storage.mainAccountId, - ) - const [published] = await waitForFeedEntries( - (entries) => - entries.some( - (entry: any) => - entry.account_ref === persisted?.mainAccountId && - entry.anthropic_account_uuid === null, - ), - 'a fresh non-oat main feed entry', - ) - expect(persisted?.mainAccountId).toBeDefined() - expect(persisted?.quota?.mainQuota?.accountIdentity).toBe( - persisted?.mainAccountId, - ) - expect(published).toEqual( - expect.objectContaining({ - account_ref: persisted?.mainAccountId, - anthropic_account_uuid: null, - }), - ) + // OAuth records without Anthropic's access-token prefix are no longer served natively: the request is + // refused with guidance and nothing reaches the model or the quota feed. + await expectNonOatMainRefused('fresh-main-non-oat') }) test('publishes and persists a fallback Anthropic account UUID', async () => { @@ -1946,18 +2740,20 @@ describe('quota header feed integration', () => { ], }), ) - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - const token = new Headers(init?.headers).get('authorization') - if (token === `Bearer ${fallbackAccess}`) { - fallbackStarted?.() - return fallbackResponse + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + const token = new Headers(init?.headers).get('authorization') + if (token === `Bearer ${fallbackAccess}`) { + fallbackStarted?.() + return fallbackResponse + } + return Promise.resolve(new Response(null, { status: 429 })) } - return Promise.resolve(new Response(null, { status: 429 })) - } - return Promise.resolve(Response.json({})) - }) as unknown as typeof fetch + return Promise.resolve(Response.json({})) + }), + ) as unknown as typeof fetch const records: LogTestRecord[] = [] let fallbackAtPersistenceReject: unknown @@ -1969,7 +2765,7 @@ describe('quota header feed integration', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -2058,7 +2854,7 @@ describe('quota header feed integration', () => { 'stale fallback quota persistence discard', ) await drainSidebarWrites() - const settled = await loadAccounts() + const settled = await readAccountStorage() const settledAccount = settled?.accounts.find( (candidate) => candidate.id === 'fallback-1', ) @@ -2119,18 +2915,20 @@ describe('quota header feed integration', () => { ], }), ) - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - const token = new Headers(init?.headers).get('authorization') - if (token === 'Bearer main-access') { - mainStarted?.() - return mainResponse + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + const token = new Headers(init?.headers).get('authorization') + if (token === 'Bearer sk-ant-oat01-main-access') { + mainStarted?.() + return mainResponse + } + return Promise.resolve(new Response(null, { status: 429 })) } - return Promise.resolve(new Response(null, { status: 429 })) - } - return Promise.resolve(Response.json({})) - }) as unknown as typeof fetch + return Promise.resolve(Response.json({})) + }), + ) as unknown as typeof fetch try { const plugin = await getPlugin() @@ -2139,7 +2937,7 @@ describe('quota header feed integration', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -2213,13 +3011,15 @@ describe('quota header feed integration', () => { const sidebarWriteRelease = new Promise((resolve) => { releaseSidebarWrite = resolve }) - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/v1/messages')) { - responseStarted() - return Promise.resolve(new Response('{}', { status: 200 })) - } - return Promise.resolve(Response.json({})) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + if (extractUrl(input).includes('/v1/messages')) { + responseStarted() + return Promise.resolve(new Response('{}', { status: 200 })) + } + return Promise.resolve(Response.json({})) + }), + ) as unknown as typeof fetch try { const plugin = await getPlugin() @@ -2227,7 +3027,7 @@ describe('quota header feed integration', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -2301,7 +3101,7 @@ describe('quota header feed integration', () => { }, })}\n`, ) - const mixedState = await loadAccounts() + const mixedState = await readAccountStorage() expect(mixedState?.quota?.mainQuota?.accountIdentity).toBe( accountIdentity, ) @@ -2364,6 +3164,11 @@ describe('quota header feed integration', () => { quotaHeaderFeed: { enabled: true }, accounts: [], }), + { + access: 'sk-ant-oat-unknown-feed', + refresh: 'unknown-refresh', + expires: Date.now() + 100000, + }, ) globalThis.fetch = mock((input: any) => { if (extractUrl(input).includes('/v1/messages')) { @@ -2550,37 +3355,44 @@ describe('quota header feed integration', () => { originalProfileHydration } } - }) - - test('sustained unknown account identity retains the fail-closed spend gate', async () => { - const now = Date.now() - await useTempAccountFile({ - ...createFallbackStorage({ - mainAccountId: 'main-slot', - quota: { - enabled: true, - checkIntervalMinutes: 5, - failClosedOnUnknownQuota: true, - mainQuota: { - accountIdentity: 'main-slot', - five_hour: { - usedPercent: 100, - remainingPercent: 0, - checkedAt: now, + }) + + test('sustained unknown account identity retains the fail-closed spend gate', async () => { + const now = Date.now() + await useTempAccountFile( + { + ...createFallbackStorage({ + mainAccountId: 'main-slot', + quota: { + enabled: true, + checkIntervalMinutes: 5, + failClosedOnUnknownQuota: true, + mainQuota: { + accountIdentity: 'main-slot', + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: now, + }, + }, + mainQuotaCheckedAt: now, + mainLastQuotaApiError: { + message: 'account A quota backoff', + checkedAt: now - 1_000, + nextRetryAt: now + 60_000, + retryCount: 1, + accountIdentity: 'main-slot', }, }, - mainQuotaCheckedAt: now, - mainLastQuotaApiError: { - message: 'account A quota backoff', - checkedAt: now - 1_000, - nextRetryAt: now + 60_000, - retryCount: 1, - accountIdentity: 'main-slot', - }, - }, - }), - accounts: [], - }) + }), + accounts: [], + }, + { + access: 'sk-ant-oat-unknown', + refresh: 'refresh-b', + expires: now + 100_000, + }, + ) let messageCalls = 0 globalThis.fetch = mock((input: any) => { const url = extractUrl(input) @@ -2615,25 +3427,32 @@ describe('quota header feed integration', () => { test('confirmed account replacement clears the prior account backoff', async () => { const now = Date.now() - await useTempAccountFile({ - ...createFallbackStorage({ - mainAccountId: 'main-slot', - quota: { - enabled: true, - checkIntervalMinutes: 5, - failClosedOnUnknownQuota: true, - mainLastQuotaApiError: { - message: 'account A quota backoff', - checkedAt: now - 1_000, - nextRetryAt: now + 60_000, - retryCount: 1, - accountIdentity: 'account-a', + await useTempAccountFile( + { + ...createFallbackStorage({ + mainAccountId: 'main-slot', + quota: { + enabled: true, + checkIntervalMinutes: 5, + failClosedOnUnknownQuota: true, + mainLastQuotaApiError: { + message: 'account A quota backoff', + checkedAt: now - 1_000, + nextRetryAt: now + 60_000, + retryCount: 1, + accountIdentity: 'account-a', + }, + mainQuotaErrorGeneration: 1, }, - mainQuotaErrorGeneration: 1, - }, - }), - accounts: [], - }) + }), + accounts: [], + }, + { + access: 'sk-ant-oat-account-b', + refresh: 'refresh-b', + expires: now + 100_000, + }, + ) let messageCalls = 0 globalThis.fetch = mock((input: any) => { const url = extractUrl(input) @@ -2671,8 +3490,12 @@ describe('quota header feed integration', () => { expect(response.status).toBe(200) expect(messageCalls).toBe(1) expect(plugin.__quotaManager.isBackedOff()).toBe(false) - expect((await loadAccounts())?.quota?.mainLastQuotaApiError).toBeUndefined() - expect((await loadAccounts())?.quota?.mainQuotaErrorGeneration).toBe(2) + expect( + (await readAccountStorage())?.quota?.mainLastQuotaApiError, + ).toBeUndefined() + expect((await readAccountStorage())?.quota?.mainQuotaErrorGeneration).toBe( + 2, + ) }) test('discards main response headers resolved under a replaced identity', async () => { @@ -2759,7 +3582,7 @@ describe('quota header feed integration', () => { ) expect((await inFlight).status).toBe(200) - // The stale-identity trace is emitted before any feed writer can start. + // Rejecting response quota for a changed account is logged before the quota observation file can be written. await waitForLogRecord( records, (record) => @@ -2790,154 +3613,15 @@ describe('quota header feed integration', () => { }) test('coalesces concurrent main identity resolution for the same token', async () => { - const originalProfileHydration = - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - try { - await useTempAccountFile({ - ...createFallbackStorage({ - mainAccountId: 'main-slot', - quota: { enabled: false }, - }), - accounts: [], - }) - let releaseBootstrap!: (response: Response) => void - const bootstrapResponse = new Promise((resolve) => { - releaseBootstrap = resolve - }) - let bootstrapStarted!: () => void - const bootstrapStartedPromise = new Promise((resolve) => { - bootstrapStarted = resolve - }) - let bootstrapCalls = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/claude_cli/bootstrap')) { - bootstrapCalls++ - bootstrapStarted() - return bootstrapResponse - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch - - const plugin = await getPlugin() - let currentAuth = { - type: 'oauth' as const, - access: 'compat-main-access', - refresh: 'main-refresh', - expires: Date.now() + 100_000, - } - const result = await plugin.auth.loader( - () => Promise.resolve(currentAuth), - { models: {} }, - ) - currentAuth = { - ...currentAuth, - access: 'sk-ant-oat-shared-token', - } - - const firstRequest = result.fetch(MESSAGES_URL, EMPTY_POST) - await bootstrapStartedPromise - const secondRequest = result.fetch(MESSAGES_URL, EMPTY_POST) - releaseBootstrap( - Response.json({ oauth_account: { account_uuid: 'shared-account' } }), - ) - - const responses = await Promise.all([firstRequest, secondRequest]) - expect(responses.map((response) => response.status)).toEqual([200, 200]) - expect(bootstrapCalls).toBe(1) - } finally { - if (originalProfileHydration === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = - originalProfileHydration - } - } + // OAuth records without Anthropic's access-token prefix are no longer served natively: the request is + // refused with guidance and nothing reaches the model or the quota feed. + await expectNonOatMainRefused('compat-main-access') }) test('aborts a request whose main identity becomes stale during resolution', async () => { - const originalProfileHydration = - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - try { - await useTempAccountFile({ - ...createFallbackStorage({ - mainAccountId: 'main-slot', - quota: { enabled: false }, - }), - accounts: [], - }) - let releaseBootstrapA!: (response: Response) => void - const bootstrapA = new Promise((resolve) => { - releaseBootstrapA = resolve - }) - let bootstrapAStarted!: () => void - const bootstrapAStartedPromise = new Promise((resolve) => { - bootstrapAStarted = resolve - }) - let bootstrapCalls = 0 - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/api/claude_cli/bootstrap')) { - bootstrapCalls += 1 - const token = new Headers(init?.headers).get('authorization') - if (token?.endsWith('resolution-a')) { - bootstrapAStarted() - return bootstrapA - } - return Promise.resolve( - Response.json({ - oauth_account: { account_uuid: 'account-b' }, - }), - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch - - const plugin = await getPlugin() - const tokenA = 'sk-ant-oat-race-resolution-a' - const tokenB = 'sk-ant-oat-race-resolution-b' - let currentAuthA = { - type: 'oauth' as const, - access: 'compat-a', - refresh: 'refresh-a', - expires: Date.now() + 100_000, - } - const resultA = await plugin.auth.loader( - () => Promise.resolve(currentAuthA), - { models: {} }, - ) - currentAuthA = { ...currentAuthA, access: tokenA } - const requestA = resultA.fetch(MESSAGES_URL, EMPTY_POST) - await bootstrapAStartedPromise - - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: tokenB, - refresh: 'refresh-b', - expires: Date.now() + 100_000, - }), - { models: {} }, - ) - releaseBootstrapA( - Response.json({ oauth_account: { account_uuid: 'account-a' } }), - ) - - await expect(requestA).rejects.toThrow( - 'Main OAuth identity changed while resolving request credentials', - ) - expect(bootstrapCalls).toBe(2) - } finally { - if (originalProfileHydration === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = - originalProfileHydration - } - } + // OAuth records without Anthropic's access-token prefix are no longer served natively: the request is + // refused with guidance and nothing reaches the model or the quota feed. + await expectNonOatMainRefused('compat-a') }) }) @@ -2957,7 +3641,7 @@ describe('main identity boot order', () => { const auth = () => { authCalls += 1 if (authCalls > 1) { - void loadAccounts().then((storage) => + void readAccountStorage().then((storage) => observedIds.push(storage?.mainAccountId), ) } @@ -2987,9 +3671,9 @@ describe('main identity boot order', () => { await plugin.auth.loader(auth, { models: {} }) await Bun.sleep(25) - const first = (await loadAccounts())?.mainAccountId + const first = (await readAccountStorage())?.mainAccountId await plugin.auth.loader(auth, { models: {} }) - const second = (await loadAccounts())?.mainAccountId + const second = (await readAccountStorage())?.mainAccountId expect(first).toMatch(/^[0-9a-f-]{36}$/) expect(second).toBe(first) @@ -3051,12 +3735,8 @@ describe('AnthropicAuthPlugin', () => { 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set', ) expect(authorize).not.toHaveBeenCalled() - const state = JSON.parse( - await readFile( - getAccountStatePath(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!), - 'utf8', - ), - ) + // The refused login added no account to the native pool. + const state = await readNativeRuntimeState() expect(state.accounts).toEqual({}) await plugin.dispose?.() } finally { @@ -3094,7 +3774,7 @@ describe('AnthropicAuthPlugin', () => { { id: accountId, type: 'oauth', - access: 'dead', + access: 'sk-ant-oat01-dead', refresh: 'dead-refresh', expires: now + 3 * 60 * 60_000, lastRefreshedAt: now - 60_000, @@ -3104,7 +3784,7 @@ describe('AnthropicAuthPlugin', () => { claustrum: { mode: 'local' }, }), ) - const newerSnapshot = await loadAccounts() + const newerSnapshot = await readAccountStorage() if (!newerSnapshot) throw new Error('missing test storage') const newerAccount = newerSnapshot.accounts.find( (account): account is OAuthAccount => @@ -3113,13 +3793,13 @@ describe('AnthropicAuthPlugin', () => { if (!newerAccount) throw new Error('missing account') newerAccount.lastRefreshError = errorAt(now, 'newer-incoming') await saveAccounts(newerSnapshot) - const afterNewer = (await loadAccounts())?.accounts.find( + const afterNewer = (await readAccountStorage())?.accounts.find( (account): account is OAuthAccount => account.id === accountId && isOAuthAccount(account), ) expect(afterNewer?.lastRefreshError?.message).toContain('newer-incoming') - const olderSnapshot = await loadAccounts() + const olderSnapshot = await readAccountStorage() if (!olderSnapshot) throw new Error('missing test storage') const olderAccount = olderSnapshot.accounts.find( (account): account is OAuthAccount => @@ -3128,7 +3808,7 @@ describe('AnthropicAuthPlugin', () => { if (!olderAccount) throw new Error('missing account') olderAccount.lastRefreshError = errorAt(now - 300_000, 'older-incoming') await saveAccounts(olderSnapshot) - const afterOlder = (await loadAccounts())?.accounts.find( + const afterOlder = (await readAccountStorage())?.accounts.find( (account): account is OAuthAccount => account.id === accountId && isOAuthAccount(account), ) @@ -3246,12 +3926,14 @@ describe('quota header feed extended integration', () => { feedParentFile, 'quota-header-feed', ) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('{"ok":true}', { - status: 200, - headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, - }), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve( + new Response('{"ok":true}', { + status: 200, + headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, + }), + ), ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -3259,7 +3941,7 @@ describe('quota header feed extended integration', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -3291,29 +3973,36 @@ describe('quota header feed extended integration', () => { quota: { enabled: false }, main: { type: 'opencode', provider: 'anthropic' }, }), + { + access: 'sk-ant-oat01-main-access-before-rotation', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }, ) - let currentAccess = 'sk-ant-oat01-main-access-before-rotation' - globalThis.fetch = mock((input: unknown, init?: RequestInit) => { - const url = extractUrl(input as string | URL | Request) - if (url.startsWith(MESSAGES_URL)) { - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, - }), - ) - } - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ - oauth_account: { - account_uuid: '44444444-4444-4444-4444-444444444444', - }, - }), - ) - } - return Promise.reject(new Error(`Unexpected test fetch: ${url}`)) - }) as unknown as typeof fetch + const currentAccess = 'sk-ant-oat01-main-access-before-rotation' + globalThis.fetch = mock( + withNativeTokenExchange((input: unknown, init?: RequestInit) => { + const url = extractUrl(input as string | URL | Request) + if (url.startsWith(MESSAGES_URL)) { + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, + }), + ) + } + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve( + Response.json({ + oauth_account: { + account_uuid: '44444444-4444-4444-4444-444444444444', + }, + }), + ) + } + return Promise.reject(new Error(`Unexpected test fetch: ${url}`)) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => @@ -3329,7 +4018,13 @@ describe('quota header feed extended integration', () => { const response = await result.fetch(MESSAGES_URL, EMPTY_POST) expect(response.status).toBe(200) - currentAccess = 'sk-ant-oat01-main-access-after-rotation' + // Another OpenCode process refreshes the main login; the pool now holds + // the rotated access token for the same account. + await refreshPoolMainElsewhere({ + access: 'sk-ant-oat01-main-access-after-rotation', + refresh: 'main-refresh-after-rotation', + expires: Date.now() + 100_000, + }) const resolved = await ( plugin as { @@ -3350,29 +4045,47 @@ describe('quota header feed extended integration', () => { accounts: [], quota: { enabled: false }, }), + { + access: 'sk-ant-oat01-main-access-a', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }, ) let requestCount = 0 - globalThis.fetch = mock(() => { - requestCount += 1 - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { - 'anthropic-ratelimit-unified-5h-utilization': String( - requestCount / 100, - ), - }, - }), - ) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission(() => { + requestCount += 1 + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': String( + requestCount / 100, + ), + }, + }), + ) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() - for (const access of ['main-access-a', 'main-access-b']) { + for (const access of [ + 'sk-ant-oat01-main-access-a', + 'sk-ant-oat01-main-access-b', + ]) { + // The second generation is a refresh of the same account in another + // OpenCode process, which rotates the pool's main access token. + if (access !== 'sk-ant-oat01-main-access-a') + await refreshPoolMainElsewhere({ + access, + refresh: `main-refresh-${access}`, + expires: Date.now() + 100000, + }) const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access, + access: 'sk-ant-oat01-main-access-a', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -3388,9 +4101,11 @@ describe('quota header feed extended integration', () => { ) expect(entries).toHaveLength(1) expect(entries[0]).toEqual( + // Quota uses Anthropic's account UUID, not the local identifier used + // to keep the main login's device identity stable across token rotation. expect.objectContaining({ identity_source: 'account_ref', - account_ref: 'stable-main-account', + account_ref: poolMainIdentity(), }), ) }) @@ -3407,7 +4122,7 @@ describe('quota header feed extended integration', () => { { id: 'unknown-fallback', type: 'oauth', - access: 'unknown-fallback-access', + access: 'sk-ant-oat01-unknown-fallback-access', refresh: 'unknown-fallback-refresh', expires: Date.now() + 5 * 60 * 60_000, }, @@ -3415,19 +4130,23 @@ describe('quota header feed extended integration', () => { }), ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) - return Promise.reject(new Error('quota source unavailable')) - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) + return Promise.reject(new Error('quota source unavailable')) + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -3436,7 +4155,9 @@ describe('quota header feed extended integration', () => { const response = await result.fetch(MESSAGES_URL, EMPTY_POST) expect(response.status).toBe(200) - expect(authorizations).toContain('Bearer unknown-fallback-access') + expect(authorizations).toContain( + 'Bearer sk-ant-oat01-unknown-fallback-access', + ) }) test('ordered fallback admission rejects unknown-quota OAuth under fail-closed', async () => { @@ -3447,7 +4168,7 @@ describe('quota header feed extended integration', () => { { id: 'unknown-fallback', type: 'oauth', - access: 'unknown-fallback-access', + access: 'sk-ant-oat01-unknown-fallback-access', refresh: 'unknown-fallback-refresh', expires: Date.now() + 5 * 60 * 60_000, }, @@ -3466,59 +4187,63 @@ describe('quota header feed extended integration', () => { retryCount: 1, accountIdentity: 'unknown-fallback', permanent: false, + // Old anthropic-auth-state.json errors carry a refresh-token hash. + // Migration preserves the matching credential's retry restriction. + tokenHash: hashRefreshToken('unknown-fallback-refresh'), }, undefined, ]) { - const storage = createFallbackStorage({ - routing: { mode: 'fallback-first' }, - quota: { - ...createFallbackStorage().quota, - failClosedOnUnknownQuota: false, - }, - accounts: [ - { - id: 'unknown-fallback', - type: 'oauth', - access: 'unknown-fallback-access', - refresh: 'unknown-fallback-refresh', - expires: now + 1_000, - lastRefreshError, + const storage = bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { + ...createFallbackStorage().quota, + failClosedOnUnknownQuota: false, }, - ], - }) + accounts: [ + { + id: 'unknown-fallback', + type: 'oauth', + access: 'sk-ant-oat01-unknown-fallback-access', + refresh: 'unknown-fallback-refresh', + expires: now + 1_000, + lastRefreshError, + }, + ], + }), + ) await useTempAccountFile(storage) - if (lastRefreshError) { - await saveAccountState( - storage, - process.env.OPENCODE_ANTHROPIC_AUTH_FILE, - { accounts: true }, - ) - } + loginIssues( + 'sk-ant-oat01-unknown-fallback-access', + 'sk-ant-oat01-refreshed-fallback-access', + ) const messageAuthorizations: string[] = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) - return Promise.resolve( - Response.json({ - access_token: 'refreshed-fallback-access', - expires_in: 18_000, - }), - ) - if (url.includes('/api/oauth/usage')) - return Promise.reject(new Error('quota source unavailable')) - if (url.includes('/v1/messages')) { - messageAuthorizations.push( - new Headers(init?.headers).get('authorization') ?? '', - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeBootstrap((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/oauth/token')) + return Promise.resolve( + Response.json({ + access_token: 'sk-ant-oat01-refreshed-fallback-access', + expires_in: 18_000, + }), + ) + if (url.includes('/api/oauth/usage')) + return Promise.reject(new Error('quota source unavailable')) + if (url.includes('/v1/messages')) { + messageAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -3528,12 +4253,12 @@ describe('quota header feed extended integration', () => { const response = await result.fetch(MESSAGES_URL, EMPTY_POST) if (lastRefreshError) { expect(messageAuthorizations).not.toContain( - 'Bearer unknown-fallback-access', + 'Bearer sk-ant-oat01-unknown-fallback-access', ) } else { expect(response.status).toBe(200) expect(messageAuthorizations).toContain( - 'Bearer refreshed-fallback-access', + 'Bearer sk-ant-oat01-refreshed-fallback-access', ) } } @@ -3548,7 +4273,7 @@ describe('quota header feed extended integration', () => { { id: 'unknown-fallback', type: 'oauth', - access: 'unknown-fallback-access', + access: 'sk-ant-oat01-unknown-fallback-access', refresh: 'unknown-fallback-refresh', expires: now + 5 * 60 * 60_000, lastRefreshError: { @@ -3578,19 +4303,21 @@ describe('quota header feed extended integration', () => { }), ) const messageRequests: string[] = [] - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) - return Promise.reject(new Error('quota source unavailable')) - if (url.includes('/v1/messages')) messageRequests.push(url) - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) + return Promise.reject(new Error('quota source unavailable')) + if (url.includes('/v1/messages')) messageRequests.push(url) + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -3624,19 +4351,21 @@ describe('quota header feed extended integration', () => { mainQuota: true, }) const messageRequests: string[] = [] - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) - return Promise.reject(new Error('quota source unavailable')) - if (url.includes('/v1/messages')) messageRequests.push(url) - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) + return Promise.reject(new Error('quota source unavailable')) + if (url.includes('/v1/messages')) messageRequests.push(url) + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -3662,17 +4391,19 @@ describe('quota header feed extended integration', () => { }), ) const messageRequests: string[] = [] - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) messageRequests.push(url) - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) messageRequests.push(url) + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -3701,7 +4432,7 @@ describe('quota header feed extended integration', () => { (entries) => entries.length === 1, 'one main published entry', ) - const mainAccountId = (await loadAccounts())?.mainAccountId + const mainAccountId = (await readAccountStorage())?.mainAccountId expect(mainEntries[0]).toEqual( expect.objectContaining({ identity_source: 'account_ref', @@ -3711,10 +4442,12 @@ describe('quota header feed extended integration', () => { expect(mainEntries[0]).not.toHaveProperty('credential_id') const fallback = await loadMainAndFetch( - createFallbackStorage({ - quotaHeaderFeed: { enabled: true }, - routing: { mode: 'fallback-first' }, - }), + bindPoolAccounts( + createFallbackStorage({ + quotaHeaderFeed: { enabled: true }, + routing: { mode: 'fallback-first' }, + }), + ), new Response('{}', { status: 200, headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, @@ -3882,35 +4615,37 @@ describe('OAuth billing lineage', () => { ) const sentBillingHeaders: string[] = [] globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ - oauth_account: { account_uuid: 'billing-relay-account' }, - }), - ) - } - if (url.startsWith('https://relay.example.test')) { - const payload = JSON.parse(String(init?.body)) as { body?: string } - const body = JSON.parse(String(payload.body)) as { - system?: Array<{ text?: string }> + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: 'billing-relay-account' }, + }), + ) } - sentBillingHeaders.push(String(body.system?.[0]?.text ?? '')) - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { - 'request-id': - sentBillingHeaders.length === 1 - ? 'req_011111111111111111111111' - : 'req_022222222222222222222222', - }, - }), - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }, + if (url.startsWith('https://relay.example.test')) { + const payload = JSON.parse(String(init?.body)) as { body?: string } + const body = JSON.parse(String(payload.body)) as { + system?: Array<{ text?: string }> + } + sentBillingHeaders.push(String(body.system?.[0]?.text ?? '')) + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { + 'request-id': + sentBillingHeaders.length === 1 + ? 'req_011111111111111111111111' + : 'req_022222222222222222222222', + }, + }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -3929,7 +4664,7 @@ describe('OAuth billing lineage', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 8 * 60 * 60_000, }), @@ -4007,32 +4742,34 @@ describe('Fable 5.1 request-scoped effort history', () => { 'req_033333333333333333333333', ] globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ - oauth_account: { account_uuid: 'effort-account' }, - }), - ) - } - if (url.includes('/v1/messages')) { - sentBody = JSON.parse(String(init?.body)) - sentHeaders = new Headers(init?.headers) - sentBillingHeaders.push(String(sentBody?.system?.[0]?.text ?? '')) - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { - 'request-id': - upstreamRequestIds[sentBillingHeaders.length - 1] ?? - 'req_099999999999999999999999', - }, - }), - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: 'effort-account' }, + }), + ) + } + if (url.includes('/v1/messages')) { + sentBody = JSON.parse(String(init?.body)) + sentHeaders = new Headers(init?.headers) + sentBillingHeaders.push(String(sentBody?.system?.[0]?.text ?? '')) + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { + 'request-id': + upstreamRequestIds[sentBillingHeaders.length - 1] ?? + 'req_099999999999999999999999', + }, + }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -4108,7 +4845,7 @@ describe('Fable 5.1 request-scoped effort history', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 8 * 60 * 60_000, }), @@ -4244,18 +4981,20 @@ describe('Fable 5.1 request-scoped effort history', () => { }), ) let messagesCalled = false - globalThis.fetch = mock((input: string | URL | Request) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ - oauth_account: { account_uuid: 'effort-account' }, - }), - ) - } - if (url.includes('/v1/messages')) messagesCalled = true - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) { + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: 'effort-account' }, + }), + ) + } + if (url.includes('/v1/messages')) messagesCalled = true + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const markedMessages = [ @@ -4325,7 +5064,7 @@ describe('Fable 5.1 request-scoped effort history', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 8 * 60 * 60_000, }), @@ -4513,38 +5252,42 @@ describe('meaningful trailing assistant history', () => { ) const modelDispatches: string[] = [] globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ - oauth_account: { account_uuid: 'trailing-account' }, - }), - ) - } - if (url.includes('/v1/messages')) { - const authorization = new Headers(init?.headers).get('authorization') - modelDispatches.push(authorization ?? '') - // An actual provider 400 remains eligible under the explicit policy, - // even when its error body matches the plugin's local refusal. - if (authorization === 'Bearer main-access') { + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) { return Promise.resolve( - Response.json( - { - type: 'error', - error: { - type: 'invalid_request_error', - message: TRAILING_ASSISTANT_HISTORY_MESSAGE, - }, - }, - { status: 400 }, - ), + Response.json({ + oauth_account: { account_uuid: 'trailing-account' }, + }), + ) + } + if (url.includes('/v1/messages')) { + const authorization = new Headers(init?.headers).get( + 'authorization', ) + modelDispatches.push(authorization ?? '') + // An actual provider 400 remains eligible under the explicit policy, + // even when its error body matches the plugin's local refusal. + if (authorization === 'Bearer sk-ant-oat01-main-access') { + return Promise.resolve( + Response.json( + { + type: 'error', + error: { + type: 'invalid_request_error', + message: TRAILING_ASSISTANT_HISTORY_MESSAGE, + }, + }, + { status: 400 }, + ), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) } return Promise.resolve(new Response('{}', { status: 200 })) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }, + }, + ), ) as unknown as typeof fetch const logs: LogTestRecord[] = [] __setLogTestSink((record) => logs.push(record)) @@ -4554,7 +5297,7 @@ describe('meaningful trailing assistant history', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 8 * 60 * 60_000, }), @@ -4601,8 +5344,8 @@ describe('meaningful trailing assistant history', () => { }) expect(control.status).toBe(200) expect(modelDispatches).toEqual([ - 'Bearer main-access', - 'Bearer fallback-access', + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-fallback-access', ]) expect(fallbackSelection).toHaveBeenCalled() }) @@ -4613,53 +5356,56 @@ describe('meaningful trailing assistant history', () => { fallbackOn: [400, 429], routing: { mode: 'fallback-first' }, refresh: { - enabled: false, - intervalMinutes: 10, - refreshBeforeExpiryMinutes: 30, - }, - accounts: [ - { - id: 'api-trailing', - type: 'api', - apiKey: 'api-trailing-key', - baseURL: 'https://api.example.test', - authHeader: 'x-api-key', - }, - ], - quota: { enabled: false } as AccountStorage['quota'], - }), - ) - const modelDispatches: string[] = [] - globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - const headers = new Headers(init?.headers) - modelDispatches.push( - headers.get('x-api-key') ?? headers.get('authorization') ?? '', - ) - // Mark main's five-hour usage at 100%. Paid API-key fallback is - // allowed only after main's quota is confirmed exhausted; choosing - // fallback-first mode alone does not permit it. - return Promise.resolve( - new Response('{}', { - status: 200, - headers: - headers.get('authorization') === 'Bearer main-access' - ? { - 'anthropic-ratelimit-unified-representative-claim': - 'five_hour', - 'anthropic-ratelimit-unified-5h-utilization': '1', - 'anthropic-ratelimit-unified-5h-reset': '1784246400', - 'anthropic-ratelimit-unified-7d-utilization': '0.4', - 'anthropic-ratelimit-unified-7d-reset': '1784628000', - } - : undefined, - }), - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }, + enabled: false, + intervalMinutes: 10, + refreshBeforeExpiryMinutes: 30, + }, + accounts: [ + { + id: 'api-trailing', + type: 'api', + apiKey: 'api-trailing-key', + baseURL: 'https://api.example.test', + authHeader: 'x-api-key', + }, + ], + quota: { enabled: false } as AccountStorage['quota'], + }), + ) + const modelDispatches: string[] = [] + globalThis.fetch = mock( + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + const headers = new Headers(init?.headers) + modelDispatches.push( + headers.get('x-api-key') ?? headers.get('authorization') ?? '', + ) + // The upstream five-hour utilization header proves 100% usage. + // Paid API-key fallback requires confirmed OAuth exhaustion; + // selecting fallback-first alone does not permit paid requests. + return Promise.resolve( + new Response('{}', { + status: 200, + headers: + headers.get('authorization') === + 'Bearer sk-ant-oat01-main-access' + ? { + 'anthropic-ratelimit-unified-representative-claim': + 'five_hour', + 'anthropic-ratelimit-unified-5h-utilization': '1', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + } + : undefined, + }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }, + ), ) as unknown as typeof fetch const logs: LogTestRecord[] = [] __setLogTestSink((record) => logs.push(record)) @@ -4669,7 +5415,7 @@ describe('meaningful trailing assistant history', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 8 * 60 * 60_000, }), @@ -4687,7 +5433,7 @@ describe('meaningful trailing assistant history', () => { headers: { 'x-session-affinity': 'ses_trailing_api' }, body: userEndedBody, }) - expect(modelDispatches).toEqual(['Bearer main-access']) + expect(modelDispatches).toEqual(['Bearer sk-ant-oat01-main-access']) modelDispatches.length = 0 await drainSidebarWrites() expect((await getSidebarState()).activeId).toBe('main') @@ -4737,10 +5483,9 @@ describe('provider.models', () => { afterEach(async () => { await drainSidebarWrites() restoreProcessTestFiles() - if (tempConfigDir) { - await rm(tempConfigDir, { recursive: true, force: true }) - tempConfigDir = undefined - } + // Clear tempConfigDir without deleting the account files used by plugins. + // TestLifetime disposes those plugins and joins their work before removal. + tempConfigDir = undefined }) test('zeros out Anthropic model costs for OAuth auth', async () => { @@ -5037,10 +5782,9 @@ describe('auth.loader', () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE await drainSidebarWrites() restoreProcessTestFiles() - if (tempConfigDir) { - await rm(tempConfigDir, { recursive: true, force: true }) - tempConfigDir = undefined - } + // Clear tempConfigDir without deleting the account files used by plugins. + // TestLifetime disposes those plugins and joins their work before removal. + tempConfigDir = undefined }) test('returns empty object for non-oauth auth', async () => { @@ -5062,15 +5806,22 @@ describe('auth.loader', () => { expires?: number } = { type: 'oauth', - access: 'initial-oauth', + access: 'sk-ant-oat01-initial-oauth', refresh: 'initial-refresh', expires: Date.now() + 100_000, } + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-initial-oauth', + refresh: 'initial-refresh', + expires: auth.expires as number, + }) let observedBody: Record | undefined - globalThis.fetch = mock(async (_input: unknown, init?: RequestInit) => { - observedBody = JSON.parse(String(init?.body)) - return new Response('{}', { status: 200 }) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission(async (_input: unknown, init?: RequestInit) => { + observedBody = JSON.parse(String(init?.body)) + return new Response('{}', { status: 200 }) + }), + ) as unknown as typeof fetch try { const plugin = await getPlugin() @@ -5100,12 +5851,17 @@ describe('auth.loader', () => { }) test('returns fetch wrapper for oauth auth', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }) const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'token', + access: 'sk-ant-oat01-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -5125,7 +5881,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5145,7 +5901,7 @@ describe('auth.loader', () => { { id: 'work-alt', type: 'oauth', - access: 'work-access', + access: 'sk-ant-oat01-work-access', refresh: 'work-refresh', expires: Date.now() + 100000, }, @@ -5159,7 +5915,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5179,7 +5935,7 @@ describe('auth.loader', () => { { id: 'work-alt', type: 'oauth', - access: 'work-access', + access: 'sk-ant-oat01-work-access', refresh: 'work-refresh', expires: Date.now() + 100000, }, @@ -5193,7 +5949,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5213,7 +5969,7 @@ describe('auth.loader', () => { { id: 'work-alt', type: 'oauth', - access: 'work-access', + access: 'sk-ant-oat01-work-access', refresh: 'work-refresh', expires: Date.now() + 100000, }, @@ -5250,7 +6006,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5275,7 +6031,7 @@ describe('auth.loader', () => { { id: 'work-alt', type: 'oauth', - access: 'work-access', + access: 'sk-ant-oat01-work-access', refresh: 'work-refresh', expires: Date.now() + 100000, }, @@ -5315,26 +6071,22 @@ describe('auth.loader', () => { ).toBe(false) await useTempAccountFile(capturedStorage) const plugin = await getPlugin() - await saveAccounts( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - quota: { - ...createFallbackStorage().quota, - failClosedOnUnknownQuota: false, - }, - accounts: [ - { - id: 'work-2', - type: 'oauth', - access: 'work-2-access', - refresh: 'work-2-refresh', - expires: Date.now() + 100000, - }, - ], - }), - ) + // An external settings write sets failClosedOnUnknownQuota=false and + // adds an account. The existing plugin must reread both changes. + await addPoolOAuthAccount('work-2', { + access: 'sk-ant-oat01-work-2-access', + refresh: 'work-2-refresh', + expires: Date.now() + 100000, + }) + await updatePoolSettings((settings) => ({ + ...settings, + quota: { + ...(settings.quota as Record), + failClosedOnUnknownQuota: false, + }, + })) expect( - (await loadAccounts())?.accounts.some( + (await readAccountStorage())?.accounts.some( (account) => account.id === 'work-2', ), ).toBe(true) @@ -5344,7 +6096,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5363,26 +6115,19 @@ describe('auth.loader', () => { const capturedStorage = createFallbackStorage({ accounts: [] }) await useTempAccountFile(capturedStorage) const plugin = await getPlugin() - await saveAccounts( - createFallbackStorage({ - accounts: [ - { - id: 'work-fresh', - type: 'oauth', - access: 'work-fresh-access', - refresh: 'work-fresh-refresh', - expires: Date.now() + 100000, - }, - ], - }), - ) + // Add an account through another runtime to verify that an existing plugin rereads shared storage before selecting a fallback. + await addPoolOAuthAccount('work-fresh', { + access: 'sk-ant-oat01-work-fresh-access', + refresh: 'work-fresh-refresh', + expires: Date.now() + 100000, + }) await seedSidebarRouting('main', 'main', Date.now()) await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5405,14 +6150,14 @@ describe('auth.loader', () => { { id: 'work-deleted', type: 'oauth', - access: 'work-deleted-access', + access: 'sk-ant-oat01-work-deleted-access', refresh: 'work-deleted-refresh', expires: Date.now() + 100000, }, { id: 'work-witness', type: 'oauth', - access: 'work-witness-stale-access', + access: 'sk-ant-oat01-work-witness-stale-access', refresh: 'work-witness-stale-refresh', expires: Date.now() + 100000, lastRefreshedAt: 100, @@ -5473,14 +6218,14 @@ describe('auth.loader', () => { { id: 'work-current', type: 'oauth', - access: 'work-current-access', + access: 'sk-ant-oat01-work-current-access', refresh: 'work-current-refresh', expires: Date.now() + 100000, }, { id: 'work-witness', type: 'oauth', - access: 'work-witness-current-access', + access: 'sk-ant-oat01-work-witness-current-access', refresh: 'work-witness-current-refresh', expires: Date.now() + 100000, lastRefreshedAt: 200, @@ -5496,7 +6241,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5508,7 +6253,7 @@ describe('auth.loader', () => { 4_000, 'fallback background refresh did not complete after the token stub was released', ) - const disk = await loadAccounts() + const disk = await readAccountStorage() expect(disk?.accounts.map((account) => account.id)).toEqual([ 'work-current', 'work-witness', @@ -5516,7 +6261,7 @@ describe('auth.loader', () => { expect( disk?.accounts.find((account) => account.id === 'work-current'), ).toMatchObject({ - access: 'work-current-access', + access: 'sk-ant-oat01-work-current-access', refresh: 'work-current-refresh', }) expect( @@ -5548,7 +6293,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5572,7 +6317,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5593,7 +6338,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5612,27 +6357,29 @@ describe('auth.loader', () => { await useTempAccountFile( createFallbackStorage({ routing: { mode: 'fallback-first' } }), ) - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0.25 }, - seven_day: { utilization: 0.3 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.25 }, + seven_day: { utilization: 0.3 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5674,13 +6421,15 @@ describe('auth.loader', () => { try { await expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), ) - const accountFile = process.env.OPENCODE_ANTHROPIC_AUTH_FILE - if (!accountFile) throw new Error('Expected isolated account file') + // Replace the shared pool config JSON file with a directory so later + // account reads fail instead of returning an outdated snapshot. + const accountFile = migratedPool?.paths.config + if (!accountFile) throw new Error('Expected a migrated pool') await rm(accountFile) await mkdir(accountFile) } finally { @@ -5728,7 +6477,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5737,7 +6486,7 @@ describe('auth.loader', () => { await expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -5757,14 +6506,14 @@ describe('auth.loader', () => { { id: 'fallback-a', type: 'oauth', - access: 'fallback-a-access', + access: 'sk-ant-oat01-fallback-a-access', refresh: 'fallback-a-refresh', expires: Date.now() + 100000, }, { id: 'fallback-b', type: 'oauth', - access: 'fallback-b-access', + access: 'sk-ant-oat01-fallback-b-access', refresh: 'fallback-b-refresh', expires: Date.now() + 100000, }, @@ -5791,7 +6540,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5801,7 +6550,7 @@ describe('auth.loader', () => { await expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -5819,7 +6568,7 @@ describe('auth.loader', () => { ) await expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -5839,15 +6588,17 @@ describe('auth.loader', () => { { id: 'work-alt', type: 'oauth', - access: 'work-access', + access: 'sk-ant-oat01-work-access', refresh: 'work-refresh', expires: Date.now() + 100000, }, ], }), ) - globalThis.fetch = mock(() => - Promise.resolve(new Response('{}', { status: 200 })), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve(new Response('{}', { status: 200 })), + ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -5855,7 +6606,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -5902,14 +6653,16 @@ describe('auth.loader', () => { }, }, mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, }), ) - globalThis.fetch = mock((_input: any, _init: any) => - Promise.resolve( - new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), + globalThis.fetch = mock( + withNativeAdmission((_input: any, _init: any) => + Promise.resolve( + new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), + ), ), ) as unknown as typeof fetch @@ -5918,7 +6671,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6008,12 +6761,14 @@ describe('auth.loader', () => { delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1749 }, })}\n\n` - globalThis.fetch = mock(() => - Promise.resolve( - new Response(`${start}${delta}`, { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve( + new Response(`${start}${delta}`, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }), + ), ), ) as unknown as typeof fetch @@ -6022,7 +6777,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6087,41 +6842,38 @@ describe('auth.loader', () => { ) const plugin = await getPlugin() - const storage = await loadAccounts() + const storage = await readAccountStorage() expect(storage).not.toBeNull() - ;(storage as AccountStorage).quota = { - ...(storage as AccountStorage).quota, - mainQuota: { - five_hour: { - usedPercent: 12, - remainingPercent: 88, - checkedAt: Date.now(), - }, - seven_day: { - usedPercent: 2, - remainingPercent: 98, - checkedAt: Date.now(), - }, - }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), - } - await saveAccountState(storage as AccountStorage, undefined, { - mainQuota: true, - }) let quotaApiCalls = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) quotaApiCalls++ - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) quotaApiCalls++ + return Promise.resolve(new Response(null, { status: 200 })) + }), + ) as unknown as typeof fetch + // Another OpenCode process records the main account's quota after this + // plugin has started. + await publishPoolQuota('main', { + five_hour: { + usedPercent: 12, + remainingPercent: 88, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 2, + remainingPercent: 98, + checkedAt: Date.now(), + }, + checkedAt: Date.now(), + }) const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6144,34 +6896,54 @@ describe('auth.loader', () => { test('sidebar state records the actual fallback-first route', async () => { await useTempAccountFile( - createFallbackStorage({ routing: { mode: 'fallback-first' } }), + bindPoolAccounts( + createFallbackStorage({ routing: { mode: 'fallback-first' } }), + ), ) + if (!migratedPool) throw new Error('Sidebar fixture did not migrate') + const fixtureRuntime = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + }) + try { + expect( + (await fixtureRuntime.read()).accounts.find( + (row) => row.id === 'fallback-1', + )?.quota?.five_hour?.usedPercent, + ).toBe(25) + } finally { + fixtureRuntime.close() + } const authorizations: string[] = [] - 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.25 }, - seven_day: { utilization: 0.3 }, - }), - { status: 200 }, - ), - ) - } + globalThis.fetch = mock( + withNativeAdmission((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.25 }, + seven_day: { utilization: 0.3 }, + }), + { status: 200 }, + ), + ) + } - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6195,7 +6967,7 @@ describe('auth.loader', () => { ) expect(state.route).toBe('fallback-first') expect(state.fallbacks[0]?.quota?.five_hour?.usedPercent).toBe(25) - expect(authorizations[0]).toBe('Bearer fallback-access') + expect(authorizations[0]).toBe('Bearer sk-ant-oat01-fallback-access') }) test('cachekeep lists tracked sessions across OpenCode plugin instances', async () => { @@ -6210,21 +6982,23 @@ describe('auth.loader', () => { }), ) - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0.25 }, - seven_day: { utilization: 0.3 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.25 }, + seven_day: { utilization: 0.3 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response(null, { status: 200 })) + }), + ) as unknown as typeof fetch const mockClient = createMockClient() const plugin = await getPlugin(mockClient) @@ -6232,7 +7006,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6253,7 +7027,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6279,7 +7053,7 @@ describe('auth.loader', () => { } await expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-cachekeep', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -6298,37 +7072,14 @@ describe('auth.loader', () => { test('routes Fable requests to OAuth fallback when main scoped Fable quota is exhausted', async () => { await useTempAccountFile( - createFallbackStorage({ - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { usedPercent: 0, remainingPercent: 100 }, - seven_day: { usedPercent: 0, remainingPercent: 100 }, - scoped: [ - { - id: 'claude-weekly-scoped-fable', - title: 'Fable only', - modelName: 'Fable', - usedPercent: 100, - remainingPercent: 0, - checkedAt: Date.now(), - }, - ], - }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), - } as AccountStorage['quota'], - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - access: 'fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - quota: { + bindPoolAccounts( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { five_hour: { usedPercent: 0, remainingPercent: 100, @@ -6344,41 +7095,80 @@ describe('auth.loader', () => { id: 'claude-weekly-scoped-fable', title: 'Fable only', modelName: 'Fable', - usedPercent: 25, - remainingPercent: 75, + usedPercent: 100, + remainingPercent: 0, checkedAt: Date.now(), }, ], }, - }, - ], - }), + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: Date.now(), + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 25, + remainingPercent: 75, + checkedAt: Date.now(), + }, + ], + }, + }, + ], + }), + ), ) const authorizations: string[] = [] - 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 }, - ), - ) - } - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + 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 }, + ), + ) + } + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6393,61 +7183,77 @@ describe('auth.loader', () => { }), }) - expect(authorizations).toEqual(['Bearer fallback-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-fallback-access']) }) test('keeps non-Fable requests on main when only main Fable quota is exhausted', async () => { await useTempAccountFile( - createFallbackStorage({ - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { usedPercent: 0, remainingPercent: 100 }, - seven_day: { usedPercent: 0, remainingPercent: 100 }, - scoped: [ - { - id: 'claude-weekly-scoped-fable', - title: 'Fable only', - modelName: 'Fable', - usedPercent: 100, - remainingPercent: 0, + bindPoolAccounts( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, checkedAt: Date.now(), }, - ], - }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), - } as AccountStorage['quota'], - }), + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: Date.now(), + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + ], + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + }), + ), ) const authorizations: string[] = [] - 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 }, - ), - ) - } - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + 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 }, + ), + ) + } + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6462,161 +7268,211 @@ describe('auth.loader', () => { }), }) - expect(authorizations).toEqual(['Bearer main-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-main-access']) }) - test('does not route API-key fallback for scoped Fable exhaustion alone', async () => { - await useTempAccountFile( - createFallbackStorage({ - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { usedPercent: 0, remainingPercent: 100 }, - seven_day: { usedPercent: 0, remainingPercent: 100 }, - scoped: [ + test.each([ + { killswitch: false, mode: 'main-first' }, + { killswitch: true, mode: 'main-first' }, + { killswitch: false, mode: 'fallback-first' }, + { killswitch: true, mode: 'fallback-first' }, + ] as const)( + 'does not route API-key fallback for scoped Fable exhaustion alone (%s)', + async ({ killswitch, mode }) => { + await useTempAccountFile( + bindPoolAccounts( + createFallbackStorage({ + routing: { mode }, + ...(killswitch + ? { + killswitch: { + enabled: true, + main: { five_hour: 0, seven_day: 0, scoped: 10 }, + }, + } + : {}), + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: Date.now(), + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + ], + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + accounts: [ { - id: 'claude-weekly-scoped-fable', - title: 'Fable only', - modelName: 'Fable', - usedPercent: 100, - remainingPercent: 0, - checkedAt: Date.now(), + id: 'kie-opus', + label: 'Kie Opus', + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', }, ], + }), + ), + ) + + const requests: Array<{ url: string; authorization: string | null }> = [] + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + 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 }, + ), + ) + } + requests.push({ + url, + authorization: new Headers(init?.headers).get('authorization'), + }) + return Promise.resolve(new Response(null, { status: 200 })) }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), - } as AccountStorage['quota'], - accounts: [ - { - id: 'kie-opus', - label: 'Kie Opus', - type: 'api', - apiKey: 'kie-key', - baseURL: 'https://api.kie.ai/claude', - authHeader: 'authorization-bearer', - }, - ], - }), - ) + ), + ) as unknown as typeof fetch - const requests: Array<{ url: string; authorization: string | null }> = [] - 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 }, - ), - ) - } - requests.push({ - url, - authorization: new Headers(init?.headers).get('authorization'), - }) - 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: 'sk-ant-oat01-main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'main-access', - refresh: 'main-refresh', - expires: Date.now() + 100000, + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-fable-5', + messages: [{ role: 'user', content: 'hello' }], }), - { models: {} }, - ) - - await result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ - model: 'claude-fable-5', - messages: [{ role: 'user', content: 'hello' }], - }), - }) + }) - expect(requests).toHaveLength(1) - expect(requests[0]).toMatchObject({ - url: 'https://api.anthropic.com/v1/messages?beta=true', - authorization: 'Bearer main-access', - }) - }) + expect(response.status).toBe(killswitch ? 429 : 200) + if (killswitch) { + expect(requests).toHaveLength(0) + return + } + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + url: 'https://api.anthropic.com/v1/messages?beta=true', + authorization: 'Bearer sk-ant-oat01-main-access', + }) + }, + ) test('refreshes stale scoped Fable exhaustion before skipping main', async () => { await useTempAccountFile( - createFallbackStorage({ - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { usedPercent: 0, remainingPercent: 100 }, - seven_day: { usedPercent: 0, remainingPercent: 100 }, - scoped: [ - { - id: 'claude-weekly-scoped-fable', - title: 'Fable only', - modelName: 'Fable', - usedPercent: 100, - remainingPercent: 0, - checkedAt: Date.now() - 60 * 60 * 1000, + bindPoolAccounts( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: Date.now(), }, - ], - }, - mainQuotaCheckedAt: Date.now() - 60 * 60 * 1000, - mainQuotaToken: tokenFingerprint('main-access'), - } as AccountStorage['quota'], - }), + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: Date.now(), + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now() - 60 * 60 * 1000, + }, + ], + }, + mainQuotaCheckedAt: Date.now() - 60 * 60 * 1000, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + }), + ), ) const requests: Array<{ url: string; authorization: string | null }> = [] let quotaCalls = 0 - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - quotaCalls++ - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - limits: [ - { - kind: 'weekly_scoped', - group: 'weekly', - percent: 10, - resets_at: null, - scope: { model: { id: null, display_name: 'Fable' } }, - }, - ], - }), - { status: 200 }, - ), - ) - } - requests.push({ - url, - authorization: new Headers(init?.headers).get('authorization'), - }) - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + quotaCalls++ + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 10, + resets_at: null, + scope: { model: { id: null, display_name: 'Fable' } }, + }, + ], + }), + { status: 200 }, + ), + ) + } + requests.push({ + url, + authorization: new Headers(init?.headers).get('authorization'), + }) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6633,55 +7489,32 @@ describe('auth.loader', () => { expect(quotaCalls).toBe(1) expect(requests).toHaveLength(1) - expect(requests[0]?.authorization).toBe('Bearer main-access') + expect(requests[0]?.authorization).toBe('Bearer sk-ant-oat01-main-access') }) test('killswitch fallback handoff filters exhausted matching scoped model quota', async () => { const now = Date.now() await useTempAccountFile( - createFallbackStorage({ - killswitch: { - enabled: true, - main: { five_hour: 50, seven_day: 20 }, - }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { usedPercent: 70, remainingPercent: 30 }, - seven_day: { usedPercent: 10, remainingPercent: 90 }, - scoped: [ - { - id: 'claude-weekly-scoped-fable', - title: 'Fable only', - modelName: 'Fable', - usedPercent: 10, - remainingPercent: 90, - checkedAt: now, - }, - ], + bindPoolAccounts( + createFallbackStorage({ + killswitch: { + enabled: true, + main: { five_hour: 50, seven_day: 20 }, }, - mainQuotaCheckedAt: now, - mainQuotaToken: tokenFingerprint('main-access'), - } as AccountStorage['quota'], - accounts: [ - { - id: 'fallback-empty', - type: 'oauth', - access: 'fallback-empty-access', - refresh: 'fallback-empty-refresh', - expires: now + 5 * 60 * 60 * 1000, - quota: { + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { five_hour: { - usedPercent: 0, - remainingPercent: 100, + usedPercent: 70, + remainingPercent: 30, checkedAt: now, }, seven_day: { - usedPercent: 0, - remainingPercent: 100, + usedPercent: 10, + remainingPercent: 90, checkedAt: now, }, scoped: [ @@ -6689,79 +7522,118 @@ describe('auth.loader', () => { id: 'claude-weekly-scoped-fable', title: 'Fable only', modelName: 'Fable', - usedPercent: 100, - remainingPercent: 0, + usedPercent: 10, + remainingPercent: 90, checkedAt: now, }, ], }, - }, - { - id: 'fallback-ok', - type: 'oauth', - access: 'fallback-ok-access', - refresh: 'fallback-ok-refresh', - expires: now + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 0, - remainingPercent: 100, - checkedAt: now, - }, - seven_day: { - usedPercent: 0, - remainingPercent: 100, - checkedAt: now, + mainQuotaCheckedAt: now, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + accounts: [ + { + id: 'fallback-empty', + type: 'oauth', + access: 'sk-ant-oat01-fallback-empty-access', + refresh: 'fallback-empty-refresh', + expires: now + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: now, + }, + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: now, + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 100, + remainingPercent: 0, + checkedAt: now, + }, + ], }, - scoped: [ - { - id: 'claude-weekly-scoped-fable', - title: 'Fable only', - modelName: 'Fable', - usedPercent: 25, - remainingPercent: 75, + }, + { + id: 'fallback-ok', + type: 'oauth', + access: 'sk-ant-oat01-fallback-ok-access', + refresh: 'fallback-ok-refresh', + expires: now + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, checkedAt: now, }, - ], + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: now, + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 25, + remainingPercent: 75, + checkedAt: now, + }, + ], + }, }, - }, - ], - }), + ], + }), + ), ) const authorizations: string[] = [] - 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: 70 }, - seven_day: { utilization: 10 }, - limits: [ - { - kind: 'weekly_scoped', - group: 'weekly', - percent: 10, - resets_at: null, - scope: { model: { id: null, display_name: 'Fable' } }, - }, - ], - }), - { status: 200 }, - ), - ) - } - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 70 }, + seven_day: { utilization: 10 }, + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 10, + resets_at: null, + scope: { model: { id: null, display_name: 'Fable' } }, + }, + ], + }), + { status: 200 }, + ), + ) + } + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6776,53 +7648,67 @@ describe('auth.loader', () => { }), }) - expect(authorizations).toEqual(['Bearer fallback-ok-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-fallback-ok-access']) }) test('does not route to API-key fallback when main OAuth quota is low but not exhausted', async () => { await useTempAccountFile( - createFallbackStorage({ - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { usedPercent: 99, remainingPercent: 1 }, - seven_day: { usedPercent: 99, remainingPercent: 1 }, - }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), - } as AccountStorage['quota'], - accounts: [ - { - id: 'kie-opus', - label: 'Kie Opus', - type: 'api', - apiKey: 'kie-key', - baseURL: 'https://api.kie.ai/claude', - authHeader: 'authorization-bearer', - }, - ], - }), + bindPoolAccounts( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 99, + remainingPercent: 1, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 99, + remainingPercent: 1, + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + accounts: [ + { + id: 'kie-opus', + label: 'Kie Opus', + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', + }, + ], + }), + ), ) const requests: Array<{ url: string; authorization: string | null }> = [] - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - requests.push({ - url, - authorization: new Headers(init?.headers).get('authorization'), - }) - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + requests.push({ + url, + authorization: new Headers(init?.headers).get('authorization'), + }) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6841,54 +7727,68 @@ describe('auth.loader', () => { expect(requests[0]?.url).toBe( 'https://api.anthropic.com/v1/messages?beta=true', ) - expect(requests[0]?.authorization).toBe('Bearer main-access') + expect(requests[0]?.authorization).toBe('Bearer sk-ant-oat01-main-access') }) test('routes to API-key fallback when cached main OAuth quota is exhausted', async () => { await useTempAccountFile( - createFallbackStorage({ - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - checkedAt: Date.now(), - five_hour: { usedPercent: 100, remainingPercent: 0 }, - seven_day: { usedPercent: 50, remainingPercent: 50 }, - }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), - } as AccountStorage['quota'], - accounts: [ - { - id: 'kie-opus', - label: 'Kie Opus', - type: 'api', - apiKey: 'kie-key', - baseURL: 'https://api.kie.ai/claude', - authHeader: 'authorization-bearer', - }, - ], - }), + bindPoolAccounts( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + checkedAt: Date.now(), + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 50, + remainingPercent: 50, + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + accounts: [ + { + id: 'kie-opus', + label: 'Kie Opus', + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', + }, + ], + }), + ), ) const requests: Array<{ url: string; authorization: string | null }> = [] - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - requests.push({ - url, - authorization: new Headers(init?.headers).get('authorization'), - }) - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + requests.push({ + url, + authorization: new Headers(init?.headers).get('authorization'), + }) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6912,61 +7812,75 @@ describe('auth.loader', () => { test('does not route to API-key fallback from stale cached main OAuth exhaustion', async () => { await useTempAccountFile( - createFallbackStorage({ - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { usedPercent: 100, remainingPercent: 0 }, - seven_day: { usedPercent: 50, remainingPercent: 50 }, - }, - mainQuotaCheckedAt: Date.now() - 60 * 60 * 1000, - mainQuotaToken: tokenFingerprint('main-access'), - } as AccountStorage['quota'], - accounts: [ - { - id: 'kie-opus', - label: 'Kie Opus', - type: 'api', - apiKey: 'kie-key', - baseURL: 'https://api.kie.ai/claude', - authHeader: 'authorization-bearer', - }, - ], - }), + bindPoolAccounts( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now() - 60 * 60_000, + }, + seven_day: { + usedPercent: 50, + remainingPercent: 50, + checkedAt: Date.now() - 60 * 60_000, + }, + }, + mainQuotaCheckedAt: Date.now() - 60 * 60 * 1000, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + accounts: [ + { + id: 'kie-opus', + label: 'Kie Opus', + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', + }, + ], + }), + ), ) const requests: Array<{ url: string; authorization: string | null }> = [] let quotaCalls = 0 - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - quotaCalls++ - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 10 }, - }), - { status: 200 }, - ), - ) - } - requests.push({ - url, - authorization: new Headers(init?.headers).get('authorization'), - }) - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + quotaCalls++ + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + requests.push({ + url, + authorization: new Headers(init?.headers).get('authorization'), + }) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -6985,7 +7899,7 @@ describe('auth.loader', () => { expect(requests).toHaveLength(1) expect(requests[0]).toMatchObject({ url: 'https://api.anthropic.com/v1/messages?beta=true', - authorization: 'Bearer main-access', + authorization: 'Bearer sk-ant-oat01-main-access', }) }) @@ -7011,7 +7925,7 @@ describe('auth.loader', () => { { id: 'fallback-1', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: Date.now() + 5 * 60 * 60 * 1000, quota: { @@ -7032,32 +7946,34 @@ describe('auth.loader', () => { ) const requests: Array<{ url: string; authorization: string | null }> = [] - 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: 10 }, - seven_day: { utilization: 10 }, - }), - { status: 200 }, - ), - ) - } - requests.push({ - url, - authorization: new Headers(init?.headers).get('authorization'), - }) - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + requests.push({ + url, + authorization: new Headers(init?.headers).get('authorization'), + }) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -7075,7 +7991,7 @@ describe('auth.loader', () => { expect(requests).toHaveLength(1) expect(requests[0]).toMatchObject({ url: 'https://api.anthropic.com/v1/messages?beta=true', - authorization: 'Bearer fallback-access', + authorization: 'Bearer sk-ant-oat01-fallback-access', }) }) @@ -7109,41 +8025,45 @@ describe('auth.loader', () => { body: Record }> = [] let quotaCalls = 0 - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - const headers = new Headers(init?.headers) - const authorization = headers.get('authorization') - if (url.includes('/api/oauth/usage')) { - quotaCalls++ - expect(authorization).toBe('Bearer main-access') - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 100 }, - seven_day: { utilization: 50 }, - }), - { status: 200 }, - ), - ) - } - requests.push({ - url, - authorization, - beta: headers.get('anthropic-beta'), - body: JSON.parse(String(init?.body)), - }) - if (requests.length === 1) { - return Promise.resolve(new Response('main exhausted', { status: 429 })) - } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + const headers = new Headers(init?.headers) + const authorization = headers.get('authorization') + if (url.includes('/api/oauth/usage')) { + quotaCalls++ + expect(authorization).toBe('Bearer sk-ant-oat01-main-access') + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 100 }, + seven_day: { utilization: 50 }, + }), + { status: 200 }, + ), + ) + } + requests.push({ + url, + authorization, + beta: headers.get('anthropic-beta'), + body: JSON.parse(String(init?.body)), + }) + if (requests.length === 1) { + return Promise.resolve( + new Response('main exhausted', { status: 429 }), + ) + } + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -7162,7 +8082,7 @@ describe('auth.loader', () => { expect(requests).toHaveLength(2) expect(requests[0]).toMatchObject({ url: 'https://api.anthropic.com/v1/messages?beta=true', - authorization: 'Bearer main-access', + authorization: 'Bearer sk-ant-oat01-main-access', body: expect.objectContaining({ fallbacks: 'default' }), }) expect(requests[0]?.beta?.split(',')).toContain(SERVER_SIDE_FALLBACK_BETA) @@ -7200,33 +8120,35 @@ describe('auth.loader', () => { const requests: Array<{ url: string; authorization: string | null }> = [] let quotaCalls = 0 - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - const authorization = new Headers(init?.headers).get('authorization') - if (url.includes('/api/oauth/usage')) { - quotaCalls++ + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + const authorization = new Headers(init?.headers).get('authorization') + if (url.includes('/api/oauth/usage')) { + quotaCalls++ + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.25 }, + seven_day: { utilization: 0.25 }, + }), + { status: 200 }, + ), + ) + } + requests.push({ url, authorization }) return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0.25 }, - seven_day: { utilization: 0.25 }, - }), - { status: 200 }, - ), + new Response('transient rate limit', { status: 429 }), ) - } - requests.push({ url, authorization }) - return Promise.resolve( - new Response('transient rate limit', { status: 429 }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -7246,7 +8168,7 @@ describe('auth.loader', () => { expect(requests).toHaveLength(1) expect(requests[0]).toMatchObject({ url: 'https://api.anthropic.com/v1/messages?beta=true', - authorization: 'Bearer main-access', + authorization: 'Bearer sk-ant-oat01-main-access', }) }) @@ -7273,20 +8195,22 @@ describe('auth.loader', () => { ) const requests: Array<{ url: string; authorization: string | null }> = [] - globalThis.fetch = mock((input: any, init: any) => { - requests.push({ - url: extractUrl(input), - authorization: new Headers(init?.headers).get('authorization'), - }) - return Promise.resolve(new Response('auth failure', { status: 403 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + requests.push({ + url: extractUrl(input), + authorization: new Headers(init?.headers).get('authorization'), + }) + return Promise.resolve(new Response('auth failure', { status: 403 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -7305,7 +8229,7 @@ describe('auth.loader', () => { expect(requests).toHaveLength(1) expect(requests[0]).toMatchObject({ url: 'https://api.anthropic.com/v1/messages?beta=true', - authorization: 'Bearer main-access', + authorization: 'Bearer sk-ant-oat01-main-access', }) }) @@ -7330,34 +8254,38 @@ describe('auth.loader', () => { const authorizations: string[] = [] let mainQuotaCalls = 0 - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - mainQuotaCalls++ - expect(new Headers(init?.headers).get('authorization')).toBe( - 'Bearer main-access', - ) - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 12 }, - seven_day: { utilization: 34 }, - }), - { status: 200 }, - ), - ) - } + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + mainQuotaCalls++ + expect(new Headers(init?.headers).get('authorization')).toBe( + 'Bearer sk-ant-oat01-main-access', + ) + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 12 }, + seven_day: { utilization: 34 }, + }), + { status: 200 }, + ), + ) + } - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + 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: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -7379,40 +8307,46 @@ describe('auth.loader', () => { ) expect(state.route).toBe('fallback-first') expect(state.main.quota?.seven_day?.usedPercent).toBe(75) - expect(mainQuotaCalls).toBe(0) - expect(authorizations[0]).toBe('Bearer fallback-access') - }) - - test('fetch wrapper sets OAuth headers and prefixes tools', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] })) - - 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 + expect(mainQuotaCalls).toBe(0) + expect(authorizations[0]).toBe('Bearer sk-ant-oat01-fallback-access') + }) + + test('fetch wrapper sets OAuth headers and prefixes tools', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }) + + let capturedHeaders: Headers | undefined + let capturedBody: string | undefined + + globalThis.fetch = mock( + withNativeAdmission((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: 'my-access-token', + access: 'sk-ant-oat01-my-access-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -7432,7 +8366,9 @@ describe('auth.loader', () => { }) expect(capturedHeaders).toBeDefined() - expect(capturedHeaders!.get('authorization')).toBe('Bearer my-access-token') + expect(capturedHeaders!.get('authorization')).toBe( + 'Bearer sk-ant-oat01-my-access-token', + ) expect(capturedHeaders!.get('x-api-key')).toBeNull() expect(capturedHeaders!.get('x-session-affinity')).toBeNull() expect(capturedHeaders!.get('x-opencode-session')).toBeNull() @@ -7464,38 +8400,45 @@ describe('auth.loader', () => { transport: 'http', }, }), + { + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }, ) let capturedUrl: string | undefined let capturedBody: string | undefined let capturedHeaders: Headers | undefined - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { + globalThis.fetch = mock( + withNativeAdmission((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 }, + ), + ) + } + capturedUrl = url + capturedBody = init?.body + capturedHeaders = new Headers(init?.headers) return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), + new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), ) - } - capturedUrl = url - capturedBody = init?.body - capturedHeaders = new Headers(init?.headers) - return Promise.resolve( - new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'my-access-token', + access: 'sk-ant-oat01-my-access-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -7527,52 +8470,53 @@ describe('auth.loader', () => { }) test('reloads relay config from sidecar after plugin startup', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] })) + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }) let capturedUrl: string | undefined - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { + globalThis.fetch = mock( + withNativeAdmission((input: 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 }, + ), + ) + } + capturedUrl = url return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), + new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), ) - } - capturedUrl = url - return Promise.resolve( - new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'my-access-token', + access: 'sk-ant-oat01-my-access-token', refresh: 'refresh', expires: Date.now() + 100000, }), { models: {} }, ) - await saveAccounts( - createFallbackStorage({ - accounts: [], - relay: { - enabled: true, - url: 'https://relay.example.test', - token: 'relay-token', - fallbackToDirect: true, - transport: 'http', - }, - }), - ) + await updatePoolRelay({ + enabled: true, + url: 'https://relay.example.test', + token: 'relay-token', + fallbackToDirect: true, + transport: 'http', + }) await result.fetch(MESSAGES_URL, { method: 'POST', @@ -7598,11 +8542,18 @@ describe('auth.loader', () => { transport: 'http', }, }), + { + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }, ) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve( + new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), + ), ), ) as unknown as typeof fetch @@ -7611,25 +8562,14 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'my-access-token', + access: 'sk-ant-oat01-my-access-token', refresh: 'refresh', expires: Date.now() + 100000, }), { models: {} }, ) - await saveAccounts( - createFallbackStorage({ - accounts: [], - relay: { - enabled: true, - url: 'https://relay.example.test', - token: 'relay-token', - fallbackToDirect: true, - transport: 'websocket', - }, - }), - ) + await updatePoolRelay({ transport: 'websocket' }) await result.fetch(MESSAGES_URL, { method: 'POST', @@ -7645,7 +8585,7 @@ describe('auth.loader', () => { test('registers /claude and handles cache settings with ignored status replies', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const mockClient = createMockClient() - const plugin = await getPlugin(mockClient) + const plugin = await getPlugin(mockClient, tempConfigDir) const config: { command?: Record } = {} await plugin.config(config) @@ -7658,48 +8598,29 @@ describe('auth.loader', () => { expect(config.command?.[command]).toBeUndefined() } - await expectHandledCommandResponse( - plugin['command.execute.before']({ - command: 'claude-cache', - arguments: 'on', - sessionID: 'session-1', - }), - ) - - expect(mockClient.session.promptAsync).toHaveBeenCalledWith({ - path: { id: 'session-1' }, - body: { - noReply: true, - parts: [ - { - type: 'text', - ignored: true, - text: expect.stringContaining('## Claude Cache Enabled'), - }, - ], - }, + // The menu action's reply is the apply result the TUI shows. + const enabled = await applyMenuAction(plugin, 'session-menu', { + sectionId: 'Cache', + actionId: 'cache-on', }) + expect(enabled.ok).toBe(true) + expect(enabled.text).toContain('## Claude Cache Enabled') + // Without a connected TUI, /claude replies with an ignored status message. await expect( plugin['command.execute.before']({ - command: 'claude-cache', + command: 'claude', arguments: '', sessionID: 'session-1', }), ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - const promptCalls = ( - mockClient.session.promptAsync as unknown as { - mock: { calls: Array<[{ body: { parts: Array<{ text: string }> } }]> } - } - ).mock.calls - const latestCall = promptCalls.at(-1)?.[0] - expect(latestCall?.body.parts[0]?.text).toContain('- Enabled: enabled') + expect(latestIgnoredReply(mockClient)).toContain('- Enabled: enabled') - const saved = JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) - expect(saved.claudeCache).toEqual({ enabled: true, mode: 'explicit' }) + expect((await readNativeSettings()).claudeCache).toEqual({ + enabled: true, + mode: 'explicit', + }) }) test('config hook registers only /claude and retires owned aliases without clobbering foreign commands', async () => { @@ -7737,15 +8658,13 @@ describe('auth.loader', () => { test('handles /claude-start by injecting one visible synthetic prompt', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const mockClient = createMockClient() - const plugin = await getPlugin(mockClient) + const plugin = await getPlugin(mockClient, tempConfigDir) - await expectHandledCommandResponse( - plugin['command.execute.before']({ - command: 'claude-start', - arguments: '', - sessionID: 'session-start', - }), - ) + const result = await applyMenuAction(plugin, 'session-start', { + sectionId: 'Extras', + actionId: 'start-fire', + }) + expect(result.ok).toBe(true) const promptCalls = ( mockClient.session.promptAsync as unknown as { @@ -7787,165 +8706,98 @@ describe('auth.loader', () => { }), ) const mockClient = createMockClient() - const plugin = await getPlugin(mockClient) - - await expect( - plugin['command.execute.before']({ - command: 'claude-cachekeep', - arguments: '09-23', - sessionID: 'session-1', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + const plugin = await getPlugin(mockClient, tempConfigDir) - const promptCalls = ( - mockClient.session.promptAsync as unknown as { - mock: { calls: Array<[{ body: { parts: Array<{ text: string }> } }]> } - } - ).mock.calls - const latestCall = promptCalls.at(-1)?.[0] - expect(latestCall?.body.parts[0]?.text).toContain( - '## Claude Cache Keep Enabled', - ) - expect(latestCall?.body.parts[0]?.text).toContain('Schedule: 09-23') - expect(latestCall?.body.parts[0]?.text).toContain('Hybrid active: yes') + const windowed = await applyMenuAction(plugin, 'session-1', { + sectionId: 'Cache', + actionId: 'cachekeep-window', + values: { startHour: 9, endHour: 23 }, + }) + expect(windowed.ok).toBe(true) + expect(windowed.text).toContain('## Claude Cache Keep Enabled') + expect(windowed.text).toContain('Schedule: 09-23') + expect(windowed.text).toContain('Hybrid active: yes') - const saved = JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) - expect(saved.cacheKeep).toEqual({ + expect((await readNativeSettings()).cacheKeep).toEqual({ enabled: true, always: false, startHour: 9, endHour: 23, }) - await expect( - plugin['command.execute.before']({ - command: 'claude-cachekeep', - arguments: 'always', - sessionID: 'session-1', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - const always = JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) - expect(always.cacheKeep).toEqual({ enabled: true, always: true }) + const always = await applyMenuAction(plugin, 'session-1', { + sectionId: 'Cache', + actionId: 'cachekeep-always', + }) + expect(always.ok).toBe(true) + expect((await readNativeSettings()).cacheKeep).toEqual({ + enabled: true, + always: true, + }) }) test('registers and handles /claude-fast slash command with ignored status replies', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const mockClient = createMockClient() - const plugin = await getPlugin(mockClient) - - await expect( - plugin['command.execute.before']({ - command: 'claude-fast', - arguments: 'on', - sessionID: 'session-1', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + const plugin = await getPlugin(mockClient, tempConfigDir) - expect(mockClient.session.promptAsync).toHaveBeenCalledWith({ - path: { id: 'session-1' }, - body: { - noReply: true, - parts: [ - { - type: 'text', - ignored: true, - text: expect.stringContaining('## Claude Fast Mode Enabled'), - }, - ], - }, + // The menu action's reply is the apply result the TUI shows. + const enabled = await applyMenuAction(plugin, 'session-menu', { + sectionId: 'Extras', + actionId: 'fast-on', }) + expect(enabled.ok).toBe(true) + expect(enabled.text).toContain('## Claude Fast Mode Enabled') + // Without a connected TUI, /claude replies with an ignored status message. await expect( plugin['command.execute.before']({ - command: 'claude-fast', + command: 'claude', arguments: '', sessionID: 'session-1', }), ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - const promptCalls = ( - mockClient.session.promptAsync as unknown as { - mock: { calls: Array<[{ body: { parts: Array<{ text: string }> } }]> } - } - ).mock.calls - const latestCall = promptCalls.at(-1)?.[0] - expect(latestCall?.body.parts[0]?.text).toContain('- Enabled: enabled') + expect(latestIgnoredReply(mockClient)).toContain('- Enabled: enabled') - const saved = JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) - expect(saved.claudeFast).toEqual({ enabled: true }) + expect((await readNativeSettings()).claudeFast).toEqual({ enabled: true }) }) test('handles /claude-routing slash command and persists routing mode', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) - const mockClient = createMockClient() - const plugin = await getPlugin(mockClient) - - await expect( - plugin['command.execute.before']({ - command: 'claude-routing', - arguments: 'sticky-balanced', - sessionID: 'session-1', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - - expect(mockClient.session.promptAsync).toHaveBeenCalledWith({ - path: { id: 'session-1' }, - body: { - noReply: true, - parts: [ - { - type: 'text', - ignored: true, - text: expect.stringContaining('Mode updated to `sticky-balanced`.'), - }, - ], - }, - }) - - await expect( - plugin['command.execute.before']({ - command: 'claude-routing', - arguments: '', - sessionID: 'session-1', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - - const promptCalls = ( - mockClient.session.promptAsync as unknown as { - mock: { calls: Array<[{ body: { parts: Array<{ text: string }> } }]> } - } - ).mock.calls - const latestCall = promptCalls.at(-1)?.[0] - expect(latestCall?.body.parts[0]?.text).toContain( - '- Mode: `sticky-balanced`', - ) + const mockClient = createMockClient() + const plugin = await getPlugin(mockClient, tempConfigDir) + + const updated = await applyMenuAction(plugin, 'session-menu', { + sectionId: 'Routing', + actionId: 'routing-mode', + values: { mode: 'sticky-balanced' }, + }) + expect(updated.ok).toBe(true) + expect(updated.text).toContain('Mode updated to `sticky-balanced`.') + // Without a connected TUI, /claude replies with an ignored status message. await expect( plugin['command.execute.before']({ - command: 'claude-routing', - arguments: 'reset', + command: 'claude', + arguments: '', sessionID: 'session-1', }), ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - const resetCall = ( - mockClient.session.promptAsync as unknown as { - mock: { calls: Array<[{ body: { parts: Array<{ text: string }> } }]> } - } - ).mock.calls.at(-1)?.[0] - expect(resetCall?.body.parts[0]?.text).toContain( - 'Claude Routing Assignment Reset', + expect(latestIgnoredReply(mockClient)).toContain( + '- Mode: `sticky-balanced`', ) - const saved = JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) - expect(saved.routing).toEqual({ mode: 'sticky-balanced' }) + const reset = await applyMenuAction(plugin, 'session-menu', { + sectionId: 'Routing', + actionId: 'routing-reset', + }) + expect(reset.ok).toBe(true) + expect(reset.text).toContain('Claude Routing Assignment Reset') + + expect((await readNativeSettings()).routing).toEqual({ + mode: 'sticky-balanced', + }) }) test('hidden slash-command replies preserve previous assistant model and variant', async () => { @@ -7976,7 +8828,7 @@ describe('auth.loader', () => { await expect( plugin['command.execute.before']({ - command: 'claude-cache', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -8010,34 +8862,16 @@ describe('auth.loader', () => { test('handles /claude-dump slash command and persists dump capture', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const mockClient = createMockClient() - const plugin = await getPlugin(mockClient) - - await expect( - plugin['command.execute.before']({ - command: 'claude-dump', - arguments: 'on', - sessionID: 'session-1', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + const plugin = await getPlugin(mockClient, tempConfigDir) - expect(mockClient.session.promptAsync).toHaveBeenCalledWith({ - path: { id: 'session-1' }, - body: { - noReply: true, - parts: [ - { - type: 'text', - ignored: true, - text: expect.stringContaining('## Claude Dump Enabled'), - }, - ], - }, + const result = await applyMenuAction(plugin, 'session-1', { + sectionId: 'Diagnostics', + actionId: 'dump-on', }) + expect(result.ok).toBe(true) + expect(result.text).toContain('## Claude Dump Enabled') - const saved = JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) - expect(saved.dump).toEqual({ enabled: true }) + expect((await readNativeSettings()).dump).toEqual({ enabled: true }) }) test('handles /claude-cache mode command and persists cache strategy', async () => { @@ -8045,34 +8879,21 @@ describe('auth.loader', () => { createFallbackStorage({ accounts: [], claudeCache: { enabled: true } }), ) const mockClient = createMockClient() - const plugin = await getPlugin(mockClient) - - await expect( - plugin['command.execute.before']({ - command: 'claude-cache', - arguments: 'mode hybrid', - sessionID: 'session-1', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + const plugin = await getPlugin(mockClient, tempConfigDir) - const saved = JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) - expect(saved.claudeCache).toEqual({ enabled: true, mode: 'hybrid' }) + const result = await applyMenuAction(plugin, 'session-1', { + sectionId: 'Cache', + actionId: 'cache-mode', + values: { mode: 'hybrid' }, + }) - expect(mockClient.session.promptAsync).toHaveBeenCalledWith({ - path: { id: 'session-1' }, - body: { - noReply: true, - parts: [ - { - type: 'text', - ignored: true, - text: expect.stringContaining('Mode updated to `hybrid`.'), - }, - ], - }, + expect((await readNativeSettings()).claudeCache).toEqual({ + enabled: true, + mode: 'hybrid', }) + + expect(result.ok).toBe(true) + expect(result.text).toContain('Mode updated to `hybrid`.') }) test('handles /claude-quota before auth loader has run', async () => { @@ -8081,7 +8902,7 @@ describe('auth.loader', () => { await expect( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -8110,7 +8931,7 @@ describe('auth.loader', () => { id: 'fallback-1', label: 'fallback personal', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: Date.now() + 5 * 60 * 60 * 1000, quota: { @@ -8133,25 +8954,30 @@ describe('auth.loader', () => { const seenTokens: string[] = [] globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - const url = String(input) - if (url.includes('/api/oauth/usage')) { - const authorization = new Headers(init?.headers).get('authorization') - if (authorization) seenTokens.push(authorization) - const utilization = authorization === 'Bearer main-access' ? 25 : 40 - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization }, - seven_day: { utilization: utilization + 10 }, - }), - { status: 200 }, - ), - ) - } + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = String(input) + if (url.includes('/api/oauth/usage')) { + const authorization = new Headers(init?.headers).get( + 'authorization', + ) + if (authorization) seenTokens.push(authorization) + const utilization = + authorization === 'Bearer sk-ant-oat01-main-access' ? 25 : 40 + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization }, + seven_day: { utilization: utilization + 10 }, + }), + { status: 200 }, + ), + ) + } - return Promise.resolve(new Response('{}', { status: 200 })) - }, + return Promise.resolve(new Response('{}', { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) @@ -8159,7 +8985,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -8168,15 +8994,17 @@ describe('auth.loader', () => { await expect( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - expect(seenTokens).toContain('Bearer main-access') + expect(seenTokens).toContain('Bearer sk-ant-oat01-main-access') expect( - seenTokens.filter((token) => token === 'Bearer fallback-access').length, + seenTokens.filter( + (token) => token === 'Bearer sk-ant-oat01-fallback-access', + ).length, ).toBeGreaterThanOrEqual(1) const promptCalls = ( mockClient.session.promptAsync as unknown as { @@ -8196,35 +9024,37 @@ describe('auth.loader', () => { const mockClient = createMockClient() let profileSignal: AbortSignal | undefined globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/profile')) { - profileSignal = init?.signal ?? undefined - return new Promise((_resolve, reject) => { - profileSignal?.addEventListener( - 'abort', - () => reject(profileSignal?.reason), - { once: true }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/profile')) { + profileSignal = init?.signal ?? undefined + return new Promise((_resolve, reject) => { + profileSignal?.addEventListener( + 'abort', + () => reject(profileSignal?.reason), + { once: true }, + ) + }) + } + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 25 }, + seven_day: { utilization: 50 }, + }), ) - }) - } - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - Response.json({ - five_hour: { utilization: 25 }, - seven_day: { utilization: 50 }, - }), - ) - } - return Promise.resolve(new Response('ok')) - }, + } + return Promise.resolve(new Response('ok')) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -8235,7 +9065,7 @@ describe('auth.loader', () => { const startedAt = performance.now() await expect( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -8254,35 +9084,37 @@ describe('auth.loader', () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const mockClient = createMockClient() let profileCalls = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/profile')) { - profileCalls++ - return Promise.resolve( - Response.json({ - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_20x', - }, - }), - ) - } - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - Response.json({ - five_hour: { utilization: 25 }, - seven_day: { utilization: 50 }, - }), - ) - } - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 25 }, + seven_day: { utilization: 50 }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -8301,7 +9133,7 @@ describe('auth.loader', () => { const command = expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -8318,7 +9150,7 @@ describe('auth.loader', () => { -1, )?.[0]?.body.parts[0]?.text as string expect(text).toContain('Max 20x') - expect((await loadAccounts())?.main?.profile).toBeUndefined() + expect((await readAccountStorage())?.main?.profile).toBeUndefined() } finally { await configLock.release() await command @@ -8335,41 +9167,43 @@ describe('auth.loader', () => { const mockClient = createMockClient() const profileCalls: string[] = [] globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - const url = extractUrl(input) - const auth = new Headers(init?.headers).get('authorization') ?? '' - if (url.includes('/api/oauth/profile')) { - profileCalls.push(auth) - return Promise.resolve( - Response.json({ - organization: { - organization_type: auth.includes('fallback') - ? 'claude_team' - : 'claude_max', - rate_limit_tier: auth.includes('fallback') - ? 'default_claude_max_5x' - : 'default_claude_max_20x', - }, - }), - ) - } - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - Response.json({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 20 }, - }), - ) - } - return Promise.resolve(new Response('ok')) - }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + const auth = new Headers(init?.headers).get('authorization') ?? '' + if (url.includes('/api/oauth/profile')) { + profileCalls.push(auth) + return Promise.resolve( + Response.json({ + organization: { + organization_type: auth.includes('fallback') + ? 'claude_team' + : 'claude_max', + rate_limit_tier: auth.includes('fallback') + ? 'default_claude_max_5x' + : 'default_claude_max_20x', + }, + }), + ) + } + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -8380,7 +9214,7 @@ describe('auth.loader', () => { for (let call = 0; call < 2; call++) { await expect( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -8396,10 +9230,10 @@ describe('auth.loader', () => { } expect(profileCalls).toEqual([ - 'Bearer main-access', - 'Bearer fallback-access', + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-fallback-access', ]) - const loaded = await loadAccounts() + const loaded = await readAccountStorage() expect(loaded?.main?.profile?.tier).toBe('default_claude_max_20x') expect((loaded?.accounts[0] as any)?.profile?.tier).toBe( 'default_claude_max_5x', @@ -8421,7 +9255,7 @@ describe('auth.loader', () => { tier: 'default_claude_max_20x', orgType: 'claude_max', checkedAt: Date.now(), - tokenFingerprint: tokenFingerprint('main-access'), + tokenFingerprint: tokenFingerprint('sk-ant-oat01-main-access'), }, }, }), @@ -8441,7 +9275,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -8451,7 +9285,7 @@ describe('auth.loader', () => { await expect( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -8471,22 +9305,29 @@ describe('auth.loader', () => { tier: 'default_claude_max_20x', orgType: 'claude_max', checkedAt: Date.now(), - tokenFingerprint: tokenFingerprint('old-access'), + tokenFingerprint: tokenFingerprint('sk-ant-oat01-old-access'), }, }, }), + { + access: 'sk-ant-oat01-new-access', + refresh: 'new-refresh', + expires: Date.now() + 100000, + }, ) const mockClient = createMockClient() let profileCalls = 0 - globalThis.fetch = mock((input: string | URL | Request) => - Promise.resolve( - (() => { - if (extractUrl(input).includes('/api/oauth/profile')) { - profileCalls++ - return new Response('failed', { status: 500 }) - } - return new Response('ok') - })(), + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => + Promise.resolve( + (() => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return new Response('failed', { status: 500 }) + } + return new Response('ok') + })(), + ), ), ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) @@ -8494,7 +9335,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'new-access', + access: 'sk-ant-oat01-new-access', refresh: 'new-refresh', expires: Date.now() + 100000, }), @@ -8504,7 +9345,7 @@ describe('auth.loader', () => { await expect( plugin['command.execute.before']({ - command: 'claude-account', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -8523,66 +9364,73 @@ describe('auth.loader', () => { }) test('in-flight hydration is shared across access-token rotation', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const firstLogin = { + access: 'sk-ant-oat01-token-a', + refresh: 'refresh-sk-ant-oat01-token-a', + expires: Date.now() + 100000, + } + await useTempAccountFile( + createFallbackStorage({ accounts: [] }), + firstLogin, + ) const mockClient = createMockClient() - let liveAccess = 'token-a' let profileCalls = 0 - let resolveFirstProfile!: (response: Response) => void - let markProfileStarted!: () => void - const profileStarted = new Promise((resolve) => { - markProfileStarted = resolve - }) - globalThis.fetch = mock((input: string | URL | Request) => { - if (!extractUrl(input).includes('/api/oauth/profile')) { - return Promise.resolve(new Response('ok')) - } - profileCalls++ - if (profileCalls === 1) { - markProfileStarted() - return new Promise((resolve) => { - resolveFirstProfile = resolve - }) - } - return Promise.resolve( - Response.json({ - organization: { - organization_type: 'claude_team', - rate_limit_tier: 'default_claude_max_5x', - }, - }), - ) - }) as unknown as typeof fetch + const firstProfile = heldResponse() + const profileStarted = lifetimeSignal() + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (!extractUrl(input).includes('/api/oauth/profile')) { + return Promise.resolve(new Response('ok')) + } + profileCalls++ + if (profileCalls === 1) { + profileStarted.raise() + return firstProfile.response + } + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + }, + }), + ) + }), + ) as unknown as typeof fetch + // The main login has served before, so the pool already knows which + // account it belongs to; a refresh elsewhere then stays on that account. + await poolMainAccess() const plugin = await getPlugin(mockClient) await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: liveAccess, - refresh: `refresh-${liveAccess}`, - expires: Date.now() + 100000, - }), + () => Promise.resolve({ type: 'oauth', ...firstLogin }), { models: {} }, ) delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION const firstCommand = expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-account', + command: 'claude', arguments: '', sessionID: 'session-1', }), ) - await profileStarted + await profileStarted.raised - liveAccess = 'token-b' + // Another OpenCode process refreshes the main login while the first + // profile request is still in flight. + await refreshPoolMainElsewhere({ + access: 'sk-ant-oat01-token-b', + refresh: 'refresh-sk-ant-oat01-token-b', + expires: Date.now() + 100000, + }) const command = expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-account', + command: 'claude', arguments: '', sessionID: 'session-1', }), ) await Bun.sleep(25) - resolveFirstProfile( + firstProfile.release( Response.json({ organization: { organization_type: 'claude_team', @@ -8607,22 +9455,24 @@ describe('auth.loader', () => { tier: 'default_claude_max_20x', orgType: 'claude_max', checkedAt: Date.now(), - tokenFingerprint: tokenFingerprint('main-access'), + tokenFingerprint: tokenFingerprint('sk-ant-oat01-main-access'), }, }, }), ) let profileCalls = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/profile')) profileCalls++ - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) profileCalls++ + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch const plugin = await getPlugin(createMockClient()) await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -8632,43 +9482,45 @@ describe('auth.loader', () => { await expect( plugin['command.execute.before']({ - command: 'claude-account', + command: 'claude', arguments: '', sessionID: 'session-1', }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - - expect(profileCalls).toBe(0) - expect((await loadAccounts())?.main?.profile?.tier).toBe( - 'default_claude_max_20x', - ) - }) - - test('boot profile hydration publishes tier labels to the sidebar', async () => { - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - await useTempAccountFile(createFallbackStorage({ accounts: [] })) - let profileCalls = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/profile')) { - profileCalls++ - return Promise.resolve( - Response.json({ - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_20x', - }, - }), - ) - } - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + + expect(profileCalls).toBe(0) + expect((await readAccountStorage())?.main?.profile?.tier).toBe( + 'default_claude_max_20x', + ) + }) + + test('boot profile hydration publishes tier labels to the sidebar', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + let profileCalls = 0 + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -8694,14 +9546,14 @@ describe('auth.loader', () => { tier: 'default_claude_max_20x', orgType: 'claude_max', checkedAt: Date.now(), - tokenFingerprint: tokenFingerprint('main-access'), + tokenFingerprint: tokenFingerprint('sk-ant-oat01-main-access'), }, }, accounts: [ { id: 'fb', type: 'oauth', - access: 'old-access', + access: 'sk-ant-oat01-old-access', refresh: 'old-refresh', expires: Date.now() + 5 * 60 * 60 * 1000, lastRefreshedAt: 100, @@ -8709,47 +9561,44 @@ describe('auth.loader', () => { ], }), ) - let resolveProfile!: (response: Response) => void - let markProfileStarted!: () => void + const profile = heldResponse() + const profileStarted = lifetimeSignal() let profileCalls = 0 - const profileStarted = new Promise((resolve) => { - markProfileStarted = resolve - }) - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/profile')) { - profileCalls++ - markProfileStarted() - return new Promise((resolve) => { - resolveProfile = resolve - }) - } - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + profileStarted.raise() + return profile.response + } + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), { models: {} }, ) - await profileStarted + await profileStarted.raised await drainSidebarWrites() - const rotated = await loadAccounts() + const rotated = await readAccountStorage() const fallback = rotated?.accounts[0] if (!rotated || fallback?.type !== 'oauth') { throw new Error('expected fallback OAuth account') } - fallback.access = 'new-access' + fallback.access = 'sk-ant-oat01-new-access' fallback.refresh = 'new-refresh' fallback.lastRefreshedAt = 200 await saveAccounts(rotated) await Bun.sleep(2) - resolveProfile( + profile.release( Response.json({ organization: { organization_type: 'claude_team', @@ -8769,7 +9618,7 @@ describe('auth.loader', () => { await drainSidebarWrites() const reloadedFallback = reloaded?.accounts[0] expect(reloadedFallback).toMatchObject({ - access: 'new-access', + access: 'sk-ant-oat01-new-access', refresh: 'new-refresh', lastRefreshedAt: 200, }) @@ -8785,55 +9634,52 @@ describe('auth.loader', () => { test('late main profile hydration cannot replace a rotated-token profile', async () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - await useTempAccountFile(createFallbackStorage({ accounts: [] })) - let liveAccess = 'old-main-access' - let resolveProfile!: (response: Response) => void - let markProfileStarted!: () => void - const profileStarted = new Promise((resolve) => { - markProfileStarted = resolve - }) - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/profile')) { - markProfileStarted() - return new Promise((resolve) => { - resolveProfile = resolve - }) - } - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + const oldLogin = { + access: 'sk-ant-oat01-old-main-access', + refresh: 'refresh-sk-ant-oat01-old-main-access', + expires: Date.now() + 100_000, + } + await useTempAccountFile(createFallbackStorage({ accounts: [] }), oldLogin) + const profile = heldResponse() + const profileStarted = lifetimeSignal() + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileStarted.raise() + return profile.response + } + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: liveAccess, - refresh: `refresh-${liveAccess}`, - expires: Date.now() + 100_000, - }), + () => Promise.resolve({ type: 'oauth', ...oldLogin }), { models: {} }, ) - await profileStarted + await profileStarted.raised await drainSidebarWrites() const initialSidebarUpdatedAt = (await getSidebarState()).lastUpdated - liveAccess = 'new-main-access' - const rotated = await loadAccounts() + // Another OpenCode process refreshes the main login, then records a newer + // profile for the rotated credential, while this plugin's profile request + // for the old access token is still in flight. + await refreshPoolMainElsewhere({ + access: 'sk-ant-oat01-new-main-access', + refresh: 'refresh-sk-ant-oat01-new-main-access', + expires: Date.now() + 100_000, + }) + const rotated = await readAccountStorage() if (!rotated) throw new Error('expected account storage') - rotated.main = { - type: 'opencode', - provider: 'anthropic', + await publishPoolMetadata('main', (identity) => ({ profile: { tier: 'default_claude_max_20x', orgType: 'claude_max', checkedAt: Date.now() + 10_000, - accountIdentity: rotated.mainAccountId, + accountIdentity: identity, }, - } - await saveAccountState(rotated, process.env.OPENCODE_ANTHROPIC_AUTH_FILE, { - mainProfile: true, - }) + })) await Bun.sleep(2) - resolveProfile( + profile.release( Response.json({ organization: { organization_type: 'claude_team', @@ -8845,7 +9691,7 @@ describe('auth.loader', () => { (state) => state.lastUpdated > initialSidebarUpdatedAt, ) - expect((await loadAccounts())?.main?.profile).toMatchObject({ + expect((await readAccountStorage())?.main?.profile).toMatchObject({ tier: 'default_claude_max_20x', accountIdentity: rotated.mainAccountId, }) @@ -8856,56 +9702,53 @@ describe('auth.loader', () => { await useTempAccountFile( createFallbackStorage({ routing: { mode: 'fallback-first' } }), ) - let resolveMainProfile!: (response: Response) => void - let markMainProfileStarted!: () => void - const mainProfileStarted = new Promise((resolve) => { - markMainProfileStarted = resolve - }) + const mainProfile = heldResponse() + const mainProfileStarted = lifetimeSignal() globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - const url = extractUrl(input) - const authorization = new Headers(init?.headers).get('authorization') - if ( - url.includes('/api/oauth/profile') && - authorization === 'Bearer main-access' - ) { - markMainProfileStarted() - return new Promise((resolve) => { - resolveMainProfile = resolve - }) - } - if (url.includes('/api/oauth/profile')) { - return Promise.resolve( - Response.json({ - organization: { - organization_type: 'claude_team', - rate_limit_tier: 'default_claude_max_5x', - }, - }), - ) - } - return Promise.resolve(new Response('ok')) - }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + const authorization = new Headers(init?.headers).get('authorization') + if ( + url.includes('/api/oauth/profile') && + authorization === 'Bearer sk-ant-oat01-main-access' + ) { + mainProfileStarted.raise() + return mainProfile.response + } + if (url.includes('/api/oauth/profile')) { + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), { models: {} }, ) - await mainProfileStarted + await mainProfileStarted.raised await result.fetch(MESSAGES_URL, EMPTY_POST) await waitForSidebarState( (state) => state.activeId === 'fallback-1' && state.route === 'fallback-first', ) - resolveMainProfile( + mainProfile.release( Response.json({ organization: { organization_type: 'claude_max', @@ -8926,50 +9769,47 @@ describe('auth.loader', () => { test('profile hydration keeps its plugin-scoped fetch across test turnover', async () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION await useTempAccountFile(createFallbackStorage()) - let resolveMainProfile!: (response: Response) => void - let markMainProfileStarted!: () => void - const mainProfileStarted = new Promise((resolve) => { - markMainProfileStarted = resolve - }) + const mainProfile = heldResponse() + const mainProfileStarted = lifetimeSignal() const firstFetchCalls: string[] = [] globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - const authorization = new Headers( - input instanceof Request ? input.headers : init?.headers, - ).get('authorization') - firstFetchCalls.push(authorization ?? '') - if (authorization === 'Bearer main-access') { - markMainProfileStarted() - return new Promise((resolve) => { - resolveMainProfile = resolve - }) - } - return Promise.resolve( - Response.json({ - organization: { - organization_type: 'claude_team', - rate_limit_tier: 'default_claude_max_5x', - }, - }), - ) - }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const authorization = new Headers( + input instanceof Request ? input.headers : init?.headers, + ).get('authorization') + firstFetchCalls.push(authorization ?? '') + if (authorization === 'Bearer sk-ant-oat01-main-access') { + mainProfileStarted.raise() + return mainProfile.response + } + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + }, + }), + ) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), { models: {} }, ) - await mainProfileStarted + await mainProfileStarted.raised const nextTestFetch = mock(() => Promise.resolve(new Response('ok'))) globalThis.fetch = nextTestFetch as unknown as typeof fetch - resolveMainProfile( + mainProfile.release( Response.json({ organization: { organization_type: 'claude_max', @@ -8982,8 +9822,8 @@ describe('auth.loader', () => { ) expect(firstFetchCalls).toEqual([ - 'Bearer main-access', - 'Bearer fallback-access', + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-fallback-access', ]) expect(nextTestFetch).not.toHaveBeenCalled() }) @@ -9001,7 +9841,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -9032,7 +9872,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -9041,7 +9881,7 @@ describe('auth.loader', () => { await expect( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -9053,34 +9893,31 @@ describe('auth.loader', () => { test('boot hydration publishes storage reloaded after the profile await', async () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION await useTempAccountFile(createFallbackStorage({ accounts: [] })) - let resolveProfile!: (response: Response) => void - let markProfileStarted!: () => void - const profileStarted = new Promise((resolve) => { - markProfileStarted = resolve - }) - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/profile')) { - markProfileStarted() - return new Promise((resolve) => { - resolveProfile = resolve - }) - } - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + const profile = heldResponse() + const profileStarted = lifetimeSignal() + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileStarted.raise() + return profile.response + } + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), { models: {} }, ) - await profileStarted + await profileStarted.raised - const storage = await loadAccounts() + const storage = await readAccountStorage() if (!storage?.quota) throw new Error('expected quota storage') const backoff = { message: 'Claude quota check failed: 429 — rate limited', @@ -9088,11 +9925,12 @@ describe('auth.loader', () => { nextRetryAt: Date.now() + 60_000, retryCount: 1, } - storage.quota.mainLastQuotaApiError = backoff - await saveAccountState(storage, process.env.OPENCODE_ANTHROPIC_AUTH_FILE, { - mainQuota: true, - }) - resolveProfile( + // Another OpenCode process records a quota backoff for the main account + // while this plugin's boot profile request is still in flight. + await publishPoolMetadata('main', () => ({ + lastQuotaRefreshError: backoff, + })) + profile.release( Response.json({ organization: { organization_type: 'claude_max', @@ -9105,52 +9943,68 @@ describe('auth.loader', () => { (value) => value.main.tierLabel === 'Max 20x', ) expect(state.main.quotaBackedOff).toBe(true) - expect((await loadAccounts())?.quota?.mainLastQuotaApiError).toEqual( - backoff, + // The stored backoff keeps its retry schedule; free-form provider text is + // not stored, so no credential fragment can be published with it. + const stored = (await readAccountStorage())?.quota?.mainLastQuotaApiError + expect(stored).toEqual( + expect.objectContaining({ + checkedAt: backoff.checkedAt, + nextRetryAt: backoff.nextRetryAt, + retryCount: backoff.retryCount, + }), ) + expectNoCredentialFragment(stored) }) test('token rotation reuses the profile for the same account identity', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const firstLogin = { + access: 'sk-ant-oat01-token-a', + refresh: 'refresh-sk-ant-oat01-token-a', + expires: Date.now() + 100000, + } + await useTempAccountFile( + createFallbackStorage({ accounts: [] }), + firstLogin, + ) const mockClient = createMockClient() const profileCalls: string[] = [] - let liveAccess = 'token-a' globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - if (extractUrl(input).includes('/api/oauth/profile')) { - const authorization = new Headers(init?.headers).get('authorization') - profileCalls.push(authorization ?? '') - const firstToken = authorization?.includes('token-a') - return Promise.resolve( - Response.json({ - organization: { - organization_type: firstToken ? 'claude_team' : 'claude_max', - rate_limit_tier: firstToken - ? 'default_claude_max_5x' - : 'default_claude_max_20x', - }, - }), - ) - } - return Promise.resolve(new Response('ok')) - }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + const authorization = new Headers(init?.headers).get( + 'authorization', + ) + profileCalls.push(authorization ?? '') + const firstToken = authorization?.includes('token-a') + return Promise.resolve( + Response.json({ + organization: { + organization_type: firstToken ? 'claude_team' : 'claude_max', + rate_limit_tier: firstToken + ? 'default_claude_max_5x' + : 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }, + ), ) as unknown as typeof fetch + // The main login has served before, so the pool already knows which + // account it belongs to; a refresh elsewhere then stays on that account. + await poolMainAccess() const plugin = await getPlugin(mockClient) await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: liveAccess, - refresh: `refresh-${liveAccess}`, - expires: Date.now() + 100000, - }), + () => Promise.resolve({ type: 'oauth', ...firstLogin }), { models: {} }, ) delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION const showAccounts = async () => { await expect( plugin['command.execute.before']({ - command: 'claude-account', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -9164,13 +10018,18 @@ describe('auth.loader', () => { (storage) => storage?.main?.profile?.accountIdentity === storage?.mainAccountId, ) - liveAccess = 'token-b' + // Another OpenCode process refreshes the main login of the same account. + await refreshPoolMainElsewhere({ + access: 'sk-ant-oat01-token-b', + refresh: 'refresh-sk-ant-oat01-token-b', + expires: Date.now() + 100000, + }) expect(await showAccounts()).toContain('Team · Max 5x') expect(await showAccounts()).toContain('Team · Max 5x') - expect(profileCalls).toEqual(['Bearer token-a']) - expect((await loadAccounts())?.main?.profile?.accountIdentity).toBe( - (await loadAccounts())?.mainAccountId, + expect(profileCalls).toEqual(['Bearer sk-ant-oat01-token-a']) + expect((await readAccountStorage())?.main?.profile?.accountIdentity).toBe( + (await readAccountStorage())?.mainAccountId, ) }) @@ -9182,26 +10041,28 @@ describe('auth.loader', () => { let profileCalls = 0 Date.now = () => now try { - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/profile')) { - profileCalls++ - return Promise.resolve( - Response.json({ - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_20x', - }, - }), - ) - } - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: now + PROFILE_TTL_MS * 3, }), @@ -9211,7 +10072,7 @@ describe('auth.loader', () => { const showAccounts = async () => { await expect( plugin['command.execute.before']({ - command: 'claude-account', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -9236,40 +10097,46 @@ describe('auth.loader', () => { }) test('completed profile hydration does not refetch later token generations', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const firstLogin = { + access: 'sk-ant-oat01-token-0', + refresh: 'refresh-sk-ant-oat01-token-0', + expires: Date.now() + 100000, + } + await useTempAccountFile( + createFallbackStorage({ accounts: [] }), + firstLogin, + ) const mockClient = createMockClient() let profileCalls = 0 - let liveAccess = 'token-0' - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/profile')) { - profileCalls++ - return Promise.resolve( - Response.json({ - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_20x', - }, - }), - ) - } - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch + // The main login has served before, so the pool already knows which + // account it belongs to; a refresh elsewhere then stays on that account. + await poolMainAccess() const plugin = await getPlugin(mockClient) await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: liveAccess, - refresh: `refresh-${liveAccess}`, - expires: Date.now() + 100000, - }), + () => Promise.resolve({ type: 'oauth', ...firstLogin }), { models: {} }, ) delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION const showAccounts = async () => { await expect( plugin['command.execute.before']({ - command: 'claude-account', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -9281,11 +10148,16 @@ describe('auth.loader', () => { (storage) => storage?.main?.profile?.accountIdentity === storage?.mainAccountId, ) - for (let generation = 0; generation < 66; generation++) { - liveAccess = `token-${generation}` + // Each generation is a refresh of the same account in another process, + // which rotates the pool's main access token. + for (let generation = 1; generation <= 66; generation++) { + await refreshPoolMainElsewhere({ + access: `sk-ant-oat01-token-${generation}`, + refresh: `refresh-sk-ant-oat01-token-${generation}`, + expires: Date.now() + 100000, + }) await showAccounts() } - liveAccess = 'token-0' await showAccounts() expect(profileCalls).toBe(1) @@ -9307,31 +10179,33 @@ describe('auth.loader', () => { }), ) let profileCalls = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/profile')) { - profileCalls++ + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } return Promise.resolve( Response.json({ - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_20x', - }, + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, }), ) - } - return Promise.resolve( - Response.json({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 20 }, - }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin(createMockClient()) await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -9341,7 +10215,7 @@ describe('auth.loader', () => { await expect( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -9357,14 +10231,16 @@ describe('auth.loader', () => { test('profile fetch failure is silent and label is omitted', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const mockClient = createMockClient() - globalThis.fetch = mock((input: string | URL | Request) => - Promise.resolve( - extractUrl(input).includes('/api/oauth/profile') - ? new Response('failed', { status: 500 }) - : Response.json({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 20 }, - }), + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => + Promise.resolve( + extractUrl(input).includes('/api/oauth/profile') + ? new Response('failed', { status: 500 }) + : Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + ), ), ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) @@ -9372,7 +10248,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -9386,7 +10262,7 @@ describe('auth.loader', () => { try { await expect( plugin['command.execute.before']({ - command: 'claude-quota', + command: 'claude', arguments: '', sessionID: 'session-1', }), @@ -9413,16 +10289,18 @@ describe('auth.loader', () => { test('profile persistence failure does not block account display', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) const mockClient = createMockClient() - globalThis.fetch = mock((input: string | URL | Request) => - Promise.resolve( - extractUrl(input).includes('/api/oauth/profile') - ? Response.json({ - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_20x', - }, - }) - : new Response('ok'), + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => + Promise.resolve( + extractUrl(input).includes('/api/oauth/profile') + ? Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }) + : new Response('ok'), + ), ), ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) @@ -9430,7 +10308,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -9449,7 +10327,7 @@ describe('auth.loader', () => { let commandError: unknown try { await plugin['command.execute.before']({ - command: 'claude-account', + command: 'claude', arguments: '', sessionID: 'session-1', }) @@ -9481,16 +10359,18 @@ describe('auth.loader', () => { test('ordinary model request never calls the profile endpoint', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) let profileCalls = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/profile')) profileCalls++ - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) profileCalls++ + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -9508,34 +10388,41 @@ describe('auth.loader', () => { accounts: [], claudeFast: { enabled: true }, }), + { + access: 'sk-ant-oat01-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }, ) 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 + globalThis.fetch = mock( + withNativeAdmission((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', + access: 'sk-ant-oat01-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -9564,34 +10451,41 @@ describe('auth.loader', () => { accounts: [], claudeFast: { enabled: true }, }), + { + access: 'sk-ant-oat01-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }, ) 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 + globalThis.fetch = mock( + withNativeAdmission((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', + access: 'sk-ant-oat01-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -9614,41 +10508,48 @@ describe('auth.loader', () => { ) test('/claude-cache on makes fetch wrapper set ttl on existing cache controls', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] })) + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }) let capturedBody: string | undefined const mockClient = createMockClient() - 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 }, - ), - ) - } - capturedBody = init?.body - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch - - const plugin = await getPlugin(mockClient) - await expect( - plugin['command.execute.before']({ - command: 'claude-cache', - arguments: 'on', - sessionID: 'session-1', + globalThis.fetch = mock( + withNativeAdmission((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 }, + ), + ) + } + capturedBody = init?.body + return Promise.resolve(new Response(null, { status: 200 })) }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + ) as unknown as typeof fetch + + const plugin = await getPlugin(mockClient, tempConfigDir) + expect( + ( + await applyMenuAction(plugin, 'session-1', { + sectionId: 'Cache', + actionId: 'cache-on', + }) + ).ok, + ).toBe(true) const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'my-access-token', + access: 'sk-ant-oat01-my-access-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -9679,29 +10580,36 @@ describe('auth.loader', () => { test('persistent claudeCache setting does not apply to subagent requests with parent session header', async () => { await useTempAccountFile( createFallbackStorage({ accounts: [], claudeCache: { enabled: true } }), + { + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }, ) let capturedBody: string | undefined let capturedHeaders: Headers | undefined const mockClient = createMockClient() globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - 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 }, - ), - ) - } - capturedBody = String(init?.body) - capturedHeaders = new Headers(init?.headers) - return Promise.resolve(new Response(null, { status: 200 })) - }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + 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 }, + ), + ) + } + capturedBody = String(init?.body) + capturedHeaders = new Headers(init?.headers) + return Promise.resolve(new Response(null, { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) @@ -9709,7 +10617,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'my-access-token', + access: 'sk-ant-oat01-my-access-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -9742,27 +10650,34 @@ describe('auth.loader', () => { accounts: [], claudeCache: { enabled: true, mode: 'hybrid' }, }), + { + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }, ) let capturedBody: string | undefined const mockClient = createMockClient() globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - 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 }, - ), - ) - } - capturedBody = String(init?.body) - return Promise.resolve(new Response(null, { status: 200 })) - }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + 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 }, + ), + ) + } + capturedBody = String(init?.body) + return Promise.resolve(new Response(null, { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin(mockClient) @@ -9770,7 +10685,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'my-access-token', + access: 'sk-ant-oat01-my-access-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -9827,6 +10742,11 @@ describe('auth.loader', () => { quota: { enabled: false }, refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, }), + { + access: 'sk-ant-oat01-access', + refresh: 'refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, ) Math.random = () => 0.5 const intervalDelays: number[] = [] @@ -9844,7 +10764,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'access', + access: 'sk-ant-oat01-access', refresh: 'refresh', expires: Date.now() + 8 * 60 * 60_000, }), @@ -9861,6 +10781,11 @@ describe('auth.loader', () => { quota: { enabled: false }, refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, }), + { + access: 'sk-ant-oat01-old-access', + refresh: 'old-refresh', + expires: Date.now() + 5 * 60_000, + }, ) const intervalHandlers: Array<() => void> = [] const setIntervalMock = mock((handler: () => void) => { @@ -9875,7 +10800,7 @@ describe('auth.loader', () => { new Response( JSON.stringify({ refresh_token: 'background-refresh-new', - access_token: 'background-access-new', + access_token: 'sk-ant-oat01-background-access-new', expires_in: 3600, }), { status: 200 }, @@ -9894,7 +10819,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'old-access', + access: 'sk-ant-oat01-old-access', refresh: 'old-refresh', expires: Date.now() + 5 * 60_000, }), @@ -9910,7 +10835,7 @@ describe('auth.loader', () => { body: { type: 'oauth', refresh: 'background-refresh-new', - access: 'background-access-new', + access: 'sk-ant-oat01-background-access-new', expires: expect.any(Number), }, }) @@ -9938,6 +10863,11 @@ describe('auth.loader', () => { }, }, }), + { + access: 'sk-ant-oat01-current-access', + refresh: 'current-refresh', + expires: now + 5 * 60_000, + }, ) const intervalHandlers: Array<() => void> = [] const setIntervalMock = mock((handler: () => void) => { @@ -9951,7 +10881,7 @@ describe('auth.loader', () => { return Promise.resolve( Response.json({ refresh_token: 'refreshed-refresh', - access_token: 'refreshed-access', + access_token: 'sk-ant-oat01-refreshed-access', expires_in: 8 * 60 * 60, }), ) @@ -9968,7 +10898,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'current-access', + access: 'sk-ant-oat01-current-access', refresh: 'current-refresh', expires: now + 5 * 60_000, }), @@ -9981,7 +10911,7 @@ describe('auth.loader', () => { expect(tokenRefreshCalls).toBe(1) expect(mockClient.auth.set).toHaveBeenCalledTimes(1) expect( - (await loadAccounts())?.refresh?.mainLastRefreshError, + (await readAccountStorage())?.refresh?.mainLastRefreshError, ).toBeUndefined() }) @@ -10006,6 +10936,11 @@ describe('auth.loader', () => { }, }, }), + { + access: 'sk-ant-oat01-current-access', + refresh: 'current-refresh', + expires: now + 5 * 60_000, + }, ) const intervalHandlers: Array<() => void> = [] const setIntervalMock = mock((handler: () => void) => { @@ -10019,7 +10954,7 @@ describe('auth.loader', () => { return Promise.resolve( Response.json({ refresh_token: 'refreshed-refresh', - access_token: 'refreshed-access', + access_token: 'sk-ant-oat01-refreshed-access', expires_in: 8 * 60 * 60, }), ) @@ -10028,7 +10963,7 @@ describe('auth.loader', () => { }) as unknown as typeof fetch const mockClient = createMockClient() - const plugin = await getPlugin(mockClient, undefined, { + const plugin = await getPlugin(mockClient, tempConfigDir, { setInterval: setIntervalMock, clearInterval: mock(() => {}) as unknown as typeof clearInterval, }) @@ -10036,26 +10971,27 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'current-access', + access: 'sk-ant-oat01-current-access', refresh: 'current-refresh', expires: now + 5 * 60_000, }), { models: {} }, ) - await expectHandledCommandResponse( - plugin['command.execute.before']({ - command: 'claude-account', - arguments: 'reset-backoff', - sessionID: 'session-1', - }), - ) + expect( + ( + await applyMenuAction(plugin, 'session-1', { + sectionId: 'Accounts', + actionId: 'reset-backoff', + }) + ).ok, + ).toBe(true) for (const handler of intervalHandlers) handler() await waitForMockCall(mockClient.auth.set) expect(tokenRefreshCalls).toBe(1) expect( - (await loadAccounts())?.refresh?.mainLastRefreshError, + (await readAccountStorage())?.refresh?.mainLastRefreshError, ).toBeUndefined() }) @@ -10066,6 +11002,11 @@ describe('auth.loader', () => { quota: { enabled: false }, refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, }), + { + access: 'sk-ant-oat01-old-access', + refresh: 'old-refresh', + expires: Date.now() + 3 * 60 * 60_000, + }, ) const intervalHandlers: Array<() => void> = [] const setIntervalMock = mock((handler: () => void) => { @@ -10080,7 +11021,7 @@ describe('auth.loader', () => { new Response( JSON.stringify({ refresh_token: 'early-refresh-new', - access_token: 'early-access-new', + access_token: 'sk-ant-oat01-early-access-new', expires_in: 3600, }), { status: 200 }, @@ -10099,7 +11040,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'old-access', + access: 'sk-ant-oat01-old-access', refresh: 'old-refresh', expires: Date.now() + 3 * 60 * 60_000, }), @@ -10114,7 +11055,7 @@ describe('auth.loader', () => { body: { type: 'oauth', refresh: 'early-refresh-new', - access: 'early-access-new', + access: 'sk-ant-oat01-early-access-new', expires: expect.any(Number), }, }) @@ -10127,6 +11068,11 @@ describe('auth.loader', () => { quota: { enabled: false }, refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, }), + { + access: 'sk-ant-oat01-expired', + refresh: 'refresh-token', + expires: Date.now() - 1000, + }, ) let tokenRefreshCalls = 0 globalThis.fetch = mock((input: any) => { @@ -10150,7 +11096,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'expired', + access: 'sk-ant-oat01-expired', refresh: 'refresh-token', expires: Date.now() - 1000, }), @@ -10207,6 +11153,11 @@ describe('auth.loader', () => { }, }, }), + { + access: 'sk-ant-oat01-relogged-main-access', + refresh: 'relogged-main-refresh', + expires: now + 60 * 60_000, + }, ) let tokenRefreshCalls = 0 globalThis.fetch = mock((input: any) => { @@ -10217,7 +11168,7 @@ describe('auth.loader', () => { new Response( JSON.stringify({ refresh_token: 'relogged-main-refresh-new', - access_token: 'relogged-main-access', + access_token: 'sk-ant-oat01-relogged-main-access', expires_in: 3600, }), { status: 200 }, @@ -10235,13 +11186,13 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'relogged-main-access', + access: 'sk-ant-oat01-relogged-main-access', refresh: 'relogged-main-refresh', expires: now + 60 * 60_000, }), { models: {} }, ) - const preflight = await loadAccounts() + const preflight = await readAccountStorage() expect(preflight?.refresh?.mainLastRefreshError).toEqual( expect.objectContaining({ accountIdentity: 'relogged-main-refresh', @@ -10258,7 +11209,7 @@ describe('auth.loader', () => { expect(tokenRefreshCalls).toBe(0) const savedState = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) expect(savedState.main.lastRefreshError).toBeUndefined() - const savedConfig = await loadAccounts() + const savedConfig = await readAccountStorage() expect(savedConfig?.quota?.mainLastQuotaApiError).toBeUndefined() }) @@ -10289,19 +11240,26 @@ describe('auth.loader', () => { }, }, }), + { + access: 'sk-ant-oat01-relogged-main-access', + refresh: 'relogged-main-refresh', + expires: now + 60 * 60_000, + }, ) - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/v1/messages')) - return Promise.resolve(new Response('{}', { status: 200 })) - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + if (extractUrl(input).includes('/v1/messages')) + return Promise.resolve(new Response('{}', { status: 200 })) + return Promise.resolve(new Response(null, { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin(createMockClient()) const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'relogged-main-access', + access: 'sk-ant-oat01-relogged-main-access', refresh: 'relogged-main-refresh', expires: now + 60 * 60_000, }), @@ -10314,7 +11272,7 @@ describe('auth.loader', () => { }) expect(response.status).toBe(200) - const savedConfig = await loadAccounts() + const savedConfig = await readAccountStorage() // The entry surviving is not the property under test — a backoff whose // retry time has been zeroed is no longer restricting anything. expect(savedConfig?.quota?.mainLastQuotaApiError).toEqual( @@ -10330,71 +11288,77 @@ describe('auth.loader', () => { }) test('fallback-first uses stale passing fallback quota while quota refresh is in progress even when main refresh is backed off', async () => { - const now = Date.now() - await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - refresh: { - enabled: true, - intervalMinutes: 10, - refreshBeforeExpiryMinutes: 240, - mainLastRefreshError: { - message: - 'Claude OAuth refresh failed: 400 — {"error":"invalid_grant"}', - checkedAt: now, - nextRetryAt: now + 60_000, - retryCount: 1, - tokenHash: hashRefreshToken('main-refresh'), - }, - }, - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - access: 'fallback-access', - refresh: 'fallback-refresh', - expires: now + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 25, - remainingPercent: 75, - checkedAt: now - 10 * 60_000, - resetsAt: '2099-01-01T00:00:00Z', - }, - seven_day: { - usedPercent: 30, - remainingPercent: 70, - checkedAt: now - 10 * 60_000, - resetsAt: '2099-01-01T00:00:00Z', - }, + const now = Date.now() + const expiredMain = { + access: 'sk-ant-oat01-main-access', + refresh: 'main-refresh', + expires: now - 1_000, + } + await useTempAccountFile( + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + refresh: { + enabled: true, + intervalMinutes: 10, + refreshBeforeExpiryMinutes: 240, + mainLastRefreshError: { + message: + 'Claude OAuth refresh failed: 400 — {"error":"invalid_grant"}', + checkedAt: now, + nextRetryAt: now + 60_000, + retryCount: 1, + tokenHash: hashRefreshToken('main-refresh'), }, }, - ], - }), + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: now + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: now - 10 * 60_000, + resetsAt: '2099-01-01T00:00:00Z', + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: now - 10 * 60_000, + resetsAt: '2099-01-01T00:00:00Z', + }, + }, + }, + ], + }), + ), + expiredMain, ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - throw new Error('Quota refresh is already in progress') - } - if (url.includes('/v1/oauth/token')) { - throw new Error('main refresh should not be attempted') - } - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve(new Response('fallback-ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeBootstrap((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + throw new Error('Quota refresh is already in progress') + } + if (url.includes('/v1/oauth/token')) { + throw new Error('main refresh should not be attempted') + } + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve(new Response('fallback-ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin(createMockClient()) const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'main-access', - refresh: 'main-refresh', - expires: now - 1_000, - }), + () => Promise.resolve({ type: 'oauth', ...expiredMain }), { models: {} }, ) @@ -10402,31 +11366,39 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('fallback-ok') - expect(authorizations).toEqual(['Bearer fallback-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-fallback-access']) }) test('fetch wrapper refreshes expired token', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-expired-token', + refresh: 'old-refresh', + expires: Date.now() - 1000, + }) + mainAccountIssues('sk-ant-oat01-new-access') const fetchCalls: Array<{ url: string; body?: string }> = [] - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - fetchCalls.push({ url, body: init?.body }) + globalThis.fetch = mock( + withNativeBootstrap((input: any, init: any) => { + const url = extractUrl(input) + fetchCalls.push({ url, body: init?.body }) - if (url.includes('/v1/oauth/token')) { - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'new-refresh', - access_token: 'new-access', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } + if (url.includes('/v1/oauth/token')) { + return Promise.resolve( + new Response( + JSON.stringify({ + refresh_token: 'new-refresh', + access_token: 'sk-ant-oat01-new-access', + expires_in: 3600, + }), + { status: 200 }, + ), + ) + } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + return Promise.resolve(new Response(null, { status: 200 })) + }), + ) as unknown as typeof fetch const mockClient = createMockClient() const plugin = await getPlugin(mockClient) @@ -10435,7 +11407,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'expired-token', + access: 'sk-ant-oat01-expired-token', refresh: 'old-refresh', expires: Date.now() - 1000, // expired }), @@ -10455,11 +11427,23 @@ describe('auth.loader', () => { expect(tokenBody.grant_type).toBe('refresh_token') expect(tokenBody.refresh_token).toBe('old-refresh') - // Should have called client.auth.set with new tokens - expect(mockClient.auth.set).toHaveBeenCalled() + // The new tokens are stored in the native account pool, where the next + // caller receives them; OpenCode's auth store keeps only the placeholder + // login and is not written. + expect(await poolMainAccess()).toBe('sk-ant-oat01-new-access') + expect(mockClient.auth.set).not.toHaveBeenCalled() + await expectHostActivationNonSecret( + 'sk-ant-oat01-new-access', + 'new-refresh', + ) }) test('fetch wrapper retries transient token refresh failures', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-expired', + refresh: 'refresh', + expires: Date.now() - 1000, + }) let tokenRefreshCalls = 0 const setTimeoutMock = mock((handler: () => unknown) => { handler() @@ -10482,7 +11466,7 @@ describe('auth.loader', () => { new Response( JSON.stringify({ refresh_token: 'new-refresh', - access_token: 'new-access', + access_token: 'sk-ant-oat01-new-access', expires_in: 3600, }), { status: 200 }, @@ -10501,7 +11485,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'expired', + access: 'sk-ant-oat01-expired', refresh: 'refresh', expires: Date.now() - 1000, }), @@ -10520,6 +11504,11 @@ describe('auth.loader', () => { }) test('fetch wrapper keeps main oauth retry count bounded when helper also supports retries', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-expired', + refresh: 'refresh', + expires: Date.now() - 1000, + }) let tokenRefreshCalls = 0 const setTimeoutMock = mock((handler: () => unknown) => { handler() @@ -10544,7 +11533,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'expired', + access: 'sk-ant-oat01-expired', refresh: 'refresh', expires: Date.now() - 1000, }), @@ -10562,6 +11551,11 @@ describe('auth.loader', () => { }) test('fetch wrapper does not retry non-transient token refresh failures', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-expired', + refresh: 'refresh', + expires: Date.now() - 1000, + }) let tokenRefreshCalls = 0 globalThis.fetch = mock((input: any) => { @@ -10578,7 +11572,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'expired', + access: 'sk-ant-oat01-expired', refresh: 'refresh', expires: Date.now() - 1000, }), @@ -10598,6 +11592,11 @@ describe('auth.loader', () => { test('fetch wrapper strips tool prefix from streaming response', async () => { await useTempAccountFile( createFallbackStorage({ accounts: [], quota: { enabled: false } }), + { + access: 'sk-ant-oat01-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }, ) const encoder = new TextEncoder() const responseStream = new ReadableStream({ @@ -10611,8 +11610,10 @@ describe('auth.loader', () => { }, }) - globalThis.fetch = mock(() => - Promise.resolve(new Response(responseStream, { status: 200 })), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve(new Response(responseStream, { status: 200 })), + ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -10620,7 +11621,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'token', + access: 'sk-ant-oat01-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -10643,27 +11644,29 @@ describe('auth.loader', () => { test('concurrent expired token refresh should deduplicate to a single token request', async () => { let tokenRefreshCount = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) + globalThis.fetch = mock( + withNativeBootstrap((input: any) => { + const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - tokenRefreshCount++ - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'new-refresh', - access_token: 'new-access', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } + if (url.includes('/v1/oauth/token')) { + tokenRefreshCount++ + return Promise.resolve( + new Response( + JSON.stringify({ + refresh_token: 'new-refresh', + access_token: 'sk-ant-oat01-new-access', + expires_in: 3600, + }), + { status: 200 }, + ), + ) + } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + return Promise.resolve(new Response(null, { status: 200 })) + }), + ) as unknown as typeof fetch - const { result } = await setupExpiredTokenLoader() + const { result } = await setupExpiredTokenLoader('sk-ant-oat01-new-access') await fireConcurrentFetches(result) // With deduplication, only ONE refresh request should be made, not 5 @@ -10673,51 +11676,47 @@ describe('auth.loader', () => { test('plugin instances sharing main auth join a refresh while auth persistence is delayed', async () => { await useTempAccountFile( createFallbackStorage({ accounts: [], quota: { enabled: false } }), + { + access: 'sk-ant-oat01-expired-token', + refresh: 'old-refresh', + expires: Date.now() - 1_000, + }, ) + mainAccountIssues('sk-ant-oat01-new-access') let tokenRefreshCount = 0 - let releaseAuthSet!: () => void - const authSetBlocked = new Promise((resolve) => { - releaseAuthSet = resolve - }) - let authSetStarted!: () => void - const authSetStartedPromise = new Promise((resolve) => { - authSetStarted = resolve - }) + // The first plugin's token exchange is held open, so the second plugin's + // request arrives while that refresh is still in progress. OpenCode's + // auth store no longer receives refreshed tokens (the pool persists + // them), so the delayed persistence the legacy test held is now the + // in-flight exchange itself. + const tokenRequestStarted = lifetimeSignal() + const tokenResponse = heldResponse() - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - tokenRefreshCount += 1 - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'new-refresh', - access_token: 'new-access', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeBootstrap((input: any) => { + const url = extractUrl(input) + if (url.includes('/v1/oauth/token')) { + tokenRefreshCount += 1 + tokenRequestStarted.raise() + return tokenResponse.response + } + return Promise.resolve(new Response(null, { status: 200 })) + }), + ) as unknown as typeof fetch const firstClient = createMockClient() - firstClient.auth.set = mock(async () => { - authSetStarted() - await authSetBlocked - }) + const secondClient = createMockClient() const secondRefreshWait = mock(() => { throw new Error('second plugin entered the cross-process refresh wait') }) as unknown as typeof setTimeout const firstPlugin = await getPlugin(firstClient, '/project-a') - const secondPlugin = await getPlugin(createMockClient(), '/project-b', { + const secondPlugin = await getPlugin(secondClient, '/project-b', { setTimeout: secondRefreshWait, }) const expiredAuth = () => Promise.resolve({ type: 'oauth' as const, - access: 'expired-token', + access: 'sk-ant-oat01-expired-token', refresh: 'old-refresh', expires: Date.now() - 1_000, }) @@ -10729,15 +11728,34 @@ describe('auth.loader', () => { }) const firstFetch = firstResult.fetch(MESSAGES_URL, EMPTY_POST) - await authSetStartedPromise + await tokenRequestStarted.raised const secondFetch = secondResult.fetch(MESSAGES_URL, EMPTY_POST) await Bun.sleep(0) - releaseAuthSet() + tokenResponse.release( + new Response( + JSON.stringify({ + refresh_token: 'new-refresh', + access_token: 'sk-ant-oat01-new-access', + expires_in: 3600, + }), + { status: 200 }, + ), + ) const responses = await Promise.all([firstFetch, secondFetch]) expect(responses.map((response) => response.status)).toEqual([200, 200]) expect(tokenRefreshCount).toBe(1) expect(secondRefreshWait).not.toHaveBeenCalled() + // Both instances share the one refreshed login through the pool, and + // neither writes token material to OpenCode's auth store. + expect(await poolMainAccess()).toBe('sk-ant-oat01-new-access') + expect(tokenRefreshCount).toBe(1) + expect(firstClient.auth.set).not.toHaveBeenCalled() + expect(secondClient.auth.set).not.toHaveBeenCalled() + await expectHostActivationNonSecret( + 'sk-ant-oat01-new-access', + 'new-refresh', + ) }) test('sticky 401 retry refuses a foreign-provider tombstone before the token endpoint', async () => { @@ -10757,13 +11775,13 @@ describe('auth.loader', () => { seven_day: { usedPercent: 10, remainingPercent: 90, checkedAt }, }, mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('live-main-access'), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-live-main-access'), }, }), ) let currentAuth: Record = { type: 'oauth', - access: 'live-main-access', + access: 'sk-ant-oat01-live-main-access', refresh: 'live-main-refresh', expires: checkedAt + 8 * 60 * 60_000, } @@ -10806,79 +11824,84 @@ describe('auth.loader', () => { }), }), ).rejects.toBeInstanceOf(CustodyTombstoneRefreshError) - expect(messageAuthorizations).toEqual(['Bearer live-main-access']) + expect(messageAuthorizations).toEqual([ + 'Bearer sk-ant-oat01-live-main-access', + ]) expect(tokenEndpointCalls).toEqual([]) await plugin.dispose?.() }) test('sticky 401 retries with a concurrently rotated main access token', async () => { const checkedAt = Date.now() + const oldLogin = { + access: 'sk-ant-oat01-old-access', + refresh: 'old-refresh', + expires: checkedAt + 8 * 60 * 60_000, + } await useTempAccountFile( - createFallbackStorage({ - accounts: [], - routing: { mode: 'sticky-balanced' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: { - checkedAt, - five_hour: { - usedPercent: 10, - remainingPercent: 90, - checkedAt, - }, - seven_day: { - usedPercent: 10, - remainingPercent: 90, + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: { checkedAt, + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, }, + mainQuotaCheckedAt: checkedAt, }, - mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('old-access'), - }, - }), + }), + oldLogin.access, + ), + oldLogin, ) - let currentAuth = { - type: 'oauth' as const, - access: 'old-access', - refresh: 'old-refresh', - expires: checkedAt + 8 * 60 * 60_000, - } let tokenRefreshCount = 0 const messageAuthorizations: string[] = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - tokenRefreshCount += 1 - return Promise.resolve( - new Response(JSON.stringify({ error: 'invalid_grant' }), { + globalThis.fetch = mock( + withNativeBootstrap(async (input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/oauth/token')) { + tokenRefreshCount += 1 + return new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400, - }), - ) - } - if (url.includes('/v1/messages')) { - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - messageAuthorizations.push(authorization) - if (authorization === 'Bearer old-access') { - currentAuth = { - type: 'oauth', - access: 'new-access', - refresh: 'new-refresh', - expires: checkedAt + 8 * 60 * 60_000, + }) + } + if (url.includes('/v1/messages')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + messageAuthorizations.push(authorization) + if (authorization === 'Bearer sk-ant-oat01-old-access') { + // Another OpenCode process refreshes the same account while this + // request is in flight; the old access token is then rejected. + await refreshPoolMainElsewhere({ + access: 'sk-ant-oat01-new-access', + refresh: 'new-refresh', + expires: checkedAt + 8 * 60 * 60_000, + }) + return new Response('unauthorized', { status: 401 }) } - return Promise.resolve(new Response('unauthorized', { status: 401 })) + return new Response('{}', { status: 200 }) } - return Promise.resolve(new Response('{}', { status: 200 })) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + return new Response('{}', { status: 200 }) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( - () => Promise.resolve(currentAuth), + () => Promise.resolve({ type: 'oauth' as const, ...oldLogin }), { models: {}, }, @@ -10895,8 +11918,8 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(messageAuthorizations).toEqual([ - 'Bearer old-access', - 'Bearer new-access', + 'Bearer sk-ant-oat01-old-access', + 'Bearer sk-ant-oat01-new-access', ]) expect(tokenRefreshCount).toBe(0) }) @@ -10904,47 +11927,51 @@ describe('auth.loader', () => { test('serves an existing sticky assignment with a live token', async () => { const checkedAt = Date.now() await useTempAccountFile( - createFallbackStorage({ - accounts: [], - routing: { mode: 'sticky-balanced' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: { - checkedAt, - five_hour: { - usedPercent: 10, - remainingPercent: 90, - checkedAt, - }, - seven_day: { - usedPercent: 10, - remainingPercent: 90, + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: { checkedAt, + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, }, - }, - mainQuotaCheckedAt: checkedAt, - }, - }), + mainQuotaCheckedAt: checkedAt, + }, + }), + ), ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - if (extractUrl(input).includes('/v1/messages')) { - authorizations.push( - new Headers(init?.headers).get('authorization') ?? '', - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + if (extractUrl(input).includes('/v1/messages')) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: checkedAt + 8 * 60 * 60_000, }), @@ -10964,45 +11991,52 @@ describe('auth.loader', () => { result.fetch(MESSAGES_URL, request), result.fetch(MESSAGES_URL, request), ]) - expect(authorizations).toEqual(['Bearer main-access', 'Bearer main-access']) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-main-access', + ]) expect(responses.map((response) => response.status)).toEqual([200, 200]) }) test('admits and sends an OAuth route when an empty quota snapshot is fail-open', async () => { await useTempAccountFile( - createFallbackStorage({ - accounts: [], - routing: { mode: 'sticky-balanced' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: false, - }, - }), + bindMainAccount( + createFallbackStorage({ + accounts: [], + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: false, + }, + }), + ), ) const usageRequests: string[] = [] const messageAuthorizations: string[] = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - usageRequests.push(url) + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + usageRequests.push(url) + return Promise.resolve(new Response('{}', { status: 200 })) + } + if (url.includes('/v1/messages')) { + messageAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } return Promise.resolve(new Response('{}', { status: 200 })) - } - if (url.includes('/v1/messages')) { - messageAuthorizations.push( - new Headers(init?.headers).get('authorization') ?? '', - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 8 * 60 * 60_000, }), @@ -11020,7 +12054,7 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(usageRequests).toHaveLength(1) - expect(messageAuthorizations).toEqual(['Bearer main-access']) + expect(messageAuthorizations).toEqual(['Bearer sk-ant-oat01-main-access']) }) test('unknown identity routes when fail-open, then unknown quota blocks when fail-closed', async () => { @@ -11036,27 +12070,29 @@ describe('auth.loader', () => { }) await useTempAccountFile(storage) const messageAuthorizations: string[] = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response('quota unavailable', { status: 500 }), - ) - } - if (url.includes('/v1/messages')) { - messageAuthorizations.push( - new Headers(init?.headers).get('authorization') ?? '', - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response('quota unavailable', { status: 500 }), + ) + } + if (url.includes('/v1/messages')) { + messageAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 8 * 60 * 60_000, }), @@ -11085,61 +12121,68 @@ describe('auth.loader', () => { expect(blocked.status).toBe(429) expect(await blocked.clone().text()).toContain('Quota API') - expect(messageAuthorizations).toEqual(['Bearer main-access']) + expect(messageAuthorizations).toEqual(['Bearer sk-ant-oat01-main-access']) }) test('keeps a sticky main route excluded during identity-agnostic active refresh backoff', async () => { const checkedAt = Date.now() await useTempAccountFile( - createFallbackStorage({ - accounts: [], - routing: { mode: 'sticky-balanced' }, - refresh: { - enabled: true, - intervalMinutes: 10, - refreshBeforeExpiryMinutes: 30, - mainLastRefreshError: { - message: 'refresh unavailable', - checkedAt, - nextRetryAt: checkedAt + 60_000, - }, - }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: { - checkedAt, - five_hour: { - usedPercent: 10, - remainingPercent: 90, + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + routing: { mode: 'sticky-balanced' }, + refresh: { + enabled: true, + intervalMinutes: 10, + refreshBeforeExpiryMinutes: 30, + // Without an account UUID, the refresh-token hash identifies which + // credential failed. Migration preserves that retry restriction. + mainLastRefreshError: { + message: 'refresh unavailable', checkedAt, + nextRetryAt: checkedAt + 60_000, + tokenHash: hashRefreshToken('main-refresh'), }, - seven_day: { - usedPercent: 10, - remainingPercent: 90, + }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: { checkedAt, + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, }, + mainQuotaCheckedAt: checkedAt, }, - mainQuotaCheckedAt: checkedAt, - }, - }), + }), + ), ) const messageRequests: string[] = [] - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/v1/messages')) { - messageRequests.push(extractUrl(input)) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + if (extractUrl(input).includes('/v1/messages')) { + messageRequests.push(extractUrl(input)) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: checkedAt + 8 * 60 * 60_000, }), @@ -11171,7 +12214,7 @@ describe('auth.loader', () => { expect(result.response.status).toBe(200) expect(result.tokenRequests).toEqual([]) expect(result.messageAuthorizations).toEqual([ - 'Bearer initial-fallback-access', + 'Bearer sk-ant-oat01-initial-fallback-access', ]) }) @@ -11187,47 +12230,49 @@ describe('auth.loader', () => { expect(result.response.status).toBe(200) expect(result.tokenRequests).toHaveLength(1) expect(result.messageAuthorizations).toEqual([ - 'Bearer refreshed-fallback-access', + 'Bearer sk-ant-oat01-refreshed-fallback-access', ]) }) test('concurrent refresh with token rotation should not cause cascading failures', async () => { const usedRefreshTokens = new Set() - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) + globalThis.fetch = mock( + withNativeBootstrap((input: any, init: any) => { + const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - const body = JSON.parse(String(init?.body)) - const refreshToken = body.refresh_token ?? '' + if (url.includes('/v1/oauth/token')) { + const body = JSON.parse(String(init?.body)) + const refreshToken = body.refresh_token ?? '' + + // Simulate refresh token rotation: first use succeeds, subsequent uses + // return 401 because the old token has been invalidated + if (usedRefreshTokens.has(refreshToken)) { + return Promise.resolve( + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 401, + }), + ) + } - // Simulate refresh token rotation: first use succeeds, subsequent uses - // return 401 because the old token has been invalidated - if (usedRefreshTokens.has(refreshToken)) { + usedRefreshTokens.add(refreshToken) return Promise.resolve( - new Response(JSON.stringify({ error: 'invalid_grant' }), { - status: 401, - }), + new Response( + JSON.stringify({ + refresh_token: 'rotated-refresh', + access_token: 'sk-ant-oat01-new-access', + expires_in: 3600, + }), + { status: 200 }, + ), ) } - usedRefreshTokens.add(refreshToken) - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'rotated-refresh', - access_token: 'new-access', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } - - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + return Promise.resolve(new Response(null, { status: 200 })) + }), + ) as unknown as typeof fetch - const { result } = await setupExpiredTokenLoader() + const { result } = await setupExpiredTokenLoader('sk-ant-oat01-new-access') // Fire 5 concurrent requests — ALL should succeed because only one refresh // fires and the rest reuse its result @@ -11246,80 +12291,105 @@ describe('auth.loader', () => { }) test('concurrent refresh should persist tokens exactly once', async () => { - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) + let tokenRefreshCount = 0 + const messageAuthorizations: string[] = [] + globalThis.fetch = mock( + withNativeBootstrap((input: any, init?: RequestInit) => { + const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'new-refresh', - access_token: 'new-access', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } + if (url.includes('/v1/oauth/token')) { + tokenRefreshCount++ + return Promise.resolve( + new Response( + JSON.stringify({ + refresh_token: 'new-refresh', + access_token: 'sk-ant-oat01-new-access', + expires_in: 3600, + }), + { status: 200 }, + ), + ) + } + if (url.includes('/v1/messages')) + messageAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + return Promise.resolve(new Response(null, { status: 200 })) + }), + ) as unknown as typeof fetch - const { mockClient, result } = await setupExpiredTokenLoader() + const { mockClient, result } = await setupExpiredTokenLoader( + 'sk-ant-oat01-new-access', + ) await fireConcurrentFetches(result) - // With deduplication, client.auth.set should be called exactly once. - // Without it, each concurrent refresh calls auth.set independently → 5 calls. - expect(mockClient.auth.set).toHaveBeenCalledTimes(1) + // Refreshed main tokens are stored in the native account pool, never in + // OpenCode's auth store, which keeps only the placeholder login. All five + // requests use the single refreshed token, and the pool hands that same + // token to a later caller without another token request: the refresh was + // persisted once and reused, not repeated per request. + expect(mockClient.auth.set).not.toHaveBeenCalled() + await expectHostActivationNonSecret( + 'sk-ant-oat01-new-access', + 'new-refresh', + ) + expect(messageAuthorizations).toEqual( + Array.from({ length: 5 }, () => 'Bearer sk-ant-oat01-new-access'), + ) + expect(await poolMainAccess()).toBe('sk-ant-oat01-new-access') + expect(tokenRefreshCount).toBe(1) }) test('refresh always reads the latest refresh token, not a stale snapshot', async () => { const tokenRequestBodies: string[] = [] + const staleLogin = { + access: 'sk-ant-oat01-expired-access', + refresh: 'stale-refresh', + expires: Date.now() - 1000, + } + await useTempAccountFile( + createFallbackStorage({ accounts: [] }), + staleLogin, + ) + mainAccountIssues('sk-ant-oat01-fresh-access') - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) + globalThis.fetch = mock( + withNativeBootstrap((input: any, init: any) => { + const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - tokenRequestBodies.push(init?.body) - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'rotated-refresh', - access_token: 'fresh-access', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } + if (url.includes('/v1/oauth/token')) { + tokenRequestBodies.push(init?.body) + return Promise.resolve( + new Response( + JSON.stringify({ + refresh_token: 'rotated-refresh', + access_token: 'sk-ant-oat01-fresh-access', + expires_in: 3600, + }), + { status: 200 }, + ), + ) + } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + return Promise.resolve(new Response(null, { status: 200 })) + }), + ) as unknown as typeof fetch - let callCount = 0 const mockClient = createMockClient() const plugin = await getPlugin(mockClient) const result = await plugin.auth.loader( - () => { - callCount++ - if (callCount === 1) { - return Promise.resolve({ - type: 'oauth', - access: 'expired-access', - refresh: 'stale-refresh', - expires: Date.now() - 1000, - }) - } - return Promise.resolve({ - type: 'oauth', - access: 'expired-access', - refresh: 'rotated-refresh-from-storage', - expires: Date.now() - 1000, - }) - }, + () => Promise.resolve({ type: 'oauth', ...staleLogin }), { models: {} }, ) + // After the loader started, a re-login elsewhere stores a newer refresh + // token for the same account in the pool. The refresh must use it. + await replacePoolMainLogin({ + access: 'sk-ant-oat01-expired-access', + refresh: 'rotated-refresh-from-storage', + expires: Date.now() - 1000, + }) await result.fetch(MESSAGES_URL, EMPTY_POST) @@ -11330,31 +12400,38 @@ describe('auth.loader', () => { }) test('fetch wrapper adds beta=true to /v1/messages URL', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }) let capturedUrl: string | undefined - globalThis.fetch = mock((input: 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 }, - ), - ) - } - capturedUrl = url - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: 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 }, + ), + ) + } + capturedUrl = url + 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', + access: 'sk-ant-oat01-token', refresh: 'refresh', expires: Date.now() + 100000, }), @@ -11373,32 +12450,34 @@ describe('auth.loader', () => { await useTempAccountFile(createFallbackStorage()) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), - ) - } - const authHeader = init?.headers?.get('authorization') - authorizations.push(authHeader) - if (authHeader === 'Bearer main-access') { - return Promise.resolve(new Response('limited', { status: 429 })) - } - return Promise.resolve(new Response('ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + const authHeader = init?.headers?.get('authorization') + authorizations.push(authHeader) + if (authHeader === 'Bearer sk-ant-oat01-main-access') { + return Promise.resolve(new Response('limited', { status: 429 })) + } + return Promise.resolve(new Response('ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -11410,31 +12489,35 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('ok') expect(authorizations).toEqual([ - 'Bearer main-access', - 'Bearer fallback-access', + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-fallback-access', ]) }) test('fetch wrapper uses fallback first when routing mode is fallback-first', async () => { await useTempAccountFile( - createFallbackStorage({ routing: { mode: 'fallback-first' } }), + bindPoolAccounts( + createFallbackStorage({ routing: { mode: 'fallback-first' } }), + ), ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - throw new Error('fallback-first should use cached quota in this test') - } - authorizations.push(init?.headers?.get('authorization')) - return Promise.resolve(new Response('fallback ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + throw new Error('fallback-first should use cached quota in this test') + } + authorizations.push(init?.headers?.get('authorization')) + return Promise.resolve(new Response('fallback ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -11445,7 +12528,7 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('fallback ok') - expect(authorizations).toEqual(['Bearer fallback-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-fallback-access']) }) test('main-first tries an OAuth fallback whose quota is unknown after main fails', async () => { @@ -11462,7 +12545,7 @@ describe('auth.loader', () => { { id: 'unknown-fallback', type: 'oauth', - access: 'unknown-fallback-access', + access: 'sk-ant-oat01-unknown-fallback-access', refresh: 'unknown-fallback-refresh', expires, }, @@ -11470,30 +12553,35 @@ describe('auth.loader', () => { }), ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.reject(new Error('quota source unavailable')) - } - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - authorizations.push(authorization) - return Promise.resolve( - new Response( - authorization === 'Bearer main-access' ? 'limited' : 'ok', - { - status: authorization === 'Bearer main-access' ? 429 : 200, - }, - ), - ) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.reject(new Error('quota source unavailable')) + } + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + authorizations.push(authorization) + return Promise.resolve( + new Response( + authorization === 'Bearer sk-ant-oat01-main-access' + ? 'limited' + : 'ok', + { + status: + authorization === 'Bearer sk-ant-oat01-main-access' ? 429 : 200, + }, + ), + ) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires, }), @@ -11503,8 +12591,8 @@ describe('auth.loader', () => { const response = await result.fetch(MESSAGES_URL, EMPTY_POST) expect(response.status).toBe(200) expect(authorizations).toEqual([ - 'Bearer main-access', - 'Bearer unknown-fallback-access', + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-unknown-fallback-access', ]) }) @@ -11521,7 +12609,7 @@ describe('auth.loader', () => { { id: 'unknown-fallback', type: 'oauth', - access: 'unknown-fallback-access', + access: 'sk-ant-oat01-unknown-fallback-access', refresh: 'unknown-fallback-refresh', expires, }, @@ -11529,23 +12617,25 @@ describe('auth.loader', () => { }), ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.reject(new Error('quota source unavailable')) - } - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - authorizations.push(authorization) - return Promise.resolve(new Response('fallback ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.reject(new Error('quota source unavailable')) + } + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + authorizations.push(authorization) + return Promise.resolve(new Response('fallback ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires, }), @@ -11556,7 +12646,9 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('fallback ok') - expect(authorizations).toEqual(['Bearer unknown-fallback-access']) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-unknown-fallback-access', + ]) }) test('fallback-first rejects an OAuth fallback whose quota is unknown under fail-closed', async () => { @@ -11567,7 +12659,7 @@ describe('auth.loader', () => { { id: 'unknown-fallback', type: 'oauth', - access: 'unknown-fallback-access', + access: 'sk-ant-oat01-unknown-fallback-access', refresh: 'unknown-fallback-refresh', expires: Date.now() + 5 * 60 * 60_000, }, @@ -11600,34 +12692,38 @@ describe('auth.loader', () => { }, }, mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, }), ) const usageTokens: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - usageTokens.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 20 }, - seven_day: { utilization: 20 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response('fallback ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + usageTokens.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 20 }, + seven_day: { utilization: 20 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('fallback ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -11641,45 +12737,54 @@ describe('auth.loader', () => { // The active-route refresh is fire-and-forget; wait for it to land. for ( let i = 0; - i < 50 && !usageTokens.includes('Bearer fallback-access'); + i < 50 && !usageTokens.includes('Bearer sk-ant-oat01-fallback-access'); i++ ) { await new Promise((r) => setTimeout(r, 10)) } - expect(usageTokens).toContain('Bearer fallback-access') + expect(usageTokens).toContain('Bearer sk-ant-oat01-fallback-access') }) test('fallback-first routing does not refresh expired main oauth when fallback succeeds', async () => { await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - refresh: { enabled: false }, - }), + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + refresh: { enabled: false }, + }), + ), + { + access: 'sk-ant-oat01-expired-main-access', + refresh: 'main-refresh', + expires: Date.now() - 1000, + }, ) let tokenRefreshCalls = 0 const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - tokenRefreshCalls += 1 - return Promise.resolve( - new Response('should not refresh', { status: 500 }), - ) - } - if (url.includes('/api/oauth/usage')) { - throw new Error('fallback-first should use cached quota in this test') - } - authorizations.push(init?.headers?.get('authorization')) - return Promise.resolve(new Response('fallback ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeBootstrap((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/v1/oauth/token')) { + tokenRefreshCalls += 1 + return Promise.resolve( + new Response('should not refresh', { status: 500 }), + ) + } + if (url.includes('/api/oauth/usage')) { + throw new Error('fallback-first should use cached quota in this test') + } + authorizations.push(init?.headers?.get('authorization')) + return Promise.resolve(new Response('fallback ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'expired-main-access', + access: 'sk-ant-oat01-expired-main-access', refresh: 'main-refresh', expires: Date.now() - 1000, }), @@ -11691,60 +12796,64 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('fallback ok') expect(tokenRefreshCalls).toBe(0) - expect(authorizations).toEqual(['Bearer fallback-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-fallback-access']) }) test('fallback-first routing tries main when no fallback account is usable', async () => { await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - accounts: [ - { - id: 'fallback-low', - type: 'oauth', - access: 'fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 95, - remainingPercent: 5, - checkedAt: Date.now(), - }, - seven_day: { - usedPercent: 10, - remainingPercent: 90, - checkedAt: Date.now(), + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + accounts: [ + { + id: 'fallback-low', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 95, + remainingPercent: 5, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: Date.now(), + }, }, }, - }, - ], - }), + ], + }), + ), ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), - ) - } - authorizations.push(init?.headers?.get('authorization')) - return Promise.resolve(new Response('main ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + authorizations.push(init?.headers?.get('authorization')) + return Promise.resolve(new Response('main ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -11755,37 +12864,39 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('main ok') - expect(authorizations).toEqual(['Bearer main-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-main-access']) }) - test('fetch wrapper skips main account when quota policy is already exhausted', async () => { - await useTempAccountFile(createFallbackStorage()) - const messageAuthorizations: string[] = [] - - 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: 100 }, - }), - { status: 200 }, - ), - ) - } + test('fetch wrapper skips main account when quota policy is already exhausted', async () => { + await useTempAccountFile(bindPoolAccounts(createFallbackStorage())) + const messageAuthorizations: string[] = [] - messageAuthorizations.push(init?.headers?.get('authorization')) - return Promise.resolve(new Response('fallback ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((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: 100 }, + }), + { status: 200 }, + ), + ) + } + + messageAuthorizations.push(init?.headers?.get('authorization')) + return Promise.resolve(new Response('fallback ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -11796,7 +12907,9 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('fallback ok') - expect(messageAuthorizations).toEqual(['Bearer fallback-access']) + expect(messageAuthorizations).toEqual([ + 'Bearer sk-ant-oat01-fallback-access', + ]) }) test('quota refresh toasts are disabled by default', async () => { @@ -11808,28 +12921,30 @@ describe('auth.loader', () => { tui: { showToast }, } - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0.25 }, - seven_day: { utilization: 0.3 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response('main ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.25 }, + seven_day: { utilization: 0.3 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('main ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin(mockClient as any) const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -11851,28 +12966,30 @@ describe('auth.loader', () => { tui: { showToast }, } - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0.25 }, - seven_day: { utilization: 0.3 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response('main ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.25 }, + seven_day: { utilization: 0.3 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('main ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin(mockClient as any) const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -11892,36 +13009,38 @@ describe('auth.loader', () => { }) test('fetch wrapper caches exhausted main quota until reset time', async () => { - await useTempAccountFile(createFallbackStorage()) + await useTempAccountFile(bindPoolAccounts(createFallbackStorage())) let quotaCalls = 0 const messageAuthorizations: string[] = [] const resetAt = new Date(Date.now() + 18 * 60 * 60 * 1000).toISOString() - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - quotaCalls += 1 - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 100, resets_at: resetAt }, - }), - { status: 200 }, - ), - ) - } + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + quotaCalls += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 100, resets_at: resetAt }, + }), + { status: 200 }, + ), + ) + } - messageAuthorizations.push(init?.headers?.get('authorization')) - return Promise.resolve(new Response('fallback ok', { status: 200 })) - }) as unknown as typeof fetch + messageAuthorizations.push(init?.headers?.get('authorization')) + return Promise.resolve(new Response('fallback ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -11933,8 +13052,8 @@ describe('auth.loader', () => { expect(quotaCalls).toBe(1) expect(messageAuthorizations).toEqual([ - 'Bearer fallback-access', - 'Bearer fallback-access', + 'Bearer sk-ant-oat01-fallback-access', + 'Bearer sk-ant-oat01-fallback-access', ]) }) @@ -11956,26 +13075,28 @@ describe('auth.loader', () => { let quotaCalls = 0 let messageCalls = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - quotaCalls += 1 - if (quotaCalls > 1) return new Promise(() => {}) + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + quotaCalls += 1 + if (quotaCalls > 1) return new Promise(() => {}) + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + messageCalls += 1 return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), + new Response(`message-${messageCalls}`, { status: 200 }), ) - } - messageCalls += 1 - return Promise.resolve( - new Response(`message-${messageCalls}`, { status: 200 }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch try { const plugin = await getPlugin() @@ -11983,7 +13104,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: now + 1_000_000, }), @@ -12030,55 +13151,59 @@ describe('auth.loader', () => { test('async main refresh does not clobber the active fallback in the sidebar', async () => { const staleCheckedAt = Date.now() - 100 * 60_000 // far past → main quota is stale await useTempAccountFile( - createFallbackStorage({ - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - // Cached, stale, and FAILING five_hour policy (used 95% → remaining 5% < 10%). - mainQuota: { - five_hour: { - usedPercent: 95, - remainingPercent: 5, - checkedAt: staleCheckedAt, - }, - seven_day: { - usedPercent: 10, - remainingPercent: 90, - checkedAt: staleCheckedAt, + bindPoolAccounts( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + // The cached five-hour quota is stale, with 5% remaining below the 10% routing minimum. + mainQuota: { + five_hour: { + usedPercent: 95, + remainingPercent: 5, + checkedAt: staleCheckedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: staleCheckedAt, + }, }, - }, - mainQuotaCheckedAt: staleCheckedAt, - // Bind the cached main quota to the access token the loader will use. - mainQuotaToken: tokenFingerprint('main-access'), - } as AccountStorage['quota'], - }), + mainQuotaCheckedAt: staleCheckedAt, + // Associate the cached main quota with the fingerprint of the loader's access token. + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + }), + ), ) - 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.95 }, - seven_day: { utilization: 0.1 }, - }), - { status: 200 }, - ), - ) - } - // Anthropic messages call — fallback serves 200, main would not be reached. - return Promise.resolve(new Response('ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((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.95 }, + seven_day: { utilization: 0.1 }, + }), + { status: 200 }, + ), + ) + } + // Anthropic messages call — fallback serves 200, main would not be reached. + return Promise.resolve(new Response('ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -12110,39 +13235,41 @@ describe('auth.loader', () => { await useTempAccountFile(createFallbackStorage()) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), - ) - } - const authHeader = init?.headers?.get('authorization') - authorizations.push(authHeader) - if (authHeader === 'Bearer main-access') { + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + const authHeader = init?.headers?.get('authorization') + authorizations.push(authHeader) + if (authHeader === 'Bearer sk-ant-oat01-main-access') { + return Promise.resolve( + new Response( + 'event: error\ndata: {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account rate limit"}}\n\n', + { status: 200 }, + ), + ) + } return Promise.resolve( - new Response( - 'event: error\ndata: {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account rate limit"}}\n\n', - { status: 200 }, - ), + new Response('data: {"type":"message_stop"}\n\n', { status: 200 }), ) - } - return Promise.resolve( - new Response('data: {"type":"message_stop"}\n\n', { status: 200 }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -12154,66 +13281,70 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toContain('message_stop') expect(authorizations).toEqual([ - 'Bearer main-access', - 'Bearer fallback-access', + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-fallback-access', ]) }) test('fetch wrapper returns inspected streaming rate limit response when fallbacks are unavailable', async () => { await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - id: 'fallback-low', - type: 'oauth', - access: 'fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 95, - remainingPercent: 5, - checkedAt: Date.now(), - }, - seven_day: { - usedPercent: 10, - remainingPercent: 90, - checkedAt: Date.now(), + bindPoolAccounts( + createFallbackStorage({ + accounts: [ + { + id: 'fallback-low', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 95, + remainingPercent: 5, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: Date.now(), + }, }, }, - }, - ], - }), + ], + }), + ), ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + authorizations.push(init?.headers?.get('authorization')) return Promise.resolve( new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), + 'event: error\ndata: {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account rate limit"}}\n\n', { status: 200 }, ), ) - } - authorizations.push(init?.headers?.get('authorization')) - return Promise.resolve( - new Response( - 'event: error\ndata: {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account rate limit"}}\n\n', - { status: 200 }, - ), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -12224,59 +13355,63 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toContain('rate_limit_error') - expect(authorizations).toEqual(['Bearer main-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-main-access']) }) test('fetch wrapper does not use fallback accounts below quota thresholds', async () => { await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - id: 'fallback-low', - type: 'oauth', - access: 'fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 95, - remainingPercent: 5, - checkedAt: Date.now(), - }, - seven_day: { - usedPercent: 10, - remainingPercent: 90, - checkedAt: Date.now(), + bindPoolAccounts( + createFallbackStorage({ + accounts: [ + { + id: 'fallback-low', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 95, + remainingPercent: 5, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: Date.now(), + }, }, }, - }, - ], - }), + ], + }), + ), ) let calls = 0 - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), - ) - } - calls += 1 - return Promise.resolve(new Response('limited', { status: 429 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + calls += 1 + return Promise.resolve(new Response('limited', { status: 429 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -12291,7 +13426,7 @@ describe('auth.loader', () => { }) test('fetch wrapper avoids fallback retries for non-replayable request bodies', async () => { - await useTempAccountFile(createFallbackStorage()) + await useTempAccountFile(bindPoolAccounts(createFallbackStorage())) let calls = 0 const stream = new ReadableStream({ start(controller) { @@ -12300,17 +13435,19 @@ describe('auth.loader', () => { }, }) - globalThis.fetch = mock(() => { - calls += 1 - return Promise.resolve(new Response('limited', { status: 429 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission(() => { + calls += 1 + return Promise.resolve(new Response('limited', { status: 429 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -12355,36 +13492,38 @@ describe('auth.loader', () => { ], }) await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'sticky-balanced' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: quota(0), - mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('main-access'), - }, - accounts: [ - { - id: 'yiyi', - type: 'oauth', - access: 'scarce-access', - refresh: 'scarce-refresh', - expires: checkedAt + 5 * 60 * 60_000, - quota: quota(13), - }, - { - id: 'ufuk2', - type: 'oauth', - access: 'abundant-access', - refresh: 'abundant-refresh', - expires: checkedAt + 5 * 60 * 60_000, - quota: quota(98), + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: quota(0), + mainQuotaCheckedAt: checkedAt, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, - ], - }), + accounts: [ + { + id: 'yiyi', + type: 'oauth', + access: 'sk-ant-oat01-scarce-access', + refresh: 'scarce-refresh', + expires: checkedAt + 5 * 60 * 60_000, + quota: quota(13), + }, + { + id: 'ufuk2', + type: 'oauth', + access: 'sk-ant-oat01-abundant-access', + refresh: 'abundant-refresh', + expires: checkedAt + 5 * 60 * 60_000, + quota: quota(98), + }, + ], + }), + ), ) const models: string[] = [] const authorizations: string[] = [] @@ -12400,46 +13539,48 @@ describe('auth.loader', () => { 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n', 'event: message_stop\ndata: {"type":"message_stop"}\n\n', ].join('') - 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 }, - ), - ) - } - if (!url.includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - const body = JSON.parse(String(init?.body)) as Record - if (body.max_tokens === 0) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - models.push(String(body.model)) - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - authorizations.push(authorization) - if (rejectMain && authorization === 'Bearer main-access') { - return Promise.resolve(new Response('forbidden', { status: 403 })) - } - if (refusal) { - refusal = false - return Promise.resolve(new Response(refusalSse, { status: 200 })) - } - return Promise.resolve(new Response(successSse, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((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 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + const body = JSON.parse(String(init?.body)) as Record + if (body.max_tokens === 0) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + models.push(String(body.model)) + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + authorizations.push(authorization) + if (rejectMain && authorization === 'Bearer sk-ant-oat01-main-access') { + return Promise.resolve(new Response('forbidden', { status: 403 })) + } + if (refusal) { + refusal = false + return Promise.resolve(new Response(refusalSse, { status: 200 })) + } + return Promise.resolve(new Response(successSse, { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin(createMockClient()) const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: checkedAt + 100_000, }), @@ -12464,8 +13605,8 @@ describe('auth.loader', () => { expect(models).toEqual(['claude-fable-5', 'claude-opus-4-8']) expect(authorizations).toEqual([ - 'Bearer abundant-access', - 'Bearer abundant-access', + 'Bearer sk-ant-oat01-abundant-access', + 'Bearer sk-ant-oat01-abundant-access', ]) const directOpus = await result.fetch(MESSAGES_URL, { @@ -12479,7 +13620,7 @@ describe('auth.loader', () => { }), }) await directOpus.text() - expect(authorizations.at(-1)).toBe('Bearer main-access') + expect(authorizations.at(-1)).toBe('Bearer sk-ant-oat01-main-access') rejectMain = true const migratedOpus = await result.fetch(MESSAGES_URL, { @@ -12493,8 +13634,8 @@ describe('auth.loader', () => { }) await migratedOpus.text() expect(authorizations.slice(-2)).toEqual([ - 'Bearer main-access', - 'Bearer scarce-access', + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-scarce-access', ]) }) @@ -12526,28 +13667,30 @@ describe('auth.loader', () => { ], }) await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'sticky-balanced' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: quota(0), - mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('main-access'), - }, - accounts: [ - { - id: 'fable-rich', - type: 'oauth', - access: 'fable-rich-access', - refresh: 'fable-rich-refresh', - expires: checkedAt + 5 * 60 * 60_000, - quota: quota(100), + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: quota(0), + mainQuotaCheckedAt: checkedAt, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, - ], - }), + accounts: [ + { + id: 'fable-rich', + type: 'oauth', + access: 'sk-ant-oat01-fable-rich-access', + refresh: 'fable-rich-refresh', + expires: checkedAt + 5 * 60 * 60_000, + quota: quota(100), + }, + ], + }), + ), ) const authorizations: string[] = [] const successSse = [ @@ -12555,32 +13698,36 @@ describe('auth.loader', () => { 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n', 'event: message_stop\ndata: {"type":"message_stop"}\n\n', ].join('') - 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 }, - ), + globalThis.fetch = mock( + withNativeAdmission((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 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', ) - } - if (!url.includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve(new Response(successSse, { status: 200 })) - }) as unknown as typeof fetch + return Promise.resolve(new Response(successSse, { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin(createMockClient()) const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: checkedAt + 100_000, }), @@ -12601,8 +13748,8 @@ describe('auth.loader', () => { await (await result.fetch(MESSAGES_URL, request('claude-opus-5'))).text() expect(authorizations).toEqual([ - 'Bearer fable-rich-access', - 'Bearer main-access', + 'Bearer sk-ant-oat01-fable-rich-access', + 'Bearer sk-ant-oat01-main-access', ]) }) @@ -12658,13 +13805,13 @@ describe('auth.loader', () => { failClosedOnUnknownQuota: true, mainQuota: quota(88), mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('main-access'), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, accounts: [ { id: 'fallback-a', type: 'oauth', - access: 'fallback-a-access', + access: 'sk-ant-oat01-fallback-a-access', refresh: 'fallback-a-refresh', expires: checkedAt + 5 * 60 * 60_000, quota: quota(0), @@ -12672,7 +13819,7 @@ describe('auth.loader', () => { { id: 'fallback-b', type: 'oauth', - access: 'fallback-b-access', + access: 'sk-ant-oat01-fallback-b-access', refresh: 'fallback-b-refresh', expires: checkedAt + 5 * 60 * 60_000, quota: quota(0), @@ -12681,15 +13828,17 @@ describe('auth.loader', () => { }), ) let messageRequests = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/v1/messages')) messageRequests += 1 - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/v1/messages')) messageRequests += 1 + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() let currentAuth = { type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: checkedAt - 1, } @@ -12720,7 +13869,7 @@ describe('auth.loader', () => { currentAuth = { type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'relogged-main-refresh', expires: checkedAt + 5 * 60 * 60_000, } @@ -12763,81 +13912,85 @@ describe('auth.loader', () => { remainingPercent, checkedAt, }, - ], - }) - await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'sticky-balanced' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: quota(0), - mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('main-access'), - }, - accounts: [ - { - id: 'oauth-fallback', - type: 'oauth', - access: 'fallback-access', - refresh: 'fallback-refresh', - expires: checkedAt + 5 * 60 * 60_000, - quota: quota(100), - }, - { - id: 'api-fallback', - type: 'api', - baseURL: 'https://provider.example/anthropic', - authHeader: 'authorization-bearer', - apiKey: 'api-key', + ], + }) + await useTempAccountFile( + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: quota(0), + mainQuotaCheckedAt: checkedAt, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, - ], - }), + accounts: [ + { + id: 'oauth-fallback', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: checkedAt + 5 * 60 * 60_000, + quota: quota(100), + }, + { + id: 'api-fallback', + type: 'api', + baseURL: 'https://provider.example/anthropic', + authHeader: 'authorization-bearer', + apiKey: 'api-key', + }, + ], + }), + ), ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 100 }, + seven_day: { utilization: 100 }, + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 100, + scope: { model: { display_name: 'Fable' } }, + }, + ], + }), + { status: 200 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + authorizations.push(authorization) return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 100 }, - seven_day: { utilization: 100 }, - limits: [ - { - kind: 'weekly_scoped', - group: 'weekly', - percent: 100, - scope: { model: { display_name: 'Fable' } }, - }, - ], - }), - { status: 200 }, - ), + authorization === 'Bearer sk-ant-oat01-fallback-access' + ? new Response('exhausted', { status: 429 }) + : new Response('ok', { status: 200 }), ) - } - if (!url.includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - authorizations.push(authorization) - return Promise.resolve( - authorization === 'Bearer fallback-access' - ? new Response('exhausted', { status: 429 }) - : new Response('ok', { status: 200 }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: checkedAt + 100_000, }), @@ -12855,7 +14008,10 @@ describe('auth.loader', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('ok') - expect(authorizations).toEqual(['Bearer fallback-access', 'Bearer api-key']) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-fallback-access', + 'Bearer api-key', + ]) }) test('sticky-balanced seeds an already-warm CacheKeep session from its current account', async () => { @@ -12886,47 +14042,53 @@ describe('auth.loader', () => { ], }) await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'main-first' }, - claudeCache: { enabled: true, mode: 'hybrid' }, - cacheKeep: { enabled: true, always: true }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: quota(13), - mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('main-access'), - }, - accounts: [ - { - id: 'ufuk2', - type: 'oauth', - access: 'abundant-access', - refresh: 'abundant-refresh', - expires: checkedAt + 5 * 60 * 60_000, - quota: quota(98), + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'main-first' }, + claudeCache: { enabled: true, mode: 'hybrid' }, + cacheKeep: { enabled: true, always: true }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: quota(13), + mainQuotaCheckedAt: checkedAt, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, - ], - }), + accounts: [ + { + id: 'ufuk2', + type: 'oauth', + access: 'sk-ant-oat01-abundant-access', + refresh: 'abundant-refresh', + expires: checkedAt + 5 * 60 * 60_000, + quota: quota(98), + }, + ], + }), + ), ) const authorizations: string[] = [] - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (!url.includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - return Promise.resolve(new Response('ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve(new Response('ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: checkedAt + 100_000, }), @@ -12944,13 +14106,16 @@ describe('auth.loader', () => { } await (await result.fetch(MESSAGES_URL, request)).text() - const storage = await loadAccounts(process.env.OPENCODE_ANTHROPIC_AUTH_FILE) - if (!storage) throw new Error('missing test storage') - storage.routing = { mode: 'sticky-balanced' } - await saveAccounts(storage, process.env.OPENCODE_ANTHROPIC_AUTH_FILE) + await updatePoolSettings((settings) => ({ + ...settings, + routing: { mode: 'sticky-balanced' }, + })) await (await result.fetch(MESSAGES_URL, request)).text() - expect(authorizations).toEqual(['Bearer main-access', 'Bearer main-access']) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-main-access', + ]) }) test('sticky-balanced retains the assigned account across transient errors and a short 5h reset', async () => { @@ -12986,87 +14151,94 @@ describe('auth.loader', () => { ], }) await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'sticky-balanced' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: quota(0), - mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('main-access'), - }, - accounts: [ - { - id: 'yiyi', - type: 'oauth', - access: 'scarce-access', - refresh: 'scarce-refresh', - expires: checkedAt + 5 * 60 * 60_000, - quota: quota(13), - }, - { - id: 'ufuk2', - type: 'oauth', - access: 'abundant-access', - refresh: 'abundant-refresh', - expires: checkedAt + 5 * 60 * 60_000, - quota: quota(98), + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: quota(0), + mainQuotaCheckedAt: checkedAt, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, - ], - }), + accounts: [ + { + id: 'yiyi', + type: 'oauth', + access: 'sk-ant-oat01-scarce-access', + refresh: 'scarce-refresh', + expires: checkedAt + 5 * 60 * 60_000, + quota: quota(13), + }, + { + id: 'ufuk2', + type: 'oauth', + access: 'sk-ant-oat01-abundant-access', + refresh: 'abundant-refresh', + expires: checkedAt + 5 * 60 * 60_000, + quota: quota(98), + }, + ], + }), + ), ) const authorizations: string[] = [] let modelRequest = 0 - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - const authorization = new Headers(init?.headers).get('authorization') - const abundant = authorization === 'Bearer abundant-access' - const main = authorization === 'Bearer main-access' - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { - utilization: abundant ? 100 : 0, - resets_at: useLongReset ? longResetAt : shortResetAt, - }, - seven_day: { utilization: 0 }, - limits: [ - { - kind: 'weekly_scoped', - group: 'weekly', - percent: main ? 100 : abundant ? 2 : 87, - resets_at: new Date( - checkedAt + 4 * 24 * 60 * 60_000, - ).toISOString(), - scope: { model: { display_name: 'Fable' } }, + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + const authorization = new Headers(init?.headers).get('authorization') + const abundant = + authorization === 'Bearer sk-ant-oat01-abundant-access' + const main = authorization === 'Bearer sk-ant-oat01-main-access' + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { + utilization: abundant ? 100 : 0, + resets_at: useLongReset ? longResetAt : shortResetAt, }, - ], - }), - { status: 200 }, - ), - ) - } - if (!url.includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - authorizations.push(new Headers(init?.headers).get('authorization') ?? '') - modelRequest += 1 - if (modelRequest === 1) { - return Promise.resolve(new Response('temporary', { status: 500 })) - } - if (modelRequest === 2) { - return Promise.resolve( - new Response( - 'event: error\ndata: {"type":"error","error":{"type":"rate_limit_error","message":"five-hour"}}\n\n', - { status: 200 }, - ), + seven_day: { utilization: 0 }, + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: main ? 100 : abundant ? 2 : 87, + resets_at: new Date( + checkedAt + 4 * 24 * 60 * 60_000, + ).toISOString(), + scope: { model: { display_name: 'Fable' } }, + }, + ], + }), + { status: 200 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', ) - } - return Promise.resolve(new Response('ok', { status: 200 })) - }) as unknown as typeof fetch + modelRequest += 1 + if (modelRequest === 1) { + return Promise.resolve(new Response('temporary', { status: 500 })) + } + if (modelRequest === 2) { + return Promise.resolve( + new Response( + 'event: error\ndata: {"type":"error","error":{"type":"rate_limit_error","message":"five-hour"}}\n\n', + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('ok', { status: 200 })) + }), + ) as unknown as typeof fetch Date.now = mock(() => now) as unknown as typeof Date.now const plugin = await getPlugin() @@ -13074,7 +14246,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: checkedAt + 10 * 60 * 60_000, }), @@ -13104,9 +14276,9 @@ describe('auth.loader', () => { const migrated = await result.fetch(MESSAGES_URL, request) expect(migrated.status).toBe(200) expect(authorizations).toEqual([ - 'Bearer abundant-access', - 'Bearer abundant-access', - 'Bearer scarce-access', + 'Bearer sk-ant-oat01-abundant-access', + 'Bearer sk-ant-oat01-abundant-access', + 'Bearer sk-ant-oat01-scarce-access', ]) }) @@ -13155,13 +14327,15 @@ describe('auth.loader', () => { ].join('') let sentBody: Record | undefined globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - if (!extractUrl(input).includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - sentBody = JSON.parse(String(init?.body)) - return Promise.resolve(new Response(fallbackSse, { status: 200 })) - }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + if (!extractUrl(input).includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + sentBody = JSON.parse(String(init?.body)) + return Promise.resolve(new Response(fallbackSse, { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -13169,7 +14343,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -13287,21 +14461,23 @@ describe('auth.loader', () => { ].join('') globalThis.fetch = mock( - (input: string | URL | Request, init?: RequestInit) => { - if (!extractUrl(input).includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - requestBodies.push(JSON.parse(String(init?.body))) - requestBetas.push( - new Headers(init?.headers).get('anthropic-beta') ?? '', - ) - modelRequest++ - return Promise.resolve( - new Response(modelRequest === 1 ? fallbackSse : restoredSse, { - status: 200, - }), - ) - }, + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + if (!extractUrl(input).includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + requestBodies.push(JSON.parse(String(init?.body))) + requestBetas.push( + new Headers(init?.headers).get('anthropic-beta') ?? '', + ) + modelRequest++ + return Promise.resolve( + new Response(modelRequest === 1 ? fallbackSse : restoredSse, { + status: 200, + }), + ) + }, + ), ) as unknown as typeof fetch const latestUserMessageId = 'msg_000000000100AAAAAAAAAAAAAA' @@ -13335,7 +14511,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -13676,51 +14852,53 @@ describe('auth.loader', () => { let releaseFinalWarm: (() => void) | undefined const successfulSse = [ 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_ok"}}\n\n', - 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n', - 'event: message_stop\ndata: {"type":"message_stop"}\n\n', - ].join('') - const refusalSse = [ - 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_filtered"}}\n\n', - 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":0}}\n\n', - 'event: message_stop\ndata: {"type":"message_stop"}\n\n', - ].join('') - - 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 }, - limits: [], - }), - { status: 200 }, - ), - ) - } - const body = JSON.parse(String(init?.body)) as Record - if (body.max_tokens === 0) { - warmBodies.push(body) - const warmResponse = () => - new Response( - JSON.stringify({ usage: { cache_read_input_tokens: 100 } }), - { status: 200 }, + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ].join('') + const refusalSse = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_filtered"}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":0}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ].join('') + + globalThis.fetch = mock( + withNativeAdmission((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 }, + limits: [], + }), + { status: 200 }, + ), ) - if (warmBodies.length === 10) { - return new Promise((resolve) => { - releaseFinalWarm = () => resolve(warmResponse()) - }) } - return Promise.resolve(warmResponse()) - } - normalModels.push(String(body.model)) - if (body.model === 'claude-fable-5' && firstFable) { - firstFable = false - return Promise.resolve(new Response(refusalSse, { status: 200 })) - } - return Promise.resolve(new Response(successfulSse, { status: 200 })) - }) as unknown as typeof fetch + const body = JSON.parse(String(init?.body)) as Record + if (body.max_tokens === 0) { + warmBodies.push(body) + const warmResponse = () => + new Response( + JSON.stringify({ usage: { cache_read_input_tokens: 100 } }), + { status: 200 }, + ) + if (warmBodies.length === 10) { + return new Promise((resolve) => { + releaseFinalWarm = () => resolve(warmResponse()) + }) + } + return Promise.resolve(warmResponse()) + } + normalModels.push(String(body.model)) + if (body.model === 'claude-fable-5' && firstFable) { + firstFable = false + return Promise.resolve(new Response(refusalSse, { status: 200 })) + } + return Promise.resolve(new Response(successfulSse, { status: 200 })) + }), + ) as unknown as typeof fetch const latestUserMessageId = 'msg_000000000100AAAAAAAAAAAAAA' const latestAssistantMessageId = 'msg_000000000200BBBBBBBBBBBBBB' @@ -13768,7 +14946,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -14173,40 +15351,42 @@ describe('auth.loader', () => { 'event: message_stop\ndata: {"type":"message_stop"}\n\n', ].join('') - 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 }, - ), - ) - } - if (!url.includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - const body = JSON.parse(String(init?.body)) as Record - if (body.max_tokens === 0) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - models.push(String(body.model)) - if (body.model === 'claude-fable-5' && firstFable) { - firstFable = false - return Promise.resolve(new Response(refusalSse, { status: 200 })) - } - return Promise.resolve(new Response(successSse, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((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 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + const body = JSON.parse(String(init?.body)) as Record + if (body.max_tokens === 0) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + models.push(String(body.model)) + if (body.model === 'claude-fable-5' && firstFable) { + firstFable = false + return Promise.resolve(new Response(refusalSse, { status: 200 })) + } + return Promise.resolve(new Response(successSse, { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -14280,36 +15460,38 @@ describe('auth.loader', () => { frame('message_stop', { type: 'message_stop' }), ].join('') - 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 }, - ), - ) - } - if (!url.includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - const body = JSON.parse(String(init?.body)) as Record - if (body.max_tokens === 0) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - models.push(String(body.model)) - return Promise.resolve(new Response(fallbackSse, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((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 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + const body = JSON.parse(String(init?.body)) as Record + if (body.max_tokens === 0) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + models.push(String(body.model)) + return Promise.resolve(new Response(fallbackSse, { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -14359,41 +15541,43 @@ describe('auth.loader', () => { 'event: message_stop\ndata: {"type":"message_stop"}\n\n', ].join('') - 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 }, - ), - ) - } - if (!url.includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - const body = JSON.parse(String(init?.body)) as Record - if (body.max_tokens === 0) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - models.push(String(body.model)) - bodies.push(body) - if (body.model === 'claude-fable-5' && firstFable) { - firstFable = false - return Promise.resolve(new Response(refusalSse, { status: 200 })) - } - return Promise.resolve(new Response(successSse, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((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 }, + ), + ) + } + if (!url.includes('/v1/messages')) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + const body = JSON.parse(String(init?.body)) as Record + if (body.max_tokens === 0) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + models.push(String(body.model)) + bodies.push(body) + if (body.model === 'claude-fable-5' && firstFable) { + firstFable = false + return Promise.resolve(new Response(refusalSse, { status: 200 })) + } + return Promise.resolve(new Response(successSse, { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -14435,20 +15619,22 @@ describe('auth.loader', () => { const refusal = 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"refusal"}}\n\n' - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response(refusal, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response(refusal, { status: 200 })) + }), + ) as unknown as typeof fetch const mockClient = createMockClient() const plugin = await getPlugin(mockClient) @@ -14456,7 +15642,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -14524,13 +15710,13 @@ describe('auth.loader', () => { ], }, mainQuotaCheckedAt: now, - mainQuotaToken: tokenFingerprint('main-access'), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), } as AccountStorage['quota'], accounts: [ { id: 'fable-fallback', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: now + 5 * 60 * 60 * 1000, quota: { @@ -14600,7 +15786,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -14632,13 +15818,17 @@ describe('auth.loader', () => { expect(calls).toEqual([ { model: 'claude-fable-5', - auth: 'Bearer fallback-access', + auth: 'Bearer sk-ant-oat01-fallback-access', + warm: false, + }, + { + model: 'claude-opus-4-8', + auth: 'Bearer sk-ant-oat01-main-access', warm: false, }, - { model: 'claude-opus-4-8', auth: 'Bearer main-access', warm: false }, { model: 'claude-fable-5', - auth: 'Bearer fallback-access', + auth: 'Bearer sk-ant-oat01-fallback-access', warm: true, }, ]) @@ -14651,7 +15841,7 @@ describe('auth.loader', () => { { id: 'fallback-1', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: Date.now() + 5 * 60 * 60 * 1000, quota: { @@ -14672,20 +15862,22 @@ describe('auth.loader', () => { }), ) - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0.42 }, - seven_day: { utilization: 0.1 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response('ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.42 }, + seven_day: { utilization: 0.1 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() // Running the loader starts the background refresh (immediate first pass). @@ -14693,7 +15885,7 @@ describe('auth.loader', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -14805,7 +15997,7 @@ describe('auth.loader', () => { } async function loadFetch( - getAccessToken: () => string = () => 'main-access', + getAccessToken: () => string = () => 'sk-ant-oat01-main-access', ) { const plugin = await getPlugin() return plugin.auth.loader( @@ -14820,19 +16012,16 @@ describe('auth.loader', () => { ) } + // Harvested quota is published to the native pool; the legacy state file + // these tests once read is retired by migration. async function waitForState(predicate: (state: any) => boolean) { let lastState: unknown for (let attempt = 0; attempt < 200; attempt++) { - try { - const state = JSON.parse( - await readFile( - getAccountStatePath(process.env.OPENCODE_ANTHROPIC_AUTH_FILE), - 'utf8', - ), - ) - lastState = state - if (predicate(state)) return state - } catch {} + // Typed loosely, as the parsed legacy file was: each test asserts the + // fields it expects to be present. + const state: any = await readNativeRuntimeState() + lastState = state + if (predicate(state)) return state await Bun.sleep(10) } throw new Error( @@ -14842,8 +16031,10 @@ describe('auth.loader', () => { test('main 200 response pushes unified headers before returning the response', async () => { await useTempAccountFile(harvestStorage()) - globalThis.fetch = mock(() => - Promise.resolve(new Response('main-ok', { headers: quotaHeaders })), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve(new Response('main-ok', { headers: quotaHeaders })), + ), ) as unknown as typeof fetch const result = await loadFetch() @@ -14854,7 +16045,9 @@ describe('auth.loader', () => { (value) => value.main?.quota?.source === 'headers', ) expect(state.main.quota.five_hour.usedPercent).toBe(78) - expect(state.main.quotaToken).toBeUndefined() + // Header quota is bound to the main account, never to the access token + // that happened to carry it (the legacy store's quotaToken). + expect(state.main.quota.accountIdentity).toBe(poolMainIdentity()) }) test('fresh header exhaustion licenses API-key fallback on the next request', async () => { @@ -14874,27 +16067,32 @@ describe('auth.loader', () => { }), ) const authorizations: Array = [] - globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { - const authorization = new Headers(init?.headers).get('authorization') - authorizations.push(authorization) - return Promise.resolve( - new Response('ok', { - headers: - authorization === 'Bearer main-access' - ? { - ...quotaHeaders, - 'anthropic-ratelimit-unified-5h-utilization': '1', - } - : undefined, - }), - ) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: unknown, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get('authorization') + authorizations.push(authorization) + return Promise.resolve( + new Response('ok', { + headers: + authorization === 'Bearer sk-ant-oat01-main-access' + ? { + ...quotaHeaders, + 'anthropic-ratelimit-unified-5h-utilization': '1', + } + : undefined, + }), + ) + }), + ) as unknown as typeof fetch const result = await loadFetch() await result.fetch(MESSAGES_URL, EMPTY_POST) await result.fetch(MESSAGES_URL, EMPTY_POST) - expect(authorizations).toEqual(['Bearer main-access', 'Bearer kie-key']) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access', + 'Bearer kie-key', + ]) }) test('stale header exhaustion does not license API-key fallback', async () => { @@ -14923,20 +16121,22 @@ describe('auth.loader', () => { checkedAt, }, mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('main-access'), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, }), ) const authorizations: Array = [] - globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { - authorizations.push(new Headers(init?.headers).get('authorization')) - return Promise.resolve(new Response('ok')) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: unknown, init?: RequestInit) => { + authorizations.push(new Headers(init?.headers).get('authorization')) + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch const result = await loadFetch() await result.fetch(MESSAGES_URL, EMPTY_POST) - expect(authorizations).toEqual(['Bearer main-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-main-access']) }) test('websocket relay response_start pushes unified headers for the served account', async () => { await useTempAccountFile( @@ -14950,6 +16150,12 @@ describe('auth.loader', () => { }, }), ) + // The relay request travels over the WebSocket; HTTP is used only for + // the pool's account check of the main login before it serves. + const providerFetch = globalThis.fetch + globalThis.fetch = mock( + withNativeAdmission(providerFetch), + ) as unknown as typeof fetch const restoreWebSocket = installRelayWebSocket(quotaHeaders) const result = await loadFetch() @@ -14970,7 +16176,9 @@ describe('auth.loader', () => { (value) => value.main?.quota?.source === 'headers', ) expect(state.main.quota.five_hour.usedPercent).toBe(78) - expect(state.main.quotaToken).toBeUndefined() + // Header quota is bound to the main account, never to the access token + // that happened to carry it (the legacy store's quotaToken). + expect(state.main.quota.accountIdentity).toBe(poolMainIdentity()) }) test('relay fallback to direct harvests quota headers exactly once', async () => { @@ -14989,19 +16197,21 @@ describe('auth.loader', () => { let relay503Calls = 0 let directCalls = 0 __setLogTestSink((record) => records.push(record)) - globalThis.fetch = mock((input: string | URL | Request) => { - const url = extractUrl(input) - if (url === 'https://relay.example.test/') { - relay503Calls += 1 + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + const url = extractUrl(input) + if (url === 'https://relay.example.test/') { + relay503Calls += 1 + return Promise.resolve( + new Response('relay unavailable', { status: 503 }), + ) + } + directCalls += 1 return Promise.resolve( - new Response('relay unavailable', { status: 503 }), + new Response('direct', { headers: quotaHeaders }), ) - } - directCalls += 1 - return Promise.resolve( - new Response('direct', { headers: quotaHeaders }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const result = await loadFetch() setLogLevel('debug') @@ -15039,6 +16249,12 @@ describe('auth.loader', () => { }, }), ) + // The relay request travels over the WebSocket; HTTP is used only for + // the pool's account check of the main login before it serves. + const providerFetch = globalThis.fetch + globalThis.fetch = mock( + withNativeAdmission(providerFetch), + ) as unknown as typeof fetch const restoreWebSocket = installRelayWebSocket({ 'content-type': 'text/event-stream', }) @@ -15058,7 +16274,9 @@ describe('auth.loader', () => { } await Bun.sleep(30) - expect((await loadAccounts())?.quota?.mainQuota?.source).toBeUndefined() + expect( + (await readAccountStorage())?.quota?.mainQuota?.source, + ).toBeUndefined() }) test('main header push persists after access-token rotation for the same account', async () => { @@ -15090,14 +16308,16 @@ describe('auth.loader', () => { }) const requestAuthorizations: Array = [] globalThis.fetch = mock( - (_input: string | URL | Request, init?: RequestInit) => - new Promise((resolve) => { - requestAuthorizations.push( - new Headers(init?.headers).get('authorization'), - ) - resolveResponse = resolve - markRequestStarted?.() - }), + withNativeAdmission( + (_input: string | URL | Request, init?: RequestInit) => + new Promise((resolve) => { + requestAuthorizations.push( + new Headers(init?.headers).get('authorization'), + ) + resolveResponse = resolve + markRequestStarted?.() + }), + ), ) as unknown as typeof fetch const result = await loadFetch(() => liveAccessToken) const records: LogTestRecord[] = [] @@ -15116,7 +16336,7 @@ describe('auth.loader', () => { 'utf8', ), ) - const reloaded = await loadAccounts() + const reloaded = await readAccountStorage() expect( records.some( @@ -15145,7 +16365,7 @@ describe('auth.loader', () => { accounts: [ { ...fallbackTemplate, - access: 'old-fallback-access', + access: 'sk-ant-oat01-old-fallback-access', quota: existingQuota, }, ], @@ -15158,26 +16378,28 @@ describe('auth.loader', () => { const fallbackStarted = new Promise((resolve) => { fallbackRequestStarted = resolve }) - globalThis.fetch = mock((input: string | URL | Request) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response('usage-unavailable', { status: 500 }), - ) - } - messageCalls++ - if (messageCalls === 1) - return Promise.resolve(new Response('limited', { status: 429 })) - return new Promise((resolve) => { - resolveFallbackResponse = resolve - fallbackRequestStarted?.() - }) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response('usage-unavailable', { status: 500 }), + ) + } + messageCalls++ + if (messageCalls === 1) + return Promise.resolve(new Response('limited', { status: 429 })) + return new Promise((resolve) => { + resolveFallbackResponse = resolve + fallbackRequestStarted?.() + }) + }), + ) as unknown as typeof fetch const result = await loadFetch() const responsePromise = result.fetch(MESSAGES_URL, EMPTY_POST) await fallbackStarted - const rotated = await loadAccounts() + const rotated = await readAccountStorage() if (!rotated) throw new Error('expected rotated fallback storage') const fallback = rotated.accounts.find( (account): account is OAuthAccount => account.id === 'fallback-1', @@ -15209,7 +16431,7 @@ describe('auth.loader', () => { } const servedAccount = { ...(createFallbackStorage().accounts[0] as OAuthAccount), - access: 'served-account-access', + access: 'sk-ant-oat01-served-account-access', quota: accountXQuota, } await useTempAccountFile( @@ -15274,8 +16496,10 @@ describe('auth.loader', () => { }, }), ) - globalThis.fetch = mock(() => - Promise.resolve(new Response('main-ok', { headers: quotaHeaders })), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve(new Response('main-ok', { headers: quotaHeaders })), + ), ) as unknown as typeof fetch const result = await loadFetch() @@ -15285,7 +16509,7 @@ describe('auth.loader', () => { const state = await waitForState( (value) => value.main?.quota?.source === 'headers', ) - const reloaded = await loadAccounts() + const reloaded = await readAccountStorage() expect(state.main.lastQuotaApiError).toEqual(pollBackoff) expect(state.main.quota.five_hour.usedPercent).toBe(78) @@ -15299,12 +16523,14 @@ describe('auth.loader', () => { let usageCalls = 0 const records: LogTestRecord[] = [] __setLogTestSink((record) => records.push(record)) - globalThis.fetch = mock((input: string | URL | Request) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) usageCalls++ - if (url.includes('/v1/messages')) messageCalls++ - return Promise.resolve(new Response('ok', { headers: quotaHeaders })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) usageCalls++ + if (url.includes('/v1/messages')) messageCalls++ + return Promise.resolve(new Response('ok', { headers: quotaHeaders })) + }), + ) as unknown as typeof fetch const result = await loadFetch() setLogLevel('debug') @@ -15330,22 +16556,24 @@ describe('auth.loader', () => { test('fallback-served response updates that fallback and not main', async () => { await useTempAccountFile(harvestStorage(createFallbackStorage().accounts)) let messages = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/usage')) { + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + ) + } + messages++ return Promise.resolve( - Response.json({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 10 }, - }), + messages === 1 + ? new Response('limited', { status: 429 }) + : new Response('fallback-ok', { headers: quotaHeaders }), ) - } - messages++ - return Promise.resolve( - messages === 1 - ? new Response('limited', { status: 429 }) - : new Response('fallback-ok', { headers: quotaHeaders }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const result = await loadFetch() expect(await (await result.fetch(MESSAGES_URL, EMPTY_POST)).text()).toBe( @@ -15372,22 +16600,24 @@ describe('auth.loader', () => { harvestStorage([{ ...fallback, lastQuotaRefreshError: pollBackoff }]), ) let messages = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/usage')) { + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + ) + } + messages++ return Promise.resolve( - Response.json({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 10 }, - }), + messages === 1 + ? new Response('limited', { status: 429 }) + : new Response('fallback-ok', { headers: quotaHeaders }), ) - } - messages++ - return Promise.resolve( - messages === 1 - ? new Response('limited', { status: 429 }) - : new Response('fallback-ok', { headers: quotaHeaders }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const result = await loadFetch() expect(await (await result.fetch(MESSAGES_URL, EMPTY_POST)).text()).toBe( @@ -15396,7 +16626,7 @@ describe('auth.loader', () => { const state = await waitForState( (value) => value.accounts?.['fallback-1']?.quota?.source === 'headers', ) - const reloaded = await loadAccounts() + const reloaded = await readAccountStorage() const reloadedFallback = reloaded?.accounts.find( (account): account is OAuthAccount => account.id === 'fallback-1' && account.type === 'oauth', @@ -15413,22 +16643,24 @@ describe('auth.loader', () => { test('sidebar state reflects header-pushed freshness and served fallback attribution', async () => { await useTempAccountFile(harvestStorage(createFallbackStorage().accounts)) let messages = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/usage')) { + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + ) + } + messages++ return Promise.resolve( - Response.json({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 10 }, - }), + messages === 1 + ? new Response('limited', { status: 429 }) + : new Response('fallback-ok', { headers: quotaHeaders }), ) - } - messages++ - return Promise.resolve( - messages === 1 - ? new Response('limited', { status: 429 }) - : new Response('fallback-ok', { headers: quotaHeaders }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const result = await loadFetch() await result.fetch(MESSAGES_URL, EMPTY_POST) @@ -15445,15 +16677,17 @@ describe('auth.loader', () => { test('non-quota response does not push or persist quota', async () => { await useTempAccountFile(harvestStorage()) - globalThis.fetch = mock(() => - Promise.resolve(new Response('ok')), + globalThis.fetch = mock( + withNativeAdmission(() => Promise.resolve(new Response('ok'))), ) as unknown as typeof fetch const result = await loadFetch() await result.fetch(MESSAGES_URL, EMPTY_POST) await Bun.sleep(30) - expect((await loadAccounts())?.quota?.mainQuota?.source).toBeUndefined() + expect( + (await readAccountStorage())?.quota?.mainQuota?.source, + ).toBeUndefined() }) test('non-finite utilization headers leave stored quota untouched', async () => { @@ -15474,18 +16708,20 @@ describe('auth.loader', () => { enabled: false, mainQuota: existingQuota, mainQuotaCheckedAt: 1, - mainQuotaToken: tokenFingerprint('main-access'), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, }), ) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('ok', { - headers: { - 'anthropic-ratelimit-unified-5h-utilization': 'garbage', - 'anthropic-ratelimit-unified-7d-utilization': 'NaN', - }, - }), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve( + new Response('ok', { + headers: { + 'anthropic-ratelimit-unified-5h-utilization': 'garbage', + 'anthropic-ratelimit-unified-7d-utilization': 'NaN', + }, + }), + ), ), ) as unknown as typeof fetch const result = await loadFetch() @@ -15493,20 +16729,24 @@ describe('auth.loader', () => { await result.fetch(MESSAGES_URL, EMPTY_POST) await Bun.sleep(30) - expect((await loadAccounts())?.quota?.mainQuota).toEqual(existingQuota) + expect((await readAccountStorage())?.quota?.mainQuota).toEqual( + existingQuota, + ) }) test('malformed quota headers never reject or replace the original response', async () => { await useTempAccountFile(harvestStorage()) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('original', { - status: 202, - headers: { - 'anthropic-ratelimit-unified-5h-utilization': '0.5', - 'anthropic-ratelimit-unified-5h-reset': '1e308', - }, - }), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve( + new Response('original', { + status: 202, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.5', + 'anthropic-ratelimit-unified-5h-reset': '1e308', + }, + }), + ), ), ) as unknown as typeof fetch const result = await loadFetch() @@ -15520,10 +16760,12 @@ describe('auth.loader', () => { test('header push persists source headers and refreshes sidebar checkedAt without a usage poll', async () => { await useTempAccountFile(harvestStorage()) let usageCalls = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - if (extractUrl(input).includes('/api/oauth/usage')) usageCalls++ - return Promise.resolve(new Response('ok', { headers: quotaHeaders })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/usage')) usageCalls++ + return Promise.resolve(new Response('ok', { headers: quotaHeaders })) + }), + ) as unknown as typeof fetch const result = await loadFetch() await result.fetch(MESSAGES_URL, EMPTY_POST) @@ -15540,8 +16782,10 @@ describe('auth.loader', () => { await useTempAccountFile(harvestStorage()) const records: LogTestRecord[] = [] __setLogTestSink((record) => records.push(record)) - globalThis.fetch = mock(() => - Promise.resolve(new Response('ok', { headers: quotaHeaders })), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve(new Response('ok', { headers: quotaHeaders })), + ), ) as unknown as typeof fetch const result = await loadFetch() setLogLevel('debug') @@ -15563,14 +16807,16 @@ describe('auth.loader', () => { await useTempAccountFile(harvestStorage()) const records: LogTestRecord[] = [] __setLogTestSink((record) => records.push(record)) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('ok', { - headers: { - 'anthropic-ratelimit-unified-5h-utilization': '0.5', - 'anthropic-ratelimit-unified-5h-reset': '1e308', - }, - }), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve( + new Response('ok', { + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.5', + 'anthropic-ratelimit-unified-5h-reset': '1e308', + }, + }), + ), ), ) as unknown as typeof fetch const result = await loadFetch() @@ -15623,43 +16869,44 @@ describe('claude-start integration', () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION await drainSidebarWrites() restoreProcessTestFiles() - if (tempConfigDir) { - await rm(tempConfigDir, { recursive: true, force: true }) - tempConfigDir = undefined - } + // Clear tempConfigDir without deleting the account files used by plugins. + // TestLifetime disposes those plugins and joins their work before removal. + tempConfigDir = undefined }) test('claude-start request shapes only its correlated OAuth turn and emits diagnostics', async () => { const sent: Array<{ body: Record; headers: Headers }> = [] const records: LogTestRecord[] = [] - globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { - sent.push({ - body: JSON.parse(String(init?.body)), - headers: new Headers(init?.headers), - }) - return Promise.resolve( - new Response( - `event: message_start\ndata: ${JSON.stringify({ - type: 'message_start', - message: { - id: 'provider-start', - model: 'claude-opus-4-8', - usage: { - input_tokens: 1, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 1, - cache_creation: { - ephemeral_5m_input_tokens: 0, - ephemeral_1h_input_tokens: 1, + globalThis.fetch = mock( + withNativeAdmission((_input: unknown, init?: RequestInit) => { + sent.push({ + body: JSON.parse(String(init?.body)), + headers: new Headers(init?.headers), + }) + return Promise.resolve( + new Response( + `event: message_start\ndata: ${JSON.stringify({ + type: 'message_start', + message: { + id: 'provider-start', + model: 'claude-opus-4-8', + usage: { + input_tokens: 1, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 1, + }, }, + diagnostics: { cache_miss_reason: null }, }, - diagnostics: { cache_miss_reason: null }, - }, - })}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n`, - { status: 200 }, - ), - ) - }) as unknown as typeof fetch + })}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n`, + { status: 200 }, + ), + ) + }), + ) as unknown as typeof fetch __setLogTestSink((record) => records.push(record)) const client = createMockClient() @@ -15685,7 +16932,7 @@ describe('claude-start integration', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -15727,7 +16974,7 @@ describe('claude-start integration', () => { await expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-cachekeep', + command: 'claude', arguments: '', sessionID: 'ses-start', }), @@ -15743,13 +16990,15 @@ describe('claude-start integration', () => { test('claude-start concurrency does not shape an interleaved real turn', async () => { const sent: Array<{ body: Record; headers: Headers }> = [] - globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { - sent.push({ - body: JSON.parse(String(init?.body)), - headers: new Headers(init?.headers), - }) - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: unknown, init?: RequestInit) => { + sent.push({ + body: JSON.parse(String(init?.body)), + headers: new Headers(init?.headers), + }) + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const startHeaders: Record = {} const realHeaders: Record = {} @@ -15774,7 +17023,7 @@ describe('claude-start integration', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -15818,9 +17067,11 @@ describe('claude-start integration', () => { dump: { enabled: true }, }), ) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), + globalThis.fetch = mock( + withNativeAdmission(() => + Promise.resolve( + new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), + ), ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -15840,7 +17091,7 @@ describe('claude-start integration', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -15889,32 +17140,35 @@ describe('claude-start integration', () => { ) const sent: Array<{ body: Record; headers: Headers }> = [] - globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { - const headers = new Headers(init?.headers) - sent.push({ body: JSON.parse(String(init?.body)), headers }) - return Promise.resolve( - new Response('{}', { - status: 200, - headers: - headers.get('authorization') === 'Bearer main-access' - ? { - 'anthropic-ratelimit-unified-representative-claim': - 'five_hour', - 'anthropic-ratelimit-unified-5h-utilization': '1', - 'anthropic-ratelimit-unified-5h-reset': '1784246400', - 'anthropic-ratelimit-unified-7d-utilization': '0.4', - 'anthropic-ratelimit-unified-7d-reset': '1784628000', - } - : undefined, - }), - ) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: unknown, init?: RequestInit) => { + const headers = new Headers(init?.headers) + sent.push({ body: JSON.parse(String(init?.body)), headers }) + return Promise.resolve( + new Response('{}', { + status: 200, + headers: + headers.get('authorization') === + 'Bearer sk-ant-oat01-main-access' + ? { + 'anthropic-ratelimit-unified-representative-claim': + 'five_hour', + 'anthropic-ratelimit-unified-5h-utilization': '1', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + } + : undefined, + }), + ) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -15979,86 +17233,90 @@ describe('claude-start integration', () => { test('claude-start shapes the OAuth fallback after an API-key failure', async () => { await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - accounts: [ - { - id: 'api-fails', - type: 'api', - apiKey: 'api-fails-key', - baseURL: 'https://api.example.test', - authHeader: 'x-api-key', - }, - { - id: 'fallback-1', - type: 'oauth', - access: 'fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 25, - remainingPercent: 75, - checkedAt: Date.now(), - }, - seven_day: { - usedPercent: 30, - remainingPercent: 70, - checkedAt: Date.now(), - }, + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + accounts: [ + { + id: 'api-fails', + type: 'api', + apiKey: 'api-fails-key', + baseURL: 'https://api.example.test', + authHeader: 'x-api-key', }, - }, - ], - quota: { - enabled: false, - mainQuota: { - five_hour: { - usedPercent: 100, - remainingPercent: 0, - checkedAt: Date.now(), + { + id: 'fallback-1', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: Date.now(), + }, + }, }, - seven_day: { - usedPercent: 40, - remainingPercent: 60, - checkedAt: Date.now(), + ], + quota: { + enabled: false, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt: Date.now(), + }, }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), - }, - }), + }), + ), ) const sent: Array<{ body: Record; headers: Headers }> = [] - globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { - const headers = new Headers(init?.headers) - const body = JSON.parse(String(init?.body)) as Record - sent.push({ body, headers }) - if (headers.get('x-api-key') === 'api-fails-key') { - return Promise.resolve(new Response('{}', { status: 429 })) - } - return Promise.resolve( - new Response('{}', { - status: 200, - headers: - headers.get('authorization') === 'Bearer main-access' - ? { - 'anthropic-ratelimit-unified-representative-claim': - 'five_hour', - 'anthropic-ratelimit-unified-5h-utilization': '1', - 'anthropic-ratelimit-unified-5h-reset': '1784246400', - 'anthropic-ratelimit-unified-7d-utilization': '0.4', - 'anthropic-ratelimit-unified-7d-reset': '1784628000', - } - : undefined, - }), - ) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: unknown, init?: RequestInit) => { + const headers = new Headers(init?.headers) + const body = JSON.parse(String(init?.body)) as Record + sent.push({ body, headers }) + if (headers.get('x-api-key') === 'api-fails-key') { + return Promise.resolve(new Response('{}', { status: 429 })) + } + return Promise.resolve( + new Response('{}', { + status: 200, + headers: + headers.get('authorization') === 'Bearer sk-ant-oat01-main-access' + ? { + 'anthropic-ratelimit-unified-representative-claim': + 'five_hour', + 'anthropic-ratelimit-unified-5h-utilization': '1', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + } + : undefined, + }), + ) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -16097,7 +17355,8 @@ describe('claude-start integration', () => { ) const oauthSend = sent.find( (entry) => - entry.headers.get('authorization') === 'Bearer fallback-access', + entry.headers.get('authorization') === + 'Bearer sk-ant-oat01-fallback-access', ) expect(apiSend?.body).toMatchObject({ max_tokens: 99 }) expect(apiSend?.body.thinking).toEqual({ @@ -16110,10 +17369,12 @@ describe('claude-start integration', () => { test('claude-start clears the one-shot header before non-OAuth passthrough and session reuse', async () => { const seenHeaders: Headers[] = [] - globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { - seenHeaders.push(new Headers(init?.headers)) - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: unknown, init?: RequestInit) => { + seenHeaders.push(new Headers(init?.headers)) + return Promise.resolve(new Response('{}', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const headers: Record = {} await plugin['chat.message']( @@ -16139,7 +17400,7 @@ describe('claude-start integration', () => { () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }), @@ -16186,7 +17447,7 @@ describe('cache diagnostics', () => { const oauthLoader = () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100_000, }) @@ -16218,10 +17479,9 @@ describe('cache diagnostics', () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION await drainSidebarWrites() restoreProcessTestFiles() - if (tempConfigDir) { - await rm(tempConfigDir, { recursive: true, force: true }) - tempConfigDir = undefined - } + // Clear tempConfigDir without deleting the account files used by plugins. + // TestLifetime disposes those plugins and joins their work before removal. + tempConfigDir = undefined }) test('cache diagnostics binds the provider predecessor at request time', async () => { @@ -16248,22 +17508,24 @@ describe('cache diagnostics', () => { }) return new Response(body, { status: 200 }) } - globalThis.fetch = mock((_input: any, init: RequestInit) => { - sentBodies.push(JSON.parse(String(init.body))) - const responsePlan = [ - ['ses-diag-A', 'provider-A'], - ['ses-diag-B', 'provider-B'], - ['ses-diag-A', 'provider-A-after'], - ['ses-diag-B', 'provider-B-after'], - ] as const - const response = responsePlan[sentBodies.length - 1] - if (!response) throw new Error('unexpected request') - return Promise.resolve( - sentBodies.length <= 2 - ? sseResponse(message(response[1])) - : delayedResponse(response[1]), - ) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: any, init: RequestInit) => { + sentBodies.push(JSON.parse(String(init.body))) + const responsePlan = [ + ['ses-diag-A', 'provider-A'], + ['ses-diag-B', 'provider-B'], + ['ses-diag-A', 'provider-A-after'], + ['ses-diag-B', 'provider-B-after'], + ] as const + const response = responsePlan[sentBodies.length - 1] + if (!response) throw new Error('unexpected request') + return Promise.resolve( + sentBodies.length <= 2 + ? sseResponse(message(response[1])) + : delayedResponse(response[1]), + ) + }), + ) as unknown as typeof fetch __setLogTestSink((record) => records.push(record)) const plugin = await getPlugin( @@ -16338,12 +17600,16 @@ describe('cache diagnostics', () => { const sentHeaders: Headers[] = [] const records: LogTestRecord[] = [] let requestNumber = 0 - globalThis.fetch = mock((_input: any, init: RequestInit) => { - sentBodies.push(JSON.parse(String(init.body))) - sentHeaders.push(new Headers(init.headers)) - requestNumber++ - return Promise.resolve(sseResponse(message(`provider-${requestNumber}`))) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: any, init: RequestInit) => { + sentBodies.push(JSON.parse(String(init.body))) + sentHeaders.push(new Headers(init.headers)) + requestNumber++ + return Promise.resolve( + sseResponse(message(`provider-${requestNumber}`)), + ) + }), + ) as unknown as typeof fetch __setLogTestSink((record) => records.push(record)) const plugin = await getPlugin() @@ -16391,25 +17657,29 @@ describe('cache diagnostics', () => { const sentBodies: Record[] = [] const betaHeaders: string[] = [] const records: LogTestRecord[] = [] - globalThis.fetch = mock((_input: any, init: RequestInit) => { - sentBodies.push(JSON.parse(String(init.body))) - betaHeaders.push(new Headers(init.headers).get('anthropic-beta') ?? '') - return Promise.resolve( - sseResponse(message(`provider-${sentBodies.length}`)), - ) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: any, init: RequestInit) => { + sentBodies.push(JSON.parse(String(init.body))) + betaHeaders.push(new Headers(init.headers).get('anthropic-beta') ?? '') + return Promise.resolve( + sseResponse(message(`provider-${sentBodies.length}`)), + ) + }), + ) as unknown as typeof fetch __setLogTestSink((record) => records.push(record)) const mockClient = createMockClient() - const plugin = await getPlugin(mockClient) + const plugin = await getPlugin(mockClient, tempConfigDir) const result = await plugin.auth.loader(oauthLoader, { models: {} }) - await expect( - plugin['command.execute.before']({ - command: 'claude-logging', - arguments: 'debug', - sessionID: 'session-cache-diagnostics', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + expect( + ( + await applyMenuAction(plugin, 'session-cache-diagnostics', { + sectionId: 'Diagnostics', + actionId: 'logging-level', + values: { level: 'debug' }, + }) + ).ok, + ).toBe(true) for (const output_config of [ undefined, { format: { type: 'json_schema' } }, @@ -16460,13 +17730,16 @@ describe('cache diagnostics', () => { test('cache diagnostics observes non-streaming envelopes and carries their provider id forward', async () => { const sentBodies: Record[] = [] const records: LogTestRecord[] = [] - globalThis.fetch = mock((_input: any, init: RequestInit) => { - sentBodies.push(JSON.parse(String(init.body))) - const id = sentBodies.length === 1 ? 'provider-json-A' : 'provider-json-B' - return Promise.resolve( - new Response(JSON.stringify(message(id)), { status: 200 }), - ) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: any, init: RequestInit) => { + sentBodies.push(JSON.parse(String(init.body))) + const id = + sentBodies.length === 1 ? 'provider-json-A' : 'provider-json-B' + return Promise.resolve( + new Response(JSON.stringify(message(id)), { status: 200 }), + ) + }), + ) as unknown as typeof fetch __setLogTestSink((record) => records.push(record)) const plugin = await getPlugin() @@ -16527,24 +17800,28 @@ describe('cache diagnostics', () => { }), ) let normalRequests = 0 - globalThis.fetch = mock((_input: any, init: RequestInit) => { - const body = JSON.parse(String(init.body)) as Record - sentBodies.push(body) - if (body.max_tokens === 0) { - resolvePrewarmStarted?.() + globalThis.fetch = mock( + withNativeAdmission((_input: any, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as Record + sentBodies.push(body) + if (body.max_tokens === 0) { + resolvePrewarmStarted?.() + return Promise.resolve( + new Response(JSON.stringify(message('provider-warm-B')), { + status: 200, + }), + ) + } + normalRequests++ return Promise.resolve( - new Response(JSON.stringify(message('provider-warm-B')), { - status: 200, - }), + sseResponse( + message( + normalRequests === 1 ? 'provider-real-A' : 'provider-real-C', + ), + ), ) - } - normalRequests++ - return Promise.resolve( - sseResponse( - message(normalRequests === 1 ? 'provider-real-A' : 'provider-real-C'), - ), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch __setLogTestSink((record) => records.push(record)) const plugin = await getPlugin() @@ -16624,11 +17901,13 @@ describe('cache diagnostics', () => { cache_miss_reason: { type: 'previous_message_not_found' }, }), ] - globalThis.fetch = mock(() => { - const response = responses.shift() - if (!response) throw new Error('unexpected request') - return Promise.resolve(sseResponse(response)) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission(() => { + const response = responses.shift() + if (!response) throw new Error('unexpected request') + return Promise.resolve(sseResponse(response)) + }), + ) as unknown as typeof fetch __setLogTestSink((record) => records.push(record)) const plugin = await getPlugin() @@ -16680,7 +17959,7 @@ describe('cache diagnostics', () => { seven_day: { usedPercent: 50, remainingPercent: 50 }, }, mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('main-access'), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), } as AccountStorage['quota'], accounts: [ { @@ -16772,11 +18051,13 @@ describe('cache diagnostics', () => { }), new Response('not an envelope', { status: 503 }), ] - globalThis.fetch = mock(() => { - const response = responses.shift() - if (!response) throw new Error('unexpected request') - return Promise.resolve(response) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission(() => { + const response = responses.shift() + if (!response) throw new Error('unexpected request') + return Promise.resolve(response) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader(oauthLoader, { models: {} }) @@ -16850,7 +18131,7 @@ describe('killswitch fetch gate', () => { const oauthLoader = () => Promise.resolve({ type: 'oauth' as const, - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }) @@ -16872,22 +18153,24 @@ describe('killswitch fetch gate', () => { }), ) - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - // five_hour remaining 8% (< routing 10, > killswitch 5), - // seven_day remaining 60% (above both). - JSON.stringify({ - five_hour: { utilization: 92 }, - seven_day: { utilization: 40 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response('message-ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + // Main has 8% five-hour quota: below the 10% routing minimum, + // but above the 5% hard-block threshold. Weekly quota is healthy. + JSON.stringify({ + five_hour: { utilization: 92 }, + seven_day: { utilization: 40 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('message-ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader(oauthLoader, { models: {} }) @@ -16907,24 +18190,29 @@ describe('killswitch fetch gate', () => { }), ) - globalThis.fetch = mock((input: any, init: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - // main killed (remaining 2%), fallback healthy (remaining 90%). - const utilization = authorization.includes('main-access') ? 98 : 10 - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization }, - seven_day: { utilization: 10 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response('message-ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + // Main's 2% remaining quota triggers the hard block. The fallback + // account has 90% remaining and can serve instead. + const utilization = authorization.includes('sk-ant-oat01-main-access') + ? 98 + : 10 + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('message-ok', { status: 200 })) + }), + ) as unknown as typeof fetch const stream = new ReadableStream({ start(controller) { @@ -16965,7 +18253,7 @@ describe('killswitch fetch gate', () => { { id: 'fallback-1', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: Date.now() + 5 * 60 * 60 * 1000, }, @@ -16974,27 +18262,33 @@ describe('killswitch fetch gate', () => { ) let mainServed = false - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - // main killed (5h remaining 2%). fallback 5h remaining 7% — above the - // killswitch threshold (5) but below the routing minimumRemaining (10). - const fiveHourUtil = authorization.includes('main-access') ? 98 : 93 - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: fiveHourUtil }, - seven_day: { utilization: 50 }, - }), - { status: 200 }, - ), - ) - } - mainServed = true - return Promise.resolve(new Response('message-ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + // Main's 2% five-hour quota triggers the hard block. The fallback's + // 7% passes the 5% hard-block threshold but not the 10% routing minimum. + const fiveHourUtil = authorization.includes( + 'sk-ant-oat01-main-access', + ) + ? 98 + : 93 + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: fiveHourUtil }, + seven_day: { utilization: 50 }, + }), + { status: 200 }, + ), + ) + } + mainServed = true + return Promise.resolve(new Response('message-ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader(oauthLoader, { models: {} }) @@ -17025,7 +18319,7 @@ describe('killswitch fetch gate', () => { { id: 'fallback-1', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: Date.now() + 5 * 60 * 60 * 1000, }, @@ -17034,35 +18328,37 @@ describe('killswitch fetch gate', () => { ) let servedAuth: string | undefined - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - const isMain = authorization.includes('main-access') - // main healthy (80%); fallback at 30% — below killswitch (50), above - // routing minimumRemaining (10). - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: isMain ? 20 : 70 }, - seven_day: { utilization: isMain ? 20 : 50 }, - }), - { status: 200 }, - ), - ) - } - servedAuth = new Headers(init?.headers).get('authorization') ?? '' - return Promise.resolve(new Response('message-ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + const isMain = authorization.includes('sk-ant-oat01-main-access') + // Main has 80% remaining and can serve. The fallback's 30% passes + // the 10% routing minimum but fails its 50% hard-block threshold. + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: isMain ? 20 : 70 }, + seven_day: { utilization: isMain ? 20 : 50 }, + }), + { status: 200 }, + ), + ) + } + servedAuth = new Headers(init?.headers).get('authorization') ?? '' + return Promise.resolve(new Response('message-ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader(oauthLoader, { models: {} }) const response = await result.fetch(MESSAGES_URL, EMPTY_POST) expect(response.status).toBe(200) - expect(servedAuth).toContain('main-access') - expect(servedAuth).not.toContain('fallback-access') + expect(servedAuth).toContain('sk-ant-oat01-main-access') + expect(servedAuth).not.toContain('sk-ant-oat01-fallback-access') }) test('fail-closed killswitch blocks the first request when main quota is unknown', async () => { @@ -17084,15 +18380,20 @@ describe('killswitch fetch gate', () => { ) let mainServed = false - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - // Quota API down → eager refresh fails → main quota stays unknown. - return Promise.resolve(new Response('upstream error', { status: 500 })) - } - mainServed = true - return Promise.resolve(new Response('message-ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + // The failed usage request cannot establish main account quota, + // so fail-closed policy must keep model dispatch blocked. + return Promise.resolve( + new Response('upstream error', { status: 500 }), + ) + } + mainServed = true + return Promise.resolve(new Response('message-ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader(oauthLoader, { models: {} }) @@ -17121,7 +18422,7 @@ describe('killswitch fetch gate', () => { { id: 'fallback-1', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: Date.now() + 5 * 60 * 60 * 1000, }, @@ -17129,26 +18430,28 @@ describe('killswitch fetch gate', () => { }), ) - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - const isMain = authorization.includes('main-access') - // main 5h 30% (passes routing 10, fails killswitch 50 → killed); - // fallback 5h 90% (passes both). 7d healthy for both. - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: isMain ? 70 : 10 }, - seven_day: { utilization: 10 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response('message-ok', { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + const isMain = authorization.includes('sk-ant-oat01-main-access') + // Main's 30% five-hour quota passes routing but triggers its 50% + // hard block. The fallback has 90%; both weekly windows are healthy. + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: isMain ? 70 : 10 }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('message-ok', { status: 200 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader(oauthLoader, { models: {} }) @@ -17176,7 +18479,7 @@ describe('killswitch fetch gate', () => { { id: 'fallback-1', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: Date.now() + 5 * 60 * 60 * 1000, }, @@ -17185,35 +18488,38 @@ describe('killswitch fetch gate', () => { ) let mainServed = false - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - if (url.includes('/api/oauth/usage')) { - const isMain = authorization.includes('main-access') - // main 30% (passes routing 10, fails killswitch 50 → killed); - // fallback 90% (a survivor). + globalThis.fetch = mock( + withNativeAdmission((input: any, init: any) => { + const url = extractUrl(input) + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + if (url.includes('/api/oauth/usage')) { + const isMain = authorization.includes('sk-ant-oat01-main-access') + // Main's 30% remaining quota triggers its 50% hard block despite + // passing routing. The fallback account has 90% and passes both. + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: isMain ? 70 : 10 }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + if (authorization.includes('sk-ant-oat01-main-access')) { + mainServed = true + return Promise.resolve(new Response('main-ok', { status: 200 })) + } + // The fallback account passes quota policy, but its model request + // receives a rate limit; the killed main must remain excluded. return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: isMain ? 70 : 10 }, - seven_day: { utilization: 10 }, - }), - { status: 200 }, - ), + new Response(JSON.stringify({ error: 'fallback-limited' }), { + status: 429, + }), ) - } - if (authorization.includes('main-access')) { - mainServed = true - return Promise.resolve(new Response('main-ok', { status: 200 })) - } - // Surviving fallback is rate-limited at request time. - return Promise.resolve( - new Response(JSON.stringify({ error: 'fallback-limited' }), { - status: 429, - }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader(oauthLoader, { models: {} }) @@ -17276,38 +18582,40 @@ describe('claude-prime direct request', () => { init: RequestInit | undefined }> = [] let quotaCalls = 0 - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - primeCalls.push({ url, init }) - return Promise.resolve( - new Response( - JSON.stringify({ - id: 'msg-test', - usage: { input_tokens: 20, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - quotaCalls += 1 - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(now - 1_000).toISOString(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + primeCalls.push({ url, init }) + return Promise.resolve( + new Response( + JSON.stringify({ + id: 'msg-test', + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + quotaCalls += 1 + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -17342,7 +18650,7 @@ describe('claude-prime direct request', () => { const body = JSON.parse(bodyText) const canonicalBody = await rewriteRequestBody( JSON.stringify(buildPrimeRequestBody()), - { identity: getClaudeCodeIdentity('main-access') }, + { identity: getClaudeCodeIdentity('sk-ant-oat01-main-access') }, ) expect(bodyText).toBe(canonicalBody) expect(body.model).toBe('claude-haiku-4-5') @@ -17388,37 +18696,39 @@ describe('claude-prime direct request', () => { ) let observedAuth: string | undefined - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - const headers = new Headers(init?.headers ?? {}) - observedAuth = headers.get('authorization') ?? undefined - return Promise.resolve( - new Response( - JSON.stringify({ - usage: { input_tokens: 20, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(now - 1_000).toISOString(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + const headers = new Headers(init?.headers ?? {}) + observedAuth = headers.get('authorization') ?? undefined + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -17432,7 +18742,7 @@ describe('claude-prime direct request', () => { await mgr!.tick() expect(observedAuth).toBeDefined() - expect(observedAuth).toContain('main-access') + expect(observedAuth).toContain('sk-ant-oat01-main-access') }) test('main host credential replacement keeps the stable lineage in the same reset window', async () => { @@ -17459,39 +18769,46 @@ describe('claude-prime direct request', () => { }, prime: { enabled: true }, }), + { + access: 'sk-ant-oat01-main-access-a', + refresh: 'main-refresh-a', + expires: Date.now() + 100000, + }, ) let sends = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - sends += 1 - return Promise.resolve( - new Response( - JSON.stringify({ - usage: { input_tokens: 20, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(now - 1_000).toISOString(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + sends += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const firstPlugin = await getPlugin() await firstPlugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access-a', + access: 'sk-ant-oat01-main-access-a', refresh: 'main-refresh-a', expires: Date.now() + 100000, }), @@ -17502,18 +18819,22 @@ describe('claude-prime direct request', () => { __primeManager: { tick: () => Promise } } ).__primeManager.tick() - const firstState = JSON.parse( - await readFile(getAccountStatePath(), 'utf8'), - ) as { main?: { primeAuthLineageId?: string } } - const firstLineage = firstState.main?.primeAuthLineageId + const firstLineage = (await readAccountStorage())?.prime?.mainAuthLineageId + // The main login's tokens rotate for the same account (a refresh in + // another OpenCode process); a second plugin instance then starts. + await refreshPoolMainElsewhere({ + access: 'sk-ant-oat01-main-access-b', + refresh: 'main-refresh-b', + expires: Date.now() + 100000, + }) const secondPlugin = await getPlugin() await secondPlugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access-b', - refresh: 'main-refresh-b', + access: 'sk-ant-oat01-main-access-a', + refresh: 'main-refresh-a', expires: Date.now() + 100000, }), { models: {} }, @@ -17525,11 +18846,10 @@ describe('claude-prime direct request', () => { ).__primeManager.tick() expect(sends).toBe(1) - const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) as { - main?: { primeAuthLineageId?: string } - } expect(firstLineage).toMatch(/^[0-9a-f-]{36}$/) - expect(state.main?.primeAuthLineageId).toBe(firstLineage) + expect((await readAccountStorage())?.prime?.mainAuthLineageId).toBe( + firstLineage, + ) }) test('main refresh through the plugin keeps the lineage and prime claim', async () => { @@ -17565,7 +18885,7 @@ describe('claude-prime direct request', () => { }) as unknown as typeof setInterval let hostAuth = { type: 'oauth', - access: 'main-access-a', + access: 'sk-ant-oat01-main-access-a', refresh: 'main-refresh-a', expires: Date.now() + 5 * 60 * 60_000, } @@ -17602,7 +18922,7 @@ describe('claude-prime direct request', () => { new Response( JSON.stringify({ refresh_token: 'main-refresh-b', - access_token: 'main-access-b', + access_token: 'sk-ant-oat01-main-access-b', expires_in: 3600, }), { status: 200 }, @@ -17657,7 +18977,7 @@ describe('claude-prime direct request', () => { ) await waitForAccountStorage( (storage) => - hostAuth.access === 'main-access-b' && + hostAuth.access === 'sk-ant-oat01-main-access-b' && storage?.refresh?.mainRefreshLeaseId === undefined, ) await manager.tick() @@ -17698,7 +19018,7 @@ describe('claude-prime direct request', () => { let hostAuth = { type: 'oauth', - access: 'main-access-a', + access: 'sk-ant-oat01-main-access-a', refresh: refreshToken, expires: Date.now() + 5 * 60 * 60_000, } @@ -17717,15 +19037,17 @@ describe('claude-prime direct request', () => { return Promise.resolve({ ...hostAuth }) } let observedAuthorization: string | null = null - globalThis.fetch = mock((_input: any, init?: RequestInit) => { - observedAuthorization = new Headers(init?.headers).get('authorization') - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((_input: any, init?: RequestInit) => { + observedAuthorization = new Headers(init?.headers).get('authorization') + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() try { @@ -17740,14 +19062,16 @@ describe('claude-prime direct request', () => { ) hostAuth = { type: 'oauth', - access: 'main-access-b', + access: 'sk-ant-oat01-main-access-b', refresh: 'main-refresh-b', expires: Date.now() + 60 * 60_000, } const response = await request expect(response.status).toBe(200) - expect(String(observedAuthorization)).toContain('main-access-b') + expect(String(observedAuthorization)).toContain( + 'sk-ant-oat01-main-access-b', + ) const state = JSON.parse( await readFile(getAccountStatePath(), 'utf8'), ) as { @@ -17784,7 +19108,7 @@ describe('claude-prime direct request', () => { { id: 'work-alt', type: 'oauth', - access: 'fb-access', + access: 'sk-ant-oat01-fb-access', refresh: 'fb-refresh', // expires must exceed the refresh-before-expiry window (4h default) // so the token is NOT marked as needing refresh and the prime @@ -17818,37 +19142,39 @@ describe('claude-prime direct request', () => { ) const calls: Array<{ url: string; auth?: string }> = [] - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - const headers = new Headers(init?.headers ?? {}) - calls.push({ url, auth: headers.get('authorization') ?? undefined }) - if (url.includes('/v1/messages')) { - return Promise.resolve( - new Response( - JSON.stringify({ - usage: { input_tokens: 20, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(now - 1_000).toISOString(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + const headers = new Headers(init?.headers ?? {}) + calls.push({ url, auth: headers.get('authorization') ?? undefined }) + if (url.includes('/v1/messages')) { + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -17863,10 +19189,11 @@ describe('claude-prime direct request', () => { const primeCall = calls.find( (c) => - c.url === `${MESSAGES_URL}?beta=true` && c.auth?.includes('fb-access'), + c.url === `${MESSAGES_URL}?beta=true` && + c.auth?.includes('sk-ant-oat01-fb-access'), ) expect(primeCall).toBeDefined() - expect(primeCall?.auth).toContain('fb-access') + expect(primeCall?.auth).toContain('sk-ant-oat01-fb-access') }) test('fallback refresh-token rotation keeps one prime claim per reset window', async () => { @@ -17878,7 +19205,7 @@ describe('claude-prime direct request', () => { { id: 'work-rotating', type: 'oauth', - access: 'fb-access-a', + access: 'sk-ant-oat01-fb-access-a', refresh: 'fb-refresh-a', expires: Date.now() + 5 * 60 * 60 * 1000, authLineageId: 'lineage-work', @@ -17900,40 +19227,42 @@ describe('claude-prime direct request', () => { }, prime: { enabled: true }, }), - ) - - let sends = 0 - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - const authorization = new Headers(init?.headers).get('authorization') - if (authorization?.includes('fb-access-')) sends += 1 - return Promise.resolve( - new Response( - JSON.stringify({ - usage: { input_tokens: 20, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(now - 1_000).toISOString(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + ) + + let sends = 0 + globalThis.fetch = mock( + withNativeAdmission((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + const authorization = new Headers(init?.headers).get('authorization') + if (authorization?.includes('fb-access-')) sends += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -17946,14 +19275,13 @@ describe('claude-prime direct request', () => { ).__primeManager await mgr!.tick() - const rotated = await loadAccounts() - const account = rotated!.accounts.find( - (candidate) => candidate.id === 'work-rotating', - )! - if (account.type !== 'oauth') throw new Error('expected OAuth account') - account.access = 'fb-access-b' - account.refresh = 'fb-refresh-b' - await saveAccounts(rotated!) + // Another process refreshes the fallback; its refresh token rotates. + loginIssues('sk-ant-oat01-fb-access-a', 'sk-ant-oat01-fb-access-b') + await refreshPoolLoginElsewhere('work-rotating', { + access: 'sk-ant-oat01-fb-access-b', + refresh: 'fb-refresh-b', + expires: Date.now() + 5 * 60 * 60 * 1000, + }) await mgr!.tick() expect(sends).toBe(1) @@ -17986,29 +19314,31 @@ describe('claude-prime direct request', () => { ) let messageCalls = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - messageCalls += 1 - return Promise.resolve(new Response('boom', { status: 500 })) - } - if (url.includes('/api/oauth/usage')) { - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(now - 1_000).toISOString(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + messageCalls += 1 + return Promise.resolve(new Response('boom', { status: 500 })) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -18025,11 +19355,8 @@ describe('claude-prime direct request', () => { // Two ticks in the same reset cycle: marker claimed → second tick skips expect(messageCalls).toBe(1) - // No counter incremented (state file has no main.prime) - const statePath = getAccountStatePath( - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - ) - const raw = JSON.parse(await readFile(statePath, 'utf8')) + // No counter incremented (the main account records no prime usage) + const raw = await readNativeRuntimeState() expect(raw.main?.prime).toBeUndefined() }) @@ -18059,35 +19386,37 @@ describe('claude-prime direct request', () => { }), ) - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - return Promise.resolve( - new Response( - JSON.stringify({ - usage: { input_tokens: 20, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(now - 1_000).toISOString(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 100000, }), @@ -18100,11 +19429,8 @@ describe('claude-prime direct request', () => { ).__primeManager await mgr!.tick() - const statePath = getAccountStatePath( - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - ) - const raw = JSON.parse(await readFile(statePath, 'utf8')) - expect(raw.main.prime).toEqual({ + const raw = await readNativeRuntimeState() + expect(raw.main?.prime).toEqual({ count: 1, inputTokens: 20, outputTokens: 1, @@ -18136,70 +19462,67 @@ describe('claude-prime direct request', () => { }, prime: { enabled: true }, }), + // The pool's main access token has expired, so it has no usable + // access token until it refreshes. + { + access: 'sk-ant-oat01-expired-main-access', + refresh: 'main-refresh', + expires: Date.now() - 1_000, + }, ) + mainAccountIssues('sk-ant-oat01-refreshed-main-access') - let authCallCount = 0 const primeCalls: Array<{ url: string; init: RequestInit | undefined }> = [] let _quotaCalls = 0 - globalThis.fetch = mock((input: any, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.url - const _headers = new Headers(init?.headers ?? {}) - if (url.includes('/v1/messages')) { - primeCalls.push({ url, init }) - return Promise.resolve( - new Response( - JSON.stringify({ - usage: { input_tokens: 20, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - _quotaCalls += 1 - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(Date.now() - 1_000).toISOString(), - }, - }) - } - if (url.includes('/v1/oauth/token')) { - return Promise.resolve( - new Response( - JSON.stringify({ - access_token: 'refreshed-main-access', - refresh_token: 'main-refresh', - expires_in: 3600, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeBootstrap((input: any, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.url + const _headers = new Headers(init?.headers ?? {}) + if (url.includes('/v1/messages')) { + primeCalls.push({ url, init }) + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + _quotaCalls += 1 + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() - 1_000).toISOString(), + }, + }) + } + if (url.includes('/v1/oauth/token')) { + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'sk-ant-oat01-refreshed-main-access', + refresh_token: 'main-refresh', + expires_in: 3600, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() - // First call: getAuth returns oauth WITHOUT access (triggers refresh). - // Second call: getAuth returns oauth WITH the refreshed access token. await plugin.auth.loader( - () => { - authCallCount += 1 - if (authCallCount === 1) { - return Promise.resolve({ - type: 'oauth', - access: undefined, - refresh: 'main-refresh', - expires: undefined, - }) - } - return Promise.resolve({ + () => + Promise.resolve({ type: 'oauth', - access: 'refreshed-main-access', + access: 'sk-ant-oat01-expired-main-access', refresh: 'main-refresh', - expires: Date.now() + 3600_000, - }) - }, + expires: Date.now() - 1_000, + }), { models: {} }, ) const mgr = ( @@ -18212,7 +19535,9 @@ describe('claude-prime direct request', () => { expect(primeCalls).toHaveLength(1) const init = primeCalls[0]?.init const headers = new Headers(init?.headers ?? {}) - expect(headers.get('authorization')).toContain('refreshed-main-access') + expect(headers.get('authorization')).toContain( + 'sk-ant-oat01-refreshed-main-access', + ) }) test('plugin instances with the same storage path adopt one prime manager', async () => { @@ -18283,12 +19608,12 @@ describe('claude-prime sidebar on toggle', () => { }) await useTempAccountFile(fixture) sidebarStateFile = process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE - const plugin = await getPlugin() + const plugin = await getPlugin(undefined, tempConfigDir) await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 3600_000, }), @@ -18298,13 +19623,14 @@ describe('claude-prime sidebar on toggle', () => { const before = await readSidebar() expect(before.prime).toBeUndefined() - await expect( - plugin['command.execute.before']({ - command: 'claude-prime', - arguments: 'on', - sessionID: 'ses_test', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + expect( + ( + await applyMenuAction(plugin, 'ses_test', { + sectionId: 'Extras', + actionId: 'prime-on', + }) + ).ok, + ).toBe(true) // After on, sidebar has the prime section. const afterOn = await readSidebar() @@ -18318,12 +19644,12 @@ describe('claude-prime sidebar on toggle', () => { }) await useTempAccountFile(fixture) sidebarStateFile = process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE - const plugin = await getPlugin() + const plugin = await getPlugin(undefined, tempConfigDir) await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 3600_000, }), @@ -18331,23 +19657,25 @@ describe('claude-prime sidebar on toggle', () => { ) // Prime a baseline sidebar write by issuing `/claude-prime on` (the // mutation path publishes the sidebar section per M7). - await expect( - plugin['command.execute.before']({ - command: 'claude-prime', - arguments: 'on', - sessionID: 'ses_test', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + expect( + ( + await applyMenuAction(plugin, 'ses_test', { + sectionId: 'Extras', + actionId: 'prime-on', + }) + ).ok, + ).toBe(true) const baseline = await readSidebar() expect(baseline.prime?.enabled).toBe(true) - await expect( - plugin['command.execute.before']({ - command: 'claude-prime', - arguments: 'off', - sessionID: 'ses_test', - }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + expect( + ( + await applyMenuAction(plugin, 'ses_test', { + sectionId: 'Extras', + actionId: 'prime-off', + }) + ).ok, + ).toBe(true) // After off, prime section is removed. const afterOff = await readSidebar() @@ -18431,7 +19759,7 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 3600_000, }), @@ -18483,26 +19811,28 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { }, } - globalThis.fetch = mock((input: any) => { - const url = typeof input === 'string' ? input : input.url - if (url.includes('/v1/messages')) { - primeCalls.push({ url }) - return Promise.resolve( - new Response( - JSON.stringify({ usage: { input_tokens: 0, output_tokens: 0 } }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = typeof input === 'string' ? input : input.url + if (url.includes('/v1/messages')) { + primeCalls.push({ url }) + return Promise.resolve( + new Response( + JSON.stringify({ usage: { input_tokens: 0, output_tokens: 0 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 3600_000, }), @@ -18536,7 +19866,7 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { { id: 'work-alt', type: 'oauth', - access: 'fb-access', + access: 'sk-ant-oat01-fb-access', refresh: 'fb-refresh', // expires must exceed the 4h refresh-before-expiry window so // the token is NOT marked as needing refresh (otherwise the @@ -18571,7 +19901,7 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { }, }, mainQuotaCheckedAt: Date.now(), - mainQuotaToken: 'main-access', + mainQuotaToken: 'sk-ant-oat01-main-access', }, prime: { enabled: true }, }), @@ -18611,7 +19941,7 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 3600_000, }), @@ -18645,7 +19975,7 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { { id: 'work-alt', type: 'oauth', - access: 'fb-access', + access: 'sk-ant-oat01-fb-access', refresh: 'fb-refresh', expires: now + 10 * 60 * 60_000, quota: cachedQuota, @@ -18665,36 +19995,38 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { }, }, mainQuotaCheckedAt: now, - mainQuotaToken: 'main-access', + mainQuotaToken: 'sk-ant-oat01-main-access', }, prime: { enabled: true }, }), ) let primeCalls = 0 - globalThis.fetch = mock((input: any) => { - const url = typeof input === 'string' ? input : input.url - if (url.includes('/api/oauth/usage')) { - return Promise.resolve(new Response('rate limited', { status: 429 })) - } - if (url.includes('/v1/messages')) { - primeCalls += 1 - return Promise.resolve( - new Response( - JSON.stringify({ usage: { input_tokens: 20, output_tokens: 1 } }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission((input: any) => { + const url = typeof input === 'string' ? input : input.url + if (url.includes('/api/oauth/usage')) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + if (url.includes('/v1/messages')) { + primeCalls += 1 + return Promise.resolve( + new Response( + JSON.stringify({ usage: { input_tokens: 20, output_tokens: 1 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: now + 3600_000, }), @@ -18711,7 +20043,11 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { undefined, ) await expect( - quotaManager.refreshFallback('work-alt', 'fb-access', undefined), + quotaManager.refreshFallback( + 'work-alt', + 'sk-ant-oat01-fb-access', + undefined, + ), ).rejects.toThrow('429') const mgr = (plugin as any).__primeManager @@ -18810,7 +20146,7 @@ describe('claude-prime — warn dedup (R3)', () => { if (authCallCount <= 2) { return Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 3600_000, }) @@ -18946,7 +20282,7 @@ describe('claude-prime — warn dedup (R3)', () => { } return Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 3600_000, }) @@ -18999,7 +20335,7 @@ describe('claude-prime — warn dedup (R3)', () => { { id: 'work-alt', type: 'oauth', - access: 'fb-access', + access: 'sk-ant-oat01-fb-access', refresh: 'fb-refresh', expires: Date.now() + 10 * 60 * 60 * 1000, quota: dueQuota, @@ -19017,7 +20353,7 @@ describe('claude-prime — warn dedup (R3)', () => { }, }, mainQuotaCheckedAt: Date.now(), - mainQuotaToken: 'main-access', + mainQuotaToken: 'sk-ant-oat01-main-access', }, prime: { enabled: true }, }), @@ -19028,7 +20364,7 @@ describe('claude-prime — warn dedup (R3)', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 3600_000, }), @@ -19036,7 +20372,7 @@ describe('claude-prime — warn dedup (R3)', () => { ) const mgr = (plugin as any).__primeManager mgr.options.refreshQuota = async () => { - const storage = await loadAccounts() + const storage = await readAccountStorage() if (!storage) throw new Error('missing test storage') storage.accounts = [] await saveAccounts(storage) @@ -19078,7 +20414,7 @@ describe('claude-prime — warn dedup (R3)', () => { { id: 'work-alt', type: 'oauth', - access: 'fb-access', + access: 'sk-ant-oat01-fb-access', refresh: 'fb-refresh', expires: Date.now() + 5 * 60 * 60_000, quota: dueQuota, @@ -19096,7 +20432,7 @@ describe('claude-prime — warn dedup (R3)', () => { }, }, mainQuotaCheckedAt: Date.now(), - mainQuotaToken: 'main-access', + mainQuotaToken: 'sk-ant-oat01-main-access', }, prime: { enabled: true }, }), @@ -19112,13 +20448,13 @@ describe('claude-prime — warn dedup (R3)', () => { () => Promise.resolve({ type: 'oauth', - access: 'main-access', + access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', expires: Date.now() + 3600_000, }), { models: {} }, ) - const storage = await loadAccounts() + const storage = await readAccountStorage() const fallback = storage!.accounts.find( (account) => account.id === 'work-alt', )! From 2dbee8af6e5ebcf968f3c8675629db75124dff01 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:35:00 +0200 Subject: [PATCH 095/169] Bind deferred primary quota before admitting paid fallback --- packages/opencode/src/index.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 576fa0db5..2b7656415 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -6580,14 +6580,19 @@ const anthropicAuthPlugin = async ( accessToken?: string, mainQuotaIdentity?: MainQuotaIdentityBinding, ) { - if (!accessToken) return false + if (!accessToken && !mainQuotaIdentity?.providerAccountUuid) + return false const entry = quotaManager.getMain( mainQuotaIdentity?.quotaKey ?? mainQuotaAccountId, ) - // A genuine response header is live routing evidence like a 429, but - // it gets no exemption from the shared freshness and token gates. + // Native metadata can establish account-owned exhaustion before + // primary credential authorization. The API send still validates + // that credential and rechecks this quota before dispatch. return Boolean( entry && + (accessToken || + entry.quota.accountIdentity === + mainQuotaIdentity?.providerAccountUuid) && entry.refreshAfter > Date.now() && quotaSnapshotIsExhausted(entry.quota), ) @@ -8098,11 +8103,27 @@ const anthropicAuthPlugin = async ( // Fallback-first reached main only after its fallback attempts. // Resolve current access now; absence from the earlier metadata // view is not evidence that the stored token needs refreshing. - if (auth.deferred) + if (auth.deferred) { Object.assign( auth, await getAuth(credentialModelId, init?.signal ?? undefined), ) + if (auth.access) { + const resolution = await resolveMainQuotaAccountIdentity( + auth.access, + credentialModelId, + auth.nativeAccountIdentity, + auth.nativeScopedAttempt?.recordVersion, + ) + if (resolution.stale) + throw new Error( + 'Main OAuth identity changed while resolving request credentials', + ) + requestMainQuotaIdentity = resolution + mainServedAccessToken = auth.access + mainProviderAccountUuid = resolution.providerAccountUuid + } + } if ( !auth.modelDenied && (!auth.access || !auth.expires || auth.expires < Date.now()) From f90be1570c45f17121db9c1f8a52e3cce7dae4a9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:59:36 +0200 Subject: [PATCH 096/169] Refuse unsupported native OAuth without poisoning refresh state --- packages/core/src/native-account-runtime.ts | 6 ++ packages/core/src/native-account-view.ts | 5 ++ .../src/tests/native-account-view.test.ts | 23 ++++++++ packages/opencode/src/index.ts | 10 ++++ packages/opencode/src/tests/index.test.ts | 57 ++++++++++++++++++- 5 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index ee1f3d2e9..5ca079717 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -613,6 +613,12 @@ export function createNativeAccountRuntime( account.type !== 'oauth' ) return { status: 'refused', reason: 'row-ineligible', persisted: false } + if (account.state === 'unsupported-access') + return { + status: 'refused', + reason: 'unsupported-access', + persisted: false, + } const metadata = await readNativeRuntime(paths.runtime, paths.storageId) const previous = metadata.status === 'ready' diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts index 693e86f5b..1f0a6b462 100644 --- a/packages/core/src/native-account-view.ts +++ b/packages/core/src/native-account-view.ts @@ -516,6 +516,11 @@ export function projectNativeAccountViews(input: { id, type: row.type, source: 'local', + ...(row.credential?.type === 'oauth' && + row.credential.access && + !row.credential.access.startsWith('sk-ant-oat') + ? { state: 'unsupported-access' } + : {}), enabled: row.enabled && row.candidate, label: row.label, addedAt: row.addedAt, diff --git a/packages/core/src/tests/native-account-view.test.ts b/packages/core/src/tests/native-account-view.test.ts index 67448f7c5..2385c7c75 100644 --- a/packages/core/src/tests/native-account-view.test.ts +++ b/packages/core/src/tests/native-account-view.test.ts @@ -268,3 +268,26 @@ for (const quotaIdentity of [ } }) } + +for (const access of ['sk-ant-oat01-synthetic', 'synthetic-api-key', '']) { + test(`local projection classifies unsupported access without exposing it (${access ? (access.startsWith('sk-ant-oat') ? 'oauth' : 'unsupported') : 'missing'})`, () => { + const credential = { + type: 'oauth' as const, + access, + refresh: 'synthetic-refresh', + expires: 1000, + } + const snapshot = projectNativeAccountViews({ + paths, + rows: [{ ...row, credential }], + settings: { mainAccountId: row.id }, + }) + expect(snapshot.accounts[0]?.state).toBe( + access && !access.startsWith('sk-ant-oat') + ? 'unsupported-access' + : undefined, + ) + expect(JSON.stringify(snapshot)).not.toContain('synthetic-api-key') + expect(JSON.stringify(snapshot)).not.toContain('synthetic-refresh') + }) +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 2b7656415..85ad1c3ef 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1397,6 +1397,13 @@ const anthropicAuthPlugin = async ( ) } } + class NativeUnsupportedCredentialError extends Error { + constructor() { + super( + 'Native OAuth requires a Claude Pro/Max credential. An API key belongs in OpenCode stock Anthropic authentication.', + ) + } + } class NativeCredentialUnavailableError extends Error { constructor( readonly account: NativeAccountView, @@ -1419,6 +1426,8 @@ const anthropicAuthPlugin = async ( const account = snapshot.accounts.find( (candidate) => candidate.id === routeId, ) + if (account?.state === 'unsupported-access') + throw new NativeUnsupportedCredentialError() if (account?.type !== 'oauth' || !account.enabled) { throw new Error('Native OAuth account is unavailable') } @@ -5348,6 +5357,7 @@ const anthropicAuthPlugin = async ( primarySnapshot, ) } catch (error) { + if (error instanceof NativeUnsupportedCredentialError) throw error if (error instanceof NativeModelPolicyError) { denied = error.account // Recheck a stale limit for this model before moving the request diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 3c0bceda0..3b862eac9 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -37,7 +37,7 @@ import { extractBillingHeaderCCH, FallbackAccountManager, getAccountStatePath, - getClaudeCodeIdentity, + getClaudeCodeIdentityForVerifiedAccount, getOrCreatePrimeAuthLineageId, hashRefreshToken, isCustodyTombstoneOAuth, @@ -1452,6 +1452,22 @@ async function expectNonOatMainRefused(access: string) { ).rejects.toThrow(/API key/i) expect(modelRequests).toEqual([]) expect(await readFeedEntries()).toEqual([]) + const runtime = createNativeAccountRuntime({ + paths: migratedPool!.paths, + host: 'opencode', + }) + try { + expect(await runtime.authorizeLocal('main')).toEqual({ + status: 'refused', + reason: 'unsupported-access', + persisted: false, + }) + expect( + (await readAccountStorage())?.refresh?.mainLastRefreshError, + ).toBeUndefined() + } finally { + runtime.close() + } } /** @@ -2205,6 +2221,12 @@ describe('quota header feed integration', () => { const fallbackAccess = `sk-ant-oat-${randomUUID()}` await useTempAccountFile( createFallbackStorage({ + quota: { + enabled: false, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + }, quotaHeaderFeed: { enabled: true }, accounts: [ { @@ -2230,6 +2252,32 @@ describe('quota header feed integration', () => { ], }), ) + if (persistedUuid) { + // First confirm the token's account UUID through the bootstrap endpoint. + // Its saved validation then permits serving when a later account lookup + // returns no UUID; a stored UUID alone must never authorize the token. + const answer = withNativeAdmission(() => Response.json({})) + globalThis.fetch = Object.assign( + mock(async (input: Parameters[0], init?: RequestInit) => { + const response = await answer(input, init) + if (!(response instanceof Response)) + throw new Error('Admission fixture must return a Response') + return response + }), + { preconnect: originalFetch.preconnect }, + ) + const runtime = createNativeAccountRuntime({ + paths: migratedPool!.paths, + host: 'opencode', + }) + try { + expect((await runtime.authorizeLocal('fallback-1')).status).toBe( + 'usable', + ) + } finally { + runtime.close() + } + } // Answer token exchanges and the primary account's bootstrap normally. // Let this test control the fallback credential's bootstrap response. globalThis.fetch = mock( @@ -18650,7 +18698,12 @@ describe('claude-prime direct request', () => { const body = JSON.parse(bodyText) const canonicalBody = await rewriteRequestBody( JSON.stringify(buildPrimeRequestBody()), - { identity: getClaudeCodeIdentity('sk-ant-oat01-main-access') }, + { + identity: getClaudeCodeIdentityForVerifiedAccount( + syntheticMainAccountUuid, + syntheticMainAccountUuid, + ), + }, ) expect(bodyText).toBe(canonicalBody) expect(body.model).toBe('claude-haiku-4-5') From d01e5e0258d6024e80304598e01d8c4e29c04d25 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:30:16 +0200 Subject: [PATCH 097/169] Verify native Prime rotation, token adoption and one-poll accounting --- packages/opencode/src/tests/index.test.ts | 599 +++++++++++----------- 1 file changed, 312 insertions(+), 287 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 3b862eac9..e24b11143 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -38,7 +38,6 @@ import { FallbackAccountManager, getAccountStatePath, getClaudeCodeIdentityForVerifiedAccount, - getOrCreatePrimeAuthLineageId, hashRefreshToken, isCustodyTombstoneOAuth, isOAuthAccount, @@ -547,9 +546,12 @@ function syntheticFallbackAccountUuid(index: number) { * account, are re-keyed to that UUID, as the legacy writer would have keyed * them had it known the account. */ -function bindPoolAccounts(storage: AccountStorage): AccountStorage { - if (storage.quota?.mainQuota) bindMainQuotaToAccount(storage) - else bindMainAccount(storage) +function bindPoolAccounts( + storage: AccountStorage, + access = 'sk-ant-oat01-main-access', +): AccountStorage { + if (storage.quota?.mainQuota) bindMainQuotaToAccount(storage, access) + else bindMainAccount(storage, access) storage.accounts.forEach((account, index) => { if (account.type !== 'oauth') return const uuid = @@ -806,8 +808,17 @@ async function legacyAccountFiles(storage: AccountStorage) { process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = statePath try { await saveAccounts(storage, configPath) - if (storage.main?.profile) - await saveAccountState(storage, configPath, { mainProfile: true }) + if ( + storage.main?.profile || + storage.prime?.mainAuthLineageId || + storage.prime?.main + ) + await saveAccountState(storage, configPath, { + mainProfile: Boolean(storage.main?.profile), + mainPrime: Boolean( + storage.prime?.mainAuthLineageId || storage.prime?.main, + ), + }) return { config: await readFile(configPath, 'utf8'), state: await readFile(statePath, 'utf8').catch(() => undefined), @@ -1061,7 +1072,10 @@ async function replacePoolMainLogin( ) { const pool = migratedPool if (!pool) throw new Error('This test has no migrated pool') - if (options.sameAccount) mainAccountIssues(credential.access) + if (options.sameAccount) { + mainAccountIssues(credential.access) + poolLogins.push({ ...credential, identity: poolMainIdentity() }) + } const runtime = createNativeAccountRuntime({ paths: pool.paths, host: 'opencode', @@ -18906,242 +18920,251 @@ describe('claude-prime direct request', () => { }) test('main refresh through the plugin keeps the lineage and prime claim', async () => { - const now = Date.now() - 60_000 - const past = now - 120_000 + const past = Date.now() - 180_000 await useTempAccountFile( - createFallbackStorage({ - accounts: [], - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { - usedPercent: 0, - remainingPercent: 100, - resetsAt: new Date(past).toISOString(), - checkedAt: 1, + bindPoolAccounts( + createFallbackStorage({ + accounts: [], + prime: { enabled: true }, + quota: { + ...createFallbackStorage().quota, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 1, + }, }, }, - mainQuotaCheckedAt: 1, - mainQuotaToken: 'fp-main', - }, - prime: { enabled: true }, - }), - ) - - const intervalHandlers: Array<() => void> = [] - const setIntervalMock = mock((handler: () => void) => { - intervalHandlers.push(handler) - return { unref() {} } - }) as unknown as typeof setInterval - let hostAuth = { - type: 'oauth', - access: 'sk-ant-oat01-main-access-a', - refresh: 'main-refresh-a', - expires: Date.now() + 5 * 60 * 60_000, - } - const mockClient = createMockClient() - const mainRefreshPublished = deferred() - let lineageObservedBeforePublish: string | undefined - let lineageObservedDuringPublish: string | undefined - ;(mockClient.auth as any).set = mock( - async (input: { - body: { - type: string - access: string - refresh: string - expires: number - } - }) => { - lineageObservedBeforePublish = await getOrCreatePrimeAuthLineageId( - 'main', - process.env.OPENCODE_ANTHROPIC_AUTH_FILE, - ) - hostAuth = { ...input.body } - lineageObservedDuringPublish = await getOrCreatePrimeAuthLineageId( - 'main', - process.env.OPENCODE_ANTHROPIC_AUTH_FILE, - ) - mainRefreshPublished.resolve() + }), + 'sk-ant-oat01-main-access-a', + ), + { + access: 'sk-ant-oat01-main-access-a', + refresh: 'main-refresh-a', + expires: Date.now() + 5 * 60 * 60_000, }, ) - let sends = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'main-refresh-b', - access_token: 'sk-ant-oat01-main-access-b', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } - if (url.includes('/v1/messages')) { - sends += 1 - return Promise.resolve( - new Response( - JSON.stringify({ - usage: { input_tokens: 20, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(now - 1_000).toISOString(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch - - const plugin = await getPlugin(mockClient, undefined, { - setInterval: setIntervalMock, + mainAccountIssues('sk-ant-oat01-main-access-b') + let primeSends = 0 + let tokenExchanges = 0 + const foregroundAuthorizations: Array = [] + globalThis.fetch = mock( + withNativeBootstrap( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/oauth/token')) { + tokenExchanges += 1 + return Promise.resolve( + Response.json({ + access_token: 'sk-ant-oat01-main-access-b', + refresh_token: 'main-refresh-b', + expires_in: 5 * 60 * 60, + }), + ) + } + if (url.includes('/api/oauth/usage')) + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(past).toISOString(), + }, + }) + if (url.includes('/v1/messages')) { + const body = JSON.parse(String(init?.body)) + if (body.model === 'claude-haiku-4-5' && body.max_tokens === 1) { + primeSends += 1 + return Promise.resolve( + Response.json({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + ) + } + const authorization = new Headers(init?.headers).get( + 'authorization', + ) + foregroundAuthorizations.push(authorization) + return Promise.resolve( + new Response('{}', { + status: + authorization === 'Bearer sk-ant-oat01-main-access-a' + ? 401 + : 200, + }), + ) + } + throw new Error(`Unexpected test endpoint: ${url}`) + }, + ), + ) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'sk-ant-oat01-main-access-a', + refresh: 'main-refresh-a', + expires: Date.now() + 5 * 60 * 60_000, + }), + { models: {} }, + ) + const manager = ( + plugin as unknown as { __primeManager: { tick(): Promise } } + ).__primeManager + await manager.tick() + const before = (await readAccountStorage())?.prime?.mainAuthLineageId + expect(before).toMatch(/^[0-9a-f-]{36}$/) + expect(tokenExchanges).toBe(0) + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'hello' }], + }), }) - await plugin.auth.loader(() => Promise.resolve(hostAuth), { models: {} }) - const manager = (plugin as any).__primeManager + expect(response.status).toBe(200) + expect(tokenExchanges).toBe(1) + expect(foregroundAuthorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access-a', + 'Bearer sk-ant-oat01-main-access-b', + ]) + expect(await poolMainAccess()).toBe('sk-ant-oat01-main-access-b') await manager.tick() - const before = JSON.parse( - await readFile(getAccountStatePath(), 'utf8'), - ) as { - main?: { - primeAuthLineageId?: string - primeAuthLineageRefreshTokenFingerprint?: string - } - } - - hostAuth.expires = Date.now() - 1 - const mainRefreshHandler = intervalHandlers.at(-1) - expect(mainRefreshHandler).toBeDefined() - mainRefreshHandler!() - await withDeadlockGuard( - mainRefreshPublished.promise, - 4_000, - 'main background refresh did not publish rotated auth', - ) - await waitForAccountStorage( - (storage) => - hostAuth.access === 'sk-ant-oat01-main-access-b' && - storage?.refresh?.mainRefreshLeaseId === undefined, + expect(primeSends).toBe(1) + expect((await readAccountStorage())?.prime?.mainAuthLineageId).toBe(before) + await expectHostActivationNonSecret( + 'sk-ant-oat01-main-access-a', + 'sk-ant-oat01-main-access-b', + 'main-refresh-a', + 'main-refresh-b', ) - await manager.tick() - - const after = JSON.parse( - await readFile(getAccountStatePath(), 'utf8'), - ) as typeof before - expect(sends).toBe(1) - expect(lineageObservedBeforePublish).toBe(before.main?.primeAuthLineageId) - expect(lineageObservedDuringPublish).toBe(before.main?.primeAuthLineageId) - expect(after.main?.primeAuthLineageId).toBe(before.main?.primeAuthLineageId) - expect(after.main?.primeAuthLineageRefreshTokenFingerprint).toBeUndefined() }) - test('a main refresh lease adopter preserves the legacy lineage binding', async () => { + test('a late main 401 adopts externally rotated credentials and preserves the imported Prime lineage', async () => { const lineage = 'main-lineage-a' const refreshToken = 'main-refresh-a' - const storage = createFallbackStorage({ - accounts: [], - refresh: { - enabled: true, - refreshBeforeExpiryMinutes: 30, - mainRefreshLeaseId: 'other-process', - mainRefreshLeaseUntil: Date.now() + 60_000, - mainRefreshLeaseTokenHash: hashRefreshToken(refreshToken), - }, - prime: { - enabled: true, - mainAuthLineageId: lineage, - mainAuthLineageRefreshTokenFingerprint: tokenFingerprint(refreshToken), + await useTempAccountFile( + bindMainAccount( + createFallbackStorage({ + accounts: [], + quota: { ...createFallbackStorage().quota, enabled: false }, + refresh: { + enabled: true, + refreshBeforeExpiryMinutes: 30, + mainRefreshLeaseId: 'other-process', + mainRefreshLeaseUntil: Date.now() + 60_000, + mainRefreshLeaseTokenHash: hashRefreshToken(refreshToken), + }, + prime: { + enabled: true, + mainAuthLineageId: lineage, + mainAuthLineageRefreshTokenFingerprint: + tokenFingerprint(refreshToken), + }, + }), + 'sk-ant-oat01-main-access-a', + ), + { + access: 'sk-ant-oat01-main-access-a', + refresh: refreshToken, + expires: Date.now() + 5 * 60 * 60_000, }, - }) - await useTempAccountFile(storage) - await saveAccountState(storage, process.env.OPENCODE_ANTHROPIC_AUTH_FILE, { - mainRefresh: true, - mainPrime: true, - }) - - let hostAuth = { - type: 'oauth', - access: 'sk-ant-oat01-main-access-a', - refresh: refreshToken, - expires: Date.now() + 5 * 60 * 60_000, - } - let observedExpiredRefresh!: () => void - const expiredRefreshRead = new Promise((resolve) => { - observedExpiredRefresh = resolve - }) - let expiredReads = 0 - const getAuth = () => { - // The request reads auth once on entry, then re-reads before checking the - // other process's refresh lease. A wall-clock sleep cannot guarantee it - // reached that second read under a busy test runner. - if (hostAuth.expires < Date.now() && ++expiredReads === 2) { - observedExpiredRefresh() - } - return Promise.resolve({ ...hostAuth }) - } - let observedAuthorization: string | null = null + ) globalThis.fetch = mock( - withNativeAdmission((_input: any, init?: RequestInit) => { - observedAuthorization = new Headers(init?.headers).get('authorization') - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ) - }), + withNativeAdmission(() => Response.json({})), + ) as unknown as typeof fetch + expect(await poolMainAccess()).toBe('sk-ant-oat01-main-access-a') + const firstRequest = bodyLifetime().gate() + const releaseRejectedResponse = bodyLifetime().gate() + const controller = new AbortController() + let reachedModel = false + const authorizations: Array = [] + let pluginTokenExchanges = 0 + globalThis.fetch = mock( + withNativeBootstrap( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/oauth/token')) { + pluginTokenExchanges += 1 + throw new Error( + 'The plugin must adopt the externally rotated token', + ) + } + if (url.includes('/v1/messages')) { + const authorization = new Headers(init?.headers).get( + 'authorization', + ) + authorizations.push(authorization) + if (authorization === 'Bearer sk-ant-oat01-main-access-a') { + reachedModel = true + firstRequest.open() + return releaseRejectedResponse.wait.then( + () => new Response('{}', { status: 401 }), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + } + if (url.includes('/api/oauth/usage')) + return freshPrimeQuotaResponse({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }) + throw new Error(`Unexpected test endpoint: ${url}`) + }, + ), ) as unknown as typeof fetch - const plugin = await getPlugin() - try { - const result = await plugin.auth.loader(getAuth, { models: {} }) - hostAuth.expires = Date.now() - 1 - const request = result.fetch(MESSAGES_URL, EMPTY_POST) - try { - await withDeadlockGuard( - expiredRefreshRead, - 4_000, - 'request did not read the expiring token before adopting the refresh lease', - ) - hostAuth = { + const result = await plugin.auth.loader( + () => + Promise.resolve({ type: 'oauth', - access: 'sk-ant-oat01-main-access-b', - refresh: 'main-refresh-b', - expires: Date.now() + 60 * 60_000, - } - const response = await request - - expect(response.status).toBe(200) - expect(String(observedAuthorization)).toContain( - 'sk-ant-oat01-main-access-b', - ) - const state = JSON.parse( - await readFile(getAccountStatePath(), 'utf8'), - ) as { - main?: { - primeAuthLineageId?: string - primeAuthLineageRefreshTokenFingerprint?: string - } - } - expect(state.main?.primeAuthLineageId).toBe(lineage) - expect(state.main?.primeAuthLineageRefreshTokenFingerprint).toBe( - tokenFingerprint(refreshToken), + access: 'sk-ant-oat01-main-access-a', + refresh: refreshToken, + expires: Date.now() + 5 * 60 * 60_000, + }), + { models: {} }, + ) + const request = result.fetch(MESSAGES_URL, { + ...EMPTY_POST, + signal: controller.signal, + }) + try { + await Promise.race([ + firstRequest.wait, + request.then(() => { + throw new Error('Request completed before the rejection fixture') + }), + ]) + if (!reachedModel) + throw new Error( + 'Model request did not reach the fixture before cancellation', ) - } finally { - await request.catch(() => {}) - } + await refreshPoolMainElsewhere({ + access: 'sk-ant-oat01-main-access-b', + refresh: 'main-refresh-b', + expires: Date.now() + 5 * 60 * 60_000, + }) + releaseRejectedResponse.open() + expect((await request).status).toBe(200) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access-a', + 'Bearer sk-ant-oat01-main-access-b', + ]) + expect(pluginTokenExchanges).toBe(0) + expect((await readAccountStorage())?.prime?.mainAuthLineageId).toBe( + lineage, + ) + await expectHostActivationNonSecret( + 'sk-ant-oat01-main-access-a', + 'sk-ant-oat01-main-access-b', + refreshToken, + 'main-refresh-b', + ) } finally { - await plugin.dispose?.() + controller.abort() + releaseRejectedResponse.open() + await request.catch(() => {}) } }) @@ -19914,80 +19937,82 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { force: true, }).catch(() => {}) await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - id: 'work-alt', - type: 'oauth', - access: 'sk-ant-oat01-fb-access', - refresh: 'fb-refresh', - // expires must exceed the 4h refresh-before-expiry window so - // the token is NOT marked as needing refresh (otherwise the - // refresh path would make a second fetch to /v1/oauth/token). - expires: Date.now() + 10 * 60 * 60 * 1000, - quota: { + bindPoolAccounts( + createFallbackStorage({ + accounts: [ + { + id: 'work-alt', + type: 'oauth', + access: 'sk-ant-oat01-fb-access', + refresh: 'fb-refresh', + // expires must exceed the 4h refresh-before-expiry window so + // the token is NOT marked as needing refresh (otherwise the + // refresh path would make a second fetch to /v1/oauth/token). + expires: Date.now() + 10 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + // A one-minute-old reading is within the five-minute poll + // interval. Startup must not start another usage request; + // this test counts only the poll triggered by Prime. + checkedAt: Date.now() - 60 * 1000, + }, + }, + }, + ], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { five_hour: { usedPercent: 0, remainingPercent: 100, - resetsAt: new Date(past).toISOString(), - // Recent (1min) so the background refresh's - // `isFallbackStale` guard does NOT fire a competing - // refresh in the same millisecond — without this the - // prime fresh-check's baseline equals the fetched - // checkedAt and `fresh` is false. - checkedAt: Date.now() - 60 * 1000, + resetsAt: new Date(Date.now() + 5 * 60 * 60_000).toISOString(), + checkedAt: Date.now(), }, }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: 'sk-ant-oat01-main-access', }, - ], - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { - usedPercent: 0, - remainingPercent: 100, - resetsAt: new Date(Date.now() + 5 * 60 * 60_000).toISOString(), - checkedAt: Date.now(), - }, - }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: 'sk-ant-oat01-main-access', - }, - prime: { enabled: true }, - }), + prime: { enabled: true }, + }), + ), ) let usageCalls = 0 let primeCalls = 0 - globalThis.fetch = mock((input: any) => { - const url = typeof input === 'string' ? input : input.url - if (url.includes('/v1/messages')) { - primeCalls += 1 - return Promise.resolve( - new Response( - JSON.stringify({ usage: { input_tokens: 20, output_tokens: 1 } }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - usageCalls += 1 - return freshPrimeQuotaResponse( - { - five_hour: { - utilization: 0, - resets_at: new Date(Date.now() - 1_000).toISOString(), - checked_at: Date.now(), + globalThis.fetch = mock( + withNativeBootstrap((input: Parameters[0]) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + primeCalls += 1 + return Promise.resolve( + new Response( + JSON.stringify({ usage: { input_tokens: 20, output_tokens: 1 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + usageCalls += 1 + return freshPrimeQuotaResponse( + { + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() - 1_000).toISOString(), + checked_at: Date.now(), + }, }, - }, - { status: 200, headers: { 'content-type': 'application/json' } }, - ) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + ) as unknown as typeof fetch const plugin = await getPlugin() await plugin.auth.loader( From 7d866fea4696456795ca9c942dd3ae0eef86783d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:48:41 +0200 Subject: [PATCH 098/169] Distinguish Prime exchange failures from unavailable accounts --- packages/opencode/src/index.ts | 6 +- packages/opencode/src/tests/index.test.ts | 547 ++++++++-------------- 2 files changed, 189 insertions(+), 364 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 85ad1c3ef..c0c6d963b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1408,6 +1408,7 @@ const anthropicAuthPlugin = async ( constructor( readonly account: NativeAccountView, status: string, + readonly tokenRefreshFailed = false, ) { super(`Native OAuth authorization refused: ${status}`) } @@ -1480,6 +1481,8 @@ const anthropicAuthPlugin = async ( throw new NativeCredentialUnavailableError( account, authorization.status, + authorization.status === 'failed' && + authorization.failure.kind === 'provider', ) result = { accessToken: authorization.access, @@ -3049,7 +3052,8 @@ const anthropicAuthPlugin = async ( } catch (error) { return { ok: false, - ...(!accessToken && { reason: 'token-refresh' as const }), + ...(error instanceof NativeCredentialUnavailableError && + error.tokenRefreshFailed && { reason: 'token-refresh' as const }), ms: Math.round(performance.now() - start), error: error instanceof Error ? error.message : String(error), } diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index e24b11143..464f1d4c6 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -21,6 +21,7 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import type { PrimeManager } from '@cortexkit/anthropic-auth-core' import { __setLogTestSink, type AccountStorage, @@ -4538,8 +4539,22 @@ describe('quota header feed extended integration', () => { createFallbackStorage({ quotaHeaderFeed: { enabled: true }, accounts: [ - { id: 'disabled-oauth', type: 'oauth', refresh: 'r', enabled: false }, - { id: 'enabled-oauth', type: 'oauth', refresh: 'r', enabled: true }, + { + id: 'disabled-oauth', + type: 'oauth', + access: 'sk-ant-oat01-disabled-access', + refresh: 'disabled-refresh', + expires: Date.now() + 8 * 60 * 60_000, + enabled: false, + }, + { + id: 'enabled-oauth', + type: 'oauth', + access: 'sk-ant-oat01-enabled-access', + refresh: 'enabled-refresh', + expires: Date.now() + 8 * 60 * 60_000, + enabled: true, + }, { id: 'api-key', type: 'api', @@ -4553,6 +4568,7 @@ describe('quota header feed extended integration', () => { headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.25' }, }), ) + expect(response.status).toBe(200) await response.text() const entries = await waitForFeedEntries( (candidate) => candidate.length === 1, @@ -20135,6 +20151,110 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { }) }) +async function primeWarningsAfterQuotaCheck( + fallback: boolean, + change: ( + runtime: ReturnType, + ) => Promise, + getHostAuth = () => + Promise.resolve({ type: 'oauth' as const, ...syntheticMainHostAuth() }), +) { + const dueQuota = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(Date.now() - 180_000).toISOString(), + checkedAt: Date.now(), + }, + } + await useTempAccountFile( + bindPoolAccounts( + createFallbackStorage({ + accounts: fallback + ? [ + { + id: 'work-alt', + type: 'oauth', + access: 'sk-ant-oat01-fb-access', + refresh: 'fb-refresh', + expires: Date.now() + 8 * 60 * 60_000, + quota: dueQuota, + }, + ] + : [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + mainQuota: fallback + ? { + five_hour: { + ...dueQuota.five_hour, + resetsAt: new Date( + Date.now() + 5 * 60 * 60_000, + ).toISOString(), + }, + } + : dueQuota, + }, + prime: { enabled: true }, + }), + ), + ) + let modelRequests = 0 + let refreshRequests = 0 + globalThis.fetch = mock( + withNativeBootstrap((input: Parameters[0]) => { + const url = extractUrl(input) + if (url.includes('/v1/oauth/token')) { + refreshRequests += 1 + return new Response('temporary refresh failure', { status: 503 }) + } + if (url.includes('/v1/messages')) { + modelRequests += 1 + return Response.json({ usage: { input_tokens: 1, output_tokens: 1 } }) + } + if (url.includes('/api/oauth/usage')) + return freshPrimeQuotaResponse({ + five_hour: { utilization: 0, resets_at: dueQuota.five_hour.resetsAt }, + }) + throw new Error(`Unexpected test endpoint: ${url}`) + }), + ) as unknown as typeof fetch + const plugin = await getPlugin() + await plugin.auth.loader(getHostAuth, { models: {} }) + const runtime = createNativeAccountRuntime({ + paths: migratedPool!.paths, + host: 'opencode', + }) + const manager = (plugin as unknown as { __primeManager: PrimeManager }) + .__primeManager + let changed = false + // Change account availability after the manager obtained its claim identity. + // Keep the real send adapter to verify its failure classification and logs. + manager.options.refreshQuota = async () => { + await change(runtime) + changed = true + return { quota: dueQuota, fresh: true } + } + const { __setLogTestSink, getLogLevel, setLogLevel } = await import( + '@cortexkit/anthropic-auth-core' + ) + const records: LogTestRecord[] = [] + const previousLevel = getLogLevel() + setLogLevel('warn') + __setLogTestSink((record) => records.push(record)) + try { + await manager.tick() + } finally { + __setLogTestSink(null) + setLogLevel(previousLevel) + runtime.close() + } + expect(changed).toBe(true) + expect(modelRequests).toBe(0) + return { records, refreshRequests } +} + describe('claude-prime — warn dedup (R3)', () => { // R3: on a fresh-check-ok-but-fire-time-token-refresh-fails path, // both the adapter-side `prime fire failed` warn (index.ts main @@ -20158,314 +20278,69 @@ describe('claude-prime — warn dedup (R3)', () => { }) test('R3: a fire-time main token refresh failure produces exactly one warn·prime·prime token refresh failed record (distinct from the generic fire-failed event)', async () => { - // Force a genuine token-refresh failure during the fire path. The - // getAuth returns no access token + a past expiry, so - // `getCurrentMainAccessToken` invokes `latestRefreshMainAccessToken`, - // which is mocked to throw. This is the ONLY way to exercise the - // `prime token refresh failed` event from the main path. - const records: any[] = [] - globalThis.fetch = mock((input: any) => { - const url = typeof input === 'string' ? input : input.url - if (url.includes('/v1/messages')) { - return Promise.resolve( - new Response( - JSON.stringify({ usage: { input_tokens: 0, output_tokens: 0 } }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(Date.now() - 1_000).toISOString(), - checked_at: Date.now(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch - - await useTempAccountFile( - createFallbackStorage({ - accounts: [], - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { - usedPercent: 0, - remainingPercent: 100, - resetsAt: new Date(Date.now() - 180_000).toISOString(), - checkedAt: 1, - }, - }, - mainQuotaCheckedAt: 1, - mainQuotaToken: 'fp-main', - }, - prime: { enabled: true }, - }), - ) - - const plugin = await getPlugin() - // The init loader calls getAuth once (call 1). Prime lineage - // reconciliation observes the refresh token (call 2), then the fresh-check - // calls getAuth (call 3) — return a valid token. The fire path calls getAuth - // (call 4) — return a no-access / past-expiry auth so the refresh path is - // exercised. The refresh function - // fetches the token endpoint which the mock returns 599 for — - // this is the genuine token-refresh failure. - let authCallCount = 0 - await plugin.auth.loader( - () => { - authCallCount += 1 - if (authCallCount <= 2) { - return Promise.resolve({ - type: 'oauth', - access: 'sk-ant-oat01-main-access', + const { records, refreshRequests } = await primeWarningsAfterQuotaCheck( + false, + async (runtime) => { + await runtime.loginOAuth({ + routeId: 'main', + replace: true, + accountIdentity: poolMainIdentity(), + credential: { + access: '', refresh: 'main-refresh', - expires: Date.now() + 3600_000, - }) - } - return Promise.resolve({ - type: 'oauth', - access: undefined, - refresh: 'main-refresh', - expires: Date.now() - 1000, + expires: Date.now() - 1_000, + }, }) }, - { models: {} }, ) - - // Capture logs via the dist sink (prime.ts compiled to dist/prime.js - // imports dist/logger.js; the dist sink captures all events). - const { __setLogTestSink, setLogLevel } = await import( - '@cortexkit/anthropic-auth-core' - ) - const setDistSink = __setLogTestSink - setLogLevel('warn') - setDistSink((r: any) => records.push(r)) - - const mgr = (plugin as any).__primeManager - // The fresh-check succeeds because the main quota is already - // stored. The fire path's `getCurrentMainAccessToken` calls - // `latestRefreshMainAccessToken` (because the cached access - // is missing/expired) and that function throws. This is the - // genuine token-refresh failure path. - // - // NOTE: the loader captures `getAuth` in a closure. The - // refresh function is also captured. We can't easily replace - // it from outside, so we rely on the getAuth returning a - // no-access / past-expiry auth, which forces the refresh - // path. The refresh function is the loader's own - // `refreshMainAccessToken`, which internally calls the - // Anthropic refresh endpoint. The mock fetch returns 599 for - // everything except /v1/messages, so the token refresh - // endpoint fetch fails with a network error, and the refresh - // function throws — which IS a token-refresh failure. - records.length = 0 - await mgr.tick() - setDistSink(null) - setLogLevel('info') - - // The token-refresh path throws because the mock fetch returns - // 599 for the token endpoint. The adapter catches the throw - // and tags it as `reason: 'token-refresh'` (the - // `isPrimeTokenRefresh` flag is set by the refresh wrapper in - // `getCurrentMainAccessToken`). The manager emits the distinct - // `prime token refresh failed` event. - const tokenRefreshWarns = records.filter( - (r) => - r.channel === 'prime' && - r.level === 'warn' && - r.message === 'prime token refresh failed', - ) - expect(tokenRefreshWarns).toHaveLength(1) - const fireFailedWarns = records.filter( - (r) => - r.channel === 'prime' && - r.level === 'warn' && - r.message === 'prime fire failed', - ) - expect(fireFailedWarns).toHaveLength(0) + expect(refreshRequests).toBe(3) + expect( + records.filter( + (record) => + record.channel === 'prime' && + record.level === 'warn' && + record.message === 'prime token refresh failed', + ), + ).toHaveLength(1) + expect( + records.filter((record) => record.message === 'prime fire failed'), + ).toHaveLength(0) }) test('R3-precision: a fire-time main auth-unavailable (latestGetAuth null) failure logs the GENERIC `prime fire failed` (NOT `prime token refresh failed`)', async () => { - // The main `getCurrentMainAccessToken` throws BEFORE reaching the - // refresh path (auth loader is null). This is NOT a token-refresh - // failure — it is a lifecycle / availability failure — and the - // manager must log the generic `prime fire failed` event, not the - // distinct `prime token refresh failed` event. - const records: any[] = [] - globalThis.fetch = mock((input: any) => { - const url = typeof input === 'string' ? input : input.url - if (url.includes('/v1/messages')) { - return Promise.resolve( - new Response( - JSON.stringify({ usage: { input_tokens: 0, output_tokens: 0 } }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ) - } - if (url.includes('/api/oauth/usage')) { - return freshPrimeQuotaResponse({ - five_hour: { - utilization: 0, - resets_at: new Date(Date.now() - 1_000).toISOString(), - checked_at: Date.now(), - }, - }) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch - - await useTempAccountFile( - createFallbackStorage({ - accounts: [], - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { - usedPercent: 0, - remainingPercent: 100, - resetsAt: new Date(Date.now() - 180_000).toISOString(), - checkedAt: 1, - }, - }, - mainQuotaCheckedAt: 1, - mainQuotaToken: 'fp-main', - }, - prime: { enabled: true }, - }), - ) - - const plugin = await getPlugin() - // Provide a getAuth so lineage reconciliation and the fresh-check succeed, - // but make the fire path's `latestGetAuth()` throw before any refresh is - // attempted. `getCurrentMainAccessToken` will throw from the - // `await latestGetAuth()` line, which is NOT a refresh failure. - let authCallCount = 0 - await plugin.auth.loader( + let activationUnavailable = false + const { records, refreshRequests } = await primeWarningsAfterQuotaCheck( + false, + async () => { + activationUnavailable = true + }, () => { - authCallCount += 1 - if (authCallCount >= 3) { + if (activationUnavailable) return Promise.reject( new Error('prime: main auth loader is not available'), ) - } - return Promise.resolve({ - type: 'oauth', - access: 'sk-ant-oat01-main-access', - refresh: 'main-refresh', - expires: Date.now() + 3600_000, - }) + return Promise.resolve({ type: 'oauth', ...syntheticMainHostAuth() }) }, - { models: {} }, - ) - - const { __setLogTestSink, setLogLevel } = await import( - '@cortexkit/anthropic-auth-core' ) - const setDistSink = __setLogTestSink - setLogLevel('warn') - setDistSink((r: any) => records.push(r)) - - const mgr = (plugin as any).__primeManager - records.length = 0 - await mgr.tick() - setDistSink(null) - setLogLevel('info') - - const tokenRefreshWarns = records.filter( - (r) => - r.channel === 'prime' && - r.level === 'warn' && - r.message === 'prime token refresh failed', - ) - expect(tokenRefreshWarns).toHaveLength(0) - const fireFailedWarns = records.filter( - (r) => - r.channel === 'prime' && - r.level === 'warn' && - r.message === 'prime fire failed', - ) - expect(fireFailedWarns).toHaveLength(1) + expect(refreshRequests).toBe(0) + expect( + records.filter( + (record) => record.message === 'prime token refresh failed', + ), + ).toHaveLength(0) + expect( + records.filter((record) => record.message === 'prime fire failed'), + ).toHaveLength(1) }) test('R3-precision: a fallback removed between fresh-check and fire logs `prime fire failed`', async () => { - const records: any[] = [] - const dueQuota = { - five_hour: { - usedPercent: 0, - remainingPercent: 100, - resetsAt: new Date(Date.now() - 180_000).toISOString(), - checkedAt: Date.now(), + const { records, refreshRequests } = await primeWarningsAfterQuotaCheck( + true, + async (runtime) => { + await runtime.remove('work-alt') }, - } - await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - id: 'work-alt', - type: 'oauth', - access: 'sk-ant-oat01-fb-access', - refresh: 'fb-refresh', - expires: Date.now() + 10 * 60 * 60 * 1000, - quota: dueQuota, - }, - ], - quota: { - enabled: true, - checkIntervalMinutes: 5, - mainQuota: { - five_hour: { - usedPercent: 0, - remainingPercent: 100, - resetsAt: new Date(Date.now() + 5 * 60 * 60_000).toISOString(), - checkedAt: Date.now(), - }, - }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: 'sk-ant-oat01-main-access', - }, - prime: { enabled: true }, - }), ) - - const plugin = await getPlugin() - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'sk-ant-oat01-main-access', - refresh: 'main-refresh', - expires: Date.now() + 3600_000, - }), - { models: {} }, - ) - const mgr = (plugin as any).__primeManager - mgr.options.refreshQuota = async () => { - const storage = await readAccountStorage() - if (!storage) throw new Error('missing test storage') - storage.accounts = [] - await saveAccounts(storage) - return { quota: dueQuota, fresh: true } - } - - const { __setLogTestSink, setLogLevel } = await import( - '@cortexkit/anthropic-auth-core' - ) - setLogLevel('warn') - __setLogTestSink((record: any) => records.push(record)) - await mgr.tick() - __setLogTestSink(null) - setLogLevel('info') - + expect(refreshRequests).toBe(0) expect( records.filter( (record) => record.message === 'prime token refresh failed', @@ -20477,80 +20352,26 @@ describe('claude-prime — warn dedup (R3)', () => { }) test('R3: a fallback refreshAccount failure logs `prime token refresh failed`', async () => { - const records: any[] = [] - const dueQuota = { - five_hour: { - usedPercent: 0, - remainingPercent: 100, - resetsAt: new Date(Date.now() - 180_000).toISOString(), - checkedAt: Date.now(), - }, - } - await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - id: 'work-alt', - type: 'oauth', - access: 'sk-ant-oat01-fb-access', + const { records, refreshRequests } = await primeWarningsAfterQuotaCheck( + true, + async (runtime) => { + const identity = (await runtime.read()).accounts.find( + (account) => account.id === 'work-alt', + )?.accountIdentity + expect(identity).toBeDefined() + await runtime.loginOAuth({ + routeId: 'work-alt', + replace: true, + accountIdentity: identity, + credential: { + access: '', refresh: 'fb-refresh', - expires: Date.now() + 5 * 60 * 60_000, - quota: dueQuota, - }, - ], - quota: { - enabled: true, - checkIntervalMinutes: 5, - mainQuota: { - five_hour: { - usedPercent: 0, - remainingPercent: 100, - resetsAt: new Date(Date.now() + 5 * 60 * 60_000).toISOString(), - checkedAt: Date.now(), - }, + expires: Date.now() - 1_000, }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: 'sk-ant-oat01-main-access', - }, - prime: { enabled: true }, - }), - ) - globalThis.fetch = mock(() => - Promise.resolve( - new Response('{"error":"invalid_grant"}', { status: 400 }), - ), - ) as unknown as typeof fetch - - const plugin = await getPlugin() - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'sk-ant-oat01-main-access', - refresh: 'main-refresh', - expires: Date.now() + 3600_000, - }), - { models: {} }, - ) - const storage = await readAccountStorage() - const fallback = storage!.accounts.find( - (account) => account.id === 'work-alt', - )! - if (fallback.type !== 'oauth') throw new Error('expected OAuth account') - fallback.expires = Date.now() - 1_000 - await saveAccounts(storage!) - const mgr = (plugin as any).__primeManager - mgr.options.refreshQuota = async () => ({ quota: dueQuota, fresh: true }) - - const { __setLogTestSink, setLogLevel } = await import( - '@cortexkit/anthropic-auth-core' + }) + }, ) - setLogLevel('warn') - __setLogTestSink((record: any) => records.push(record)) - await mgr.tick() - __setLogTestSink(null) - setLogLevel('info') - + expect(refreshRequests).toBe(3) expect( records.filter( (record) => record.message === 'prime token refresh failed', From beb5ce5498404f63261d6fc1172ddccd284b7032 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:00:51 +0200 Subject: [PATCH 099/169] Invalidate main quota cache on explicit credential replacement --- packages/opencode/src/index.ts | 37 ++++++ packages/opencode/src/tests/index.test.ts | 147 ++++++++++++++-------- 2 files changed, 129 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c0c6d963b..622eb9f71 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1177,6 +1177,9 @@ const anthropicAuthPlugin = async ( ? { connect: runtimeOverrides.claustrumScopedConnect } : undefined, }) + let mainQuotaCredentialEpoch: + | { rowId: string; credentialEpoch: number } + | undefined // The AccountStorage-shaped projection supplies quota, settings, and account // metadata to routing and display code. It contains no OAuth access/refresh // tokens or API keys; credential material comes only from native authorization. @@ -1186,6 +1189,16 @@ const anthropicAuthPlugin = async ( if (path !== accountStoragePath) throw new Error('Native account storage mismatch') const snapshot = await nativeAccounts.read() + if (!mainQuotaCredentialEpoch) { + const binding = snapshot.accounts.find( + (account) => account.id === 'main', + )?.binding + if (binding) + mainQuotaCredentialEpoch = { + rowId: binding.rowId, + credentialEpoch: binding.credentialEpoch, + } + } if ( snapshot.mode !== 'claustrum' || !snapshot.accounts.some((account) => account.source === 'vault') @@ -5327,6 +5340,30 @@ const anthropicAuthPlugin = async ( ) } const primarySnapshot = await nativeAccounts.read() + const primaryAccount = primarySnapshot.accounts.find( + (account) => account.id === 'main', + ) + const primaryBinding = primaryAccount?.binding + if ( + primaryAccount && + primaryBinding && + (!mainQuotaCredentialEpoch || + primaryBinding.rowId !== mainQuotaCredentialEpoch.rowId || + primaryBinding.credentialEpoch > + mainQuotaCredentialEpoch.credentialEpoch) + ) { + // An explicit replacement clears persisted quota, even when the + // verified account UUID stays the same. Clear the matching cache + // and fence old polls too; ordinary token refresh keeps its epoch. + quotaManager.clearMain() + quotaManager.setMainQuotaAccountIdentity( + primaryAccount.accountIdentity, + ) + mainQuotaCredentialEpoch = { + rowId: primaryBinding.rowId, + credentialEpoch: primaryBinding.credentialEpoch, + } + } if ( deferCredential && getRoutingMode(primarySnapshot.policyStorage) === 'fallback-first' diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 464f1d4c6..bbb619d14 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -13859,62 +13859,96 @@ describe('auth.loader', () => { ], }) await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'sticky-balanced' }, - refresh: { - enabled: true, - intervalMinutes: 10, - refreshBeforeExpiryMinutes: 240, - mainLastRefreshError: { - message: - 'Claude OAuth refresh failed: 400 — {"error":"invalid_grant"}', - checkedAt, - nextRetryAt: checkedAt + 24 * 60 * 60_000, - retryCount: 1, - tokenHash: hashRefreshToken('main-refresh'), - status: 400, - permanent: true, - }, - }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: quota(88), - mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), - }, - accounts: [ - { - id: 'fallback-a', - type: 'oauth', - access: 'sk-ant-oat01-fallback-a-access', - refresh: 'fallback-a-refresh', - expires: checkedAt + 5 * 60 * 60_000, - quota: quota(0), + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'sticky-balanced' }, + refresh: { + enabled: true, + intervalMinutes: 10, + refreshBeforeExpiryMinutes: 240, + mainLastRefreshError: { + message: + 'Claude OAuth refresh failed: 400 — {"error":"invalid_grant"}', + checkedAt, + nextRetryAt: checkedAt + 24 * 60 * 60_000, + retryCount: 1, + tokenHash: hashRefreshToken('main-refresh'), + status: 400, + permanent: true, + }, }, - { - id: 'fallback-b', - type: 'oauth', - access: 'sk-ant-oat01-fallback-b-access', - refresh: 'fallback-b-refresh', - expires: checkedAt + 5 * 60 * 60_000, - quota: quota(0), + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: quota(88), + mainQuotaCheckedAt: checkedAt, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), }, - ], - }), + accounts: [ + { + id: 'fallback-a', + type: 'oauth', + access: 'sk-ant-oat01-fallback-a-access', + refresh: 'fallback-a-refresh', + expires: checkedAt + 5 * 60 * 60_000, + quota: quota(0), + }, + { + id: 'fallback-b', + type: 'oauth', + access: 'sk-ant-oat01-fallback-b-access', + refresh: 'fallback-b-refresh', + expires: checkedAt + 5 * 60 * 60_000, + quota: quota(0), + }, + ], + }), + ), + { + access: 'sk-ant-oat01-main-access', + refresh: 'main-refresh', + expires: checkedAt - 1, + }, ) let messageRequests = 0 + let reLoginQuotaPolls = 0 + const servedAuthorizations: Array = [] globalThis.fetch = mock( - withNativeAdmission((input: string | URL | Request) => { - if (extractUrl(input).includes('/v1/messages')) messageRequests += 1 - return Promise.resolve(new Response('{}', { status: 200 })) - }), + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + reLoginQuotaPolls += 1 + return Promise.resolve( + Response.json({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 12, + scope: { model: { display_name: 'Fable' } }, + }, + ], + }), + ) + } + if (url.includes('/v1/messages')) { + messageRequests += 1 + servedAuthorizations.push( + new Headers(init?.headers).get('authorization'), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin() - let currentAuth = { + const currentAuth = { type: 'oauth' as const, access: 'sk-ant-oat01-main-access', refresh: 'main-refresh', @@ -13945,12 +13979,11 @@ describe('auth.loader', () => { }) expect(messageRequests).toBe(0) - currentAuth = { - type: 'oauth', + await replacePoolMainLogin({ access: 'sk-ant-oat01-main-access', refresh: 'relogged-main-refresh', expires: checkedAt + 5 * 60 * 60_000, - } + }) const recovered = await result.fetch(MESSAGES_URL, { method: 'POST', headers: { 'x-session-affinity': 'ses_sticky_no_fable_route' }, @@ -13961,10 +13994,14 @@ describe('auth.loader', () => { }), }) - expect(recovered.status).toBe(401) - expect(messageRequests).toBe(0) - const savedState = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) - expect(savedState.main?.lastRefreshError).toBeDefined() + expect(recovered.status).toBe(200) + expect(messageRequests).toBe(1) + expect(servedAuthorizations).toEqual(['Bearer sk-ant-oat01-main-access']) + expect(reLoginQuotaPolls).toBe(1) + const saved = await readAccountStorage() + expect(saved?.refresh?.mainLastRefreshError).toBeUndefined() + expect(saved?.quota?.mainQuota?.scoped?.[0]?.remainingPercent).toBe(88) + expect(saved?.quota?.mainQuota?.accountIdentity).toBe(poolMainIdentity()) }) test('sticky-balanced uses API routes only after confirmed OAuth exhaustion', async () => { From 0dce669c453b6c373decb2c4c6db9cda39a70720 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:16:54 +0200 Subject: [PATCH 100/169] Use native OAuth bearers in status profile regression fixtures --- .../src/tests/native-status-profile.test.ts | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/tests/native-status-profile.test.ts b/packages/opencode/src/tests/native-status-profile.test.ts index 605f0dfa3..9456a071c 100644 --- a/packages/opencode/src/tests/native-status-profile.test.ts +++ b/packages/opencode/src/tests/native-status-profile.test.ts @@ -42,22 +42,23 @@ const fallbackIdentity = '22222222-2222-4222-8222-222222222222' const otherIdentity = '33333333-3333-4333-8333-333333333333' /** Which account each synthetic bearer belongs to. */ const bearerIdentity: Record = { - 'synthetic-main-access': mainIdentity, - 'synthetic-main-rotated': mainIdentity, - 'synthetic-fallback-access': fallbackIdentity, - 'synthetic-fallback-rotated': fallbackIdentity, - 'synthetic-fallback-rotated-again': fallbackIdentity, - 'synthetic-other-access': otherIdentity, - 'synthetic-other-rotated': otherIdentity, + 'sk-ant-oat01-synthetic-main-access': mainIdentity, + 'sk-ant-oat01-synthetic-main-rotated': mainIdentity, + 'sk-ant-oat01-synthetic-fallback-access': fallbackIdentity, + 'sk-ant-oat01-synthetic-fallback-rotated': fallbackIdentity, + 'sk-ant-oat01-synthetic-fallback-rotated-again': fallbackIdentity, + 'sk-ant-oat01-synthetic-other-access': otherIdentity, + 'sk-ant-oat01-synthetic-other-rotated': otherIdentity, 'synthetic-vault-main-bearer': mainIdentity, 'synthetic-vault-fallback-bearer': fallbackIdentity, } /** The access token a synthetic refresh credential is exchanged for. */ const rotatedAccess: Record = { - 'synthetic-main-refresh': 'synthetic-main-rotated', - 'synthetic-fallback-refresh': 'synthetic-fallback-rotated', - 'synthetic-fallback-refresh-again': 'synthetic-fallback-rotated-again', - 'synthetic-other-refresh': 'synthetic-other-rotated', + 'synthetic-main-refresh': 'sk-ant-oat01-synthetic-main-rotated', + 'synthetic-fallback-refresh': 'sk-ant-oat01-synthetic-fallback-rotated', + 'synthetic-fallback-refresh-again': + 'sk-ant-oat01-synthetic-fallback-rotated-again', + 'synthetic-other-refresh': 'sk-ant-oat01-synthetic-other-rotated', } /** The tier the profile endpoint reports for each account. */ const accountTier: Record = { @@ -124,7 +125,7 @@ function localLegacyConfig() { id: 'fallback-route', type: 'oauth', enabled: true, - access: 'synthetic-fallback-access', + access: 'sk-ant-oat01-synthetic-fallback-access', refresh: 'synthetic-fallback-refresh', expires: Date.now() + 8 * 60 * 60_000, }, @@ -142,7 +143,7 @@ function localHostAuth() { return { anthropic: { type: 'oauth', - access: 'synthetic-main-access', + access: 'sk-ant-oat01-synthetic-main-access', refresh: 'synthetic-main-refresh', expires: Date.now() + 8 * 60 * 60_000, }, @@ -560,7 +561,7 @@ test('a fresh saved profile survives access rotation without a new profile reque runtime.close() } expect(await readFile(paths.state, 'utf8')).toContain( - 'synthetic-fallback-rotated', + 'sk-ant-oat01-synthetic-fallback-rotated', ) }, }, @@ -597,7 +598,7 @@ test('a profile read for a replaced account is not shown or saved for its succes await runtime.loginOAuth({ routeId: 'fallback-route', credential: { - access: 'synthetic-other-access', + access: 'sk-ant-oat01-synthetic-other-access', refresh: 'synthetic-other-refresh', expires: Date.now() + 8 * 60 * 60_000, }, @@ -780,7 +781,7 @@ test('a profile whose save was refused is not shown once the refusal is known', await runtime.loginOAuth({ routeId: 'fallback-route', credential: { - access: 'synthetic-fallback-rotated', + access: 'sk-ant-oat01-synthetic-fallback-rotated', refresh: 'synthetic-fallback-refresh-again', expires: Date.now() + 8 * 60 * 60_000, }, From b1ae4012621c539010f27c496d69701ba1173310 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:46:13 +0200 Subject: [PATCH 101/169] Exercise quota feed preservation through native pool writers --- packages/opencode/src/tests/index.test.ts | 314 ++++++++++++++-------- 1 file changed, 198 insertions(+), 116 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index bbb619d14..637478939 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1517,6 +1517,33 @@ async function readNativeSettings(): Promise> { } } +/** Publish a poll through the current native credential's guarded writer. */ +async function publishNativeMainQuota(quota: OAuthQuotaSnapshot) { + if (!migratedPool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + }) + try { + const authorization = await runtime.authorizeLocal('main') + if (authorization.status !== 'usable') + throw new Error( + `Main quota publication cannot authorize: ${authorization.status}`, + ) + if (!authorization.binding.identity) + throw new Error('Main quota publication requires an account UUID') + if ( + !(await runtime.publishLocal(authorization.subject, { + quota: { ...quota, accountIdentity: authorization.binding.identity }, + })) + ) + throw new Error('Main quota publication refused the current credential') + return authorization.subject + } finally { + runtime.close() + } +} + /** * Per-account runtime state as the native pool records it, in the shape of * the retired legacy state file: `main` and `accounts[routeId]`, each with @@ -2505,8 +2532,8 @@ describe('quota header feed integration', () => { let clock = 1_000_000 Date.now = () => clock try { - const mainAccountId = 'cache-seeded-main' - const accessToken = 'sk-ant-oat-cache-seeded' + const mainAccountId = syntheticMainAccountUuid + const accessToken = 'sk-ant-oat01-cache-seeded' const pollCheckedAt = 900_000 const initialPollQuota: OAuthQuotaSnapshot = { source: 'poll', @@ -2535,110 +2562,161 @@ describe('quota header feed integration', () => { checkedAt: pollCheckedAt, }, ] - const extraUsage = { + const extraUsage: NonNullable = { used: { amountMinor: 1261, currency: 'USD', exponent: 2 }, limit: { amountMinor: 10000, currency: 'USD', exponent: 2 }, utilizationPercent: 12.61, severity: 'normal', exhausted: false, } - const storage = createFallbackStorage({ + const storage = bindMainQuotaToAccount( + createFallbackStorage({ + mainAccountId, + quotaHeaderFeed: { enabled: true }, + accounts: [], + quota: { + ...createFallbackStorage().quota, + mainQuota: initialPollQuota, + mainQuotaCheckedAt: pollCheckedAt, + mainQuotaToken: tokenFingerprint(accessToken), + }, + }), + accessToken, mainAccountId, - quotaHeaderFeed: { enabled: true }, - accounts: [], - quota: { - ...createFallbackStorage().quota, - mainQuota: initialPollQuota, - mainQuotaCheckedAt: pollCheckedAt, - mainQuotaToken: tokenFingerprint(accessToken), - }, - }) - await useTempAccountFile(storage) - await saveAccountState( - storage, - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - { - mainQuota: true, - }, ) + await useTempAccountFile(storage, { + access: accessToken, + refresh: 'main-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }) process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ oauth_account: { account_uuid: mainAccountId } }), - ) - } - if (url.includes('/v1/messages')) { - return Promise.resolve( - new Response('{}', { + let startPollWriter: () => Promise = async () => { + throw new Error('Poll writer is not ready') + } + let modelRequests = 0 + globalThis.fetch = mock( + withNativeAdmission(async (input: Parameters[0]) => { + if (extractUrl(input).includes('/v1/messages')) { + if (modelRequests++ === 0) await startPollWriter() + return new Response('{}', { status: 200, headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.04', 'anthropic-ratelimit-unified-7d-utilization': '0.52', }, - }), - ) - } - return Promise.resolve(Response.json({})) - }) as unknown as typeof fetch - // Keep the first header persistence pending to model a slower writer on the same host. - const stateWriteLock = await acquireRefreshFileLock({ - name: 'state-write', - ttlMs: 10_000, - path: process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - renew: true, + }) + } + throw new Error(`Unexpected test endpoint: ${extractUrl(input)}`) + }), + ) as unknown as typeof fetch + const pollSubject = await publishNativeMainQuota(initialPollQuota) + if (!migratedPool) throw new Error('Missing migrated fixture') + const entered = bodyLifetime().gate() + const release = bodyLifetime().gate() + // Pause a real pool quota write after credential admission. The header + // harvest must reconcile the poll's fields when that write completes. + const writer = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + beforePoolWrite: async () => { + entered.open() + await release.wait + }, }) - if (!stateWriteLock) throw new Error('failed to hold account state lock') - - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: accessToken, - refresh: 'main-refresh', - expires: Date.now() + 100000, + let pollWriter: ReturnType | undefined + startPollWriter = async () => { + pollWriter = writer.publishLocal(pollSubject, { + quota: { ...initialPollQuota, scoped, extraUsage }, + }) + bodyLifetime().trackDetached(pollWriter) + await Promise.race([ + entered.wait, + pollWriter.then(() => { + throw new Error('Poll writer did not reach its pause') }), - { models: {} }, - ) - const firstResponse = await result.fetch(MESSAGES_URL, EMPTY_POST) - await firstResponse.text() - - const statePath = getAccountStatePath( - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - ) - const state = JSON.parse(await readFile(statePath, 'utf8')) as { - main?: Record + ]) } - state.main = { - ...(state.main ?? {}), - quota: { - ...initialPollQuota, - scoped, + let firstRequest: Promise | undefined + try { + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: accessToken, + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + // Response completion also records account use under the pool locks. + // Release the poll when headers reach the cache, not after that write. + firstRequest = Promise.resolve(result.fetch(MESSAGES_URL, EMPTY_POST)) + bodyLifetime().trackDetached(firstRequest) + await entered.wait + const cache = ( + plugin as unknown as { + __quotaManager: { + getMain(identity: string): { quota: OAuthQuotaSnapshot } | null + } + } + ).__quotaManager + for ( + let attempt = 0; + attempt < 100 && + cache.getMain(mainAccountId)?.quota.checkedAt !== clock; + attempt++ + ) + await Bun.sleep(10) + expect(cache.getMain(mainAccountId)?.quota.checkedAt).toBe(clock) + release.open() + if (!pollWriter) + throw new Error( + 'First model request did not start the native poll writer', + ) + expect(await pollWriter).toBe(true) + const firstResponse = await firstRequest + expect(firstResponse.status).toBe(200) + await firstResponse.text() + await waitForAccountStorage( + (loaded) => loaded?.quota?.mainQuota?.checkedAt === clock, + ) + clock += 1_000 + const secondResponse = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(secondResponse.status).toBe(200) + await secondResponse.text() + const secondEntry = ( + await waitForFeedEntries( + (entries) => + entries.length === 1 && + entries[0] !== null && + typeof entries[0] === 'object' && + 'observed_at_ms' in entries[0] && + entries[0].observed_at_ms === clock, + 'second header observation', + ) + )[0] + if ( + secondEntry === null || + typeof secondEntry !== 'object' || + !('quota' in secondEntry) + ) + throw new Error('Published feed entry has no quota') + const quota = secondEntry.quota + if (quota === null || typeof quota !== 'object') + throw new Error('Published quota is not an object') + expect('scoped' in quota ? quota.scoped : undefined).toEqual(scoped) + expect('extraUsage' in quota ? quota.extraUsage : undefined).toEqual( extraUsage, - }, - quotaCheckedAt: pollCheckedAt + 50_000, - quotaToken: tokenFingerprint(accessToken), - } - await writeFile(statePath, `${JSON.stringify(state)}\n`) - await stateWriteLock.release() - await waitForAccountStorage( - (loaded) => loaded?.quota?.mainQuotaCheckedAt === clock, - ) - - clock += 1_000 - const secondResponse = await result.fetch(MESSAGES_URL, EMPTY_POST) - await secondResponse.text() - const secondEntry = ( - await waitForFeedEntries( - (entries) => entries.length === 1, - 'one published entry', ) - )[0] as any - expect(secondEntry.quota.scoped).toEqual(scoped) - expect(secondEntry.quota.extraUsage).toEqual(extraUsage) - expect(secondEntry.quota.bindingWindow).toBe('claude-weekly-scoped-fable') + expect('bindingWindow' in quota ? quota.bindingWindow : undefined).toBe( + 'claude-weekly-scoped-fable', + ) + } finally { + release.open() + await Promise.allSettled([pollWriter, firstRequest]) + writer.close() + } } finally { Date.now = originalNow delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION @@ -2650,9 +2728,9 @@ describe('quota header feed integration', () => { const clock = 1_000_000 Date.now = () => clock try { - const mainAccountId = 'rotated-token-main' - const pollAccessToken = 'sk-ant-oat-poll-token' - const rotatedAccessToken = 'sk-ant-oat-rotated-token' + const mainAccountId = syntheticMainAccountUuid + const pollAccessToken = 'sk-ant-oat01-poll-token' + const rotatedAccessToken = 'sk-ant-oat01-rotated-token' const pollCheckedAt = 900_000 const scoped = [ { @@ -2700,42 +2778,47 @@ describe('quota header feed integration', () => { mainQuotaToken: tokenFingerprint(pollAccessToken), }, }) - await useTempAccountFile(storage) - await saveAccountState( - storage, - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - { mainQuota: true }, + await useTempAccountFile( + bindMainQuotaToAccount(storage, pollAccessToken, mainAccountId), + { + access: pollAccessToken, + refresh: 'poll-refresh-token', + expires: clock + 8 * 60 * 60_000, + }, ) process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ oauth_account: { account_uuid: mainAccountId } }), - ) - } - if (url.includes('/v1/messages')) { - return Promise.resolve( - new Response('{}', { + const served: Array = [] + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + if (!extractUrl(input).includes('/v1/messages')) + throw new Error(`Unexpected test endpoint: ${extractUrl(input)}`) + served.push(new Headers(init?.headers).get('authorization')) + return new Response('{}', { status: 200, headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.11', 'anthropic-ratelimit-unified-7d-utilization': '0.22', }, - }), - ) - } - return Promise.resolve(Response.json({})) - }) as unknown as typeof fetch + }) + }, + ), + ) as unknown as typeof fetch + mainAccountIssues(rotatedAccessToken) + await refreshPoolLoginElsewhere('main', { + access: rotatedAccessToken, + refresh: 'rotated-refresh-token', + expires: clock + 5 * 60 * 60_000, + }) const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: rotatedAccessToken, - refresh: 'rotated-refresh-token', - expires: clock + 5 * 60 * 60 * 1000, + access: pollAccessToken, + refresh: 'poll-refresh-token', + expires: clock + 8 * 60 * 60_000, }), { models: {} }, ) @@ -2752,9 +2835,8 @@ describe('quota header feed integration', () => { expect(persisted?.quota?.mainQuota?.bindingWindow).toBe( 'claude-weekly-scoped-fable', ) - expect(persisted?.quota?.mainQuotaToken).toBe( - tokenFingerprint(pollAccessToken), - ) + expect(persisted?.quota?.mainQuotaToken).toBe(mainAccountId) + expect(served).toEqual([`Bearer ${rotatedAccessToken}`]) } finally { Date.now = originalNow delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION From 6a31ce76c62e8192e42667bcbf897c073bbe1380 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:19:02 +0200 Subject: [PATCH 102/169] Admit response quota to cache only after native publication --- packages/opencode/src/index.ts | 104 +++++++-- packages/opencode/src/tests/index.test.ts | 244 ++++++++++++++-------- 2 files changed, 238 insertions(+), 110 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 622eb9f71..45c328a3e 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -2004,6 +2004,7 @@ const anthropicAuthPlugin = async ( async function persistPushedQuota( served: ServedQuotaHeaders, entry: QuotaEntry, + previousEntry: QuotaEntry | null | undefined, ): Promise { const snapshot = await nativeAccounts.read() const account = snapshot.accounts.find( @@ -2025,12 +2026,55 @@ const anthropicAuthPlugin = async ( ? await nativeAccounts.publishLocal(served.localSubject, { quota }) : false if (!persisted) { - if (served.accountId === 'main') quotaManager.clearMain() - else if (quotaManager.getAllFallbacks().get(served.accountId) === entry) + if (served.accountId === 'main') { + if ( + quotaManager.getMain(served.mainQuotaIdentity?.quotaKey) === + previousEntry + ) + quotaManager.clearMain() + } else if ( + quotaManager.getAllFallbacks().get(served.accountId) === previousEntry + ) { quotaManager.clearFallback(served.accountId) + } + logger.debug('quota', 'native quota publication refused', { + accountId: served.accountId, + }) return null } - return { ...entry, quota } + // The pool merges quota under its locks. A poll can finish while this + // publication waits, so read the committed result instead of replaying + // the pre-write merge into cache and dropping that poll's fields. + const currentSnapshot = await nativeAccounts.read() + const current = currentSnapshot.accounts.find( + (candidate) => candidate.id === served.accountId, + ) + if ( + !current?.quota || + current.accountIdentity !== served.anthropicAccountUuid || + current.source !== account.source + ) + return null + if ( + served.localSubject && + current.binding?.credentialEpoch !== + served.localSubject.binding.credentialEpoch + ) + return null + if ( + served.scopedAttempt && + current.credentialId !== served.scopedAttempt.credentialId + ) + return null + return { + ...entry, + quota: current.quota, + refreshAfter: getQuotaNextRefreshAt( + current.quota, + currentSnapshot.policyStorage, + entry.checkedAt, + ), + } } function logPersistFailure(error: unknown) { @@ -2185,33 +2229,51 @@ const anthropicAuthPlugin = async ( return } } - const entry = + const previousEntry = served.accountId === 'main' - ? quotaManager.pushMainFromHeaders( - mainQuotaIdentity?.quotaKey, - incoming, - ) - : quotaManager.pushFallbackFromHeaders(served.accountId, incoming, { - authLineageId: served.authLineageId, - }) - if (!entry) return + ? quotaManager.getMain(mainQuotaIdentity?.quotaKey) + : quotaManager.getAllFallbacks().get(served.accountId) + const checkedAt = incoming.checkedAt ?? Date.now() void (async () => { - let feedEntry = entry + let persistedEntry: QuotaEntry | null try { - const persistedEntry = await persistPushedQuota(served, entry) - if (persistedEntry === null) return - feedEntry = persistedEntry + persistedEntry = await persistPushedQuota( + served, + { quota: incoming, checkedAt, refreshAfter: checkedAt }, + previousEntry, + ) } catch (error) { logPersistFailure(error) + return } + if (persistedEntry === null) return + if ( + served.accountId === 'main' && + quotaManager.getMainQuotaIdentityGeneration() !== + mainQuotaIdentity?.generation + ) + return + // Publication checks the credential that supplied these headers before + // any cache or display can use them. A rejected old response must never + // become a transient quota reading for replacement credentials. + const entry = persistedEntry + if (served.accountId === 'main') + quotaManager.setMain(mainQuotaIdentity?.quotaKey, entry) + else + quotaManager.setFallback(served.accountId, entry, { + authLineageId: served.authLineageId, + }) void refreshSidebarQuota().catch(() => {}) - await publishQuotaHeaderFeed(served, feedEntry) + await publishQuotaHeaderFeed(served, entry) + logger.debug('quota', 'harvested response quota', { + account: served.accountId, + fiveHourPercent: entry.quota.five_hour?.usedPercent, + sevenDayPercent: entry.quota.seven_day?.usedPercent, + source: 'headers', + }) })().catch(logQuotaHeaderFeedFailure) - logger.debug('quota', 'harvested response quota', { + logger.trace('quota', 'response quota awaiting publication', { account: served.accountId, - fiveHourPercent: entry.quota.five_hour?.usedPercent, - sevenDayPercent: entry.quota.seven_day?.usedPercent, - source: 'headers', }) } catch (error) { warnQuotaNormalizeOnce(error) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 637478939..94e8ee424 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -41,6 +41,7 @@ import { getClaudeCodeIdentityForVerifiedAccount, hashRefreshToken, isCustodyTombstoneOAuth, + isNativeLocalCredentialValidation, isOAuthAccount, type LogTestRecord, loadAccounts, @@ -2637,6 +2638,8 @@ describe('quota header feed integration', () => { ]) } let firstRequest: Promise | undefined + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) try { const plugin = await getPlugin() const result = await plugin.auth.loader( @@ -2649,8 +2652,9 @@ describe('quota header feed integration', () => { }), { models: {} }, ) + setLogLevel('trace') // Response completion also records account use under the pool locks. - // Release the poll when headers reach the cache, not after that write. + // Release the poll after header arrival, not after that write. firstRequest = Promise.resolve(result.fetch(MESSAGES_URL, EMPTY_POST)) bodyLifetime().trackDetached(firstRequest) await entered.wait @@ -2661,26 +2665,35 @@ describe('quota header feed integration', () => { } } ).__quotaManager - for ( - let attempt = 0; - attempt < 100 && - cache.getMain(mainAccountId)?.quota.checkedAt !== clock; - attempt++ + await waitForLogRecord( + records, + (record) => + record.channel === 'quota' && + record.message === 'response quota awaiting publication' && + record.payload?.account === 'main', + 'header observation awaiting the held poll write', + ) + expect(cache.getMain(mainAccountId)?.quota.checkedAt).toBe( + pollCheckedAt, ) - await Bun.sleep(10) - expect(cache.getMain(mainAccountId)?.quota.checkedAt).toBe(clock) release.open() if (!pollWriter) throw new Error( 'First model request did not start the native poll writer', ) expect(await pollWriter).toBe(true) + expect((await readAccountStorage())?.quota?.mainQuota?.scoped).toEqual( + scoped, + ) const firstResponse = await firstRequest expect(firstResponse.status).toBe(200) await firstResponse.text() await waitForAccountStorage( (loaded) => loaded?.quota?.mainQuota?.checkedAt === clock, ) + expect((await readAccountStorage())?.quota?.mainQuota?.scoped).toEqual( + scoped, + ) clock += 1_000 const secondResponse = await result.fetch(MESSAGES_URL, EMPTY_POST) expect(secondResponse.status).toBe(200) @@ -2716,6 +2729,8 @@ describe('quota header feed integration', () => { release.open() await Promise.allSettled([pollWriter, firstRequest]) writer.close() + __setLogTestSink(null) + setLogLevel('info') } } finally { Date.now = originalNow @@ -2844,46 +2859,50 @@ describe('quota header feed integration', () => { }) test('stale fallback headers do not persist or publish after re-login', async () => { - const fallbackAccess = 'fallback-lineage-a-access' + const fallbackAccess = 'sk-ant-oat01-fallback-lineage-a-access' const fallbackCheckedAt = Date.now() - let releaseFallbackResponse: ((response: Response) => void) | undefined - let fallbackStarted: (() => void) | undefined - const fallbackResponse = new Promise((resolve) => { - releaseFallbackResponse = resolve - }) - const fallbackRequestStarted = new Promise((resolve) => { - fallbackStarted = resolve - }) + const responseGate = bodyLifetime().gate() + const startedGate = bodyLifetime().gate() + let fallbackValue = new Response('{}', { status: 503 }) + const fallbackResponse = responseGate.wait.then(() => fallbackValue) + const releaseFallbackResponse = (response: Response) => { + fallbackValue = response + responseGate.open() + } + const fallbackStarted = () => startedGate.open() + const fallbackRequestStarted = startedGate.wait await useTempAccountFile( - createFallbackStorage({ - quotaHeaderFeed: { enabled: true }, - quota: { - ...createFallbackStorage().quota, - checkIntervalMinutes: 60, - }, - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - access: fallbackAccess, - refresh: 'fallback-lineage-a-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - authLineageId: 'lineage-a', - quota: { - five_hour: { - usedPercent: 25, - remainingPercent: 75, - checkedAt: fallbackCheckedAt, - }, - seven_day: { - usedPercent: 30, - remainingPercent: 70, - checkedAt: fallbackCheckedAt, + bindPoolAccounts( + createFallbackStorage({ + quotaHeaderFeed: { enabled: true }, + quota: { + ...createFallbackStorage().quota, + checkIntervalMinutes: 60, + }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: fallbackAccess, + refresh: 'fallback-lineage-a-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + authLineageId: 'lineage-a', + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: fallbackCheckedAt, + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: fallbackCheckedAt, + }, }, }, - }, - ], - }), + ], + }), + ), ) globalThis.fetch = mock( withNativeAdmission((input: any, init?: RequestInit) => { @@ -2901,6 +2920,7 @@ describe('quota header feed integration', () => { ) as unknown as typeof fetch const records: LogTestRecord[] = [] + let responsePromise: Promise | undefined let fallbackAtPersistenceReject: unknown const sidebarStates: unknown[] = [] try { @@ -2921,8 +2941,7 @@ describe('quota header feed integration', () => { records.push(record) if ( record.channel === 'quota' && - record.message === - 'skipped stale fallback quota persistence after lineage change' + record.message === 'native quota publication refused' ) { queueMicrotask(() => { fallbackAtPersistenceReject = quotaManager @@ -2931,16 +2950,25 @@ describe('quota header feed integration', () => { }) } }) - expect( - quotaManager.getFallback('fallback-1', { - authLineageId: 'lineage-a', + responsePromise = Promise.resolve( + result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), }), - ).not.toBeNull() - const responsePromise = result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), - }) - await fallbackRequestStarted + ) + bodyLifetime().trackDetached(responsePromise) + await Promise.race([ + fallbackRequestStarted, + responsePromise.then(() => { + throw new Error( + 'Request completed without reaching the expected fallback', + ) + }), + ]) + expect( + quotaManager.getAllFallbacks().get('fallback-1')?.quota.five_hour + ?.usedPercent, + ).toBe(25) await drainSidebarWrites() __setSidebarStateWriteTestHooks({ beforeRename: async (_stateFile, tempFile) => { @@ -2948,30 +2976,57 @@ describe('quota header feed integration', () => { }, }) - const replacement = await loadAccounts( - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - ) - if (!replacement) throw new Error('fallback storage unexpectedly missing') - const replacementAccount = replacement.accounts.find( + if (!migratedPool) throw new Error('Missing migrated pool') + const replacementRuntime = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + }) + const newAccess = 'sk-ant-oat01-fallback-lineage-b-access' + loginIssues(fallbackAccess, newAccess) + try { + await replacementRuntime.loginOAuth({ + routeId: 'fallback-1', + accountIdentity: syntheticFallbackAccountUuid(0), + replace: true, + credential: { + access: newAccess, + refresh: 'fallback-lineage-b-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + }) + const subject = + await replacementRuntime.captureLocalSubject('fallback-1') + if (!isNativeLocalCredentialValidation(subject)) + throw new Error( + 'Replacement subject has no exact account and credential version', + ) + expect( + await replacementRuntime.publishLocal(subject, { + quota: { + accountIdentity: subject.binding.identity, + checkedAt: fallbackCheckedAt, + five_hour: { + usedPercent: 60, + remainingPercent: 40, + checkedAt: fallbackCheckedAt, + }, + seven_day: { + usedPercent: 70, + remainingPercent: 30, + checkedAt: fallbackCheckedAt, + }, + }, + }), + ).toBe(true) + } finally { + replacementRuntime.close() + } + const replacedAccount = (await readAccountStorage())?.accounts.find( (account) => account.id === 'fallback-1', ) - if (!replacementAccount || !isOAuthAccount(replacementAccount)) { - throw new Error('fallback account unexpectedly missing') - } - replacementAccount.authLineageId = 'lineage-b' - replacementAccount.quota = { - five_hour: { - usedPercent: 60, - remainingPercent: 40, - checkedAt: fallbackCheckedAt, - }, - seven_day: { - usedPercent: 70, - remainingPercent: 30, - checkedAt: fallbackCheckedAt, - }, - } - await saveAccounts(replacement) + if (!replacedAccount || !isOAuthAccount(replacedAccount)) + throw new Error('Replacement OAuth account is missing') + expect(replacedAccount.quota?.five_hour?.usedPercent).toBe(60) setLogLevel('trace') releaseFallbackResponse?.( @@ -2992,10 +3047,8 @@ describe('quota header feed integration', () => { (record) => record.level === 'debug' && record.channel === 'quota' && - record.message === - 'skipped stale fallback quota persistence after lineage change' && - record.payload?.accountId === 'fallback-1' && - record.payload?.servedLineage === 'lineage-a', + record.message === 'native quota publication refused' && + record.payload?.accountId === 'fallback-1', 'stale fallback quota persistence discard', ) await drainSidebarWrites() @@ -3009,16 +3062,29 @@ describe('quota header feed integration', () => { : undefined, ).toBe(fallbackCheckedAt) expect(fallbackAtPersistenceReject).toBeUndefined() - // A rejected harvest must not add a quota-only refresh that re-reads the - // replacement account's newer persisted numbers. - expect( - sidebarStates.some( - (state: any) => - state.fallbacks?.[0]?.quota?.five_hour?.usedPercent === 60, - ), - ).toBe(false) + if (!settledAccount || !isOAuthAccount(settledAccount)) + throw new Error('Settled OAuth account is missing') + // Route completion may display the replacement's verified quota. The + // refused old response must not supply its quota to that display. + expect(sidebarStates).not.toContainEqual( + expect.objectContaining({ + fallbacks: expect.arrayContaining([ + expect.objectContaining({ + id: 'fallback-1', + quota: expect.objectContaining({ + five_hour: expect.objectContaining({ usedPercent: 4 }), + }), + }), + ]), + }), + ) + expect(settledAccount?.quota?.five_hour?.usedPercent).toBe(60) + expect(settledAccount?.quota?.seven_day?.usedPercent).toBe(70) expect(await readFeedEntries()).toEqual([]) } finally { + responseGate.open() + await Promise.allSettled([responsePromise, fallbackResponse]) + await drainSidebarWrites() __setLogTestSink(null) __setSidebarStateWriteTestHooks(null) setLogLevel('info') From 7deb2258bb513d7c19de2b7469fa4e48b10a96fd Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:36:40 +0200 Subject: [PATCH 103/169] Bind fallback quota caches to native credential generations --- packages/core/src/native-account-view.ts | 10 ++ .../src/tests/native-account-runtime.test.ts | 30 ++-- .../src/tests/native-account-view.test.ts | 33 ++++ .../src/tests/native-status-profile.test.ts | 2 +- packages/opencode/src/tests/index.test.ts | 159 +++++++++++------- 5 files changed, 155 insertions(+), 79 deletions(-) diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts index 1f0a6b462..71cd561d4 100644 --- a/packages/core/src/native-account-view.ts +++ b/packages/core/src/native-account-view.ts @@ -588,6 +588,16 @@ export function projectNativeAccountViews(input: { | undefined, claustrumScopedCredentialId: row.credentialId, claustrumScopedState: row.state, + // Quota caches discard replaced logins when this identifier changes. + // Pool and row IDs, account UUID and credential epoch stay stable + // during token refresh; the epoch changes when login is replaced. + // Vault entries use credential ID and account UUID. No tokens enter + // this identifier. + authLineageId: row.binding + ? JSON.stringify(row.binding) + : row.credentialId + ? JSON.stringify([row.credentialId, row.accountIdentity]) + : undefined, quota: row.quota, profile: row.profile, prime: row.prime, diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts index 115924d46..8a1ce859a 100644 --- a/packages/core/src/tests/native-account-runtime.test.ts +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -50,7 +50,7 @@ async function fixture( identity: uuid, credential: { type: 'oauth', - access: 'synthetic-access', + access: 'sk-ant-oat01-synthetic-access', refresh: 'synthetic-refresh', expires: now + 3_600_000, }, @@ -75,7 +75,7 @@ async function fixture( refreshToken: async () => { counts.refresh++ return { - access: 'synthetic-successor', + access: 'sk-ant-oat01-synthetic-successor', refresh: 'synthetic-new-refresh', expires: now + 4_000_000, expiresIn: 4000, @@ -111,7 +111,7 @@ test('native facade reads no legacy authority and does not import legacy persist const read = await f.runtime.read() expect(read.accounts[0]?.id).toBe('main') expect(f.counts.lookups).toBe(0) - expect(JSON.stringify(read)).not.toContain('synthetic-access') + expect(JSON.stringify(read)).not.toContain('sk-ant-oat01-synthetic-access') const result = await f.runtime.authorizeLocal('main') expect(result.status).toBe('usable') expect(f.counts.lookups).toBe(1) @@ -161,8 +161,8 @@ test('native background quota/profile authorize positively and refresh one rejec ) const quota = await f.runtime.fetchQuota('main', transport) expect(headers).toEqual([ - 'Bearer synthetic-access', - 'Bearer synthetic-successor', + 'Bearer sk-ant-oat01-synthetic-access', + 'Bearer sk-ant-oat01-synthetic-successor', ]) expect(quota.scoped).toEqual([]) expect(quota.checkedAt).toBe(now) @@ -185,7 +185,7 @@ test('shared local recovery never captures request model policy', async () => { entered.open() await release.wait return { - access: 'synthetic-shared-successor', + access: 'sk-ant-oat01-synthetic-shared-successor', refresh: 'synthetic-shared-refresh', expires: now + 4_000_000, expiresIn: 4000, @@ -194,7 +194,7 @@ test('shared local recovery never captures request model policy', async () => { }) await f.store.rotate('imported-main', { type: 'oauth', - access: 'synthetic-expired', + access: 'sk-ant-oat01-synthetic-expired', refresh: 'synthetic-refresh', expires: now - 1, }) @@ -303,7 +303,7 @@ test('backoff reset fences exact refresh lineage and monotonically clears native ).toBe(1) await f.store.rotate('imported-main', { type: 'oauth', - access: 'synthetic-new-access', + access: 'sk-ant-oat01-synthetic-new-access', refresh: 'synthetic-new-lineage', expires: now + 4_000_000, }) @@ -336,7 +336,7 @@ test('native lineage and cumulative Prime updates are atomic and replacement fen accountIdentity: uuid, replace: true, credential: { - access: 'synthetic-relogin', + access: 'sk-ant-oat01-synthetic-relogin', refresh: 'synthetic-relogin-refresh', expires: now + 4_000_000, }, @@ -398,7 +398,7 @@ test('normal local refresh preserves Prime lineage and counters but relogin star replace: true, accountIdentity: uuid, credential: { - access: 'synthetic-relogin', + access: 'sk-ant-oat01-synthetic-relogin', refresh: 'synthetic-relogin-refresh', expires: now + 4_000_000, }, @@ -559,7 +559,7 @@ test('authorization recovers an interrupted replacement epoch without reusing ol 'imported-main', { type: 'oauth', - access: 'synthetic-replaced-access', + access: 'sk-ant-oat01-synthetic-replaced-access', refresh: 'synthetic-replaced-refresh', expires: now + 4_000_000, }, @@ -653,7 +653,7 @@ test('cross-process remove and readd same native route and UUID creates new Prim routeId: 'main', accountIdentity: uuid, credential: { - access: 'synthetic-readded-access', + access: 'sk-ant-oat01-synthetic-readded-access', refresh: 'synthetic-readded-refresh', expires: now + 4_000_000, }, @@ -686,7 +686,7 @@ test('cross-process remove and readd same native route and UUID creates new Prim refreshToken: async () => { staleCalls++ return { - access: 'synthetic-never-used', + access: 'sk-ant-oat01-synthetic-never-used', refresh: 'synthetic-never-used-refresh', expires: now + 4_000_000, expiresIn: 4000, @@ -1053,7 +1053,7 @@ test('only an add that creates a new account row logs account added, without cre await f.runtime.loginOAuth({ routeId: 'fallback-oauth', credential: { - access: 'synthetic-oauth-access', + access: 'sk-ant-oat01-synthetic-oauth-access', refresh: 'synthetic-oauth-refresh', expires: now + 3_600_000, }, @@ -1215,7 +1215,7 @@ test('local enable and disable log only a flag change made by their own write', identity: uuid, credential: { type: 'oauth', - access: 'synthetic-dup-access', + access: 'sk-ant-oat01-synthetic-dup-access', refresh: 'synthetic-dup-refresh', expires: now + 3_600_000, }, diff --git a/packages/core/src/tests/native-account-view.test.ts b/packages/core/src/tests/native-account-view.test.ts index 2385c7c75..7335f7c87 100644 --- a/packages/core/src/tests/native-account-view.test.ts +++ b/packages/core/src/tests/native-account-view.test.ts @@ -91,6 +91,39 @@ test('secret-free native policy projection preserves primary and fallback order expect(snapshot.policyStorage.prime?.main).toBeUndefined() }) +test('fallback quota lineage changes on replacement but survives ordinary token rotation without exposing secrets', () => { + const lineage = (candidate: PoolRow) => { + const account = projectNativeAccountViews({ + paths, + rows: [row, candidate], + settings: { mainAccountId: row.id }, + }).policyStorage.accounts[0] + return account?.type === 'oauth' ? account.authLineageId : undefined + } + const fallback = { ...row, id: 'fallback' } + const first = lineage(fallback) + expect(typeof first).toBe('string') + if (!first) throw new Error('Fallback has no credential lineage') + expect(first).not.toContain('synthetic-secret') + const rotated = lineage({ + ...fallback, + credential: { + type: 'oauth', + access: 'rotated-access-secret', + refresh: 'rotated-refresh-secret', + expires: 2000, + }, + }) + expect(rotated).toBe(first) + expect(rotated).not.toContain('rotated-access-secret') + expect(rotated).not.toContain('rotated-refresh-secret') + expect(lineage({ ...fallback, credentialEpoch: 3 })).not.toBe(first) + expect( + lineage({ ...fallback, identity: '22222222-2222-4333-8444-555555555555' }), + ).not.toBe(first) + expect(lineage({ ...fallback, id: 'another-fallback' })).not.toBe(first) +}) + test('vault primary alias maps to main while runtime scopes and windowless freshness survive', () => { const state: NativeRuntimeState = { version: 1, diff --git a/packages/core/src/tests/native-status-profile.test.ts b/packages/core/src/tests/native-status-profile.test.ts index 555335dcf..9c05b42b0 100644 --- a/packages/core/src/tests/native-status-profile.test.ts +++ b/packages/core/src/tests/native-status-profile.test.ts @@ -51,7 +51,7 @@ async function localFixture() { identity: uuid, credential: { type: 'oauth', - access: 'synthetic-access', + access: 'sk-ant-oat01-synthetic-access', refresh: 'synthetic-refresh', expires: now + 3_600_000, }, diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 94e8ee424..3a57f20fd 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -3093,52 +3093,79 @@ describe('quota header feed integration', () => { test('main-route sidebar writes use current fallback storage after re-login', async () => { const fallbackCheckedAt = Date.now() - let releaseMainResponse: ((response: Response) => void) | undefined - let mainStarted: (() => void) | undefined - const mainResponse = new Promise((resolve) => { - releaseMainResponse = resolve - }) - const mainRequestStarted = new Promise((resolve) => { - mainStarted = resolve - }) + const fallbackAccess = 'sk-ant-oat01-sidebar-fallback-a' + const responseGate = bodyLifetime().gate() + const startedGate = bodyLifetime().gate() + let mainValue = new Response('{}', { status: 503 }) + const mainResponse = responseGate.wait.then(() => mainValue) + const releaseMainResponse = (response: Response) => { + mainValue = response + responseGate.open() + } + const mainStarted = () => startedGate.open() + const mainRequestStarted = startedGate.wait + let responsePromise: Promise | undefined await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - refresh: 'fallback-lineage-a-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - authLineageId: 'lineage-a', - quota: { + bindPoolAccounts( + createFallbackStorage({ + quota: { + ...createFallbackStorage().quota, + mainQuota: { + source: 'poll', + checkedAt: fallbackCheckedAt, five_hour: { - usedPercent: 25, - remainingPercent: 75, + usedPercent: 4, + remainingPercent: 96, checkedAt: fallbackCheckedAt, }, seven_day: { - usedPercent: 30, - remainingPercent: 70, + usedPercent: 12, + remainingPercent: 88, checkedAt: fallbackCheckedAt, }, }, + mainQuotaCheckedAt: fallbackCheckedAt, }, - ], - }), + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: fallbackAccess, + refresh: 'fallback-lineage-a-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + authLineageId: 'lineage-a', + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: fallbackCheckedAt, + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: fallbackCheckedAt, + }, + }, + }, + ], + }), + ), ) globalThis.fetch = mock( - withNativeAdmission((input: any, init?: RequestInit) => { - const url = extractUrl(input) - if (url.includes('/v1/messages')) { - const token = new Headers(init?.headers).get('authorization') - if (token === 'Bearer sk-ant-oat01-main-access') { - mainStarted?.() - return mainResponse + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + const token = new Headers(init?.headers).get('authorization') + if (token === 'Bearer sk-ant-oat01-main-access') { + mainStarted?.() + return mainResponse + } + return Promise.resolve(new Response(null, { status: 429 })) } - return Promise.resolve(new Response(null, { status: 429 })) - } - return Promise.resolve(Response.json({})) - }), + return Promise.resolve(Response.json({})) + }, + ), ) as unknown as typeof fetch try { @@ -3155,41 +3182,44 @@ describe('quota header feed integration', () => { { models: {} }, ) const quotaManager = plugin.__quotaManager + responsePromise = Promise.resolve( + result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), + }), + ) + bodyLifetime().trackDetached(responsePromise) + await Promise.race([ + mainRequestStarted, + responsePromise.then(() => { + throw new Error('Request completed without reaching main') + }), + ]) expect( quotaManager.getAllFallbacks().get('fallback-1')?.quota.five_hour ?.usedPercent, ).toBe(25) - - const responsePromise = result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ model: 'claude-sonnet-4-5', messages: [] }), + if (!migratedPool) throw new Error('Missing migrated pool') + const replacementRuntime = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', }) - await mainRequestStarted - - const replacement = await loadAccounts( - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - ) - if (!replacement) throw new Error('fallback storage unexpectedly missing') - const replacementAccount = replacement.accounts.find( - (account) => account.id === 'fallback-1', - ) - if (!replacementAccount || !isOAuthAccount(replacementAccount)) { - throw new Error('fallback account unexpectedly missing') - } - replacementAccount.authLineageId = 'lineage-b' - replacementAccount.quota = { - five_hour: { - usedPercent: 60, - remainingPercent: 40, - checkedAt: fallbackCheckedAt, - }, - seven_day: { - usedPercent: 70, - remainingPercent: 30, - checkedAt: fallbackCheckedAt, - }, + try { + const replacementAccess = 'sk-ant-oat01-sidebar-fallback-b' + loginIssues(fallbackAccess, replacementAccess) + await replacementRuntime.loginOAuth({ + routeId: 'fallback-1', + replace: true, + accountIdentity: syntheticFallbackAccountUuid(0), + credential: { + access: replacementAccess, + refresh: 'fallback-lineage-b-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + }) + } finally { + replacementRuntime.close() } - await saveAccounts(replacement) releaseMainResponse?.(new Response('{}', { status: 200 })) const response = await responsePromise @@ -3202,6 +3232,9 @@ describe('quota header feed integration', () => { ).toBeUndefined() await drainSidebarWrites() } finally { + responseGate.open() + await Promise.allSettled([responsePromise, mainResponse]) + await drainSidebarWrites() setLogLevel('info') } }) From db9f4bc83da000a67d25384d921302079f5689e1 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:04:46 +0200 Subject: [PATCH 104/169] Exercise native main identity and quota-feed fences --- packages/opencode/src/tests/index.test.ts | 415 +++++++++++----------- 1 file changed, 216 insertions(+), 199 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 3a57f20fd..6834639c0 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1018,7 +1018,6 @@ async function nativeBootstrapAnswer( return Response.json({ oauth_account: { account_uuid: identity } }) } -/** Answer OAuth exchange and account-bootstrap requests; pass other requests to the test. */ function withNativeAdmission( handler: (input: Input, init: Init) => unknown, ): (input: Input, init: Init) => Promise { @@ -1066,11 +1065,14 @@ function withNativeBootstrap( * Replace the primary OAuth credential through a separate account runtime. * With sameAccount=true, register the new access token under the existing * account UUID; otherwise the test supplies its own bootstrap response. - * The tested plugin must discover the replacement from shared storage. + * accountIdentity supplies an already known UUID; token validation still + * runs before serving. The plugin discovers the change from shared storage. */ async function replacePoolMainLogin( credential: { access: string; refresh: string; expires: number }, - options: { sameAccount: boolean } = { sameAccount: true }, + options: { sameAccount: boolean; accountIdentity?: string } = { + sameAccount: true, + }, ) { const pool = migratedPool if (!pool) throw new Error('This test has no migrated pool') @@ -1083,7 +1085,14 @@ async function replacePoolMainLogin( host: 'opencode', }) try { - await runtime.loginOAuth({ routeId: 'main', replace: true, credential }) + await runtime.loginOAuth({ + routeId: 'main', + replace: true, + credential, + ...(options.accountIdentity + ? { accountIdentity: options.accountIdentity } + : {}), + }) } finally { runtime.close() } @@ -3308,27 +3317,18 @@ describe('quota header feed integration', () => { const requestCheckedAt = 1_000_000 Date.now = () => requestCheckedAt try { - const accountIdentity = 'account-a' - const accessToken = 'sk-ant-oat-cross-account-timestamp' - await useTempAccountFile( + const accountIdentity = syntheticMainAccountUuid + const accessToken = 'sk-ant-oat01-cross-account-timestamp' + const otherCheckedAt = 2_000_000 + const storage = bindMainQuotaToAccount( createFallbackStorage({ mainAccountId: 'main-slot', quotaHeaderFeed: { enabled: true }, accounts: [], - }), - ) - const otherCheckedAt = 2_000_000 - const statePath = getAccountStatePath( - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - ) - await writeFile( - statePath, - `${JSON.stringify({ - version: 1, - main: { - quota: { + quota: { + ...createFallbackStorage().quota, + mainQuota: { source: 'headers', - accountIdentity, five_hour: { usedPercent: 4, remainingPercent: 96, @@ -3340,36 +3340,44 @@ describe('quota header feed integration', () => { checkedAt: requestCheckedAt, }, }, - quotaCheckedAt: otherCheckedAt, - quotaToken: tokenFingerprint(accessToken), + mainQuotaCheckedAt: otherCheckedAt, }, - })}\n`, + }), + accessToken, + accountIdentity, ) + await useTempAccountFile(storage, { + access: accessToken, + refresh: 'main-refresh', + expires: requestCheckedAt + 8 * 60 * 60_000, + }) + // Migration orders quota by the account-bound snapshot, not the unrelated + // standalone clock. The runtime projection must retain that ordering. const mixedState = await readAccountStorage() expect(mixedState?.quota?.mainQuota?.accountIdentity).toBe( accountIdentity, ) - expect(mixedState?.quota?.mainQuotaCheckedAt).toBe(otherCheckedAt) - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ oauth_account: { account_uuid: accountIdentity } }), - ) - } - if (url.includes('/v1/messages')) { - return Promise.resolve( - new Response('{}', { + expect(mixedState?.quota?.mainQuotaCheckedAt).toBe(requestCheckedAt) + globalThis.fetch = Object.assign( + mock(async (input: Parameters[0], init?: RequestInit) => { + const admitted = await nativeAdmissionAnswer(input, init) + if (admitted) return admitted + if (extractUrl(input).includes('/v1/messages')) + return new Response('{}', { status: 200, headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.04', 'anthropic-ratelimit-unified-7d-utilization': '0.52', }, - }), - ) - } - return Promise.resolve(Response.json({})) - }) as unknown as typeof fetch + }) + return Response.json({}) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, + }, + ) const plugin = await getPlugin() const result = await plugin.auth.loader( () => @@ -3377,31 +3385,31 @@ describe('quota header feed integration', () => { type: 'oauth' as const, access: accessToken, refresh: 'main-refresh', - expires: Date.now() + 100000, + expires: requestCheckedAt + 8 * 60 * 60_000, }), { models: {} }, ) const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(response.status).toBe(200) await response.text() - - const published = ( - await waitForFeedEntries( - (entries) => - entries.length === 1 && - (entries[0] as any)?.account_ref === 'main-slot', - 'one account-a published entry', - ) - )[0] as any - expect(published.account_ref).toBe('main-slot') - expect(published.anthropic_account_uuid).toBe(accountIdentity) - expect(published.observed_at_ms).toBe(requestCheckedAt) - expect(published.observed_at_ms).not.toBe(otherCheckedAt) + const entries = await waitForFeedEntries( + (entries) => entries.length === 1, + 'one account-bound header observation', + ) + expect(entries[0]).toMatchObject({ + account_ref: accountIdentity, + anthropic_account_uuid: accountIdentity, + observed_at_ms: requestCheckedAt, + }) + expect(entries[0]).not.toMatchObject({ observed_at_ms: otherCheckedAt }) } finally { Date.now = originalNow } }) test('withholds the main feed entry when bootstrap cannot identify the account', async () => { + let modelCalls = 0 + let bootstrapCalls = 0 await useTempAccountFile( createFallbackStorage({ mainAccountId: 'unknown-main', @@ -3409,195 +3417,204 @@ describe('quota header feed integration', () => { accounts: [], }), { - access: 'sk-ant-oat-unknown-feed', + access: 'sk-ant-oat01-unknown-feed', refresh: 'unknown-refresh', - expires: Date.now() + 100000, + expires: Date.now() + 8 * 60 * 60_000, + }, + ) + globalThis.fetch = Object.assign( + mock((input: Parameters[0]) => { + const url = extractUrl(input) + if (url.includes('/v1/oauth/token')) + return Promise.resolve( + Response.json({ + access_token: 'sk-ant-oat01-unknown-feed-successor', + refresh_token: 'unknown-refresh-successor', + expires_in: 28800, + }), + ) + if (url.includes('/claude_cli/bootstrap')) bootstrapCalls++ + if (url.includes('/v1/messages')) { + modelCalls++ + return Promise.resolve(new Response('{}', { status: 200 })) + } + return Promise.resolve(Response.json({})) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, }, ) - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/v1/messages')) { - return Promise.resolve( - new Response('{}', { - status: 200, - headers: { - 'anthropic-ratelimit-unified-5h-utilization': '0.04', - 'anthropic-ratelimit-unified-7d-utilization': '0.52', - }, - }), - ) - } - return Promise.resolve(Response.json({})) - }) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'sk-ant-oat-unknown-feed', + access: 'sk-ant-oat01-unknown-feed', refresh: 'unknown-refresh', expires: Date.now() + 100000, }), { models: {} }, ) - const response = await result.fetch(MESSAGES_URL, EMPTY_POST) - await response.text() + await expect(result.fetch(MESSAGES_URL, EMPTY_POST)).rejects.toThrow( + 'Claude OAuth refresh is backed off', + ) + expect(bootstrapCalls).toBeGreaterThan(0) + expect(modelCalls).toBe(0) expect(await readFeedEntries()).toEqual([]) }) test('main account replacement fences quota, backoff, and feed identity', async () => { - const originalProfileHydration = - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - try { - await useTempAccountFile({ - ...createFallbackStorage({ - quotaHeaderFeed: { enabled: true }, + const uuidA = syntheticMainAccountUuid + const uuidB = syntheticFallbackAccountUuid(99) + const accessA = 'sk-ant-oat01-token-a' + const accessB = 'sk-ant-oat01-token-b' + const checkedAt = Date.now() + const oldPollEntered = bodyLifetime().gate() + const releaseOldPoll = bodyLifetime().gate() + let priorPoll: Promise | undefined + let modelRequest: Promise | undefined + await useTempAccountFile( + bindMainQuotaToAccount( + createFallbackStorage({ mainAccountId: 'main-slot', + accounts: [], + quotaHeaderFeed: { enabled: true }, quota: { enabled: true, checkIntervalMinutes: 5, failClosedOnUnknownQuota: false, + mainQuota: { + source: 'poll', + checkedAt, + five_hour: { usedPercent: 100, remainingPercent: 0, checkedAt }, + }, + mainQuotaCheckedAt: checkedAt, }, }), - accounts: [], - }) - - let releaseOldPoll!: (response: Response) => void - let oldPollEntered!: () => void - const oldPollStarted = new Promise((resolve) => { - oldPollEntered = resolve - }) - const oldPoll = new Promise((resolve) => { - releaseOldPoll = resolve - }) - globalThis.fetch = mock((input: any, init?: RequestInit) => { + accessA, + uuidA, + ), + { + access: accessA, + refresh: 'refresh-a', + expires: checkedAt + 8 * 60 * 60_000, + }, + ) + globalThis.fetch = Object.assign( + mock(async (input: Parameters[0], init?: RequestInit) => { const url = extractUrl(input) - if (url.includes('/api/claude_cli/bootstrap')) { - const token = new Headers(init?.headers).get('authorization') - return Promise.resolve( - Response.json({ - oauth_account: { - account_uuid: token?.includes('token-a') - ? 'account-a' - : 'account-b', - }, - }), - ) - } + const token = new Headers(init?.headers).get('authorization') + if (url.includes('/claude_cli/bootstrap')) + return Response.json({ + oauth_account: { + account_uuid: token === `Bearer ${accessA}` ? uuidA : uuidB, + }, + }) if (url.includes('/api/oauth/usage')) { - const token = new Headers(init?.headers).get('authorization') - if (token?.includes('token-a')) { - oldPollEntered() - return oldPoll + if (token === `Bearer ${accessA}`) { + oldPollEntered.open() + await releaseOldPoll.wait + return new Response('rate limited', { status: 429 }) } - return Promise.resolve( - Response.json({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 20 }, - }), - ) + return Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }) } - return Promise.resolve( - new Response('{}', { + if (url.includes('/v1/messages')) + return new Response('{}', { status: 200, headers: { 'anthropic-ratelimit-unified-5h-utilization': '0.2', - 'anthropic-ratelimit-unified-5h-reset': '1800000000', 'anthropic-ratelimit-unified-7d-utilization': '0.3', - 'anthropic-ratelimit-unified-7d-reset': '1800000000', }, - }), - ) - }) as unknown as typeof fetch - - const plugin = await getPlugin() - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: 'sk-ant-oat-token-a', - refresh: 'refresh-a', - expires: Date.now() + 100_000, - }), - { models: {} }, - ) - const quotaManager = plugin.__quotaManager - const checkedAt = Date.now() - quotaManager.setMain('main-slot', { - quota: { - accountIdentity: 'main-slot', - five_hour: { - usedPercent: 100, - remainingPercent: 0, - checkedAt, - }, + }) + return Response.json({}) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') }, - refreshAfter: checkedAt + 300_000, - checkedAt, - }) - const priorPoll = quotaManager.refreshMain( - 'main-slot', - 'sk-ant-oat-token-a', - ) - await oldPollStarted - - const resultB = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: 'sk-ant-oat-token-b', - refresh: 'refresh-b', - expires: Date.now() + 100_000, - }), - { models: {} }, - ) - expect(quotaManager.getMain('main-slot')).toBeNull() - const replacementPoll = quotaManager.refreshMain( - 'main-slot', - 'sk-ant-oat-token-b', + }, + ) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: accessA, + refresh: 'refresh-a', + expires: checkedAt + 8 * 60 * 60_000, + }), + { models: {} }, + ) + const quotaManager = plugin.__quotaManager + try { + const oldRequest = quotaManager.refreshMain(uuidA, accessA) + priorPoll = oldRequest + bodyLifetime().trackDetached(Promise.allSettled([oldRequest])) + await Promise.race([ + oldPollEntered.wait, + oldRequest.then(() => { + throw new Error('Old poll did not reach the usage endpoint') + }), + ]) + await replacePoolMainLogin( + { + access: accessB, + refresh: 'refresh-b', + expires: Date.now() + 8 * 60 * 60_000, + }, + { sameAccount: false, accountIdentity: uuidB }, ) - releaseOldPoll(new Response('rate limited', { status: 429 })) + // The old usage request fails after credentials have been replaced. + // Its error must not impose backoff on the replacement account. + releaseOldPoll.open() await expect(priorPoll).rejects.toThrow('429') - await replacementPoll - - expect(quotaManager.getMain('main-slot')?.quota.five_hour).toEqual( - expect.objectContaining({ remainingPercent: 90 }), - ) - expect(quotaManager.isBackedOff()).toBe(false) - - const response = await resultB.fetch(MESSAGES_URL, EMPTY_POST) + expect( + (await readAccountStorage())?.quota?.mainLastQuotaApiError, + ).toBeUndefined() + modelRequest = Promise.resolve(result.fetch(MESSAGES_URL, EMPTY_POST)) + bodyLifetime().trackDetached(Promise.allSettled([modelRequest])) + const response = await modelRequest + expect(response.status).toBe(200) await response.text() - expect(quotaManager.getMain('main-slot')?.quota.accountIdentity).toBe( - 'main-slot', - ) const entries = await waitForFeedEntries( - (candidate) => - candidate.length === 1 && - candidate.some((entry: any) => entry.account_ref === 'main-slot'), - 'one main-slot published entry', + (entries) => + entries.some( + (entry) => + entry !== null && + typeof entry === 'object' && + 'anthropic_account_uuid' in entry && + entry.anthropic_account_uuid === uuidB, + ), + 'replacement account quota feed', ) - expect(entries).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - identity_source: 'account_ref', - account_ref: 'main-slot', - anthropic_account_uuid: 'account-b', - }), - ]), + expect(quotaManager.getMain(uuidA)).toBeNull() + expect(quotaManager.getMain(uuidB)?.quota.accountIdentity).toBe(uuidB) + await quotaManager.refreshMain(uuidB, accessB) + expect( + quotaManager.getMain(uuidB)?.quota.five_hour?.remainingPercent, + ).toBe(90) + expect(quotaManager.isBackedOff()).toBe(false) + expect( + (await readAccountStorage())?.quota?.mainLastQuotaApiError, + ).toBeUndefined() + expect(entries).toContainEqual( + expect.objectContaining({ + identity_source: 'account_ref', + anthropic_account_uuid: uuidB, + }), ) - expect(entries).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ anthropic_account_uuid: 'account-a' }), - ]), + expect(await readFeedEntries()).not.toContainEqual( + expect.objectContaining({ anthropic_account_uuid: uuidA }), ) } finally { - if (originalProfileHydration === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = - originalProfileHydration - } + releaseOldPoll.open() + await Promise.allSettled([priorPoll, modelRequest]) } }) From 9292fe34f77bd651500bdfc2add2bd9b4513c965 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:47:36 +0200 Subject: [PATCH 105/169] Honor account usage waits before primary credential admission --- packages/opencode/src/index.ts | 144 +++- packages/opencode/src/tests/index.test.ts | 805 +++++++++++++++++----- 2 files changed, 757 insertions(+), 192 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 45c328a3e..ea261974a 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1615,6 +1615,78 @@ const anthropicAuthPlugin = async ( return nativeAccounts.fetchQuota(routeId) }, }) + function quotaSnapshotIsExhausted( + quota: OAuthQuotaSnapshot | null | undefined, + ) { + return (['five_hour', 'seven_day'] as const).some( + (key) => (quota?.[key]?.remainingPercent ?? 1) <= 0, + ) + } + // Account-wide provider waits precede token acquisition in ordered routing. + // Model-scoped readings retain their separate last-main policy; permanent + // authentication failures and sticky allocation keep their own error paths. + function mainProviderQuotaRestriction( + storage: AccountStorage | null, + accountUuid: string | undefined, + ) { + if ( + !storage || + storage.quota?.enabled === false || + !accountUuid || + getRoutingMode(storage) === 'sticky-balanced' || + isPermanentRefreshError(storage.refresh?.mainLastRefreshError) + ) + return undefined + const now = Date.now() + const error = storage.quota?.mainLastQuotaApiError + if ( + error && + error.accountIdentity === accountUuid && + error.nextRetryAt !== undefined && + error.nextRetryAt > now + ) { + return { + message: formatQuotaBackoffMessage(error, now), + retryAfter: Math.max(1, Math.ceil((error.nextRetryAt - now) / 1000)), + } + } + const quota = storage.quota?.mainQuota + // Header percentages are rounded for display and cannot alone prove + // exhaustion. Leave them to normal request and routing policy. + const checkedAt = quotaSnapshotCheckedAt(quota) + if ( + quota?.accountIdentity !== accountUuid || + quota.source === 'headers' || + !quotaSnapshotIsExhausted(quota) || + checkedAt <= 0 || + checkedAt > now || + getQuotaNextRefreshAt(quota, storage, checkedAt) <= now + ) + return undefined + const resetTimes = (['five_hour', 'seven_day'] as const).flatMap((key) => { + const window = quota[key] + const reset = window?.resetsAt ? Date.parse(window.resetsAt) : Number.NaN + return window && + window.remainingPercent <= 0 && + Number.isFinite(reset) && + reset > now + ? [reset] + : [] + }) + return { + message: 'Claude OAuth quota is exhausted for this account.', + retryAfter: Math.max( + 1, + Math.ceil( + ((resetTimes.length + ? Math.max(...resetTimes) + : now + getQuotaCheckIntervalMs(storage)) - + now) / + 1000, + ), + ), + } + } async function reconcileMainQuotaAccountIdentity( accessToken: string, quotaKey: string | undefined, @@ -5428,7 +5500,12 @@ const anthropicAuthPlugin = async ( } if ( deferCredential && - getRoutingMode(primarySnapshot.policyStorage) === 'fallback-first' + (getRoutingMode(primarySnapshot.policyStorage) === + 'fallback-first' || + mainProviderQuotaRestriction( + primarySnapshot.policyStorage, + primaryAccount?.accountIdentity, + )) ) { const primary = primarySnapshot.accounts.find( (account) => account.id === 'main', @@ -6668,20 +6745,6 @@ const anthropicAuthPlugin = async ( ) } - // The fallbacks routing may actually send to: usable accounts that - // also pass the killswitch policy. Every fallback-selection path - // (fallback-first, soft-quota skip-main, the killswitch gate, reactive - // retries) must go through this so the killswitch is a hard block on - // ALL routes — a killswitch-killed account must never serve a request, - // even if it still passes the softer routing quota policy. - function quotaSnapshotIsExhausted( - quota: OAuthQuotaSnapshot | null | undefined, - ) { - return (['five_hour', 'seven_day'] as const).some( - (key) => (quota?.[key]?.remainingPercent ?? 1) <= 0, - ) - } - function responseShowsMainQuotaExhausted( response: Response, streamingRateLimited: boolean, @@ -6731,6 +6794,8 @@ const anthropicAuthPlugin = async ( } } + // All fallback-selection paths apply killswitch policy after finding + // usable accounts; softer quota routing cannot bypass a killswitch. async function getRoutableFallbackAccounts( storageArg: Awaited>, options: { includeApiRoutes?: boolean; modelId?: string } = {}, @@ -7683,6 +7748,55 @@ const anthropicAuthPlugin = async ( const replayableRequest = isReplayableRequest(input, init?.body) const requestModelId = parseRequestModel(init?.body) ?? credentialModelId + const providerRestriction = auth.deferred + ? mainProviderQuotaRestriction(storage, requestMainProviderUuid) + : undefined + if (providerRestriction) { + // A provider-issued wait applies before primary token acquisition. + // Available fallbacks may serve; a blocked primary must not refresh + // or obtain a bearer merely to discover the same restriction again. + const fallbackAccounts = replayableRequest + ? await getRoutableFallbackAccounts(storage, { + includeApiRoutes: mainQuotaEntryIsFreshExhausted( + undefined, + requestMainQuotaIdentity, + ), + modelId: requestModelId, + }) + : [] + const response = await tryUsableFallbackAccounts( + input, + init, + fallbackAccounts, + storage, + undefined, + trace, + { + onSuccess: (account) => + writeCurrentSidebarState(account.id, 'fallback'), + mainQuotaIdentity: requestMainQuotaIdentity, + fableRequest, + laneStartRequest, + }, + ) + if (response) return wrapResponse(response) + return new Response( + JSON.stringify({ + type: 'error', + error: { + type: 'rate_limit_error', + message: providerRestriction.message, + }, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': String(providerRestriction.retryAfter), + }, + }, + ) + } const modelDeniedResponse = () => { const retryAfter = killswitchRetryAfterSeconds( quotaManager.getMain(requestMainQuotaIdentity?.quotaKey) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 6834639c0..8957ac88e 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -3620,16 +3620,19 @@ describe('quota header feed integration', () => { test('sustained unknown account identity retains the fail-closed spend gate', async () => { const now = Date.now() + const access = 'sk-ant-oat01-unknown' await useTempAccountFile( - { - ...createFallbackStorage({ + bindMainQuotaToAccount( + createFallbackStorage({ mainAccountId: 'main-slot', + accounts: [], quota: { enabled: true, checkIntervalMinutes: 5, failClosedOnUnknownQuota: true, mainQuota: { - accountIdentity: 'main-slot', + source: 'poll', + checkedAt: now, five_hour: { usedPercent: 100, remainingPercent: 0, @@ -3642,234 +3645,682 @@ describe('quota header feed integration', () => { checkedAt: now - 1_000, nextRetryAt: now + 60_000, retryCount: 1, - accountIdentity: 'main-slot', + accountIdentity: syntheticMainAccountUuid, }, }, }), - accounts: [], - }, - { - access: 'sk-ant-oat-unknown', - refresh: 'refresh-b', - expires: now + 100_000, - }, + access, + ), + { access, refresh: 'refresh-b', expires: now + 8 * 60 * 60_000 }, ) let messageCalls = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/claude_cli/bootstrap')) { - return Promise.resolve( - new Response('bootstrap unavailable', { status: 503 }), + let credentialCalls = 0 + globalThis.fetch = Object.assign( + mock((input: Parameters[0]) => { + const url = extractUrl(input) + if ( + url.includes('/v1/oauth/token') || + url.includes('/claude_cli/bootstrap') ) - } - if (url.includes('/v1/messages')) { - messageCalls += 1 - return Promise.resolve(new Response('{}', { status: 200 })) - } - return Promise.resolve(new Response('not-mocked', { status: 599 })) - }) as unknown as typeof fetch - + credentialCalls++ + if (url.includes('/claude_cli/bootstrap')) + return Promise.resolve( + new Response('bootstrap unavailable', { status: 503 }), + ) + if (url.includes('/v1/messages')) { + messageCalls++ + return Promise.resolve(new Response('{}', { status: 200 })) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, + }, + ) const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'sk-ant-oat-unknown', + access, refresh: 'refresh-b', - expires: now + 100_000, + expires: now + 8 * 60 * 60_000, }), { models: {} }, ) - const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + const request = Promise.resolve(result.fetch(MESSAGES_URL, EMPTY_POST)) + await expect(request).resolves.toMatchObject({ status: 429 }) + const response = await request expect(response.status).toBe(429) + expect(Number(response.headers.get('retry-after'))).toBeGreaterThan(0) expect(messageCalls).toBe(0) + expect(credentialCalls).toBe(0) expect(plugin.__quotaManager.isBackedOff()).toBe(true) + expect( + (await readAccountStorage())?.quota?.mainLastQuotaApiError + ?.accountIdentity, + ).toBe(syntheticMainAccountUuid) }) - test('confirmed account replacement clears the prior account backoff', async () => { + test('fresh account-wide exhaustion blocks primary credential acquisition', async () => { const now = Date.now() + const access = 'sk-ant-oat01-fresh-exhaustion' + const refresh = 'refresh-fresh-exhaustion' await useTempAccountFile( - { - ...createFallbackStorage({ - mainAccountId: 'main-slot', + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], quota: { enabled: true, checkIntervalMinutes: 5, failClosedOnUnknownQuota: true, - mainLastQuotaApiError: { - message: 'account A quota backoff', - checkedAt: now - 1_000, - nextRetryAt: now + 60_000, - retryCount: 1, - accountIdentity: 'account-a', + mainQuota: { + source: 'poll', + checkedAt: now, + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: now, + resetsAt: new Date(now + 120_000).toISOString(), + }, + seven_day: { + usedPercent: 12, + remainingPercent: 88, + checkedAt: now, + }, }, - mainQuotaErrorGeneration: 1, + mainQuotaCheckedAt: now, }, }), - accounts: [], - }, + access, + ), + { access, refresh, expires: now + 8 * 60 * 60_000 }, + ) + let calls = 0 + globalThis.fetch = Object.assign( + mock(() => { + calls++ + return Promise.resolve(Response.json({})) + }), { - access: 'sk-ant-oat-account-b', - refresh: 'refresh-b', - expires: now + 100_000, + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, }, ) - let messageCalls = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/api/claude_cli/bootstrap')) { - return Promise.resolve( - Response.json({ oauth_account: { account_uuid: 'account-b' } }), - ) - } - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - Response.json({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 20 }, - }), - ) - } - if (url.includes('/v1/messages')) messageCalls += 1 - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch - const plugin = await getPlugin() const result = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth' as const, - access: 'sk-ant-oat-account-b', - refresh: 'refresh-b', - expires: now + 100_000, + access, + refresh, + expires: now + 8 * 60 * 60_000, }), { models: {} }, ) - expect(plugin.__quotaManager.getMainQuotaErrorGeneration()).toBe(2) - const response = await result.fetch(MESSAGES_URL, EMPTY_POST) - expect(response.status).toBe(200) - expect(messageCalls).toBe(1) - expect(plugin.__quotaManager.isBackedOff()).toBe(false) - expect( - (await readAccountStorage())?.quota?.mainLastQuotaApiError, - ).toBeUndefined() - expect((await readAccountStorage())?.quota?.mainQuotaErrorGeneration).toBe( - 2, + expect(response.status).toBe(429) + expect(Number(response.headers.get('retry-after'))).toBeGreaterThanOrEqual( + 118, ) + expect(Number(response.headers.get('retry-after'))).toBeLessThanOrEqual(120) + expect(calls).toBe(0) }) - test('discards main response headers resolved under a replaced identity', async () => { - const originalProfileHydration = - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' - try { - await useTempAccountFile({ - ...createFallbackStorage({ - quotaHeaderFeed: { enabled: true }, - mainAccountId: 'main-slot', - quota: { enabled: true, checkIntervalMinutes: 5 }, + test('rounded header percentages cannot preempt primary credential acquisition', async () => { + const now = Date.now() + const access = 'sk-ant-oat01-rounded-header' + const refresh = 'refresh-rounded-header' + await useTempAccountFile( + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + mainQuota: { + source: 'headers', + checkedAt: now, + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: now, + }, + seven_day: { + usedPercent: 12, + remainingPercent: 88, + checkedAt: now, + }, + }, + mainQuotaCheckedAt: now, + }, }), - accounts: [], - }) - let releaseResponse!: (response: Response) => void - let responseStarted!: () => void - const response = new Promise((resolve) => { - releaseResponse = resolve - }) - const started = new Promise((resolve) => { - responseStarted = resolve - }) - globalThis.fetch = mock((input: any, init?: RequestInit) => { + access, + ), + { access, refresh, expires: now + 8 * 60 * 60_000 }, + ) + let bootstrapCalls = 0 + let modelCalls = 0 + globalThis.fetch = Object.assign( + mock((input: Parameters[0]) => { const url = extractUrl(input) - if (url.includes('/api/claude_cli/bootstrap')) { - const token = new Headers(init?.headers).get('authorization') + if (url.includes('/claude_cli/bootstrap')) { + bootstrapCalls++ return Promise.resolve( Response.json({ - oauth_account: { - account_uuid: token?.includes('token-a') - ? 'account-a' - : 'account-b', - }, + oauth_account: { account_uuid: syntheticMainAccountUuid }, }), ) } - if (url.includes('/v1/messages')) { - responseStarted() - return response - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch - - const plugin = await getPlugin() - const records: LogTestRecord[] = [] - __setLogTestSink((record) => records.push(record)) - setLogLevel('trace') - const tokenA = 'sk-ant-oat-race-token-a' - const tokenB = 'sk-ant-oat-race-token-b' - const resultA = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: tokenA, - refresh: 'refresh-a', - expires: Date.now() + 100_000, - }), - { models: {} }, - ) - const inFlight = resultA.fetch(MESSAGES_URL, EMPTY_POST) - await started + if (url.includes('/v1/messages')) modelCalls++ + return Promise.resolve(Response.json({})) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, + }, + ) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access, + refresh, + expires: now + 8 * 60 * 60_000, + }), + { models: {} }, + ) + const response = await result.fetch(MESSAGES_URL, { + ...EMPTY_POST, + body: JSON.stringify({ + model: 'claude-sonnet-5', + max_tokens: 1, + messages: [ + { role: 'user', content: 'synthetic rounded-header control' }, + ], + }), + }) + expect(response.status).toBe(200) + expect(bootstrapCalls).toBeGreaterThan(0) + expect(modelCalls).toBe(1) + }) - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: tokenB, - refresh: 'refresh-b', - expires: Date.now() + 100_000, - }), - { models: {} }, - ) - releaseResponse( - new Response('{}', { - status: 200, - headers: { - 'anthropic-ratelimit-unified-5h-utilization': '0.1', - 'anthropic-ratelimit-unified-5h-reset': '1800000000', - 'anthropic-ratelimit-unified-7d-utilization': '0.2', - 'anthropic-ratelimit-unified-7d-reset': '1800000000', + test('permanent main refresh failure is not masked by active usage backoff', async () => { + const now = Date.now() + const access = 'sk-ant-oat01-permanent-refresh-quota' + const refresh = 'refresh-permanent-quota' + await useTempAccountFile( + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + failClosedOnUnknownQuota: true, + mainQuota: { + source: 'poll', + checkedAt: now, + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: now, + }, + }, + mainQuotaCheckedAt: now, + mainLastQuotaApiError: { + message: 'usage wait', + checkedAt: now, + nextRetryAt: now + 60_000, + accountIdentity: syntheticMainAccountUuid, + }, }, }), - ) - expect((await inFlight).status).toBe(200) - - // Rejecting response quota for a changed account is logged before the quota observation file can be written. - await waitForLogRecord( - records, - (record) => - record.level === 'trace' && - record.channel === 'quota' && - record.message === 'discarded stale main quota headers', - 'stale main quota header discard', - ) - const entries = await readFeedEntries() - expect(entries).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ account_ref: 'account-a' }), - ]), - ) - expect( - plugin.__quotaManager.getMain('main-slot')?.quota.five_hour, - ).toBeUndefined() - } finally { - __setLogTestSink(null) - setLogLevel('info') - if (originalProfileHydration === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = - originalProfileHydration - } + access, + ), + { access, refresh, expires: now + 8 * 60 * 60_000 }, + ) + if (!migratedPool) throw new Error('This test has no migrated pool') + let refreshCalls = 0 + globalThis.fetch = Object.assign( + mock((input: Parameters[0]) => { + if (!extractUrl(input).includes('/v1/oauth/token')) + throw new Error( + 'Unexpected request while preparing a permanent refresh failure', + ) + refreshCalls++ + return Promise.resolve( + Response.json({ error: 'invalid_grant' }, { status: 400 }), + ) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, + }, + ) + const runtime = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + }) + try { + expect( + (await runtime.authorizeLocal('main', { rejectedAccessToken: access })) + .status, + ).toBe('failed') + expect(refreshCalls).toBe(1) + expect( + (await runtime.read()).accounts.find((account) => account.id === 'main') + ?.lastRefreshError?.permanent, + ).toBe(true) + } finally { + runtime.close() + } + let calls = 0 + globalThis.fetch = Object.assign( + mock(() => { + calls++ + return Promise.resolve(Response.json({})) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, + }, + ) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access, + refresh, + expires: now + 8 * 60 * 60_000, + }), + { models: {} }, + ) + await expect( + result.fetch(MESSAGES_URL, { + ...EMPTY_POST, + body: JSON.stringify({ + model: 'claude-sonnet-5', + max_tokens: 1, + messages: [ + { role: 'user', content: 'synthetic permanent-failure control' }, + ], + }), + }), + ).rejects.toThrow('Native OAuth authorization refused: refused') + expect(calls).toBe(0) + expect( + (await readAccountStorage())?.refresh?.mainLastRefreshError?.permanent, + ).toBe(true) + expect( + (await readAccountStorage())?.refresh?.mainLastRefreshError?.status, + ).toBe(400) + }) + + test('expired account usage backoff permits credential validation and dispatch', async () => { + const now = Date.now() + const access = 'sk-ant-oat01-expired-usage-backoff' + await useTempAccountFile( + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + failClosedOnUnknownQuota: true, + mainQuota: { + source: 'poll', + checkedAt: now, + five_hour: { + usedPercent: 4, + remainingPercent: 96, + checkedAt: now, + }, + seven_day: { + usedPercent: 12, + remainingPercent: 88, + checkedAt: now, + }, + }, + mainQuotaCheckedAt: now, + mainLastQuotaApiError: { + message: 'expired usage backoff', + checkedAt: now - 60_000, + nextRetryAt: now - 1, + retryCount: 1, + accountIdentity: syntheticMainAccountUuid, + }, + }, + }), + access, + ), + { + access, + refresh: 'refresh-expired-usage-backoff', + expires: now + 8 * 60 * 60_000, + }, + ) + let bootstrapCalls = 0 + let modelCalls = 0 + globalThis.fetch = Object.assign( + mock((input: Parameters[0]) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) { + bootstrapCalls++ + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: syntheticMainAccountUuid }, + }), + ) + } + if (url.includes('/v1/messages')) modelCalls++ + return Promise.resolve(Response.json({})) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, + }, + ) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access, + refresh: 'refresh-expired-usage-backoff', + expires: now + 8 * 60 * 60_000, + }), + { models: {} }, + ) + expect((await result.fetch(MESSAGES_URL, EMPTY_POST)).status).toBe(200) + expect(bootstrapCalls).toBeGreaterThan(0) + expect(modelCalls).toBe(1) + expect(plugin.__quotaManager.isBackedOff()).toBe(false) + }) + + test('confirmed account replacement clears the prior account backoff', async () => { + const now = Date.now() + const uuidA = syntheticMainAccountUuid + const uuidB = syntheticFallbackAccountUuid(96) + const accessA = 'sk-ant-oat01-account-a' + const accessB = 'sk-ant-oat01-account-b' + const oldError = { + message: 'account A quota backoff', + checkedAt: now - 1_000, + nextRetryAt: now + 60_000, + retryCount: 1, + accountIdentity: uuidA, + } + await useTempAccountFile( + bindMainAccount( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + failClosedOnUnknownQuota: true, + mainLastQuotaApiError: oldError, + mainQuotaErrorGeneration: 1, + }, + }), + accessA, + uuidA, + ), + { access: accessA, refresh: 'refresh-a', expires: now + 8 * 60 * 60_000 }, + ) + let messageCalls = 0 + globalThis.fetch = Object.assign( + mock((input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + const token = new Headers(init?.headers).get('authorization') + if (url.includes('/claude_cli/bootstrap')) + return Promise.resolve( + Response.json({ + oauth_account: { + account_uuid: token === `Bearer ${accessA}` ? uuidA : uuidB, + }, + }), + ) + if (url.includes('/api/oauth/usage')) + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + ) + if (url.includes('/v1/messages')) messageCalls++ + return Promise.resolve(new Response('{}', { status: 200 })) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, + }, + ) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: accessA, + refresh: 'refresh-a', + expires: now + 8 * 60 * 60_000, + }), + { models: {} }, + ) + expect( + (await readAccountStorage())?.quota?.mainLastQuotaApiError + ?.accountIdentity, + ).toBe(uuidA) + expect(plugin.__quotaManager.isBackedOff()).toBe(true) + if (!migratedPool) throw new Error('This test has no migrated pool') + const runtime = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + }) + try { + const oldSubject = await runtime.captureLocalSubject('main') + if (!isNativeLocalCredentialValidation(oldSubject)) + throw new Error('Main subject is not account and version bound') + await replacePoolMainLogin( + { + access: accessB, + refresh: 'refresh-b', + expires: now + 8 * 60 * 60_000, + }, + { sameAccount: false, accountIdentity: uuidB }, + ) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(response.status).toBe(200) + expect(messageCalls).toBe(1) + expect(plugin.__quotaManager.isBackedOff()).toBe(false) + expect( + (await readAccountStorage())?.quota?.mainLastQuotaApiError, + ).toBeUndefined() + expect( + await runtime.publishLocal(oldSubject, { + lastQuotaRefreshError: oldError, + }), + ).toBe(false) + expect( + (await readAccountStorage())?.quota?.mainLastQuotaApiError, + ).toBeUndefined() + expect( + (await runtime.read()).accounts.find((account) => account.id === 'main') + ?.accountIdentity, + ).toBe(uuidB) + } finally { + runtime.close() + } + }) + + test('discards main response headers resolved under a replaced identity', async () => { + const uuidA = syntheticMainAccountUuid + const uuidB = syntheticFallbackAccountUuid(97) + const tokenA = 'sk-ant-oat01-race-token-a' + const tokenB = 'sk-ant-oat01-race-token-b' + const now = Date.now() + await useTempAccountFile( + bindMainQuotaToAccount( + createFallbackStorage({ + mainAccountId: 'main-slot', + accounts: [], + quotaHeaderFeed: { enabled: true }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + failClosedOnUnknownQuota: false, + mainQuota: { + source: 'poll', + checkedAt: now, + five_hour: { + usedPercent: 4, + remainingPercent: 96, + checkedAt: now, + }, + seven_day: { + usedPercent: 12, + remainingPercent: 88, + checkedAt: now, + }, + }, + mainQuotaCheckedAt: now, + }, + }), + tokenA, + uuidA, + ), + { access: tokenA, refresh: 'refresh-a', expires: now + 8 * 60 * 60_000 }, + ) + const started = bodyLifetime().gate() + const release = bodyLifetime().gate() + let inFlight: Promise | undefined + globalThis.fetch = Object.assign( + mock(async (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + const token = new Headers(init?.headers).get('authorization') + if (url.includes('/claude_cli/bootstrap')) + return Response.json({ + oauth_account: { + account_uuid: token === `Bearer ${tokenA}` ? uuidA : uuidB, + }, + }) + if (url.includes('/api/oauth/usage')) + return Response.json({ + five_hour: { utilization: 60 }, + seven_day: { utilization: 70 }, + }) + if (url.includes('/v1/messages')) { + if (token === `Bearer ${tokenA}`) { + started.open() + await release.wait + return new Response('{}', { + status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.1', + 'anthropic-ratelimit-unified-7d-utilization': '0.2', + }, + }) + } + return new Response('{}', { + status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.6', + 'anthropic-ratelimit-unified-7d-utilization': '0.7', + }, + }) + } + return Response.json({}) + }), + { + preconnect() { + throw new Error('Unexpected preconnect in test fixture') + }, + }, + ) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: tokenA, + refresh: 'refresh-a', + expires: now + 8 * 60 * 60_000, + }), + { models: {} }, + ) + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + setLogLevel('trace') + try { + inFlight = Promise.resolve(result.fetch(MESSAGES_URL, EMPTY_POST)) + bodyLifetime().trackDetached(Promise.allSettled([inFlight])) + await Promise.race([ + started.wait, + inFlight.then(() => { + throw new Error('Old model request did not reach the expected sender') + }), + ]) + await replacePoolMainLogin( + { + access: tokenB, + refresh: 'refresh-b', + expires: Date.now() + 8 * 60 * 60_000, + }, + { sameAccount: false, accountIdentity: uuidB }, + ) + const currentResponse = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(currentResponse.status).toBe(200) + await currentResponse.text() + await waitForFeedEntries( + (entries) => + entries.some( + (entry) => + entry !== null && + typeof entry === 'object' && + 'anthropic_account_uuid' in entry && + entry.anthropic_account_uuid === uuidB, + ), + 'new account observation before old response release', + ) + expect( + plugin.__quotaManager.getMain(uuidB)?.quota.five_hour?.usedPercent, + ).toBe(60) + release.open() + expect((await inFlight).status).toBe(200) + await waitForLogRecord( + records, + (record) => + record.channel === 'quota' && + record.message === 'discarded stale main quota headers', + 'old account header discard', + ) + expect(await readFeedEntries()).not.toContainEqual( + expect.objectContaining({ anthropic_account_uuid: uuidA }), + ) + expect(plugin.__quotaManager.getMain(uuidA)).toBeNull() + expect( + plugin.__quotaManager.getMain(uuidB)?.quota.five_hour?.usedPercent, + ).toBe(60) + expect( + (await readAccountStorage())?.quota?.mainQuota?.accountIdentity, + ).toBe(uuidB) + expect( + (await readAccountStorage())?.quota?.mainQuota?.five_hour?.usedPercent, + ).toBe(60) + } finally { + release.open() + await Promise.allSettled([inFlight]) + await drainSidebarWrites() + __setLogTestSink(null) + setLogLevel('info') } }) From a69dd35cf20626730127d65acf0cc450df400658 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:08:15 +0200 Subject: [PATCH 106/169] fix: route fresh quota without blocking on publication --- packages/core/src/native-runtime.ts | 66 +-- .../core/src/tests/native-runtime.test.ts | 225 ++++++++- packages/opencode/src/index.ts | 449 +++++++++++++++--- packages/opencode/src/tests/index.test.ts | 326 ++++++++++++- 4 files changed, 961 insertions(+), 105 deletions(-) diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index 034e34b58..9fb88af2a 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -502,43 +502,53 @@ export async function readNativeRuntime( ): Promise { checkPath(path) if (!digest(storageId)) throw new NativeRuntimeError('invalid-runtime') + const noFollow = constants.O_NOFOLLOW ?? 0 try { - const info = await lstat(path) - if ( - !info.isFile() || - (info.mode & 0o777) !== 0o600 || - (typeof process.geteuid === 'function' && info.uid !== process.geteuid()) - ) - throw new NativeRuntimeError('unsafe-runtime') - const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) - try { - const opened = await file.stat() + for (let attempt = 0; attempt < 2; attempt++) { + const info = await lstat(path) if ( - opened.dev !== info.dev || - opened.ino !== info.ino || - !opened.isFile() || - (opened.mode & 0o777) !== 0o600 || + !info.isFile() || + (info.mode & 0o777) !== 0o600 || (typeof process.geteuid === 'function' && - opened.uid !== process.geteuid()) + info.uid !== process.geteuid()) ) throw new NativeRuntimeError('unsafe-runtime') - let contents: string + const file = await open(path, constants.O_RDONLY | noFollow) try { - contents = new TextDecoder('utf-8', { fatal: true }).decode( - await file.readFile(), + const opened = await file.stat() + if ( + !opened.isFile() || + (opened.mode & 0o777) !== 0o600 || + (typeof process.geteuid === 'function' && + opened.uid !== process.geteuid()) ) - } catch (error) { - if (error instanceof TypeError) - throw new NativeRuntimeError('invalid-runtime') - throw error - } - return { - status: 'ready', - state: decodeNativeRuntime(parseJsonRedacted(contents), storageId), + throw new NativeRuntimeError('unsafe-runtime') + // Atomic publication may replace the file between lstat and open. + // O_NOFOLLOW and the opened handle's permissions establish its safety. + // Without that flag, require matching file identity and retry once. + if (!noFollow && (opened.dev !== info.dev || opened.ino !== info.ino)) { + if (attempt === 0) continue + throw new NativeRuntimeError('unsafe-runtime') + } + let contents: string + try { + contents = new TextDecoder('utf-8', { fatal: true }).decode( + await file.readFile(), + ) + } catch (error) { + if (error instanceof TypeError) + throw new NativeRuntimeError('invalid-runtime') + throw error + } + return { + status: 'ready', + state: decodeNativeRuntime(parseJsonRedacted(contents), storageId), + } + } finally { + await file.close() } - } finally { - await file.close() } + throw new NativeRuntimeError('unsafe-runtime') } catch (error) { if (missing(error)) return { status: 'missing' } if (error instanceof NativeRuntimeError) throw error diff --git a/packages/core/src/tests/native-runtime.test.ts b/packages/core/src/tests/native-runtime.test.ts index fcd58b4f6..c81d7702f 100644 --- a/packages/core/src/tests/native-runtime.test.ts +++ b/packages/core/src/tests/native-runtime.test.ts @@ -1,4 +1,5 @@ -import { expect } from 'bun:test' +import { expect, spyOn } from 'bun:test' +import * as fsPromises from 'node:fs/promises' import { chmod, lstat, @@ -110,6 +111,228 @@ async function fixture() { return join(root, 'private', 'native-state.json') } +test('runtime reader accepts owner-only atomic replacement between stat and open', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => ({ + version: 1, + storageId, + accounts: { + row: { + binding: { kind: 'local', storageId, rowId: 'row', credentialEpoch: 1 }, + lastUsed: 1, + }, + }, + })) + const before = await lstat(path) + const realOpen = fsPromises.open + let armed = true + let replacements = 0 + const openSpy = spyOn(fsPromises, 'open').mockImplementation( + async (openedPath, flags, mode) => { + if (armed && openedPath === path) { + armed = false + await updateNativeRuntime(path, storageId, (state) => { + const row = state.accounts.row + if (!row) throw new Error('Expected the seeded runtime row') + row.lastUsed = 2 + return state + }) + const after = await lstat(path) + expect(after.ino).not.toBe(before.ino) + expect(before.mode & 0o777).toBe(0o600) + expect(after.mode & 0o777).toBe(0o600) + expect(after.uid).toBe(before.uid) + replacements++ + } + return realOpen(openedPath, flags, mode) + }, + ) + try { + await expect(readNativeRuntime(path, storageId)).resolves.toMatchObject({ + status: 'ready', + state: { accounts: { row: { lastUsed: 2 } } }, + }) + expect(replacements).toBe(1) + } finally { + openSpy.mockRestore() + } +}) + +for (const replacement of ['permissions', 'symlink'] as const) { + test(`runtime reader refuses ${replacement} replacement between stat and open`, async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => ({ + version: 1, + storageId, + accounts: {}, + })) + const replacementPath = join(dirname(path), 'replacement.json') + await writeFile( + replacementPath, + JSON.stringify({ version: 1, storageId, accounts: {} }), + { mode: replacement === 'permissions' ? 0o644 : 0o600 }, + ) + const realOpen = fsPromises.open + let armed = true + let replacements = 0 + const openSpy = spyOn(fsPromises, 'open').mockImplementation( + async (openedPath, flags, mode) => { + if (armed && openedPath === path) { + armed = false + if (replacement === 'permissions') + await fsPromises.rename(replacementPath, path) + else { + await rm(path) + await symlink(replacementPath, path) + } + replacements++ + } + return realOpen(openedPath, flags, mode) + }, + ) + try { + await expect(readNativeRuntime(path, storageId)).rejects.toMatchObject({ + code: replacement === 'permissions' ? 'unsafe-runtime' : 'runtime-io', + }) + expect(replacements).toBe(1) + } finally { + openSpy.mockRestore() + } + }) +} + +test('runtime reader refuses a foreign owner on the opened handle', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => ({ + version: 1, + storageId, + accounts: {}, + })) + const realOpen = fsPromises.open + let changedOwner = false + const openSpy = spyOn(fsPromises, 'open').mockImplementation( + async (openedPath, flags, mode) => { + const file = await realOpen(openedPath, flags, mode) + if (openedPath === path) { + const realStat = file.stat.bind(file) + Object.defineProperty(file, 'stat', { + value: async () => { + const opened = await realStat() + Object.defineProperty(opened, 'uid', { value: opened.uid + 1 }) + changedOwner = true + return opened + }, + }) + } + return file + }, + ) + try { + await expect(readNativeRuntime(path, storageId)).rejects.toMatchObject({ + code: 'unsafe-runtime', + }) + expect(changedOwner).toBe(true) + } finally { + openSpy.mockRestore() + } +}) + +async function runtimeReaderWithoutNoFollow( + path: string, +): Promise { + const sourceUrl = new URL('../native-runtime.ts', import.meta.url) + const source = await readFile(sourceUrl, 'utf8') + const flag = 'const noFollow = constants.O_NOFOLLOW ?? 0' + expect(source.split(flag)).toHaveLength(2) + // Simulate a platform without the flag in an isolated copy of the actual + // reader. All file checks and retry logic stay identical to production. + const isolated = source + .replace(flag, 'const noFollow = 0') + .replace(/from '([^']+)'/g, (_match, specifier: string) => { + const resolved = specifier.startsWith('.') + ? new URL(specifier, sourceUrl).href + : specifier.startsWith('node:') + ? specifier + : import.meta.resolve(specifier) + return `from '${resolved}'` + }) + const modulePath = join(dirname(path), 'reader-without-no-follow.ts') + await writeFile(modulePath, isolated, { mode: 0o600 }) + const module: typeof import('../native-runtime.ts') = await import(modulePath) + return module.readNativeRuntime +} + +for (const replacements of [1, 2]) { + test(`runtime reader without no-follow bounds atomic replacement recovery at ${replacements} replacements`, async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, () => ({ + version: 1, + storageId, + accounts: { + row: { + binding: { + kind: 'local', + storageId, + rowId: 'row', + credentialEpoch: 1, + }, + lastUsed: 0, + }, + }, + })) + const reader = await runtimeReaderWithoutNoFollow(path) + const realOpen = fsPromises.open + let publishing = false + let changed = 0 + let openedByReader = 0 + let closedByReader = 0 + const openSpy = spyOn(fsPromises, 'open').mockImplementation( + async (openedPath, flags, mode) => { + const outerRead = openedPath === path && !publishing + if (outerRead && changed < replacements) { + publishing = true + try { + await updateNativeRuntime(path, storageId, (state) => { + const row = state.accounts.row + if (!row) throw new Error('Expected seeded runtime row') + row.lastUsed = ++changed + return state + }) + } finally { + publishing = false + } + } + const file = await realOpen(openedPath, flags, mode) + if (outerRead) { + openedByReader++ + const realClose = file.close.bind(file) + file.close = async () => { + closedByReader++ + return realClose() + } + } + return file + }, + ) + try { + if (replacements === 1) + await expect(reader(path, storageId)).resolves.toMatchObject({ + status: 'ready', + state: { accounts: { row: { lastUsed: 1 } } }, + }) + else + await expect(reader(path, storageId)).rejects.toMatchObject({ + code: 'unsafe-runtime', + }) + expect(changed).toBe(replacements) + expect(openedByReader).toBe(2) + expect(closedByReader).toBe(2) + } finally { + openSpy.mockRestore() + } + }) +} + async function joinRuntimeUpdates(updates: Promise[]) { try { return await Promise.all(updates) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ea261974a..608f1211f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -769,6 +769,7 @@ type MainQuotaIdentityBinding = { quotaKey: string | undefined providerAccountUuid?: ProviderAccountUuid generation: number + routingLineage?: string } type MainQuotaIdentityResolution = MainQuotaIdentityBinding & { @@ -777,6 +778,15 @@ type MainQuotaIdentityResolution = MainQuotaIdentityBinding & { state: CustodyStatusState } +const HEADER_ROUTING_OBSERVATION_LIMIT = 1000 + +type HeaderRoutingObservation = { + lineage: string + accountUuid: string + entry: QuotaEntry + generalExhausted: boolean +} + type ServedQuotaHeaders = { accountId: 'main' | string accessToken: string @@ -1177,6 +1187,121 @@ const anthropicAuthPlugin = async ( ? { connect: runtimeOverrides.claustrumScopedConnect } : undefined, }) + // Routing may use received headers before disk publication, but only for + // the current account and credential epoch. Display and cache publication + // continue through the guarded writer below; this map contains no tokens. + const headerRoutingObservations = new Map() + let headerRoutingPolicyStorage: AccountStorage | null = null + function localHeaderRoutingLineage(binding: NativeAccountView['binding']) { + return binding?.identity + ? JSON.stringify([ + binding.storageId, + binding.rowId, + binding.credentialEpoch, + binding.identity, + ]) + : undefined + } + function headerRoutingLineage( + subject: NativeKnownCredentialSubject | undefined, + receipt?: NativeCustodyReceipt, + ) { + return subject + ? localHeaderRoutingLineage(subject.binding) + : receipt + ? JSON.stringify([ + receipt.credentialId, + receipt.accountIdentity, + receipt.recordVersion, + ]) + : undefined + } + function currentHeaderRoutingObservation( + accountId: string, + lineage: string | undefined, + accountUuid: string | undefined, + storage: AccountStorage | null = headerRoutingPolicyStorage, + ) { + const observation = headerRoutingObservations.get(accountId) + if ( + !observation || + !lineage || + observation.lineage !== lineage || + observation.accountUuid !== accountUuid || + getQuotaNextRefreshAt( + observation.entry.quota, + storage, + observation.entry.checkedAt, + ) <= Date.now() + ) + return undefined + return observation + } + function routingQuotaEntry( + accountId: string, + lineage: string | undefined, + accountUuid: string | undefined, + committed: QuotaEntry | null | undefined, + storage: AccountStorage | null = headerRoutingPolicyStorage, + ) { + const observation = currentHeaderRoutingObservation( + accountId, + lineage, + accountUuid, + storage, + ) + if (!observation) return committed + if (!committed) + return { + ...observation.entry, + refreshAfter: getQuotaNextRefreshAt( + observation.entry.quota, + storage, + observation.entry.checkedAt, + ), + } + const quota = mergeHeaderQuotaForPersistence( + committed.quota, + observation.entry.quota, + ) + return { + ...observation.entry, + quota, + refreshAfter: getQuotaNextRefreshAt( + quota, + storage, + quotaSnapshotCheckedAt(quota), + ), + } + } + function routingAccountQuota( + account: NativeAccountView, + storage: AccountStorage, + receipt?: NativeCustodyReceipt, + ) { + if (!headerRoutingObservations.has(account.id)) return account.quota + const lineage = account.binding + ? localHeaderRoutingLineage(account.binding) + : headerRoutingLineage(undefined, receipt) + const checkedAt = quotaSnapshotCheckedAt(account.quota) + return routingQuotaEntry( + account.id, + lineage, + account.accountIdentity, + account.quota + ? { + quota: account.quota, + checkedAt, + refreshAfter: getQuotaNextRefreshAt( + account.quota, + storage, + checkedAt, + ), + } + : null, + storage, + )?.quota + } let mainQuotaCredentialEpoch: | { rowId: string; credentialEpoch: number } | undefined @@ -1189,6 +1314,7 @@ const anthropicAuthPlugin = async ( if (path !== accountStoragePath) throw new Error('Native account storage mismatch') const snapshot = await nativeAccounts.read() + headerRoutingPolicyStorage = snapshot.policyStorage if (!mainQuotaCredentialEpoch) { const binding = snapshot.accounts.find( (account) => account.id === 'main', @@ -1448,7 +1574,9 @@ const anthropicAuthPlugin = async ( const checkModelPolicy = ( view: NativeAccountView, storage: AccountStorage, + receipt?: NativeCustodyReceipt, ) => { + const quota = routingAccountQuota(view, storage, receipt) // Ordered routing can try main after eligible fallbacks are exhausted. // A cached scoped limit must not remove that final provider attempt; // explicit killswitch limits still block it before dispatch. @@ -1460,13 +1588,12 @@ const anthropicAuthPlugin = async ( modelId && ((isKillswitchEnabled(storage) && !killswitchPassesPolicy( - view.quota, + quota, storage, routeId === 'main' ? undefined : routeId, modelId, )) || - (!lastMainAttempt && - !quotaSnapshotPassesModelScope(view.quota, modelId))) + (!lastMainAttempt && !quotaSnapshotPassesModelScope(quota, modelId))) ) throw new NativeModelPolicyError(view, getQuotaCheckIntervalMs(storage)) } @@ -1534,7 +1661,7 @@ const anthropicAuthPlugin = async ( selected.credentialId !== result.scopedAttempt.credentialId ) throw new Error('Native vault credential binding changed before dispatch') - checkModelPolicy(selected, current.policyStorage) + checkModelPolicy(selected, current.policyStorage, result.scopedAttempt) return result } @@ -1628,6 +1755,7 @@ const anthropicAuthPlugin = async ( function mainProviderQuotaRestriction( storage: AccountStorage | null, accountUuid: string | undefined, + headerExhaustionProven = false, ) { if ( !storage || @@ -1656,7 +1784,7 @@ const anthropicAuthPlugin = async ( const checkedAt = quotaSnapshotCheckedAt(quota) if ( quota?.accountIdentity !== accountUuid || - quota.source === 'headers' || + (quota.source === 'headers' && !headerExhaustionProven) || !quotaSnapshotIsExhausted(quota) || checkedAt <= 0 || checkedAt > now || @@ -2306,43 +2434,108 @@ const anthropicAuthPlugin = async ( ? quotaManager.getMain(mainQuotaIdentity?.quotaKey) : quotaManager.getAllFallbacks().get(served.accountId) const checkedAt = incoming.checkedAt ?? Date.now() + const lineage = headerRoutingLineage( + served.localSubject, + served.scopedAttempt, + ) + const observation: HeaderRoutingObservation | undefined = + lineage && served.anthropicAccountUuid + ? { + lineage, + accountUuid: served.anthropicAccountUuid, + entry: { + quota: { + ...incoming, + accountIdentity: served.anthropicAccountUuid, + }, + checkedAt, + refreshAfter: checkedAt, + }, + generalExhausted: (['five_hour', 'seven_day'] as const).some( + (key) => { + const raw = headers.get( + key === 'five_hour' + ? 'anthropic-ratelimit-unified-5h-utilization' + : 'anthropic-ratelimit-unified-7d-utilization', + ) + return ( + incoming[key] !== undefined && + raw !== null && + Number.isFinite(Number(raw)) && + Number(raw) >= 1 + ) + }, + ), + } + : undefined + if (observation) { + headerRoutingObservations.delete(served.accountId) + if ( + headerRoutingObservations.size >= HEADER_ROUTING_OBSERVATION_LIMIT + ) { + const oldest = headerRoutingObservations.keys().next().value + if (oldest !== undefined) headerRoutingObservations.delete(oldest) + } + headerRoutingObservations.set(served.accountId, observation) + } void (async () => { - let persistedEntry: QuotaEntry | null + let committed = false try { - persistedEntry = await persistPushedQuota( - served, - { quota: incoming, checkedAt, refreshAfter: checkedAt }, - previousEntry, + let persistedEntry: QuotaEntry | null + try { + persistedEntry = await persistPushedQuota( + served, + { quota: incoming, checkedAt, refreshAfter: checkedAt }, + previousEntry, + ) + } catch (error) { + logPersistFailure(error) + return + } + if (persistedEntry === null) return + if ( + served.accountId === 'main' && + quotaManager.getMainQuotaIdentityGeneration() !== + mainQuotaIdentity?.generation ) - } catch (error) { - logPersistFailure(error) - return - } - if (persistedEntry === null) return - if ( - served.accountId === 'main' && - quotaManager.getMainQuotaIdentityGeneration() !== - mainQuotaIdentity?.generation - ) - return - // Publication checks the credential that supplied these headers before - // any cache or display can use them. A rejected old response must never - // become a transient quota reading for replacement credentials. - const entry = persistedEntry - if (served.accountId === 'main') - quotaManager.setMain(mainQuotaIdentity?.quotaKey, entry) - else - quotaManager.setFallback(served.accountId, entry, { - authLineageId: served.authLineageId, + return + // Publication checks the credential that supplied these headers before + // any cache or display can use them. A rejected old response must never + // become a transient quota reading for replacement credentials. + const entry = persistedEntry + if ( + observation && + headerRoutingObservations.get(served.accountId) === observation + ) { + observation.entry = entry + committed = true + } + if (served.accountId === 'main') + quotaManager.setMain(mainQuotaIdentity?.quotaKey, entry) + else + quotaManager.setFallback(served.accountId, entry, { + authLineageId: served.authLineageId, + }) + void refreshSidebarQuota().catch(() => {}) + await publishQuotaHeaderFeed(served, entry) + logger.debug('quota', 'harvested response quota', { + account: served.accountId, + fiveHourPercent: entry.quota.five_hour?.usedPercent, + sevenDayPercent: entry.quota.seven_day?.usedPercent, + source: 'headers', }) - void refreshSidebarQuota().catch(() => {}) - await publishQuotaHeaderFeed(served, entry) - logger.debug('quota', 'harvested response quota', { - account: served.accountId, - fiveHourPercent: entry.quota.five_hour?.usedPercent, - sevenDayPercent: entry.quota.seven_day?.usedPercent, - source: 'headers', - }) + } finally { + if ( + !committed && + observation && + headerRoutingObservations.get(served.accountId) === observation + ) { + headerRoutingObservations.delete(served.accountId) + logger.debug('quota', 'discarded pending response quota', { + accountId: served.accountId, + }) + } + } })().catch(logQuotaHeaderFeedFailure) logger.trace('quota', 'response quota awaiting publication', { account: served.accountId, @@ -2416,10 +2609,33 @@ const anthropicAuthPlugin = async ( ) => { storage = storage ?? null const usable: OAuthAccount[] = [] + const currentAccounts = storage?.accounts.some( + (account) => + isOAuthAccount(account) && headerRoutingObservations.has(account.id), + ) + ? (await nativeAccounts.read()).accounts + : [] for (const account of storage?.accounts ?? []) { if (!isOAuthAccount(account) || account.enabled === false) continue + const currentAccount = currentAccounts.find( + (view) => view.id === account.id, + ) + const committedQuota = + quotaManager.getFallback( + account.id, + currentAccount?.binding + ? { authLineageId: JSON.stringify(currentAccount.binding) } + : account, + )?.quota ?? + currentAccount?.quota ?? + account.quota const quota = - quotaManager.getAllFallbacks().get(account.id)?.quota ?? account.quota + currentAccount && storage + ? routingAccountQuota( + { ...currentAccount, quota: committedQuota }, + storage, + ) + : committedQuota if ( !quotaSnapshotPassesPolicy(quota, storage) || !quotaSnapshotPassesModelScope(quota, options?.modelId) || @@ -5462,6 +5678,7 @@ const anthropicAuthPlugin = async ( expires: 0, refresh: undefined, nativeAccountIdentity: undefined, + nativeRoutingLineage: undefined, nativeLocalSource: undefined, nativeScopedAttempt: undefined, modelDenied: false, @@ -5474,10 +5691,36 @@ const anthropicAuthPlugin = async ( ) } const primarySnapshot = await nativeAccounts.read() + headerRoutingPolicyStorage = primarySnapshot.policyStorage const primaryAccount = primarySnapshot.accounts.find( (account) => account.id === 'main', ) const primaryBinding = primaryAccount?.binding + const primaryRoutingQuota = primaryAccount + ? routingAccountQuota(primaryAccount, primarySnapshot.policyStorage) + : undefined + const primaryRoutingStorage = + primaryRoutingQuota && primaryRoutingQuota !== primaryAccount?.quota + ? { + ...primarySnapshot.policyStorage, + quota: { + ...primarySnapshot.policyStorage.quota, + mainQuota: primaryRoutingQuota, + }, + } + : primarySnapshot.policyStorage + const primaryObservation = currentHeaderRoutingObservation( + 'main', + localHeaderRoutingLineage(primaryBinding), + primaryAccount?.accountIdentity, + primarySnapshot.policyStorage, + ) + const primaryHeaderExhaustionProven = Boolean( + primaryBinding && + primaryObservation?.lineage === + localHeaderRoutingLineage(primaryBinding) && + primaryObservation?.generalExhausted === true, + ) if ( primaryAccount && primaryBinding && @@ -5503,8 +5746,9 @@ const anthropicAuthPlugin = async ( (getRoutingMode(primarySnapshot.policyStorage) === 'fallback-first' || mainProviderQuotaRestriction( - primarySnapshot.policyStorage, + primaryRoutingStorage, primaryAccount?.accountIdentity, + primaryHeaderExhaustionProven, )) ) { const primary = primarySnapshot.accounts.find( @@ -5517,6 +5761,9 @@ const anthropicAuthPlugin = async ( expires: 0, refresh: undefined, nativeAccountIdentity: primary?.accountIdentity, + nativeRoutingLineage: primary?.binding + ? localHeaderRoutingLineage(primary.binding) + : undefined, nativeLocalSource: undefined, nativeScopedAttempt: undefined, modelDenied: false, @@ -5585,6 +5832,14 @@ const anthropicAuthPlugin = async ( access: credential?.accessToken ?? '', expires: credential?.expires ?? 0, refresh: undefined, + nativeRoutingLineage: + headerRoutingLineage( + credential?.localSubject, + credential?.scopedAttempt, + ) ?? + (primaryAccount?.binding + ? localHeaderRoutingLineage(primaryAccount.binding) + : undefined), nativeAccountIdentity: credential?.accountIdentity ?? denied?.accountIdentity ?? @@ -5920,9 +6175,7 @@ const anthropicAuthPlugin = async ( ) { assertNativeEnvironment() if (!accessToken) { - const general = identity?.quotaKey - ? quotaManager.getMain(identity.quotaKey) - : null + const general = routingMainQuotaEntry(identity) if ( !identity?.providerAccountUuid || !general || @@ -5936,7 +6189,14 @@ const anthropicAuthPlugin = async ( // This primary authorization validates the existing general-quota // gate for an API route. It does not permit the denied Claude model. const primary = await authorizeOAuth('main', signal) - if (primary.accountIdentity !== identity.providerAccountUuid) + if ( + primary.accountIdentity !== identity.providerAccountUuid || + (identity.routingLineage && + headerRoutingLineage( + primary.localSubject, + primary.scopedAttempt, + ) !== identity.routingLineage) + ) throw new Error('Primary identity changed before API fallback') accessToken = primary.accessToken } @@ -5948,6 +6208,15 @@ const anthropicAuthPlugin = async ( // API credential. Only account-wide OAuth exhaustion permits API // fallback; model-only limits, auth errors or an unknown account // do not justify using a paid API route. + const currentPrimary = (await nativeAccounts.read()).accounts.find( + (row) => row.id === 'main', + ) + if ( + currentPrimary?.binding && + localHeaderRoutingLineage(currentPrimary.binding) !== + identity?.routingLineage + ) + throw new Error('Primary identity changed before API fallback') if (!mainQuotaEntryIsFreshExhausted(accessToken, identity)) throw new Error( 'API fallback requires fresh general primary OAuth exhaustion', @@ -6752,15 +7021,25 @@ const anthropicAuthPlugin = async ( return response.status === 429 || streamingRateLimited } + function routingMainQuotaEntry( + identity?: MainQuotaIdentityBinding, + storage: AccountStorage | null = headerRoutingPolicyStorage, + ) { + return routingQuotaEntry( + 'main', + identity?.routingLineage, + identity?.providerAccountUuid, + quotaManager.getMain(identity?.quotaKey ?? mainQuotaAccountId), + storage, + ) + } function mainQuotaEntryIsFreshExhausted( accessToken?: string, mainQuotaIdentity?: MainQuotaIdentityBinding, ) { if (!accessToken && !mainQuotaIdentity?.providerAccountUuid) return false - const entry = quotaManager.getMain( - mainQuotaIdentity?.quotaKey ?? mainQuotaAccountId, - ) + const entry = routingMainQuotaEntry(mainQuotaIdentity) // Native metadata can establish account-owned exhaustion before // primary credential authorization. The API send still validates // that credential and rechecks this quota before dispatch. @@ -6770,7 +7049,18 @@ const anthropicAuthPlugin = async ( entry.quota.accountIdentity === mainQuotaIdentity?.providerAccountUuid) && entry.refreshAfter > Date.now() && - quotaSnapshotIsExhausted(entry.quota), + quotaSnapshotIsExhausted(entry.quota) && + (entry.quota.source !== 'headers' || + currentHeaderRoutingObservation( + 'main', + mainQuotaIdentity?.routingLineage, + mainQuotaIdentity?.providerAccountUuid, + )?.generalExhausted === true || + (['five_hour', 'seven_day'] as const).some( + (key) => + entry.quota.fieldSources?.[key] === 'poll' && + (entry.quota[key]?.remainingPercent ?? 1) <= 0, + )), ) } @@ -7696,7 +7986,10 @@ const anthropicAuthPlugin = async ( 'Main OAuth identity changed while resolving request credentials', ) } - requestMainQuotaIdentity = resolution + requestMainQuotaIdentity = { + ...resolution, + routingLineage: auth.nativeRoutingLineage, + } if (requestMainProviderUuid) { mainServedAccessToken = auth.access mainProviderAccountUuid = resolution.providerAccountUuid @@ -7721,6 +8014,7 @@ const anthropicAuthPlugin = async ( quotaKey: requestMainProviderUuid, providerAccountUuid: requestMainProviderUuid, generation: quotaManager.getMainQuotaIdentityGeneration(), + routingLineage: auth.nativeRoutingLineage, stale: false, state: isScopedCustodyActive(storage) ? 'on-cold' : 'na', } @@ -7748,8 +8042,33 @@ const anthropicAuthPlugin = async ( const replayableRequest = isReplayableRequest(input, init?.body) const requestModelId = parseRequestModel(init?.body) ?? credentialModelId + const mainRoutingEntry = routingMainQuotaEntry( + requestMainQuotaIdentity, + storage, + ) + const currentMainObservation = currentHeaderRoutingObservation( + 'main', + requestMainQuotaIdentity?.routingLineage, + requestMainProviderUuid, + storage, + ) const providerRestriction = auth.deferred - ? mainProviderQuotaRestriction(storage, requestMainProviderUuid) + ? mainProviderQuotaRestriction( + mainRoutingEntry && storage + ? { + ...storage, + accounts: storage?.accounts ?? [], + quota: { + ...storage?.quota, + mainQuota: mainRoutingEntry.quota, + }, + } + : storage, + requestMainProviderUuid, + currentMainObservation?.lineage === + requestMainQuotaIdentity?.routingLineage && + currentMainObservation?.generalExhausted === true, + ) : undefined if (providerRestriction) { // A provider-issued wait applies before primary token acquisition. @@ -8346,7 +8665,10 @@ const anthropicAuthPlugin = async ( throw new Error( 'Main OAuth identity changed while resolving request credentials', ) - requestMainQuotaIdentity = resolution + requestMainQuotaIdentity = { + ...resolution, + routingLineage: auth.nativeRoutingLineage, + } mainServedAccessToken = auth.access mainProviderAccountUuid = resolution.providerAccountUuid } @@ -8507,8 +8829,9 @@ const anthropicAuthPlugin = async ( const quotaStart = nowMs() // Identity-aware read prevents routing with a previous main // account's quota after a slot switch. - let routingQuotaEntry = quotaManager.getMain( - requestMainQuotaIdentity?.quotaKey, + let routingQuotaEntry = routingMainQuotaEntry( + requestMainQuotaIdentity, + storage, ) let routingQuota = routingQuotaEntry?.quota if (!routingQuota) { @@ -8517,8 +8840,9 @@ const anthropicAuthPlugin = async ( auth.access, requestMainQuotaIdentity?.generation, ) - routingQuotaEntry = quotaManager.getMain( - requestMainQuotaIdentity?.quotaKey, + routingQuotaEntry = routingMainQuotaEntry( + requestMainQuotaIdentity, + storage, ) showQuotaToastFromCache() } else if ( @@ -8545,8 +8869,9 @@ const anthropicAuthPlugin = async ( auth.access, requestMainQuotaIdentity?.generation, ) - routingQuotaEntry = quotaManager.getMain( - requestMainQuotaIdentity?.quotaKey, + routingQuotaEntry = routingMainQuotaEntry( + requestMainQuotaIdentity, + storage, ) } else { // Stale OR every-N request boundary — background refresh, @@ -8631,8 +8956,9 @@ const anthropicAuthPlugin = async ( } } - let mainQuota = quotaManager.getMain( - requestMainQuotaIdentity?.quotaKey, + let mainQuota = routingMainQuotaEntry( + requestMainQuotaIdentity, + storage, )?.quota if ( storage?.quota?.failClosedOnUnknownQuota && @@ -8712,8 +9038,9 @@ const anthropicAuthPlugin = async ( // Re-read after the eager refresh so the killswitch evaluates // against fresh quota. The initial read above is null on the // first request, before the refresh populates the cache. - mainQuota = quotaManager.getMain( - requestMainQuotaIdentity?.quotaKey, + mainQuota = routingMainQuotaEntry( + requestMainQuotaIdentity, + storage, )?.quota } diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 8957ac88e..2a3a06ed3 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -22,6 +22,7 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import type { PrimeManager } from '@cortexkit/anthropic-auth-core' +import * as Core from '@cortexkit/anthropic-auth-core' import { __setLogTestSink, type AccountStorage, @@ -16685,6 +16686,291 @@ describe('auth.loader', () => { ...overrides, }) + function pauseHeaderPublication() { + const lifetime = bodyLifetime() + const entered = lifetime.gate() + const release = lifetime.gate() + const operations: Promise[] = [] + const factory = Core.createNativeAccountRuntime + const factorySpy = spyOn( + Core, + 'createNativeAccountRuntime', + ).mockImplementation((options) => { + const runtime = factory(options) + const publish = runtime.publishLocal.bind(runtime) + runtime.publishLocal = (subject, patch) => { + if (patch.quota?.source !== 'headers') return publish(subject, patch) + // Pause before the real publisher runs; its ownership and version + // checks remain unchanged when the test releases it. + const operation = (async () => { + entered.open() + await release.wait + return publish(subject, patch) + })() + operations.push(operation) + lifetime.trackDetached(Promise.allSettled([operation])) + return operation + } + return runtime + }) + return { entered, release, operations, factorySpy } + } + const apiFallbackForHeaderTests = { + id: 'header-api', + type: 'api' as const, + apiKey: 'header-key', + baseURL: 'https://example.test/claude', + authHeader: 'authorization-bearer' as const, + } + + test('pending quota routes without waiting for header publication', async () => { + await useTempAccountFile( + harvestStorage([apiFallbackForHeaderTests], { + routing: { mode: 'fallback-first' }, + }), + ) + const pause = pauseHeaderPublication() + const authorizations: Array = [] + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + if (extractUrl(input).includes('/claude_cli/bootstrap')) + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: poolMainIdentity() }, + }), + ) + const authorization = new Headers(init?.headers).get( + 'authorization', + ) + authorizations.push(authorization) + return Promise.resolve( + new Response('ok', { + headers: + authorization === 'Bearer sk-ant-oat01-main-access' + ? { + ...quotaHeaders, + 'anthropic-ratelimit-unified-5h-utilization': '1', + } + : undefined, + }), + ) + }, + ), + ) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'sk-ant-oat01-main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + try { + expect((await result.fetch(MESSAGES_URL, EMPTY_POST)).status).toBe(200) + await pause.entered.wait + expect(plugin.__quotaManager.getMain(poolMainIdentity())).toBeNull() + expect((await result.fetch(MESSAGES_URL, EMPTY_POST)).status).toBe(200) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access', + 'Bearer header-key', + ]) + expect((await readAccountStorage())?.quota?.mainQuota).toBeUndefined() + } finally { + pause.release.open() + await Promise.allSettled(pause.operations) + pause.factorySpy.mockRestore() + } + }) + + test('pending rounded header usage cannot license API fallback', async () => { + await useTempAccountFile( + harvestStorage([apiFallbackForHeaderTests], { + routing: { mode: 'fallback-first' }, + }), + ) + const pause = pauseHeaderPublication() + const authorizations: Array = [] + globalThis.fetch = mock( + withNativeAdmission((_input: unknown, init?: RequestInit) => { + authorizations.push(new Headers(init?.headers).get('authorization')) + return Promise.resolve( + new Response('ok', { + headers: { + ...quotaHeaders, + 'anthropic-ratelimit-unified-5h-utilization': '0.995', + }, + }), + ) + }), + ) as unknown as typeof fetch + const result = await loadFetch() + try { + await result.fetch(MESSAGES_URL, EMPTY_POST) + await pause.entered.wait + expect((await result.fetch(MESSAGES_URL, EMPTY_POST)).status).toBe(200) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-main-access', + ]) + } finally { + pause.release.open() + await Promise.allSettled(pause.operations) + pause.factorySpy.mockRestore() + } + }) + + test('pending quota from a superseded credential epoch cannot license API fallback', async () => { + await useTempAccountFile( + harvestStorage([apiFallbackForHeaderTests], { + routing: { mode: 'fallback-first' }, + }), + ) + const pause = pauseHeaderPublication() + const authorizations: Array = [] + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + if (extractUrl(input).includes('/claude_cli/bootstrap')) + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: poolMainIdentity() }, + }), + ) + const authorization = new Headers(init?.headers).get( + 'authorization', + ) + authorizations.push(authorization) + return Promise.resolve( + new Response('ok', { + headers: + authorization === 'Bearer sk-ant-oat01-main-access' + ? { + ...quotaHeaders, + 'anthropic-ratelimit-unified-5h-utilization': '1', + } + : undefined, + }), + ) + }, + ), + ) as unknown as typeof fetch + const result = await loadFetch() + try { + await result.fetch(MESSAGES_URL, EMPTY_POST) + await pause.entered.wait + await replacePoolMainLogin( + { + access: 'sk-ant-oat01-new-epoch', + refresh: 'new-epoch-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + { sameAccount: false, accountIdentity: poolMainIdentity() }, + ) + expect((await result.fetch(MESSAGES_URL, EMPTY_POST)).status).toBe(200) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-new-epoch', + ]) + } finally { + pause.release.open() + await Promise.allSettled(pause.operations) + pause.factorySpy.mockRestore() + } + }) + + test('refused header publication removes a same-epoch pending observation', async () => { + await useTempAccountFile( + harvestStorage([apiFallbackForHeaderTests], { + routing: { mode: 'fallback-first' }, + }), + ) + const pause = pauseHeaderPublication() + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + setLogLevel('debug') + const authorizations: Array = [] + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + if (extractUrl(input).includes('/claude_cli/bootstrap')) + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: poolMainIdentity() }, + }), + ) + const authorization = new Headers(init?.headers).get( + 'authorization', + ) + authorizations.push(authorization) + return Promise.resolve( + new Response('ok', { + headers: + authorization === 'Bearer sk-ant-oat01-main-access' + ? { + ...quotaHeaders, + 'anthropic-ratelimit-unified-5h-utilization': '1', + } + : undefined, + }), + ) + }, + ), + ) as unknown as typeof fetch + const result = await loadFetch() + setLogLevel('debug') + try { + await result.fetch(MESSAGES_URL, EMPTY_POST) + await pause.entered.wait + if (!migratedPool) throw new Error('This test has no migrated pool') + const reader = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + }) + try { + const before = await reader.captureLocalSubject('main') + await refreshPoolMainElsewhere({ + access: 'sk-ant-oat01-same-epoch-new-access', + refresh: 'main-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }) + const after = await reader.captureLocalSubject('main') + expect(after.binding.credentialEpoch).toBe( + before.binding.credentialEpoch, + ) + expect(after.binding.identity).toBe(before.binding.identity) + expect(after.version?.accessFingerprint).not.toBe( + before.version?.accessFingerprint, + ) + } finally { + reader.close() + } + pause.release.open() + expect(await pause.operations[0]).toBe(false) + await waitForLogRecord( + records, + (record) => + record.channel === 'quota' && + record.message === 'discarded pending response quota', + 'refused pending observation cleanup', + ) + expect((await result.fetch(MESSAGES_URL, EMPTY_POST)).status).toBe(200) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-same-epoch-new-access', + ]) + } finally { + pause.release.open() + await Promise.allSettled(pause.operations) + pause.factorySpy.mockRestore() + __setLogTestSink(null) + setLogLevel('info') + } + }) + function installRelayWebSocket(responseHeaders: Record) { const originalWebSocket = globalThis.WebSocket @@ -16832,21 +17118,31 @@ describe('auth.loader', () => { ) const authorizations: Array = [] globalThis.fetch = mock( - withNativeAdmission((_input: unknown, init?: RequestInit) => { - const authorization = new Headers(init?.headers).get('authorization') - authorizations.push(authorization) - return Promise.resolve( - new Response('ok', { - headers: - authorization === 'Bearer sk-ant-oat01-main-access' - ? { - ...quotaHeaders, - 'anthropic-ratelimit-unified-5h-utilization': '1', - } - : undefined, - }), - ) - }), + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + if (extractUrl(input).includes('/claude_cli/bootstrap')) + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: poolMainIdentity() }, + }), + ) + const authorization = new Headers(init?.headers).get( + 'authorization', + ) + authorizations.push(authorization) + return Promise.resolve( + new Response('ok', { + headers: + authorization === 'Bearer sk-ant-oat01-main-access' + ? { + ...quotaHeaders, + 'anthropic-ratelimit-unified-5h-utilization': '1', + } + : undefined, + }), + ) + }, + ), ) as unknown as typeof fetch const result = await loadFetch() From 486915ed40bb21160447d06cc3cd5bd74e355158 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:35:39 +0200 Subject: [PATCH 107/169] test: register native quota and runtime safety mutations --- docs/testing/mutations.md | 27 ++--- mutations.toml | 99 ++++++++++++++++++- .../core/src/tests/native-runtime.test.ts | 12 ++- scripts/run-mutation-bun.ts | 12 ++- scripts/tests/run-mutation-bun.test.ts | 60 +++++++++++ scripts/tests/run-mutation-ci.test.ts | 60 ++++++++++- 6 files changed, 249 insertions(+), 21 deletions(-) diff --git a/docs/testing/mutations.md b/docs/testing/mutations.md index 24e8a27bc..af4b82586 100644 --- a/docs/testing/mutations.md +++ b/docs/testing/mutations.md @@ -1,6 +1,6 @@ # Mutation proofs for request guards -`mutations.toml` records three deliberately bounded runtime breaks against existing +`mutations.toml` records nine deliberately bounded runtime breaks against named assertions. Run it only in an isolated checkout: `ckdev-mutate` edits its working checkout; it does **not** create a scratch worktree. Never reset/check out a target while replay is active, and never point builds at a live host installation. @@ -39,6 +39,12 @@ sibling repositories. | `history-refuses-meaningful-assistant` | Treat meaningful assistant trailers as removable empty trailers. | `refuses unknown or meaningful content 0 without mutating history` in Core request-history tests. | | `fast-excludes-opus-46-47` | Widen `claude-opus-4-8` to `claude-opus-4-`, incorrectly enabling fast mode for Opus 4.6 and 4.7. | Core asserts that fast mode is not supported for Opus 4.6, 4.7 and `4.7[1m]`. | | `thinking-binding-excludes-api-key` | Call `applyThinkingBindingControls(body, 'drop_block')` even when Pi has no OAuth identity, bypassing the check that excludes API-key routes. | `buildAnthropicRequest — Fable/Mythos thinking > does not add Fable 5.1 binding controls to an API-key request`. | +| `header-routing-current-credential-epoch` | Accept pending quota from a replaced login. | The next request must use the replacement OAuth token, not a paid API key. | +| `header-routing-drops-refused-publication` | Retain pending quota after its publication is refused. | A real token rotation followed by refused publication must remove the paid-routing hint. | +| `header-routing-raw-exhaustion-proof` | Treat raw utilization of 0.995 as exhausted. | Display rounding must not permit paid API fallback. | +| `runtime-read-no-follow` | Follow a symlink installed between inspection and opening. | The runtime reader must reject the symlink. | +| `runtime-read-opened-mode` | Ignore the opened file's permissions. | A replacement with mode 0644 must be rejected. | +| `runtime-read-opened-owner` | Ignore the opened file's owner. | A foreign owner on the opened handle must be rejected. | The `thinking-binding-excludes-api-key` guard is owned by Pi's converter at its `applyThinkingBindingControls` call boundary. Pi imports the built Core helper, @@ -65,7 +71,7 @@ bun scripts/run-mutation-bun.ts packages/core/src/tests/fast.test.ts \ 'fast mode eligibility for claude-opus-4-7[1m] is false' ``` -The wrapper accepts only the three reviewed owning test files. It rejects empty, +The wrapper accepts only the five reviewed owning test files. It rejects empty, control-character and boundary-whitespace names and unsafe/unsupported paths. Bun prints nested scopes separated by ` > `, but its name filter joins those scopes with spaces. The wrapper applies that observed convention, escapes regex @@ -120,17 +126,16 @@ Other healthy companions verify that fast mode remains supported for Opus requests: `buildAnthropicRequest — Fable/Mythos thinking > adds Fable 5.1 binding controls when compacted history replays signed thinking`. -A named-only proof does **not** observe package breadth. Under `--broad`, the two -Core rows additionally run the full Core unit directory (`packages/core/src/tests`) -and the Pi row runs the full Pi unit directory (`packages/pi/src/tests`). Each -uses its own package cwd/config, in a separate sequential Bun process. Measured -clean baselines on the pinned toolchain contain **1,114 Core tests** and **155 Pi -tests**, all green. These are unit-package domains, not workspace or E2E audits. -Core history/fast guards also affect OpenCode and Pi; auditing Core alone does -**not** prove absence of host/workspace collateral. +A named-only proof does **not** observe package breadth. Under `--broad`, each +row runs its full owning unit directory: Core, OpenCode or Pi. Each uses its own +package cwd/config in a separate sequential Bun process and requires a clean +current baseline. These are unit-package domains, not workspace or E2E audits. +Core guards also affect OpenCode and Pi; auditing Core alone does **not** prove +absence of host/workspace collateral. The supported fields are `broad_command`, `broad_report` and `broad_id = "{name}"`. -Bun creates fresh JUnit XML at `tmp/mutations/core.xml` or `tmp/mutations/pi.xml`. +Bun creates fresh JUnit XML at the owning package's `tmp/mutations/core.xml`, +`tmp/mutations/opencode.xml` or `tmp/mutations/pi.xml`. The runner deletes the selected report before every invocation and validates the complete XML, expected identities and baseline failures. Missing, empty, garbage, ambiguous or mismatched reports are refused. Such reports are not evidence that diff --git a/mutations.toml b/mutations.toml index 4e3a42bc0..5f44f4b18 100644 --- a/mutations.toml +++ b/mutations.toml @@ -1,4 +1,4 @@ -# Commands select exact expect_red tests; --broad runs the Core or Pi package set below. +# Commands select exact expect_red tests; --broad runs the full owning package. prebuild = [ { name = "core-current-source", command = ["bun", "run", "--cwd", "packages/core", "build"] }, ] @@ -54,3 +54,100 @@ expect_message = "error: expect(received).toBeUndefined()" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/pi", "tmp/mutations/pi.xml"] broad_report = "tmp/mutations/pi.xml" broad_id = "{name}" + +[[control]] +id = "header-routing-current-credential-epoch" +guards = "Pending response quota must not license paid fallback after the OAuth login is replaced." +file = "packages/opencode/src/index.ts" +old = "observation.lineage !== lineage" +new = "false" +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["auth.loader > quota header harvest > pending quota from a superseded credential epoch cannot license API fallback"] +expect_message = "error: expect(received).toEqual(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" + +[[control]] +id = "header-routing-drops-refused-publication" +guards = "A refused quota publication must remove its routing hint without retaining permission for paid fallback." +file = "packages/opencode/src/index.ts" +old = ''' headerRoutingObservations.delete(served.accountId) + logger.debug('quota', 'discarded pending response quota', {''' +new = ''' logger.debug('quota', 'discarded pending response quota', {''' +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["auth.loader > quota header harvest > refused header publication removes a same-epoch pending observation"] +expect_message = "error: expect(received).toEqual(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" + +[[control]] +id = "header-routing-raw-exhaustion-proof" +guards = "Display rounding from 99.5 percent must not license paid fallback before actual OAuth exhaustion." +file = "packages/opencode/src/index.ts" +old = "Number(raw) >= 1" +new = "Number(raw) >= 0.995" +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["auth.loader > quota header harvest > pending rounded header usage cannot license API fallback"] +expect_message = "error: expect(received).toEqual(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" + +[[control]] +id = "runtime-read-no-follow" +guards = "A runtime file replaced by a symlink between inspection and opening must not be read." +file = "packages/core/src/native-runtime.ts" +old = "const file = await open(path, constants.O_RDONLY | noFollow)" +new = "const file = await open(path, constants.O_RDONLY)" +test_file = "packages/core/src/tests/native-runtime.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-runtime.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["runtime reader refuses symlink replacement between stat and open"] +expect_message = "error: expect(received).toMatchObject(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "runtime-read-opened-mode" +guards = "Runtime metadata with unsafe permissions must be rejected using the opened file's mode." +file = "packages/core/src/native-runtime.ts" +old = "(opened.mode & 0o777) !== 0o600" +new = "false" +test_file = "packages/core/src/tests/native-runtime.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-runtime.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["runtime reader refuses permissions replacement between stat and open"] +expect_message = "error: expect(received).toMatchObject(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "runtime-read-opened-owner" +guards = "Runtime metadata opened under a foreign owner must be rejected even if the path inspection saw the expected owner." +file = "packages/core/src/native-runtime.ts" +old = "opened.uid !== process.geteuid()" +new = "false" +test_file = "packages/core/src/tests/native-runtime.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-runtime.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["runtime reader refuses a foreign owner on the opened handle"] +expect_message = "error: expect(received).toMatchObject(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" diff --git a/packages/core/src/tests/native-runtime.test.ts b/packages/core/src/tests/native-runtime.test.ts index c81d7702f..57d7e3acd 100644 --- a/packages/core/src/tests/native-runtime.test.ts +++ b/packages/core/src/tests/native-runtime.test.ts @@ -191,7 +191,10 @@ for (const replacement of ['permissions', 'symlink'] as const) { }, ) try { - await expect(readNativeRuntime(path, storageId)).rejects.toMatchObject({ + const outcome = await readNativeRuntime(path, storageId).catch( + (error: unknown) => error, + ) + expect(outcome).toMatchObject({ code: replacement === 'permissions' ? 'unsafe-runtime' : 'runtime-io', }) expect(replacements).toBe(1) @@ -228,9 +231,10 @@ test('runtime reader refuses a foreign owner on the opened handle', async () => }, ) try { - await expect(readNativeRuntime(path, storageId)).rejects.toMatchObject({ - code: 'unsafe-runtime', - }) + const outcome = await readNativeRuntime(path, storageId).catch( + (error: unknown) => error, + ) + expect(outcome).toMatchObject({ code: 'unsafe-runtime' }) expect(changedOwner).toBe(true) } finally { openSpy.mockRestore() diff --git a/scripts/run-mutation-bun.ts b/scripts/run-mutation-bun.ts index a7b05fbe6..5effae1da 100644 --- a/scripts/run-mutation-bun.ts +++ b/scripts/run-mutation-bun.ts @@ -85,13 +85,17 @@ export function selectionFor(file: string, name: string) { ![ 'packages/core/src/tests/request-history.test.ts', 'packages/core/src/tests/fast.test.ts', + 'packages/core/src/tests/native-runtime.test.ts', + 'packages/opencode/src/tests/index.test.ts', 'packages/pi/src/tests/convert.test.ts', ].includes(file) ) throw new Error(`Unsafe or unsupported test file: ${file}`) const cwd = file.startsWith('packages/core/') ? 'packages/core' - : 'packages/pi' + : file.startsWith('packages/opencode/') + ? 'packages/opencode' + : 'packages/pi' return { cwd, file: file.slice(cwd.length + 1), @@ -224,10 +228,10 @@ export function parseExecution( (name !== undefined && Number(summary[2]) !== 1) ) throw new Error('Missing, zero or ambiguous executed total') - const expects = matches(/^ \d+ expect\(\) calls$/) + const expects = matches(/^ (?:\d+ snapshots?, )?\d+ expect\(\) calls$/) if ( expects.length !== 1 || - !/^ [1-9]\d* expect\(\) calls$/.test(expects[0] ?? '') + !/^ (?:\d+ snapshots?, )?[1-9]\d* expect\(\) calls$/.test(expects[0] ?? '') ) throw new Error('No verified assertion executed') const errors = matches(/^error:/) @@ -429,7 +433,7 @@ export function normalizeReport( export function packageSelection(pkg: string, report: string) { if ( - !['packages/core', 'packages/pi'].includes(pkg) || + !['packages/core', 'packages/opencode', 'packages/pi'].includes(pkg) || report !== `tmp/mutations/${pkg.slice('packages/'.length)}.xml` ) throw new Error('Unsafe package or broad report path') diff --git a/scripts/tests/run-mutation-bun.test.ts b/scripts/tests/run-mutation-bun.test.ts index bea5f5a41..3dc7c59f8 100644 --- a/scripts/tests/run-mutation-bun.test.ts +++ b/scripts/tests/run-mutation-bun.test.ts @@ -69,6 +69,30 @@ test('accepts the real pinned baseline and assertion-failure summary shapes', () expect(executedCount(name, red())).toBe(1) }) +test('package snapshot summaries still require a verified assertion count', () => { + const fixture = green() + fixture.stderr = fixture.stderr.replace( + ' 1 expect() calls', + ' 8 snapshots, 1 expect() calls', + ) + expect(parseExecution(undefined, fixture).count).toBe(1) + for (const summary of [ + ' 8 snapshots', + ' 8 snapshots, 0 expect() calls', + ' 8 snapshots, expect() calls', + ]) { + expect(() => + parseExecution(undefined, { + ...fixture, + stderr: fixture.stderr.replace( + ' 8 snapshots, 1 expect() calls', + summary, + ), + }), + ).toThrow() + } +}) + test('anchors literal punctuation and keeps Bun filter/display names distinct', () => { const regex = new RegExp(exactPattern(name)) expect(regex.test(name)).toBe(true) @@ -706,6 +730,42 @@ test('missing, empty, garbage and mismatched report identities refuse', () => { ).toThrow() }) +test('native guard selection uses the actual owning package and reviewed preload config', () => { + for (const [file, cwd, relative] of [ + [ + 'packages/core/src/tests/native-runtime.test.ts', + 'packages/core', + 'src/tests/native-runtime.test.ts', + ], + [ + 'packages/opencode/src/tests/index.test.ts', + 'packages/opencode', + 'src/tests/index.test.ts', + ], + ]) { + if (!file || !cwd || !relative) + throw new Error('Expected complete test selection') + const name = + 'auth.loader > quota header harvest > pending quota routes without waiting for header publication' + expect(selectionFor(file, name)).toEqual({ + cwd, + file: relative, + name, + filterName: name.replaceAll(' > ', ' '), + }) + } + expect(() => + selectionFor('packages/opencode/src/tests/preload-sandbox.ts', 'unsafe'), + ).toThrow('Unsafe or unsupported test file') + expect( + packageSelection('packages/opencode', 'tmp/mutations/opencode.xml'), + ).toEqual({ + cwd: 'packages/opencode', + file: 'src/tests', + report: 'tmp/mutations/opencode.xml', + }) +}) + test('package/report selection cannot escape reviewed unit configs or report paths', () => { expect(packageSelection('packages/core', 'tmp/mutations/core.xml')).toEqual({ cwd: 'packages/core', diff --git a/scripts/tests/run-mutation-ci.test.ts b/scripts/tests/run-mutation-ci.test.ts index b7c952ae9..85abc06cb 100644 --- a/scripts/tests/run-mutation-ci.test.ts +++ b/scripts/tests/run-mutation-ci.test.ts @@ -192,7 +192,21 @@ process.exit(process.env.FAKE_FAILURE === '1' ? 1 : 0) `, ) await chmod(binary, 0o700) - const env = { ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined } + // This disposable repository must not depend on or change a real author's + // identity or Git configuration. + const env = { + ...process.env, + GIT_DIR: undefined, + GIT_WORK_TREE: undefined, + GIT_INDEX_FILE: undefined, + GIT_CONFIG_COUNT: undefined, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + } for (const argv of [ ['init', '-q'], ['add', '.'], @@ -210,6 +224,10 @@ process.exit(process.env.FAKE_FAILURE === '1' ? 1 : 0) stdout: 'pipe', stderr: 'pipe', }) + if (child.exitCode !== 0) + throw new Error( + `Synthetic Git ${argv.join(' ')} failed: ${child.stderr.toString()}`, + ) expect(child.exitCode).toBe(0) } const base = Bun.spawnSync(['git', 'rev-parse', 'HEAD'], { @@ -222,6 +240,46 @@ process.exit(process.env.FAKE_FAILURE === '1' ? 1 : 0) return { root, base, env } } +lifetimes.test( + 'synthetic Git author stays in the disposable repository and leaves real config unchanged', + async () => { + const cwd = resolve(import.meta.dir, '../..') + const realEnv = { ...process.env } + // A remote snapshot can have a different owner. Permit only this checkout + // for the read-only path lookup, without changing any Git configuration. + const configPath = Bun.spawnSync( + [ + 'git', + '-c', + `safe.directory=${cwd}`, + 'rev-parse', + '--git-path', + 'config', + ], + { cwd, env: realEnv, stdout: 'pipe', stderr: 'pipe' }, + ) + if (configPath.exitCode !== 0) + throw new Error( + `Real Git config lookup failed: ${configPath.stderr.toString()}`, + ) + expect(configPath.exitCode).toBe(0) + const realConfig = resolve(cwd, configPath.stdout.toString().trim()) + const before = await readFile(realConfig) + const fixture = await ciFixture() + const author = Bun.spawnSync( + ['git', 'log', '-1', '--format=%an <%ae>%n%cn <%ce>'], + { cwd: fixture.root, env: fixture.env, stdout: 'pipe', stderr: 'pipe' }, + ) + expect(author.exitCode).toBe(0) + expect(author.stdout.toString().trim()).toBe( + 'Fixture \nFixture ', + ) + expect(fixture.env.GIT_CONFIG_GLOBAL).toBe('/dev/null') + expect(fixture.env.GIT_CONFIG_NOSYSTEM).toBe('1') + expect(before.equals(await readFile(realConfig))).toBe(true) + }, +) + function invokeCI( fixture: Awaited>, event: string, From c53ae360b06909fd0e76f4dc46beaf84adce744f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:03:02 +0200 Subject: [PATCH 108/169] fix: preserve account quota across native token refresh --- docs/testing/mutations.md | 5 +- mutations.toml | 49 ++-- packages/core/src/native-account-runtime.ts | 27 +- packages/opencode/src/tests/index.test.ts | 280 +++++++++++++++----- 4 files changed, 266 insertions(+), 95 deletions(-) diff --git a/docs/testing/mutations.md b/docs/testing/mutations.md index af4b82586..06bbe6f02 100644 --- a/docs/testing/mutations.md +++ b/docs/testing/mutations.md @@ -1,6 +1,6 @@ # Mutation proofs for request guards -`mutations.toml` records nine deliberately bounded runtime breaks against named +`mutations.toml` records ten deliberately bounded runtime breaks against named assertions. Run it only in an isolated checkout: `ckdev-mutate` edits its working checkout; it does **not** create a scratch worktree. Never reset/check out a target while replay is active, and never point builds at a live host installation. @@ -40,8 +40,9 @@ sibling repositories. | `fast-excludes-opus-46-47` | Widen `claude-opus-4-8` to `claude-opus-4-`, incorrectly enabling fast mode for Opus 4.6 and 4.7. | Core asserts that fast mode is not supported for Opus 4.6, 4.7 and `4.7[1m]`. | | `thinking-binding-excludes-api-key` | Call `applyThinkingBindingControls(body, 'drop_block')` even when Pi has no OAuth identity, bypassing the check that excludes API-key routes. | `buildAnthropicRequest — Fable/Mythos thinking > does not add Fable 5.1 binding controls to an API-key request`. | | `header-routing-current-credential-epoch` | Accept pending quota from a replaced login. | The next request must use the replacement OAuth token, not a paid API key. | -| `header-routing-drops-refused-publication` | Retain pending quota after its publication is refused. | A real token rotation followed by refused publication must remove the paid-routing hint. | | `header-routing-raw-exhaustion-proof` | Treat raw utilization of 0.995 as exhausted. | Display rounding must not permit paid API fallback. | +| `local-quota-survives-token-refresh` | Reject writes containing only quota when the token rotates, although the account UUID and credential epoch are unchanged. | The account's quota must survive refresh; writes containing other metadata must still require the original token version. | +| `local-metadata-requires-token-version` | Apply the account-only quota exception to writes that also contain other metadata. | Writes containing metadata from the old token must still be refused after refresh, even when they also contain quota. | | `runtime-read-no-follow` | Follow a symlink installed between inspection and opening. | The runtime reader must reject the symlink. | | `runtime-read-opened-mode` | Ignore the opened file's permissions. | A replacement with mode 0644 must be rejected. | | `runtime-read-opened-owner` | Ignore the opened file's owner. | A foreign owner on the opened handle must be rejected. | diff --git a/mutations.toml b/mutations.toml index 5f44f4b18..b22e13950 100644 --- a/mutations.toml +++ b/mutations.toml @@ -71,23 +71,6 @@ broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/open broad_report = "tmp/mutations/opencode.xml" broad_id = "{name}" -[[control]] -id = "header-routing-drops-refused-publication" -guards = "A refused quota publication must remove its routing hint without retaining permission for paid fallback." -file = "packages/opencode/src/index.ts" -old = ''' headerRoutingObservations.delete(served.accountId) - logger.debug('quota', 'discarded pending response quota', {''' -new = ''' logger.debug('quota', 'discarded pending response quota', {''' -test_file = "packages/opencode/src/tests/index.test.ts" -runner = "command" -command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] -test_count_pattern = "CKDEV executed {count} tests" -expect_red = ["auth.loader > quota header harvest > refused header publication removes a same-epoch pending observation"] -expect_message = "error: expect(received).toEqual(expected)" -broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] -broad_report = "tmp/mutations/opencode.xml" -broad_id = "{name}" - [[control]] id = "header-routing-raw-exhaustion-proof" guards = "Display rounding from 99.5 percent must not license paid fallback before actual OAuth exhaustion." @@ -151,3 +134,35 @@ expect_message = "error: expect(received).toMatchObject(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] broad_report = "tmp/mutations/core.xml" broad_id = "{name}" + +[[control]] +id = "local-quota-survives-token-refresh" +guards = "Account-owned quota must survive same-account token refresh while stale metadata and mixed writes remain refused." +file = "packages/core/src/native-account-runtime.ts" +old = "quota !== undefined && Object.keys(metadata).length === 0" +new = "false" +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["auth.loader > quota header harvest > quota-only publication survives native token refresh without admitting stale metadata"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" + +[[control]] +id = "local-metadata-requires-token-version" +guards = "The account-only quota exception must not permit stale metadata or mixed patches after token refresh." +file = "packages/core/src/native-account-runtime.ts" +old = "quota !== undefined && Object.keys(metadata).length === 0" +new = "true" +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["auth.loader > quota header harvest > quota-only publication survives native token refresh without admitting stale metadata"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index 5ca079717..cc7882cca 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -1069,17 +1069,22 @@ export function createNativeAccountRuntime( async publishLocal(subject, rawPatch) { const patch = captureNativeMetadataPatch(rawPatch) await authority() + const { quota, ...metadata } = patch + const quotaOnly = + quota !== undefined && Object.keys(metadata).length === 0 const row = await poolRow(subject.binding.rowId) if ( - !row || - !nativeLocalCredentialValidationMatches( - subject, - subject.binding, - row.credential, - ) + row?.stamp !== 'bound' || + row.credential?.type !== 'oauth' || + !nativeLocalPoolBindingMatches(subject.binding, paths, row) || + (!quotaOnly && + !nativeLocalCredentialValidationMatches( + subject, + subject.binding, + row.credential, + )) ) return false - const { quota, ...metadata } = patch if (quota) { if (quota.accountIdentity !== subject.binding.identity) throw new NativeRuntimeError('publication-refused') @@ -1094,8 +1099,12 @@ export function createNativeAccountRuntime( toNativeQuotaMap(quota), ) } - return localWrite(subject, (old) => - applyNativeMetadataPatch(old, metadata), + // Quota remains valid across a same-account refresh. Other metadata + // still requires the exact credential version that produced it. + return localWrite( + subject, + (old) => applyNativeMetadataPatch(old, metadata), + quotaOnly ? 'binding' : true, ) }, publishApi: (subject, patch) => diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 2a3a06ed3..954f3d93b 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -21,7 +21,10 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import type { PrimeManager } from '@cortexkit/anthropic-auth-core' +import type { + NativeRefreshSubject, + PrimeManager, +} from '@cortexkit/anthropic-auth-core' import * as Core from '@cortexkit/anthropic-auth-core' import { __setLogTestSink, @@ -1099,6 +1102,31 @@ async function replacePoolMainLogin( } } +/** Require all stored fields that a known-account publication must carry. */ +function requireKnownPoolSubject( + subject: NativeRefreshSubject, +): Parameters< + ReturnType['publishLocal'] +>[0] { + const { binding, credentialFingerprint, version } = subject + if ( + !binding.identity || + !credentialFingerprint || + !version?.accessFingerprint || + version.expires === undefined + ) + throw new Error('Expected a complete known pool credential subject') + return { + binding: { ...binding, identity: binding.identity }, + credentialFingerprint, + version: { + ...version, + accessFingerprint: version.accessFingerprint, + expires: version.expires, + }, + } +} + /** * Refresh the pool's main login in another plugin process; see * refreshPoolLoginElsewhere. @@ -16882,7 +16910,7 @@ describe('auth.loader', () => { } }) - test('refused header publication removes a same-epoch pending observation', async () => { + test('refused header publication cannot persist superseded-epoch quota', async () => { await useTempAccountFile( harvestStorage([apiFallbackForHeaderTests], { routing: { mode: 'fallback-first' }, @@ -16932,13 +16960,16 @@ describe('auth.loader', () => { }) try { const before = await reader.captureLocalSubject('main') - await refreshPoolMainElsewhere({ - access: 'sk-ant-oat01-same-epoch-new-access', - refresh: 'main-refresh', - expires: Date.now() + 8 * 60 * 60_000, - }) + await replacePoolMainLogin( + { + access: 'sk-ant-oat01-new-epoch-access', + refresh: 'main-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + { sameAccount: false, accountIdentity: poolMainIdentity() }, + ) const after = await reader.captureLocalSubject('main') - expect(after.binding.credentialEpoch).toBe( + expect(after.binding.credentialEpoch).toBeGreaterThan( before.binding.credentialEpoch, ) expect(after.binding.identity).toBe(before.binding.identity) @@ -16950,6 +16981,9 @@ describe('auth.loader', () => { } pause.release.open() expect(await pause.operations[0]).toBe(false) + expect((await readNativeRuntimeState()).main?.quota?.source).not.toBe( + 'headers', + ) await waitForLogRecord( records, (record) => @@ -16960,7 +16994,7 @@ describe('auth.loader', () => { expect((await result.fetch(MESSAGES_URL, EMPTY_POST)).status).toBe(200) expect(authorizations).toEqual([ 'Bearer sk-ant-oat01-main-access', - 'Bearer sk-ant-oat01-same-epoch-new-access', + 'Bearer sk-ant-oat01-new-epoch-access', ]) } finally { pause.release.open() @@ -17339,81 +17373,193 @@ describe('auth.loader', () => { ).toBeUndefined() }) + test('quota-only publication survives native token refresh without admitting stale metadata', async () => { + await useTempAccountFile(harvestStorage()) + globalThis.fetch = mock( + withNativeAdmission(async () => Response.json({})), + ) as unknown as typeof fetch + if (!migratedPool) throw new Error('Expected migrated pool') + const reader = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + }) + try { + expect((await reader.authorizeLocal('main')).status).toBe('usable') + const before = requireKnownPoolSubject( + await reader.captureLocalSubject('main'), + ) + await refreshPoolMainElsewhere({ + ...syntheticMainHostAuth(), + access: 'sk-ant-oat01-refreshed-quota-access', + }) + const after = await reader.captureLocalSubject('main') + expect(after.binding.credentialEpoch).toBe( + before.binding.credentialEpoch, + ) + expect(after.binding.identity).toBe(before.binding.identity) + expect(after.version?.accessFingerprint).not.toBe( + before.version?.accessFingerprint, + ) + const quota: OAuthQuotaSnapshot = { + accountIdentity: before.binding.identity, + source: 'headers', + checkedAt: Date.now(), + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt: Date.now(), + }, + } + expect( + await reader.publishLocal(before, { quota, lastUsed: Date.now() }), + ).toBe(false) + expect(await reader.publishLocal(before, { quota })).toBe(true) + expect( + (await reader.read()).accounts.find( + (account) => account.id === 'main', + )?.quota?.five_hour?.usedPercent, + ).toBe(78) + expect( + await reader.publishLocal(before, { lastUsed: Date.now() }), + ).toBe(false) + } finally { + reader.close() + } + }) + test('main header push persists after access-token rotation for the same account', async () => { - let liveAccessToken = 'old-main-access' + const initialAuth = { + ...syntheticMainHostAuth(), + access: 'sk-ant-oat01-old-main-access', + } + let liveAccessToken = initialAuth.access const existingQuota = { - five_hour: { - usedPercent: 11, - remainingPercent: 89, - checkedAt: 1, - }, + five_hour: { usedPercent: 11, remainingPercent: 89, checkedAt: 1 }, source: 'poll' as const, checkedAt: 1, } await useTempAccountFile( - createFallbackStorage({ - mainAccountId: 'account-x', - accounts: [], - quota: { - enabled: false, - mainQuota: existingQuota, - mainQuotaCheckedAt: 1, - }, - }), + bindMainQuotaToAccount( + createFallbackStorage({ + mainAccountId: 'account-x', + accounts: [], + quota: { + enabled: false, + mainQuota: existingQuota, + mainQuotaCheckedAt: 1, + }, + }), + initialAuth.access, + ), + initialAuth, ) - let resolveResponse: ((response: Response) => void) | undefined - let markRequestStarted: (() => void) | undefined - const requestStarted = new Promise((resolve) => { - markRequestStarted = resolve - }) + const requestStarted = bodyLifetime().gate() + const releaseResponse = bodyLifetime().gate() const requestAuthorizations: Array = [] globalThis.fetch = mock( withNativeAdmission( - (_input: string | URL | Request, init?: RequestInit) => - new Promise((resolve) => { - requestAuthorizations.push( - new Headers(init?.headers).get('authorization'), - ) - resolveResponse = resolve - markRequestStarted?.() - }), + async (input: Parameters[0], init?: RequestInit) => { + if (!extractUrl(input).includes('/v1/messages')) + return Response.json({}) + requestAuthorizations.push( + new Headers(init?.headers).get('authorization'), + ) + requestStarted.open() + await releaseResponse.wait + return new Response('main-ok', { headers: quotaHeaders }) + }, ), ) as unknown as typeof fetch const result = await loadFetch(() => liveAccessToken) const records: LogTestRecord[] = [] __setLogTestSink((record) => records.push(record)) setLogLevel('debug') - const responsePromise = result.fetch(MESSAGES_URL, EMPTY_POST) - await requestStarted - liveAccessToken = 'new-main-access' - resolveResponse?.(new Response('main-ok', { headers: quotaHeaders })) - await responsePromise - await Bun.sleep(100) - const rawState = JSON.parse( - await readFile( - getAccountStatePath(process.env.OPENCODE_ANTHROPIC_AUTH_FILE), - 'utf8', - ), - ) - const reloaded = await readAccountStorage() - - expect( - records.some( - (record) => - record.channel === 'quota' && - record.message === 'harvested response quota', - ), - ).toBe(true) - expect(requestAuthorizations[0]).toBe('Bearer old-main-access') - expect(rawState.main.quota.five_hour.usedPercent).toBe(78) - expect(rawState.main.quota.accountIdentity).toBe('account-x') - expect(rawState.main.quotaToken).toBeUndefined() - expect(reloaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(78) - expect(reloaded?.quota?.mainQuota?.accountIdentity).toBe('account-x') - expect(reloaded?.quota?.mainQuotaToken).toBeUndefined() - __setLogTestSink(null) - setLogLevel('info') + try { + await Promise.race([ + requestStarted.wait, + responsePromise.then(() => { + throw new Error('Expected a held main model request') + }), + ]) + if (!migratedPool) throw new Error('Expected migrated pool') + const reader = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + }) + try { + const before = requireKnownPoolSubject( + await reader.captureLocalSubject('main'), + ) + liveAccessToken = 'sk-ant-oat01-new-main-access' + await refreshPoolMainElsewhere({ + ...initialAuth, + access: liveAccessToken, + }) + const after = await reader.captureLocalSubject('main') + expect(after.binding.credentialEpoch).toBe( + before.binding.credentialEpoch, + ) + expect(after.binding.identity).toBe(before.binding.identity) + expect(after.version?.accessFingerprint).not.toBe( + before.version?.accessFingerprint, + ) + expect( + await reader.publishLocal(before, { + quota: { + ...existingQuota, + accountIdentity: syntheticMainAccountUuid, + five_hour: { + usedPercent: 99, + remainingPercent: 1, + checkedAt: Date.now(), + }, + checkedAt: Date.now(), + }, + lastUsed: Date.now(), + }), + ).toBe(false) + expect( + (await reader.read()).accounts.find( + (account) => account.id === 'main', + )?.quota?.five_hour?.usedPercent, + ).toBe(11) + } finally { + reader.close() + } + releaseResponse.open() + await responsePromise + const state = await waitForState( + (value) => value.main?.quota?.source === 'headers', + ) + const reloaded = await readAccountStorage() + expect( + records.some( + (record) => + record.channel === 'quota' && + record.message === 'harvested response quota', + ), + ).toBe(true) + expect(requestAuthorizations[0]).toBe( + 'Bearer sk-ant-oat01-old-main-access', + ) + expect(state.main.quota.five_hour.usedPercent).toBe(78) + expect(state.main.quota.accountIdentity).toBe(syntheticMainAccountUuid) + expect(state.main.quotaToken).toBeUndefined() + expect(reloaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(78) + expect(reloaded?.quota?.mainQuota?.accountIdentity).toBe( + syntheticMainAccountUuid, + ) + // In the policy view, mainQuotaToken is an account UUID, not a token. + expect(reloaded?.quota?.mainQuotaToken).toBe(syntheticMainAccountUuid) + expect(JSON.stringify(reloaded)).not.toContain(initialAuth.access) + expect(JSON.stringify(reloaded)).not.toContain(liveAccessToken) + } finally { + releaseResponse.open() + await Promise.allSettled([responsePromise]) + __setLogTestSink(null) + setLogLevel('info') + } }) test('fallback header push persists after access-token rotation for the same account', async () => { From a6535fd64a8dc54e161280473fea976e7136ea25 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:06:53 +0200 Subject: [PATCH 109/169] test: rotate fallback credentials through the native pool --- packages/opencode/src/tests/index.test.ts | 135 +++++++++++++--------- 1 file changed, 82 insertions(+), 53 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 954f3d93b..9261a7416 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -17565,66 +17565,95 @@ describe('auth.loader', () => { test('fallback header push persists after access-token rotation for the same account', async () => { const fallbackTemplate = createFallbackStorage() .accounts[0] as OAuthAccount - const existingQuota = fallbackTemplate.quota + const oldAccess = 'sk-ant-oat01-old-fallback-access' + const newAccess = 'sk-ant-oat01-new-fallback-access' await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - ...fallbackTemplate, - access: 'sk-ant-oat01-old-fallback-access', - quota: existingQuota, - }, - ], - quota: { enabled: false }, - }), + bindPoolAccounts( + createFallbackStorage({ + accounts: [ + { + ...fallbackTemplate, + access: oldAccess, + quota: fallbackTemplate.quota, + }, + ], + quota: { enabled: false }, + }), + ), ) let messageCalls = 0 - let resolveFallbackResponse: ((response: Response) => void) | undefined - let fallbackRequestStarted: (() => void) | undefined - const fallbackStarted = new Promise((resolve) => { - fallbackRequestStarted = resolve - }) + const fallbackStarted = bodyLifetime().gate() + const releaseFallback = bodyLifetime().gate() + const authorizations: Array = [] globalThis.fetch = mock( - withNativeAdmission((input: string | URL | Request) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response('usage-unavailable', { status: 500 }), - ) - } - messageCalls++ - if (messageCalls === 1) - return Promise.resolve(new Response('limited', { status: 429 })) - return new Promise((resolve) => { - resolveFallbackResponse = resolve - fallbackRequestStarted?.() - }) - }), + withNativeAdmission( + async (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) + return new Response('usage-unavailable', { status: 500 }) + if (!url.includes('/v1/messages')) return Response.json({}) + authorizations.push(new Headers(init?.headers).get('authorization')) + if (++messageCalls === 1) + return new Response('limited', { status: 429 }) + fallbackStarted.open() + await releaseFallback.wait + return new Response('fallback-ok', { headers: quotaHeaders }) + }, + ), ) as unknown as typeof fetch const result = await loadFetch() const responsePromise = result.fetch(MESSAGES_URL, EMPTY_POST) - await fallbackStarted - - const rotated = await readAccountStorage() - if (!rotated) throw new Error('expected rotated fallback storage') - const fallback = rotated.accounts.find( - (account): account is OAuthAccount => account.id === 'fallback-1', - ) - if (!fallback) throw new Error('expected fallback account') - fallback.access = 'new-fallback-access' - await saveAccounts(rotated) - resolveFallbackResponse?.( - new Response('fallback-ok', { headers: quotaHeaders }), - ) - await responsePromise - - const state = await waitForState( - (value) => value.accounts?.['fallback-1']?.quota?.source === 'headers', - ) - expect(state.accounts['fallback-1'].quota.accountIdentity).toBe( - 'fallback-1', - ) - expect(state.accounts['fallback-1'].quota.five_hour.usedPercent).toBe(78) + try { + await Promise.race([ + fallbackStarted.wait, + responsePromise.then(() => { + throw new Error('Expected a held fallback request') + }), + ]) + if (!migratedPool) throw new Error('Expected migrated pool') + const reader = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + }) + try { + const before = await reader.captureLocalSubject('fallback-1') + loginIssues(oldAccess, newAccess) + await refreshPoolLoginElsewhere('fallback-1', { + access: newAccess, + refresh: fallbackTemplate.refresh, + expires: Date.now() + 8 * 60 * 60_000, + }) + const after = await reader.captureLocalSubject('fallback-1') + expect(after.binding.credentialEpoch).toBe( + before.binding.credentialEpoch, + ) + expect(after.binding.identity).toBe(before.binding.identity) + expect(after.version?.accessFingerprint).not.toBe( + before.version?.accessFingerprint, + ) + } finally { + reader.close() + } + releaseFallback.open() + await responsePromise + const state = await waitForState( + (value) => + value.accounts?.['fallback-1']?.quota?.source === 'headers', + ) + expect(state.accounts['fallback-1'].quota.accountIdentity).toBe( + syntheticFallbackAccountUuid(0), + ) + expect(state.accounts['fallback-1'].quota.five_hour.usedPercent).toBe( + 78, + ) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access', + `Bearer ${oldAccess}`, + ]) + } finally { + releaseFallback.open() + await Promise.allSettled([responsePromise]) + } }) test('fallback header push does not persist onto a different account identity', async () => { From ad9845e0428c351f513bc8cd7b2d98579448a395 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:22:32 +0200 Subject: [PATCH 110/169] test: bind header fixtures to native account authority --- packages/opencode/src/tests/index.test.ts | 186 ++++++++++++++-------- 1 file changed, 121 insertions(+), 65 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 9261a7416..9066ec42b 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -8,7 +8,7 @@ import { mock, spyOn, } from 'bun:test' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' import { chmod, @@ -17318,6 +17318,13 @@ describe('auth.loader', () => { expect(relay503Calls).toBe(1) expect(directCalls).toBe(1) await waitForState((value) => value.main?.quota?.source === 'headers') + await waitForLogRecord( + records, + (record) => + record.channel === 'quota' && + record.message === 'harvested response quota', + 'published direct-fallback quota log', + ) expect( records.filter( (record) => @@ -17657,79 +17664,96 @@ describe('auth.loader', () => { }) test('fallback header push does not persist onto a different account identity', async () => { - const accountXQuota = { - five_hour: { - usedPercent: 11, - remainingPercent: 89, - checkedAt: 1, - }, - } - const servedAccount = { - ...(createFallbackStorage().accounts[0] as OAuthAccount), - access: 'sk-ant-oat01-served-account-access', - quota: accountXQuota, + const accountXQuota: OAuthQuotaSnapshot = { + accountIdentity: syntheticFallbackAccountUuid(0), + five_hour: { usedPercent: 11, remainingPercent: 89, checkedAt: 1 }, } + const template = createFallbackStorage().accounts[0] as OAuthAccount await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - ...(createFallbackStorage().accounts[0] as OAuthAccount), - id: 'account-x', - lastRefreshError: { - message: 'dead fallback', - checkedAt: Date.now(), - permanent: true, - accountIdentity: 'account-x', + bindPoolAccounts( + createFallbackStorage({ + accounts: [ + { + ...template, + id: 'account-x', + lastRefreshError: { + message: 'dead fallback', + checkedAt: Date.now(), + permanent: true, + // Import a failure bound to the exact stored refresh token. + tokenHash: createHash('sha256') + .update(template.refresh) + .digest('hex'), + accountIdentity: syntheticFallbackAccountUuid(0), + }, + quota: accountXQuota, }, - quota: accountXQuota, - }, - servedAccount, - ], - quota: { enabled: false }, - }), + { + ...template, + access: 'sk-ant-oat01-served-account-access', + refresh: 'served-account-refresh', + quota: { + ...accountXQuota, + accountIdentity: syntheticFallbackAccountUuid(1), + }, + }, + ], + quota: { enabled: false }, + }), + ), ) let messageCalls = 0 - globalThis.fetch = mock((input: string | URL | Request) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { + globalThis.fetch = mock( + withNativeAdmission((input: Parameters[0]) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) + return Promise.resolve( + new Response('usage-unavailable', { status: 500 }), + ) + if (!url.includes('/v1/messages')) + return Promise.resolve(Response.json({})) return Promise.resolve( - new Response('usage-unavailable', { status: 500 }), + ++messageCalls === 1 + ? new Response('limited', { status: 429 }) + : new Response('fallback-ok', { headers: quotaHeaders }), ) - } - messageCalls++ - return Promise.resolve( - messageCalls === 1 - ? new Response('limited', { status: 429 }) - : new Response('fallback-ok', { headers: quotaHeaders }), - ) - }) as unknown as typeof fetch + }), + ) as unknown as typeof fetch + const before = await readNativeRuntimeState() + expect(before.accounts['account-x']?.lastRefreshError?.permanent).toBe( + true, + ) const result = await loadFetch() - await result.fetch(MESSAGES_URL, EMPTY_POST) const state = await waitForState( (value) => value.accounts?.['fallback-1']?.quota?.source === 'headers', ) expect(state.accounts['account-x'].quota).toEqual(accountXQuota) expect(state.accounts['fallback-1'].quota.accountIdentity).toBe( - 'fallback-1', + syntheticFallbackAccountUuid(1), ) + expect(state.accounts['fallback-1'].quota.five_hour.usedPercent).toBe(78) }) test('main header push preserves persisted poll backoff across reload', async () => { const pollBackoff = { + status: 429, + accountIdentity: syntheticMainAccountUuid, message: 'Claude quota check failed: 429 — rate limited', checkedAt: Date.now(), nextRetryAt: Date.now() + 60_000, retryCount: 1, } await useTempAccountFile( - createFallbackStorage({ - accounts: [], - quota: { - enabled: false, - mainLastQuotaApiError: pollBackoff, - }, - }), + bindMainAccount( + createFallbackStorage({ + accounts: [], + quota: { + enabled: false, + mainLastQuotaApiError: pollBackoff, + }, + }), + ), ) globalThis.fetch = mock( withNativeAdmission(() => @@ -17746,9 +17770,16 @@ describe('auth.loader', () => { ) const reloaded = await readAccountStorage() - expect(state.main.lastQuotaApiError).toEqual(pollBackoff) + // Public native views retain backoff fields but hide stored error text. + expect(state.main.lastQuotaApiError).toEqual({ + ...pollBackoff, + message: 'OAuth operation failed', + }) expect(state.main.quota.five_hour.usedPercent).toBe(78) - expect(reloaded?.quota?.mainLastQuotaApiError).toEqual(pollBackoff) + expect(reloaded?.quota?.mainLastQuotaApiError).toEqual({ + ...pollBackoff, + message: 'OAuth operation failed', + }) expect(reloaded?.quota?.mainQuota?.source).toBe('headers') }) @@ -17776,6 +17807,13 @@ describe('auth.loader', () => { expect(messageCalls).toBe(1) expect(usageCalls).toBe(0) + await waitForLogRecord( + records, + (record) => + record.channel === 'quota' && + record.message === 'harvested response quota', + 'published primary quota log', + ) expect( records.filter( (record) => @@ -17822,6 +17860,8 @@ describe('auth.loader', () => { test('fallback header push preserves persisted poll backoff across reload', async () => { const pollBackoff = { + status: 429, + accountIdentity: syntheticFallbackAccountUuid(0), message: 'Claude quota check failed: 429 — rate limited', checkedAt: Date.now(), nextRetryAt: Date.now() + 60_000, @@ -17832,7 +17872,9 @@ describe('auth.loader', () => { throw new Error('expected OAuth fallback fixture') } await useTempAccountFile( - harvestStorage([{ ...fallback, lastQuotaRefreshError: pollBackoff }]), + bindPoolAccounts( + harvestStorage([{ ...fallback, lastQuotaRefreshError: pollBackoff }]), + ), ) let messages = 0 globalThis.fetch = mock( @@ -17867,11 +17909,15 @@ describe('auth.loader', () => { account.id === 'fallback-1' && account.type === 'oauth', ) - expect(state.accounts['fallback-1'].lastQuotaRefreshError).toEqual( - pollBackoff, - ) + expect(state.accounts['fallback-1'].lastQuotaRefreshError).toEqual({ + ...pollBackoff, + message: 'OAuth operation failed', + }) expect(state.accounts['fallback-1'].quota.five_hour.usedPercent).toBe(78) - expect(reloadedFallback?.lastQuotaRefreshError).toEqual(pollBackoff) + expect(reloadedFallback?.lastQuotaRefreshError).toEqual({ + ...pollBackoff, + message: 'OAuth operation failed', + }) expect(reloadedFallback?.quota?.source).toBe('headers') }) @@ -17927,6 +17973,7 @@ describe('auth.loader', () => { test('non-finite utilization headers leave stored quota untouched', async () => { const existingQuota = { + accountIdentity: syntheticMainAccountUuid, five_hour: { usedPercent: 11, remainingPercent: 89, @@ -17937,15 +17984,17 @@ describe('auth.loader', () => { checkedAt: 1, } await useTempAccountFile( - createFallbackStorage({ - accounts: [], - quota: { - enabled: false, - mainQuota: existingQuota, - mainQuotaCheckedAt: 1, - mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), - }, - }), + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + quota: { + enabled: false, + mainQuota: existingQuota, + mainQuotaCheckedAt: 1, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + }, + }), + ), ) globalThis.fetch = mock( withNativeAdmission(() => @@ -18027,6 +18076,13 @@ describe('auth.loader', () => { await result.fetch(MESSAGES_URL, EMPTY_POST) + await waitForLogRecord( + records, + (record) => + record.channel === 'quota' && + record.message === 'harvested response quota', + 'published quota log', + ) const harvested = records.filter( (record) => record.channel === 'quota' && From 17072c0e44815a67000d7347efe41b10f4e7b751 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:29:32 +0200 Subject: [PATCH 111/169] test: await refreshed-main quota publication logging --- packages/opencode/src/tests/index.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 9066ec42b..d39a24d23 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -17540,6 +17540,13 @@ describe('auth.loader', () => { (value) => value.main?.quota?.source === 'headers', ) const reloaded = await readAccountStorage() + await waitForLogRecord( + records, + (record) => + record.channel === 'quota' && + record.message === 'harvested response quota', + 'published refreshed-main quota log', + ) expect( records.some( (record) => From b9e3b5e1b6420f40262e1972bee892a21e478864 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:46:52 +0200 Subject: [PATCH 112/169] fix: make sidebar callback available to early quota fallback --- packages/opencode/src/index.ts | 4 +- packages/opencode/src/tests/index.test.ts | 223 ++++++++++++---------- 2 files changed, 129 insertions(+), 98 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 608f1211f..1bfca9b97 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -8158,10 +8158,10 @@ const anthropicAuthPlugin = async ( requestMainQuotaIdentity, ) } - const writeCurrentSidebarState = async ( + async function writeCurrentSidebarState( activeId: string | undefined, route: string, - ) => { + ) { let sidebarStorage = storage let skipFallbackQuotaSeed = false if ( diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index d39a24d23..8e128c97d 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -43,8 +43,10 @@ import { FallbackAccountManager, getAccountStatePath, getClaudeCodeIdentityForVerifiedAccount, + getDumpDirectory, hashRefreshToken, isCustodyTombstoneOAuth, + isDumpEnabled, isNativeLocalCredentialValidation, isOAuthAccount, type LogTestRecord, @@ -8661,67 +8663,71 @@ describe('auth.loader', () => { test('does not use API-key route in fallback-first before main quota is exhausted', async () => { await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - } as AccountStorage['quota'], - accounts: [ - { - id: 'kie-opus', - label: 'Kie Opus', - type: 'api', - apiKey: 'kie-key', - baseURL: 'https://api.kie.ai/claude', - authHeader: 'authorization-bearer', - }, - { - id: 'fallback-1', - type: 'oauth', - access: 'sk-ant-oat01-fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 25, - remainingPercent: 75, - checkedAt: Date.now(), - }, - seven_day: { - usedPercent: 30, - remainingPercent: 70, - checkedAt: Date.now(), + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + } as AccountStorage['quota'], + accounts: [ + { + id: 'kie-opus', + label: 'Kie Opus', + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', + }, + { + id: 'fallback-1', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: Date.now(), + }, }, }, - }, - ], - }), + ], + }), + ), ) const requests: Array<{ url: string; authorization: string | null }> = [] globalThis.fetch = mock( - withNativeAdmission((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 10 }, - seven_day: { utilization: 10 }, - }), - { status: 200 }, - ), - ) - } - requests.push({ - url, - authorization: new Headers(init?.headers).get('authorization'), - }) - return Promise.resolve(new Response(null, { status: 200 })) - }), + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + requests.push({ + url, + authorization: new Headers(init?.headers).get('authorization'), + }) + return Promise.resolve(new Response(null, { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -19244,56 +19250,81 @@ describe('cache diagnostics', () => { process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir try { await useTempAccountFile( - createFallbackStorage({ - dump: { enabled: true }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - checkedAt: Date.now(), - five_hour: { usedPercent: 100, remainingPercent: 0 }, - seven_day: { usedPercent: 50, remainingPercent: 50 }, - }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), - } as AccountStorage['quota'], - accounts: [ - { - id: 'kie-opus', - type: 'api', - apiKey: 'kie-key', - baseURL: 'https://api.kie.ai/claude', - authHeader: 'authorization-bearer', - }, - ], - }), + bindPoolAccounts( + createFallbackStorage({ + dump: { enabled: true }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + checkedAt: Date.now(), + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 50, + remainingPercent: 50, + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + accounts: [ + { + id: 'kie-opus', + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', + }, + ], + }), + ), ) const records: LogTestRecord[] = [] let sentBody: Record | undefined let sentBeta = '' - globalThis.fetch = mock((_input: any, init: RequestInit) => { - sentBody = JSON.parse(String(init.body)) - sentBeta = new Headers(init.headers).get('anthropic-beta') ?? '' - return Promise.resolve(sseResponse(message('provider-api'))) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + expect(extractUrl(input)).toBe( + 'https://api.kie.ai/claude/v1/messages?beta=true', + ) + expect(new Headers(init?.headers).get('authorization')).toBe( + 'Bearer kie-key', + ) + sentBody = JSON.parse(String(init?.body)) + sentBeta = new Headers(init?.headers).get('anthropic-beta') ?? '' + return Promise.resolve(sseResponse(message('provider-api'))) + }, + ), + ) as unknown as typeof fetch __setLogTestSink((record) => records.push(record)) + expect((await readAccountStorage())?.dump?.enabled).toBe(true) const plugin = await getPlugin() + expect(isDumpEnabled()).toBe(true) const result = await plugin.auth.loader(oauthLoader, { models: {} }) - await ( - await result.fetch(MESSAGES_URL, { - method: 'POST', - headers: { 'x-session-affinity': 'ses-api' }, - body: JSON.stringify({ - model: 'claude-opus-4-8', - stream: true, - messages: [{ role: 'user', content: 'hello' }], - }), - }) - ).text() + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-api' }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + expect(response.status).toBe(200) + await response.text() + expect(sentBody).toBeDefined() + expect(isDumpEnabled()).toBe(true) + expect(getDumpDirectory()).toBe(dumpDir) expect(sentBody?.diagnostics).toBeUndefined() expect(sentBeta).not.toContain('cache-diagnosis-2026-04-07') expect( From a9258e89a6f47a5af963c5a9e4cd16f20fa8f1dc Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:57:41 +0200 Subject: [PATCH 113/169] test: require account-bound profile cache inheritance OAIAUTH confirmed that an old-token-only legacy profile must not be adopted by a different credential. Cover fresh lookup failure without inherited tier, retain zero-fetch reuse for the exact account UUID, and require exact per-account hydration counts without assuming request order. --- packages/opencode/src/tests/index.test.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 8e128c97d..6646df871 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -9991,9 +9991,11 @@ describe('auth.loader', () => { } } - expect(profileCalls).toEqual([ - 'Bearer sk-ant-oat01-main-access', + // Independent account hydration may finish in either order. Require + // exactly one request per account, including after the second status read. + expect(profileCalls.toSorted()).toEqual([ 'Bearer sk-ant-oat01-fallback-access', + 'Bearer sk-ant-oat01-main-access', ]) const loaded = await readAccountStorage() expect(loaded?.main?.profile?.tier).toBe('default_claude_max_20x') @@ -10056,7 +10058,7 @@ describe('auth.loader', () => { expect(profileCalls).toBe(0) }) - test('legacy profile is adopted under the current main identity', async () => { + test('unbound legacy profile is not adopted by a different credential', async () => { await useTempAccountFile( createFallbackStorage({ accounts: [], @@ -10115,14 +10117,11 @@ describe('auth.loader', () => { const text = (mockClient.session.promptAsync as any).mock.calls.at(-1)?.[0] ?.body.parts[0]?.text - expect(text).toContain('Max 20x') - expect(profileCalls).toBe(0) - const adoptedStorage = await waitForAccountStorage( - (storage) => storage?.main?.profile?.accountIdentity !== undefined, - ) - expect(adoptedStorage?.main?.profile?.accountIdentity).toBe( - adoptedStorage?.mainAccountId, - ) + // An old access-token fingerprint cannot prove the replacement account's + // tier. A failed fresh lookup must not restore that unclaimed profile. + expect(text).not.toContain('Max 20x') + expect(profileCalls).toBe(1) + expect((await readAccountStorage())?.main?.profile).toBeUndefined() }) test('in-flight hydration is shared across access-token rotation', async () => { @@ -10214,6 +10213,8 @@ describe('auth.loader', () => { type: 'opencode', provider: 'anthropic', profile: { + accountIdentity: syntheticMainAccountUuid, + providerAccountUuid: syntheticMainAccountUuid, tier: 'default_claude_max_20x', orgType: 'claude_max', checkedAt: Date.now(), From 7b966204a25ef5026314933f37fbfcf763f7c222 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:14:31 +0200 Subject: [PATCH 114/169] fix: hydrate native startup profiles and publish saved tiers --- packages/opencode/src/index.ts | 54 ++++++++++++++++++++++- packages/opencode/src/tests/index.test.ts | 3 ++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1bfca9b97..0a97e61da 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1995,6 +1995,7 @@ const anthropicAuthPlugin = async ( // instance never leaves a metadata write running after it is torn down. const profilePublications = new Set>() const profileHydrationShutdown = new AbortController() + let bootProfileHydrationStarted = false function profileHydrationKey(id: string, accountIdentity?: string) { return JSON.stringify([id, accountIdentity ?? null]) @@ -2054,12 +2055,35 @@ const anthropicAuthPlugin = async ( hydration.profile = profile hydration.persistence = 'pending' } - const saving = persisted.then((outcome) => { + const saving = persisted.then(async (outcome) => { hydration.persistence = outcome if (outcome === 'failed') logger.debug('quota', 'failed to save account profile', { account: account.id, }) + if ( + outcome === 'saved' && + !profileHydrationShutdown.signal.aborted + ) { + // Display-only writes reread native state. Refresh after the + // profile commits so that reread can include the new tier. + try { + const current = await nativeAccounts.read() + if (!profileHydrationShutdown.signal.aborted) + await writeSidebarState(current.policyStorage, { + ...lastSidebarRouting, + routingAuthoritative: false, + }) + } catch { + logger.debug( + 'quota', + 'failed to publish account profile to sidebar', + { + account: account.id, + }, + ) + } + } }) profilePublications.add(saving) void saving.then(() => profilePublications.delete(saving)) @@ -5665,6 +5689,34 @@ const anthropicAuthPlugin = async ( } } const journal = await readNativeMigrationJournal(nativePaths) + if ( + !bootProfileHydrationStarted && + isCustodyTombstoneOAuth(activation, 'anthropic') && + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION !== '1' + ) { + // Show native account tiers before the first model request, without + // making provider initialization wait for profile network or storage. + bootProfileHydrationStarted = true + const hydration = readNativeSnapshotForDisplay( + profileHydrationShutdown.signal, + ) + .then((snapshot) => { + if (!profileHydrationShutdown.signal.aborted) + return writeSidebarState(snapshot.policyStorage, { + ...lastSidebarRouting, + routingAuthoritative: false, + }) + }) + .catch(() => { + if (!profileHydrationShutdown.signal.aborted) + logger.debug( + 'quota', + 'startup account profile hydration failed', + ) + }) + profilePublications.add(hydration) + void hydration.then(() => profilePublications.delete(hydration)) + } const getAuth = async ( modelId?: string, signal?: AbortSignal, diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 6646df871..58b6e12a0 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -10260,6 +10260,9 @@ describe('auth.loader', () => { test('boot profile hydration publishes tier labels to the sidebar', async () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION await useTempAccountFile(createFallbackStorage({ accounts: [] })) + expect( + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION, + ).toBeUndefined() let profileCalls = 0 globalThis.fetch = mock( withNativeAdmission((input: string | URL | Request) => { From 20b2d1f46b9bf0d7f53c0fead4d3febfca905fd5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:34:17 +0200 Subject: [PATCH 115/169] test: verify native profile rotation and bounded quota display --- packages/opencode/src/tests/index.test.ts | 139 +++++++++++++++------- 1 file changed, 99 insertions(+), 40 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 58b6e12a0..904a473db 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -9782,9 +9782,34 @@ describe('auth.loader', () => { }) test('/claude-quota bounds stalled profile hydration without hiding quota output', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const checkedAt = Date.now() + await useTempAccountFile( + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + main: { + type: 'opencode', + provider: 'anthropic', + profile: { + checkedAt: checkedAt - 8 * 24 * 60 * 60_000, + tier: 'default_claude_free', + orgType: 'claude_free', + }, + }, + quota: { + enabled: true, + mainQuota: { + five_hour: { usedPercent: 25, remainingPercent: 75, checkedAt }, + seven_day: { usedPercent: 50, remainingPercent: 50, checkedAt }, + checkedAt, + }, + }, + }), + ), + ) const mockClient = createMockClient() let profileSignal: AbortSignal | undefined + let usageCalls = 0 globalThis.fetch = mock( withNativeAdmission( (input: string | URL | Request, init?: RequestInit) => { @@ -9800,6 +9825,7 @@ describe('auth.loader', () => { }) } if (url.includes('/api/oauth/usage')) { + usageCalls++ return Promise.resolve( Response.json({ five_hour: { utilization: 25 }, @@ -9835,6 +9861,7 @@ describe('auth.loader', () => { expect(performance.now() - startedAt).toBeLessThan(4_000) expect(profileSignal?.aborted).toBe(true) + expect(usageCalls).toBe(0) const text = (mockClient.session.promptAsync as any).mock.calls.at(-1)?.[0] ?.body.parts[0]?.text as string expect(text).toContain('## Claude Quotas') @@ -10309,6 +10336,8 @@ describe('auth.loader', () => { type: 'opencode', provider: 'anthropic', profile: { + accountIdentity: syntheticMainAccountUuid, + providerAccountUuid: syntheticMainAccountUuid, tier: 'default_claude_max_20x', orgType: 'claude_max', checkedAt: Date.now(), @@ -10354,48 +10383,78 @@ describe('auth.loader', () => { await profileStarted.raised await drainSidebarWrites() - const rotated = await readAccountStorage() - const fallback = rotated?.accounts[0] - if (!rotated || fallback?.type !== 'oauth') { - throw new Error('expected fallback OAuth account') - } - fallback.access = 'sk-ant-oat01-new-access' - fallback.refresh = 'new-refresh' - fallback.lastRefreshedAt = 200 - await saveAccounts(rotated) - await Bun.sleep(2) - profile.release( - Response.json({ - organization: { - organization_type: 'claude_team', - rate_limit_tier: 'default_claude_max_5x', - }, - }), - ) - const reloaded = await waitForAccountStorage((storage) => { - const account = storage?.accounts.find( - (candidate) => candidate.id === 'fb', + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const reader = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + setLogLevel('debug') + try { + const before = await reader.captureLocalSubject('fb') + loginIssues('sk-ant-oat01-old-access', 'sk-ant-oat01-new-access') + await refreshPoolLoginElsewhere('fb', { + access: 'sk-ant-oat01-new-access', + refresh: 'new-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }) + const rotated = await reader.captureLocalSubject('fb') + expect(rotated.binding.identity).toBe(before.binding.identity) + expect(rotated.binding.credentialEpoch).toBe( + before.binding.credentialEpoch, ) - return ( - account?.type === 'oauth' && - account.profile?.tier === 'default_claude_max_5x' + expect(rotated.version?.accessFingerprint).not.toBe( + before.version?.accessFingerprint, ) - }) - await drainSidebarWrites() - const reloadedFallback = reloaded?.accounts[0] - expect(reloadedFallback).toMatchObject({ - access: 'sk-ant-oat01-new-access', - refresh: 'new-refresh', - lastRefreshedAt: 200, - }) - if (reloadedFallback?.type !== 'oauth') { - throw new Error('expected reloaded fallback OAuth account') + profile.release( + Response.json({ + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + }, + }), + ) + // Profile metadata still requires its captured token version. The late + // response must not replace credentials or publish stale metadata. + await waitForLogRecord( + records, + (record) => + record.channel === 'quota' && + record.message === 'failed to save account profile' && + record.payload?.account === 'fb', + 'stale fallback profile publication refusal', + ) + await drainSidebarWrites() + const after = await reader.captureLocalSubject('fb') + expect(after).toEqual(rotated) + const snapshot = await reader.read() + expect( + snapshot.accounts.find((account) => account.id === 'fb')?.profile, + ).toBeUndefined() + const stored = await createNativePoolStore({ + paths: pool.paths, + quota: nativeQuotaCodec, + }).read() + expect(stored.status).toBe('ready') + const credential = + stored.status === 'ready' + ? stored.rows.find((row) => row.id === rotated.binding.rowId) + ?.credential + : undefined + expect(credential).toMatchObject({ + type: 'oauth', + access: 'sk-ant-oat01-new-access', + refresh: 'new-refresh', + }) + expect(profileCalls).toBe(1) + } finally { + profile.release(new Response('cancelled', { status: 499 })) + reader.close() + __setLogTestSink(null) + setLogLevel('info') } - expect(reloadedFallback.profile).toMatchObject({ - tier: 'default_claude_max_5x', - accountIdentity: 'fb', - }) - expect(profileCalls).toBe(1) }) test('late main profile hydration cannot replace a rotated-token profile', async () => { From faff5e76ab30aefe93eb9c1d17d2b3de2183a359 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:37:22 +0200 Subject: [PATCH 116/169] test: drain auth bodies before restoring request transport --- packages/opencode/src/tests/index.test.ts | 56 +++++++++++++++++------ 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 904a473db..8d4d74ffa 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -181,6 +181,16 @@ function bodyLifetime(): TestLifetime { type TestBody = () => unknown +async function drainBeforeReset( + lifetime: TestLifetime | undefined, + reset: () => void | Promise, +) { + // Bun can time out the test before its async body finishes. Keep its + // transport and environment intact until that body and its work drain. + await lifetime?.finish() + await reset() +} + /** Bun's test, with the body run inside this test's fixture lifetime. */ function test(name: string, body: TestBody, timeout?: number) { bunTest(name, () => bodyLifetime().runBody(body), timeout) @@ -6529,20 +6539,38 @@ describe('auth.loader', () => { }) afterEach(async () => { - globalThis.fetch = originalFetch - pluginRuntimeOverrides = {} - Math.random = originalRandom - Date.now = originalDateNow - resetNotificationsForTest() - __setInitialSidebarRoutingTestHooks(null) - __setSidebarStateWriteTestHooks(null) - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - delete process.env.OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE - await drainSidebarWrites() - restoreProcessTestFiles() - // Clear tempConfigDir without deleting the account files used by plugins. - // TestLifetime disposes those plugins and joins their work before removal. - tempConfigDir = undefined + await drainBeforeReset(testLifetime, async () => { + globalThis.fetch = originalFetch + pluginRuntimeOverrides = {} + Math.random = originalRandom + Date.now = originalDateNow + resetNotificationsForTest() + __setInitialSidebarRoutingTestHooks(null) + __setSidebarStateWriteTestHooks(null) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + delete process.env.OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE + await drainSidebarWrites() + restoreProcessTestFiles() + tempConfigDir = undefined + }) + }) + + test('teardown drains an interrupted body before resetting its request transport', async () => { + const scope = new TestLifetime() + const resume = scope.gate() + const events: string[] = [] + let transport = () => events.push('owned-request') + const body = scope.runBody(async () => { + await resume.wait + events.push('body-resumed') + transport() + }) + await drainBeforeReset(scope, () => { + events.push('transport-reset') + transport = () => events.push('network-guard') + }) + await body + expect(events).toEqual(['body-resumed', 'owned-request', 'transport-reset']) }) test('returns empty object for non-oauth auth', async () => { From b5661c17d64415c01ee1fc6c6d09823eb2955095 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:43:36 +0200 Subject: [PATCH 117/169] test: hold the actual native profile publication writer --- packages/opencode/src/tests/index.test.ts | 41 ++++++++++++++++++----- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 8d4d74ffa..15a453597 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -29,7 +29,6 @@ import * as Core from '@cortexkit/anthropic-auth-core' import { __setLogTestSink, type AccountStorage, - acquireRefreshFileLock, buildPrimeRequestBody, buildRefreshOperationError, ClaudeOAuthRefreshError, @@ -67,6 +66,7 @@ import { TRAILING_ASSISTANT_HISTORY_MESSAGE, tokenFingerprint, } from '@cortexkit/anthropic-auth-core' +import { withLock } from '@cortexkit/common-auth/fs' import { nativeQuotaCodec } from '../../../core/src/native-quota-codec.ts' import { TestLifetime } from '../../../core/src/tests/test-lifetime.ts' import { EFFORT_MARKER_PREFIX } from '../effort-history' @@ -9939,14 +9939,36 @@ describe('auth.loader', () => { ) delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - const configLock = await acquireRefreshFileLock({ - name: 'config-write', - path: process.env.OPENCODE_ANTHROPIC_AUTH_FILE, - ttlMs: 10_000, - renew: true, + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const reader = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', }) - expect(configLock).not.toBeNull() - if (!configLock) throw new Error('expected account config lock') + try { + expect((await reader.authorizeLocal('main')).status).toBe('usable') + } finally { + reader.close() + } + const locked = bodyLifetime().gate() + const release = bodyLifetime().gate() + // Block the actual native profile destination, not the retired config + // file. Credential validation completed before acquiring this writer. + const writer = withLock( + pool.paths.runtime, + { + name: 'native-runtime', + ttlMs: 10_000, + timeoutMs: 15_000, + renew: true, + }, + async () => { + locked.open() + await release.wait + }, + ) + bodyLifetime().trackDetached(writer) + await locked.wait const command = expectHandledCommandResponse( plugin['command.execute.before']({ @@ -9969,7 +9991,8 @@ describe('auth.loader', () => { expect(text).toContain('Max 20x') expect((await readAccountStorage())?.main?.profile).toBeUndefined() } finally { - await configLock.release() + release.open() + await writer await command } From 67c048525d73c1bbf0870cebe905cb304a737103 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:50:57 +0200 Subject: [PATCH 118/169] test: isolate profile transport counts from usage polling --- packages/opencode/src/tests/index.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 15a453597..9ef9a58b5 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -10654,6 +10654,14 @@ describe('auth.loader', () => { const authorization = new Headers( input instanceof Request ? input.headers : init?.headers, ).get('authorization') + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 25 }, + seven_day: { utilization: 30 }, + }), + ) + } firstFetchCalls.push(authorization ?? '') if (authorization === 'Bearer sk-ant-oat01-main-access') { mainProfileStarted.raise() @@ -10697,9 +10705,9 @@ describe('auth.loader', () => { (state) => state.fallbacks[0]?.tierLabel === 'Team · Max 5x', ) - expect(firstFetchCalls).toEqual([ - 'Bearer sk-ant-oat01-main-access', + expect(firstFetchCalls.toSorted()).toEqual([ 'Bearer sk-ant-oat01-fallback-access', + 'Bearer sk-ant-oat01-main-access', ]) expect(nextTestFetch).not.toHaveBeenCalled() }) From 6cb3fd3bba66495408697151e775334a3eb424e5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:40:52 +0200 Subject: [PATCH 119/169] fix: fence profile display without hiding tiers on write failure Distinguish saved, refused and I/O-failed publication. Recheck exact local credential or vault record provenance before displaying fetched tiers, including after storage failure. Classify lost writer ownership as refusal. Cover actual rename faults, changed credentials, newer vault receipts, warning-once behavior and the three named mutation controls. --- docs/testing/mutations.md | 7 +- mutations.toml | 52 +++++ packages/core/src/native-account-runtime.ts | 28 ++- packages/core/src/native-runtime.ts | 6 +- packages/core/src/native-vault-runtime.ts | 95 +++++++-- .../src/tests/native-account-runtime.test.ts | 79 +++++++ .../src/tests/native-display-profile.test.ts | 42 ++++ .../src/tests/native-first-identity.test.ts | 2 +- .../src/tests/native-known-validation.test.ts | 2 +- .../src/tests/native-refresh-failure.test.ts | 2 +- .../core/src/tests/native-runtime.test.ts | 24 +++ .../src/tests/native-status-profile.test.ts | 59 ++++-- .../src/tests/native-vault-runtime.test.ts | 87 ++++++++ packages/opencode/src/index.ts | 33 ++- packages/opencode/src/tests/index.test.ts | 198 +++++++++++------- scripts/run-mutation-bun.ts | 1 + scripts/tests/run-mutation-bun.test.ts | 5 + 17 files changed, 601 insertions(+), 121 deletions(-) create mode 100644 packages/core/src/tests/native-display-profile.test.ts diff --git a/docs/testing/mutations.md b/docs/testing/mutations.md index 06bbe6f02..ca6eeb81e 100644 --- a/docs/testing/mutations.md +++ b/docs/testing/mutations.md @@ -1,6 +1,6 @@ # Mutation proofs for request guards -`mutations.toml` records ten deliberately bounded runtime breaks against named +`mutations.toml` records thirteen deliberately bounded runtime breaks against named assertions. Run it only in an isolated checkout: `ckdev-mutate` edits its working checkout; it does **not** create a scratch worktree. Never reset/check out a target while replay is active, and never point builds at a live host installation. @@ -43,6 +43,9 @@ sibling repositories. | `header-routing-raw-exhaustion-proof` | Treat raw utilization of 0.995 as exhausted. | Display rounding must not permit paid API fallback. | | `local-quota-survives-token-refresh` | Reject writes containing only quota when the token rotates, although the account UUID and credential epoch are unchanged. | The account's quota must survive refresh; writes containing other metadata must still require the original token version. | | `local-metadata-requires-token-version` | Apply the account-only quota exception to writes that also contain other metadata. | Writes containing metadata from the old token must still be refused after refresh, even when they also contain quota. | +| `profile-publication-refusal-is-not-io` | Label a rejected profile write as an I/O failure. | Stale-subject refusal must remain distinct from storage failure. | +| `profile-current-credential-display` | Omit the current-credential display check. | A fetched tier must not cross a token rotation after its write failed. | +| `runtime-owner-loss-is-publication-refusal` | Label runtime lease loss as storage I/O. | A failed ownership fence must never use the fresh-tier I/O exception. | | `runtime-read-no-follow` | Follow a symlink installed between inspection and opening. | The runtime reader must reject the symlink. | | `runtime-read-opened-mode` | Ignore the opened file's permissions. | A replacement with mode 0644 must be rejected. | | `runtime-read-opened-owner` | Ignore the opened file's owner. | A foreign owner on the opened handle must be rejected. | @@ -72,7 +75,7 @@ bun scripts/run-mutation-bun.ts packages/core/src/tests/fast.test.ts \ 'fast mode eligibility for claude-opus-4-7[1m] is false' ``` -The wrapper accepts only the five reviewed owning test files. It rejects empty, +The wrapper accepts only the six reviewed owning test files. It rejects empty, control-character and boundary-whitespace names and unsafe/unsupported paths. Bun prints nested scopes separated by ` > `, but its name filter joins those scopes with spaces. The wrapper applies that observed convention, escapes regex diff --git a/mutations.toml b/mutations.toml index b22e13950..007847b36 100644 --- a/mutations.toml +++ b/mutations.toml @@ -166,3 +166,55 @@ expect_message = "error: expect(received).toBe(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] broad_report = "tmp/mutations/opencode.xml" broad_id = "{name}" + +[[control]] +id = "profile-publication-refusal-is-not-io" +guards = "A stale credential publication must remain distinct from a storage failure." +file = "packages/core/src/native-vault-runtime.ts" +old = "saved ? 'saved' : 'refused'" +new = "saved ? 'saved' : 'failed'" +test_file = "packages/core/src/tests/native-display-profile.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-display-profile.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["profile publication fence refusal is distinct from an I/O failure"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "profile-current-credential-display" +guards = "A fetched tier after a failed write must not be displayed for changed credentials." +file = "packages/opencode/src/index.ts" +old = "!(await hydration.isCurrent?.())" +new = "false" +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["auth.loader > profile I/O failure hides a fetched tier after the credential changes"] +expect_message = "error: expect(received).not.toContain(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" + +[[control]] +id = "runtime-owner-loss-is-publication-refusal" +guards = "Lost writer ownership must not license display through the I/O failure exception." +file = "packages/core/src/native-runtime.ts" +old = ''' try { + await lock.assertOwned() + } catch { + throw new NativeRuntimeError('publication-refused') + }''' +new = ''' await lock.assertOwned()''' +test_file = "packages/core/src/tests/native-runtime.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-runtime.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["runtime writer ownership loss is a publication refusal, not an I/O display fallback"] +expect_message = "error: expect(received).toMatchObject(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index cc7882cca..b5bd7f451 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -1241,10 +1241,30 @@ export function createNativeAccountRuntime( if ((await runtime.read()).mode === 'claustrum') return vault.fetchProfileForDisplay(routeId, fetchImpl, signal) const result = await readLocalProfile(routeId, fetchImpl, signal) - return nativeDisplayProfile(result.value, () => - runtime.publishLocal(result.attempt.subject, { - profile: result.value, - }), + const subject = structuredClone(result.attempt.subject) + const profile = result.value + return nativeDisplayProfile( + profile, + () => runtime.publishLocal(subject, { profile }), + async () => { + if ( + (await runtime.read()).mode !== 'local' || + (await physicalId(routeId)) !== subject.binding.rowId + ) + return false + const row = await poolRow(subject.binding.rowId) + return ( + row?.stamp === 'bound' && + row.enabled !== false && + row.credential?.type === 'oauth' && + nativeLocalPoolBindingMatches(subject.binding, paths, row) && + nativeLocalCredentialValidationMatches( + subject, + subject.binding, + row.credential, + ) + ) + }, ) }, close() { diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index 9fb88af2a..fc4e70702 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -754,7 +754,11 @@ async function writeNativeRuntime( } catch { throw new NativeRuntimeError('publication-refused') } - await lock.assertOwned() + try { + await lock.assertOwned() + } catch { + throw new NativeRuntimeError('publication-refused') + } await rename(stage, path) ownsStage = false try { diff --git a/packages/core/src/native-vault-runtime.ts b/packages/core/src/native-vault-runtime.ts index 29b203612..26d922fd8 100644 --- a/packages/core/src/native-vault-runtime.ts +++ b/packages/core/src/native-vault-runtime.ts @@ -53,7 +53,10 @@ import { updateNativeRuntime, } from './native-runtime.ts' import { fetchOAuthAccountProfile } from './oauth-profile.ts' -import { requireNativePoolAuthority } from './pool-authority.ts' +import { + NativeAuthorityError, + requireNativePoolAuthority, +} from './pool-authority.ts' import type { NativePoolPaths } from './pool-paths.ts' import { createNativePoolStore } from './pool-store.ts' @@ -74,7 +77,7 @@ export type NativeScoped401Site = | 'prime' | 'quota-profile' -export type NativeProfilePersistence = 'saved' | 'failed' +export type NativeProfilePersistence = 'saved' | 'refused' | 'failed' /** The profile fields a display may receive; never the legacy token fingerprint. */ export type NativeDisplayProfileMetadata = Pick< @@ -83,25 +86,28 @@ export type NativeDisplayProfileMetadata = Pick< > /** - * A profile read for display, returned as soon as the validated profile - * response arrives. `profile` is a copy of the profile's metadata fields only; - * it carries no credential, receipt or subject. `persisted` follows the same - * fenced publication that fetchProfile awaits, resolves 'saved' or 'failed', - * and never rejects, so a caller may leave it unawaited. + * A fetched account tier for immediate display. `profile` contains metadata + * only, never tokens or authorization receipts. `persisted` reports whether + * the write succeeded, was refused because credentials or lock ownership + * changed, or failed because storage was unavailable. It never rejects. + * Call isCurrent before displaying the tier, including after a storage failure. */ export interface NativeDisplayProfile { profile: NativeDisplayProfileMetadata persisted: Promise + /** Check the same account and token or vault record version, without fetching credentials. */ + isCurrent(): Promise } /** Build a NativeDisplayProfile from a validated profile and its publication. */ export function nativeDisplayProfile( profile: OAuthAccountProfile, publish: () => Promise, + isCurrent: () => Promise, ): NativeDisplayProfile { return { - // Copy named fields rather than spreading, so nothing else stored on a - // profile, such as a legacy token fingerprint, reaches a display. + // Copy named fields rather than spreading, so legacy token fingerprints + // and any future credential fields never reach the display metadata. profile: { tier: profile.tier, orgType: profile.orgType, @@ -113,13 +119,26 @@ export function nativeDisplayProfile( providerAccountUuid: profile.providerAccountUuid, }), }, - // A refused fence and a thrown write both mean the profile was not saved. + // Changed credentials or lost write-lock ownership invalidate the tier. + // A storage error alone does not, but display still checks the credentials. persisted: Promise.resolve() .then(publish) .then( - (saved): NativeProfilePersistence => (saved ? 'saved' : 'failed'), - (): NativeProfilePersistence => 'failed', + (saved): NativeProfilePersistence => (saved ? 'saved' : 'refused'), + (error: unknown): NativeProfilePersistence => + error instanceof NativeCustodyError || + error instanceof NativeAuthorityError || + (error instanceof NativeRuntimeError && error.code !== 'runtime-io') + ? 'refused' + : 'failed', ), + isCurrent: () => + Promise.resolve() + .then(isCurrent) + .then( + (current) => current, + () => false, + ), } } @@ -578,8 +597,10 @@ export function createNativeVaultRuntime( }, async fetchProfileForDisplay(routeId, fetchImpl = fetch, signal) { const result = await readProfile(routeId, fetchImpl, signal) - return nativeDisplayProfile(result.value, () => - runtime.publish(result.receipt, { profile: result.value }), + return nativeDisplayProfile( + result.value, + () => runtime.publish(result.receipt, { profile: result.value }), + currentDisplaySubject(result.receipt), ) }, async setEnabled(routeId, enabled) { @@ -628,6 +649,52 @@ export function createNativeVaultRuntime( custody.close() }, } + function currentDisplaySubject(receipt: NativeCustodyReceipt) { + const { routeId, logicalId } = requireIssued(receipt) + // Keep only route, account ID, credential ID and version for later checks. + // Checking a display must not acquire or retain an access token. + const subject = { + routeId, + logicalId, + credentialId: receipt.credentialId, + accountIdentity: receipt.assertedAccountIdentity, + recordVersion: receipt.recordVersion, + } + const key = JSON.stringify([ + routeId, + subject.credentialId, + subject.accountIdentity, + ]) + return async () => { + await requireActive() + const row = await rowFor( + await readVaultRoster(paths.roster), + subject.logicalId, + ) + if ( + !row?.enabled || + row.state !== 'active' || + row.routeId !== subject.routeId || + row.accountIdentity !== subject.accountIdentity || + (row.credentialId !== subject.credentialId && + !row.aliases?.includes(subject.credentialId)) || + (versions.get(key) ?? 0) > subject.recordVersion + ) + return false + const read = await readNativeRuntime(paths.runtime, paths.storageId) + if (read.status === 'missing') return true + if (read.status !== 'ready') return false + const binding = read.state.accounts[subject.routeId]?.binding + return ( + !binding || + (binding.kind === 'custody' && + binding.accountIdentity === subject.accountIdentity && + binding.credentialId === subject.credentialId && + binding.recordVersion <= subject.recordVersion) + ) + } + } + async function mutateReceipt( receipt: NativeCustodyReceipt, change: (old: NativeRuntimeEntry) => NativeRuntimeEntry, diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts index 8a1ce859a..b0e73851d 100644 --- a/packages/core/src/tests/native-account-runtime.test.ts +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -1,4 +1,5 @@ import { expect, spyOn } from 'bun:test' +import * as filesystem from 'node:fs/promises' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { ProviderAccountUuid } from '../claude-code.ts' @@ -1224,3 +1225,81 @@ test('local enable and disable log only a flag change made by their own write', await expect(f.runtime.setEnabled('fallback-dup', true)).rejects.toThrow() expect(logs.take()).toEqual([]) }) + +for (const rotate of [false, true]) { + test(`profile writer I/O failure rechecks the current credential (rotation=${rotate})`, async () => { + const f = await fixture() + expect((await f.runtime.authorizeLocal('main')).status).toBe('usable') + const before = await readFile(f.paths.runtime) + const original = filesystem.rename + let failedWrites = 0 + // Fail the rename after the account, token and write locks are checked. + // Failing those checks would mean changed credentials or lost ownership, + // not a storage error. + const writer = spyOn(filesystem, 'rename').mockImplementation( + async (from, to) => { + if (to === f.paths.runtime) { + failedWrites++ + throw Object.assign(new Error('Synthetic runtime rename failure'), { + code: 'EIO', + }) + } + await original(from, to) + }, + ) + deferCleanup(() => writer.mockRestore()) + const transport = Object.assign( + async () => + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + { preconnect() {} }, + ) + const result = await f.runtime.fetchProfileForDisplay('main', transport) + expect(await result.persisted).toBe('failed') + expect(failedWrites).toBe(1) + expect(await readFile(f.paths.runtime)).toEqual(before) + writer.mockRestore() + if (rotate) + await f.store.rotate('imported-main', { + type: 'oauth', + access: 'sk-ant-oat01-different-profile-version', + refresh: 'different-profile-refresh', + expires: now + 8_000_000, + }) + expect(await result.isCurrent()).toBe(!rotate) + expect(result.profile.tier).toBe('default_claude_max_20x') + }) +} + +test('late profile publication refusal cannot authorize display for rotated credentials', async () => { + const f = await fixture() + expect((await f.runtime.authorizeLocal('main')).status).toBe('usable') + const transport = Object.assign( + async () => { + await f.store.rotate('imported-main', { + type: 'oauth', + access: 'sk-ant-oat01-profile-refusal-successor', + refresh: 'profile-refusal-refresh', + expires: now + 8_000_000, + }) + return Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }) + }, + { preconnect() {} }, + ) + const result = await f.runtime.fetchProfileForDisplay('main', transport) + expect(await result.persisted).toBe('refused') + expect(await result.isCurrent()).toBe(false) + expect( + (await f.runtime.read()).accounts.find((account) => account.id === 'main') + ?.profile, + ).toBeUndefined() +}) diff --git a/packages/core/src/tests/native-display-profile.test.ts b/packages/core/src/tests/native-display-profile.test.ts new file mode 100644 index 000000000..93760ab36 --- /dev/null +++ b/packages/core/src/tests/native-display-profile.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from 'bun:test' +import { NativeRuntimeError } from '../native-runtime.ts' +import { nativeDisplayProfile } from '../native-vault-runtime.ts' + +const profile = { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: 1_000_000, + accountIdentity: '11111111-2222-4333-8444-555555555555', +} + +test('profile publication fence refusal is distinct from an I/O failure', async () => { + const result = nativeDisplayProfile( + profile, + async () => false, + async () => true, + ) + expect(await result.persisted).toBe('refused') +}) + +test('thrown profile publication refusal remains a refusal', async () => { + const result = nativeDisplayProfile( + profile, + async () => { + throw new NativeRuntimeError('publication-refused') + }, + async () => true, + ) + expect(await result.persisted).toBe('refused') +}) + +test('profile I/O failure settles without rejecting its display result', async () => { + const result = nativeDisplayProfile( + profile, + async () => { + throw new NativeRuntimeError('runtime-io') + }, + async () => true, + ) + expect(await result.persisted).toBe('failed') + expect(result.profile).toEqual(profile) +}) diff --git a/packages/core/src/tests/native-first-identity.test.ts b/packages/core/src/tests/native-first-identity.test.ts index 5c7732174..e06ac45d3 100644 --- a/packages/core/src/tests/native-first-identity.test.ts +++ b/packages/core/src/tests/native-first-identity.test.ts @@ -755,7 +755,7 @@ for (const name of ['pool-config', 'pool-state', 'native-runtime']) { ).toEqual([]) expect(error).toMatchObject({ name: 'NativeRuntimeError', - code: name === 'native-runtime' ? 'runtime-io' : 'publication-refused', + code: 'publication-refused', }) expect(controls.held.size).toBe(0) }) diff --git a/packages/core/src/tests/native-known-validation.test.ts b/packages/core/src/tests/native-known-validation.test.ts index 6ddc2db35..fe8e2995e 100644 --- a/packages/core/src/tests/native-known-validation.test.ts +++ b/packages/core/src/tests/native-known-validation.test.ts @@ -828,7 +828,7 @@ for (const name of ['pool-config', 'pool-state', 'native-runtime']) { await refused( f, f.event, - name === 'native-runtime' ? 'runtime-io' : 'publication-refused', + 'publication-refused', runtime.publishNativeLocalCredentialValidation, ) expect(controls.renames).toBe(0) diff --git a/packages/core/src/tests/native-refresh-failure.test.ts b/packages/core/src/tests/native-refresh-failure.test.ts index 47f245b97..793cd31b6 100644 --- a/packages/core/src/tests/native-refresh-failure.test.ts +++ b/packages/core/src/tests/native-refresh-failure.test.ts @@ -436,7 +436,7 @@ for (const name of ['pool-config', 'pool-state', 'native-runtime']) { await refused( f, () => runtime.publishNativeLocalRefreshFailure(f.paths, f.event, policy), - name === 'native-runtime' ? 'runtime-io' : 'publication-refused', + 'publication-refused', ) expect(controls.renames).toBe(0) expect(controls.held.size).toBe(0) diff --git a/packages/core/src/tests/native-runtime.test.ts b/packages/core/src/tests/native-runtime.test.ts index 57d7e3acd..05cc38524 100644 --- a/packages/core/src/tests/native-runtime.test.ts +++ b/packages/core/src/tests/native-runtime.test.ts @@ -1470,3 +1470,27 @@ for (const seam of ['beforeRename', 'afterRename']) { } }) } + +test('runtime writer ownership loss is a publication refusal, not an I/O display fallback', async () => { + const path = await fixture() + await updateNativeRuntime(path, storageId, (state) => state) + const before = await readFile(path) + const directory = dirname(path) + const moved = `${directory}-moved` + let relocated = false + try { + await expect( + updateNativeRuntime(path, storageId, (state) => state, { + beforeRename: async () => { + // Move the fixture after its staged file is ready. The final writer + // check must see its lock file is missing, without waiting for expiry. + await fsPromises.rename(directory, moved) + relocated = true + }, + }), + ).rejects.toMatchObject({ code: 'publication-refused' }) + } finally { + if (relocated) await fsPromises.rename(moved, directory) + } + expect(await readFile(path)).toEqual(before) +}) diff --git a/packages/core/src/tests/native-status-profile.test.ts b/packages/core/src/tests/native-status-profile.test.ts index 9c05b42b0..26629801c 100644 --- a/packages/core/src/tests/native-status-profile.test.ts +++ b/packages/core/src/tests/native-status-profile.test.ts @@ -15,11 +15,10 @@ import { createNativePoolStore, nativePoolStoreLocks } from '../pool-store.ts' import { initializeNativeTestAuthority } from './native-authority-fixture.ts' import { createTestLifetimeSuite } from './test-lifetime.ts' -// Account and quota displays may show a fetched profile before it is saved. -// fetchProfileForDisplay returns a metadata-only copy as soon as the profile -// response is validated; its `persisted` promise follows the same fenced save -// that fetchProfile still waits for, and reports a failed save without -// rejecting. +// A fetched tier can display before its file is written. Saving requires the +// account, credentials and write-lock ownership to remain unchanged. A change +// refuses the save; a storage error fails it. Neither outcome rejects its +// promise, and displaying unsaved metadata still requires current credentials. const { test, deferCleanup, gate, trackDetached } = createTestLifetimeSuite() const uuid = '11111111-2222-4333-8444-555555555555' as ProviderAccountUuid @@ -229,12 +228,17 @@ test('local display profile returns while its save is held, then saves through t // Exactly the allowlisted metadata: no bearer, receipt, subject or token // fingerprint. expect(display.profile).toEqual(displayProfile) - expect(Object.keys(display).sort()).toEqual(['persisted', 'profile']) + expect(Object.keys(display).sort()).toEqual([ + 'isCurrent', + 'persisted', + 'profile', + ]) expect(outcome).toBeUndefined() expect(savedRuntimeText(f.paths.runtime)).not.toContain(tier) release.open() await hold?.work expect(await persisted).toBe('saved') + expect(await display.isCurrent()).toBe(true) const read = await f.runtime.read() expect(read.accounts.find((row) => row.id === 'main')?.profile?.tier).toBe( tier, @@ -255,7 +259,7 @@ test('vault display profile returns metadata only and saves through the fence', expect(savedRuntimeText(f.paths.runtime)).toContain(tier) }) -test('a save refused by the credential fence resolves failed and stores nothing', async () => { +test('a save refused by the credential fence resolves refused and stores nothing', async () => { const f = await localFixture() const display = await f.runtime.fetchProfileForDisplay( 'main', @@ -266,11 +270,12 @@ test('a save refused by the credential fence resolves failed and stores nothing' }), ) expect(display.profile).toEqual(displayProfile) - expect(await display.persisted).toBe('failed') + expect(await display.persisted).toBe('refused') + expect(await display.isCurrent()).toBe(false) expect(savedRuntimeText(f.paths.runtime)).not.toContain(tier) }) -test('a save that throws resolves failed without an unhandled rejection in a fresh process', async () => { +test('lost authority during a profile read resolves refused without an unhandled rejection in a fresh process', async () => { const f = await localFixture() const script = join(dirname(f.paths.journal), 'persist-failure.ts') const coreSource = new URL('../', import.meta.url).pathname @@ -307,14 +312,36 @@ runtime.close() stdout: 'pipe', stderr: 'pipe', }) - const [code, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]) - expect({ code, stdout, stderr }).toEqual({ + const cancel = gate() + let finished = false + const exit = child.exited.finally(() => { + finished = true + cancel.open() + }) + trackDetached( + cancel.wait.then(() => { + if (!finished) child.kill() + }), + ) + const stdout = new Response(child.stdout).text() + const stderr = new Response(child.stderr).text() + for (const work of [exit, stdout, stderr]) work.catch(() => cancel.open()) + const results = await Promise.allSettled([exit, stdout, stderr]) + const [code, out, err] = results + if ( + code.status !== 'fulfilled' || + out.status !== 'fulfilled' || + err.status !== 'fulfilled' + ) + throw new AggregateError( + results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ), + 'Profile child failed', + ) + expect({ code: code.value, stdout: out.value, stderr: err.value }).toEqual({ code: 0, - stdout: `profile ${tier}\npersisted failed\n`, + stdout: `profile ${tier}\npersisted refused\n`, stderr: '', }) expect(savedRuntimeText(f.paths.runtime)).not.toContain(tier) diff --git a/packages/core/src/tests/native-vault-runtime.test.ts b/packages/core/src/tests/native-vault-runtime.test.ts index 27a81f8f2..4a9c087e4 100644 --- a/packages/core/src/tests/native-vault-runtime.test.ts +++ b/packages/core/src/tests/native-vault-runtime.test.ts @@ -1,4 +1,5 @@ import { expect, spyOn } from 'bun:test' +import * as filesystem from 'node:fs/promises' import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { @@ -918,3 +919,89 @@ test('vault account reorder logs once when the locked roster or stored order cha }, ]) }) + +test('vault profile I/O failure rechecks non-secret provenance without another GET', async () => { + const f = await fixture() + const initial = await f.runtime.authorize('main') + expect(await f.runtime.publish(initial, { lastUsed: now })).toBe(true) + const before = await readFile(f.paths.runtime) + const originalRename = filesystem.rename + let failedWrites = 0 + const writer = spyOn(filesystem, 'rename').mockImplementation( + async (from, to) => { + if (to === f.paths.runtime) { + failedWrites++ + throw Object.assign( + new Error('Synthetic vault profile rename failure'), + { code: 'EIO' }, + ) + } + await originalRename(from, to) + }, + ) + deferCleanup(() => writer.mockRestore()) + const transport = Object.assign( + async () => + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + { preconnect() {} }, + ) + const result = await f.runtime.fetchProfileForDisplay('main', transport) + expect(await result.persisted).toBe('failed') + expect(failedWrites).toBe(1) + expect(await readFile(f.paths.runtime)).toEqual(before) + const gets = f.counts.gets + expect(await result.isCurrent()).toBe(true) + expect(f.counts.gets).toBe(gets) + writer.mockRestore() + f.version(8) + const peer = createNativeVaultRuntime(f.options) + deferCleanup(() => peer.close()) + const successor = await peer.authorize('main') + expect(await peer.publish(successor, { lastUsed: now + 1 })).toBe(true) + const successorGets = f.counts.gets + expect(await result.isCurrent()).toBe(false) + expect(f.counts.gets).toBe(successorGets) +}) + +test('disabled vault route cannot validate a fetched profile for display', async () => { + const f = await fixture() + const transport = Object.assign( + async () => { + await f.runtime.setEnabled('main', false) + return Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }) + }, + { preconnect() {} }, + ) + const result = await f.runtime.fetchProfileForDisplay('main', transport) + expect(await result.isCurrent()).toBe(false) +}) + +test('a newer vault receipt refuses stale profile publication and display', async () => { + const f = await fixture() + const transport = Object.assign( + async () => { + f.version(8) + await f.runtime.authorize('main') + return Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }) + }, + { preconnect() {} }, + ) + const result = await f.runtime.fetchProfileForDisplay('main', transport) + expect(await result.persisted).toBe('refused') + expect(await result.isCurrent()).toBe(false) +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 0a97e61da..229e64420 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1987,7 +1987,8 @@ const anthropicAuthPlugin = async ( */ deadline?: AbortSignal profile?: OAuthAccountProfile - persistence?: 'pending' | 'saved' | 'failed' + persistence?: 'pending' | 'saved' | 'refused' | 'failed' + isCurrent?: () => Promise } const profileHydrations = new Map() // Saving a fetched profile continues after the display has used it. Dispose @@ -2001,16 +2002,18 @@ const anthropicAuthPlugin = async ( return JSON.stringify([id, accountIdentity ?? null]) } - /** A fetched profile may be displayed while fresh unless its persistence failed. */ - function displayableProfile( + /** A fetched tier requires current credentials; a storage failure alone does not hide it. */ + async function displayableProfile( hydration: ProfileHydration | undefined, now: number, ) { - return hydration?.profile && - hydration.persistence !== 'failed' && - oauthProfileIsFresh(hydration.profile, now) - ? hydration.profile - : undefined + if ( + !hydration?.profile || + !oauthProfileIsFresh(hydration.profile, now) || + !(await hydration.isCurrent?.()) + ) + return undefined + return hydration.persistence === 'refused' ? undefined : hydration.profile } function startProfileHydration( @@ -2044,7 +2047,7 @@ const anthropicAuthPlugin = async ( ]), ) .then( - ({ profile, persisted }) => { + ({ profile, persisted, isCurrent }) => { // Keep a profile only for the account it was read for. A route // whose identity was not yet known accepts the identity this read // reports; the display checks it against the route again later. @@ -2053,12 +2056,17 @@ const anthropicAuthPlugin = async ( profile.accountIdentity === account.accountIdentity ) { hydration.profile = profile + hydration.isCurrent = isCurrent hydration.persistence = 'pending' } const saving = persisted.then(async (outcome) => { hydration.persistence = outcome if (outcome === 'failed') - logger.debug('quota', 'failed to save account profile', { + logger.warn('quota', 'failed to persist account profile', { + account: account.id, + }) + else if (outcome === 'refused') + logger.debug('quota', 'refused account profile publication', { account: account.id, }) if ( @@ -2196,7 +2204,10 @@ const anthropicAuthPlugin = async ( ) profileHydrations.set(key, earlier) if (storedProfileIsFresh(account, later)) continue - const profile = displayableProfile(profileHydrations.get(key), later) + const profile = await displayableProfile( + profileHydrations.get(key), + later, + ) if (!profile || profile.accountIdentity !== account.accountIdentity) continue account.profile = profile diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 9ef9a58b5..bc342cb0a 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -10,8 +10,8 @@ import { } from 'bun:test' import { createHash, randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' +import * as filesystem from 'node:fs/promises' import { - chmod, mkdir, mkdtemp, readdir, @@ -10473,7 +10473,7 @@ describe('auth.loader', () => { records, (record) => record.channel === 'quota' && - record.message === 'failed to save account profile' && + record.message === 'refused account profile publication' && record.payload?.account === 'fb', 'stale fallback profile publication refusal', ) @@ -11170,75 +11170,133 @@ describe('auth.loader', () => { ).toHaveLength(1) }) - test('profile persistence failure does not block account display', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] })) - const mockClient = createMockClient() - globalThis.fetch = mock( - withNativeAdmission((input: string | URL | Request) => - Promise.resolve( - extractUrl(input).includes('/api/oauth/profile') - ? Response.json({ - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_20x', - }, - }) - : new Response('ok'), - ), - ), - ) as unknown as typeof fetch - const plugin = await getPlugin(mockClient) - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'sk-ant-oat01-main-access', - refresh: 'main-refresh', - expires: Date.now() + 100000, - }), - { models: {} }, - ) - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - const statePath = getAccountStatePath( - process.env.OPENCODE_ANTHROPIC_AUTH_FILE, - ) - const stateDir = dirname(statePath) - await chmod(stateDir, 0o555) - const records: LogTestRecord[] = [] - __setLogTestSink((record) => records.push(record)) - setLogLevel('debug') - - let commandError: unknown - try { - await plugin['command.execute.before']({ - command: 'claude', - arguments: '', - sessionID: 'session-1', - }) - } catch (error) { - commandError = error - } finally { - await chmod(stateDir, 0o755) - __setLogTestSink(null) - setLogLevel('info') - } - const text = (mockClient.session.promptAsync as any).mock.calls.at(-1)?.[0] - ?.body.parts[0]?.text as string - - expect(commandError).toBeInstanceOf(Error) - expect((commandError as Error).message).toContain( - '__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__', + for (const rotate of [false, true]) { + test( + rotate + ? 'profile I/O failure hides a fetched tier after the credential changes' + : 'profile persistence failure does not block account display', + async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + let profileCalls = 0 + globalThis.fetch = mock( + withNativeAdmission((input: Parameters[0]) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }), + ) as unknown as typeof fetch + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'sk-ant-oat01-main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const reader = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + expect((await reader.authorizeLocal('main')).status).toBe('usable') + const before = await readFile(pool.paths.runtime) + const originalRename = filesystem.rename + let failedWrites = 0 + // Fail only the actual metadata rename after credential admission and + // production publication guards. Leave credential files untouched. + const writer = spyOn(filesystem, 'rename').mockImplementation( + async (from, to) => { + if (to === pool.paths.runtime) { + failedWrites++ + throw Object.assign( + new Error('Synthetic profile rename failure'), + { code: 'EIO' }, + ) + } + await originalRename(from, to) + }, + ) + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + setLogLevel('debug') + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + const command = () => + expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude', + arguments: '', + sessionID: 'session-1', + }), + ) + try { + await command() + await waitForLogRecord( + records, + (record) => + record.level === 'warn' && + record.channel === 'quota' && + record.message === 'failed to persist account profile', + 'profile I/O failure warning', + ) + expect(failedWrites).toBe(1) + expect(await readFile(pool.paths.runtime)).toEqual(before) + writer.mockRestore() + if (rotate) + await refreshPoolMainElsewhere({ + access: 'sk-ant-oat01-profile-io-successor', + refresh: 'profile-io-successor-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }) + // Reopen after the I/O outcome, so this assertion cannot pass merely + // because the first display raced the failing writer. + await command() + const calls = ( + mockClient.session.promptAsync as unknown as { + mock: { + calls: Array<[{ body: { parts: Array<{ text: string }> } }]> + } + } + ).mock.calls + const text = calls.at(-1)?.[0].body.parts[0]?.text + if (rotate) expect(text).not.toContain('Max 20x') + else expect(text).toContain('Max 20x') + expect(profileCalls).toBe(1) + expect( + (await reader.read()).accounts.find( + (account) => account.id === 'main', + )?.profile, + ).toBeUndefined() + expect( + records.filter( + (record) => + record.level === 'warn' && + record.channel === 'quota' && + record.message === 'failed to persist account profile', + ), + ).toHaveLength(1) + } finally { + writer.mockRestore() + reader.close() + __setLogTestSink(null) + setLogLevel('info') + } + }, ) - expect(text).toContain('Max 20x') - expect( - records.filter( - (record) => - record.level === 'debug' && - record.channel === 'quota' && - record.message === 'failed to persist account profile', - ), - ).toHaveLength(1) - }) + } test('ordinary model request never calls the profile endpoint', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] })) diff --git a/scripts/run-mutation-bun.ts b/scripts/run-mutation-bun.ts index 5effae1da..02fdd3dc3 100644 --- a/scripts/run-mutation-bun.ts +++ b/scripts/run-mutation-bun.ts @@ -86,6 +86,7 @@ export function selectionFor(file: string, name: string) { 'packages/core/src/tests/request-history.test.ts', 'packages/core/src/tests/fast.test.ts', 'packages/core/src/tests/native-runtime.test.ts', + 'packages/core/src/tests/native-display-profile.test.ts', 'packages/opencode/src/tests/index.test.ts', 'packages/pi/src/tests/convert.test.ts', ].includes(file) diff --git a/scripts/tests/run-mutation-bun.test.ts b/scripts/tests/run-mutation-bun.test.ts index 3dc7c59f8..9587aee25 100644 --- a/scripts/tests/run-mutation-bun.test.ts +++ b/scripts/tests/run-mutation-bun.test.ts @@ -737,6 +737,11 @@ test('native guard selection uses the actual owning package and reviewed preload 'packages/core', 'src/tests/native-runtime.test.ts', ], + [ + 'packages/core/src/tests/native-display-profile.test.ts', + 'packages/core', + 'src/tests/native-display-profile.test.ts', + ], [ 'packages/opencode/src/tests/index.test.ts', 'packages/opencode', From 4879a34a4706cac7c50ebf33f1e1477b9283b838 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:50:32 +0200 Subject: [PATCH 120/169] test: verify proactive main refresh in the native credential pool --- packages/opencode/src/tests/index.test.ts | 85 +++++++++++++++-------- 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index bc342cb0a..ee3077195 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -11718,11 +11718,14 @@ describe('auth.loader', () => { test('background refresh proactively rotates main oauth before expiry', async () => { await useTempAccountFile( - createFallbackStorage({ - accounts: [], - quota: { enabled: false }, - refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, - }), + bindMainAccount( + createFallbackStorage({ + accounts: [], + quota: { enabled: false }, + refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, + }), + 'sk-ant-oat01-old-access', + ), { access: 'sk-ant-oat01-old-access', refresh: 'old-refresh', @@ -11735,22 +11738,20 @@ describe('auth.loader', () => { return { unref() {} } }) as unknown as typeof setInterval - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'background-refresh-new', - access_token: 'sk-ant-oat01-background-access-new', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + mainAccountIssues('sk-ant-oat01-background-access-new') + globalThis.fetch = mock( + async (input: Parameters[0], init?: RequestInit) => { + const admitted = await nativeBootstrapAnswer(input, init) + if (admitted) return admitted + if (extractUrl(input).includes('/v1/oauth/token')) + return Response.json({ + refresh_token: 'background-refresh-new', + access_token: 'sk-ant-oat01-background-access-new', + expires_in: 3600, + }) + return new Response(null, { status: 200 }) + }, + ) as unknown as typeof fetch const mockClient = createMockClient() const plugin = await getPlugin(mockClient, undefined, { @@ -11770,17 +11771,45 @@ describe('auth.loader', () => { expect(intervalHandlers.length).toBeGreaterThanOrEqual(2) for (const handler of intervalHandlers) handler() - await waitForMockCall(mockClient.auth.set) - - expect(mockClient.auth.set).toHaveBeenCalledWith({ - path: { id: 'anthropic' }, - body: { + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const reader = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + let subject = await reader.captureLocalSubject('main') + for ( + let attempt = 0; + attempt < 50 && + subject.version?.accessFingerprint !== + tokenFingerprint('sk-ant-oat01-background-access-new'); + attempt++ + ) { + await Bun.sleep(10) + subject = await reader.captureLocalSubject('main') + } + const stored = await createNativePoolStore({ + paths: pool.paths, + quota: nativeQuotaCodec, + }).read() + expect(stored.status).toBe('ready') + const credential = + stored.status === 'ready' + ? stored.rows.find((row) => row.id === subject.binding.rowId) + ?.credential + : undefined + expect(credential).toMatchObject({ type: 'oauth', refresh: 'background-refresh-new', access: 'sk-ant-oat01-background-access-new', expires: expect.any(Number), - }, - }) + }) + expect(subject.binding.identity).toBe(syntheticMainAccountUuid) + expect(mockClient.auth.set).not.toHaveBeenCalled() + } finally { + reader.close() + } }) test('background refresh retries after a permanent main backoff belongs to an older refresh token', async () => { From 076472b7bab015e167e8f4849811e1cd5bd93e18 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:00:33 +0200 Subject: [PATCH 121/169] test: preserve native proactive refresh and backoff reset behavior --- packages/opencode/src/tests/index.test.ts | 254 ++++++++++++++-------- 1 file changed, 162 insertions(+), 92 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index ee3077195..4f53fda42 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1152,6 +1152,46 @@ async function refreshPoolMainElsewhere(successor: { await refreshPoolLoginElsewhere('main', successor) } +/** Wait for an account-bound credential write without changing test deadlines. */ +async function waitForPoolCredential(routeId: string, access: string) { + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const reader = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + const store = createNativePoolStore({ + paths: pool.paths, + quota: nativeQuotaCodec, + }) + try { + for (let attempt = 0; attempt < 50; attempt++) { + const subject = await reader.captureLocalSubject(routeId) + const read = await store.read() + const row = + read.status === 'ready' + ? read.rows.find((row) => row.id === subject.binding.rowId) + : undefined + const credential = row?.credential + if ( + row?.stamp === 'bound' && + row.identity === subject.binding.identity && + row.credentialEpoch === subject.binding.credentialEpoch && + credential?.type === 'oauth' && + credential.access === access && + subject.version?.accessFingerprint === tokenFingerprint(access) + ) + return { subject, credential } + await Bun.sleep(10) + } + throw new Error( + `Expected a persisted native OAuth successor for ${routeId}`, + ) + } finally { + reader.close() + } +} + /** * Rotate the chosen account's tokens using a separate account runtime. * Register the returned access token with loginIssues or mainAccountIssues @@ -11815,25 +11855,28 @@ describe('auth.loader', () => { test('background refresh retries after a permanent main backoff belongs to an older refresh token', async () => { const now = Date.now() await useTempAccountFile( - createFallbackStorage({ - accounts: [], - mainAccountId: 'main-account-id', - quota: { enabled: false }, - refresh: { - enabled: true, - refreshBeforeExpiryMinutes: 30, - mainLastRefreshError: { - message: 'Claude OAuth refresh failed: 400 — invalid_grant', - checkedAt: now - 1_000, - nextRetryAt: now + 24 * 60 * 60_000, - retryCount: 1, - accountIdentity: 'main-account-id', - refreshTokenFingerprint: tokenFingerprint('failed-refresh'), - status: 400, - permanent: true, + bindMainAccount( + createFallbackStorage({ + accounts: [], + mainAccountId: 'main-account-id', + quota: { enabled: false }, + refresh: { + enabled: true, + refreshBeforeExpiryMinutes: 30, + mainLastRefreshError: { + message: 'Claude OAuth refresh failed: 400 — invalid_grant', + checkedAt: now - 1_000, + nextRetryAt: now + 24 * 60 * 60_000, + retryCount: 1, + accountIdentity: 'main-account-id', + refreshTokenFingerprint: tokenFingerprint('failed-refresh'), + status: 400, + permanent: true, + }, }, - }, - }), + }), + 'sk-ant-oat01-current-access', + ), { access: 'sk-ant-oat01-current-access', refresh: 'current-refresh', @@ -11846,19 +11889,22 @@ describe('auth.loader', () => { return { unref() {} } }) as unknown as typeof setInterval let tokenRefreshCalls = 0 - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/v1/oauth/token')) { - tokenRefreshCalls += 1 - return Promise.resolve( - Response.json({ + mainAccountIssues('sk-ant-oat01-refreshed-access') + globalThis.fetch = mock( + async (input: Parameters[0], init?: RequestInit) => { + const admitted = await nativeBootstrapAnswer(input, init) + if (admitted) return admitted + if (extractUrl(input).includes('/v1/oauth/token')) { + tokenRefreshCalls += 1 + return Response.json({ refresh_token: 'refreshed-refresh', access_token: 'sk-ant-oat01-refreshed-access', expires_in: 8 * 60 * 60, - }), - ) - } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + }) + } + return new Response(null, { status: 200 }) + }, + ) as unknown as typeof fetch const mockClient = createMockClient() const plugin = await getPlugin(mockClient, undefined, { @@ -11877,36 +11923,44 @@ describe('auth.loader', () => { ) for (const handler of intervalHandlers) handler() - await waitForMockCall(mockClient.auth.set) - + const refreshed = await waitForPoolCredential( + 'main', + 'sk-ant-oat01-refreshed-access', + ) + expect(refreshed.credential.refresh).toBe('refreshed-refresh') + expect(refreshed.subject.binding.identity).toBe(syntheticMainAccountUuid) expect(tokenRefreshCalls).toBe(1) - expect(mockClient.auth.set).toHaveBeenCalledTimes(1) + expect(mockClient.auth.set).not.toHaveBeenCalled() expect( (await readAccountStorage())?.refresh?.mainLastRefreshError, ).toBeUndefined() }) - test('reset-backoff clears a legacy main latch before the next background refresh', async () => { + test('reset-backoff clears an imported main latch before the next background refresh', async () => { const now = Date.now() await useTempAccountFile( - createFallbackStorage({ - accounts: [], - mainAccountId: 'main-account-id', - quota: { enabled: false }, - refresh: { - enabled: true, - refreshBeforeExpiryMinutes: 30, - mainLastRefreshError: { - message: 'Claude OAuth refresh failed: 400 — invalid_grant', - checkedAt: now - 1_000, - nextRetryAt: now + 24 * 60 * 60_000, - retryCount: 1, - accountIdentity: 'main-account-id', - status: 400, - permanent: true, + bindMainAccount( + createFallbackStorage({ + accounts: [], + mainAccountId: 'main-account-id', + quota: { enabled: false }, + refresh: { + enabled: true, + refreshBeforeExpiryMinutes: 30, + mainLastRefreshError: { + message: 'Claude OAuth refresh failed: 400 — invalid_grant', + checkedAt: now - 1_000, + nextRetryAt: now + 24 * 60 * 60_000, + retryCount: 1, + accountIdentity: syntheticMainAccountUuid, + tokenHash: hashRefreshToken('current-refresh'), + status: 400, + permanent: true, + }, }, - }, - }), + }), + 'sk-ant-oat01-current-access', + ), { access: 'sk-ant-oat01-current-access', refresh: 'current-refresh', @@ -11919,19 +11973,22 @@ describe('auth.loader', () => { return { unref() {} } }) as unknown as typeof setInterval let tokenRefreshCalls = 0 - globalThis.fetch = mock((input: any) => { - if (extractUrl(input).includes('/v1/oauth/token')) { - tokenRefreshCalls += 1 - return Promise.resolve( - Response.json({ + mainAccountIssues('sk-ant-oat01-refreshed-access') + globalThis.fetch = mock( + async (input: Parameters[0], init?: RequestInit) => { + const admitted = await nativeBootstrapAnswer(input, init) + if (admitted) return admitted + if (extractUrl(input).includes('/v1/oauth/token')) { + tokenRefreshCalls += 1 + return Response.json({ refresh_token: 'refreshed-refresh', access_token: 'sk-ant-oat01-refreshed-access', expires_in: 8 * 60 * 60, - }), - ) - } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + }) + } + return new Response(null, { status: 200 }) + }, + ) as unknown as typeof fetch const mockClient = createMockClient() const plugin = await getPlugin(mockClient, tempConfigDir, { @@ -11949,6 +12006,10 @@ describe('auth.loader', () => { { models: {} }, ) + expect( + (await readNativeRuntimeState()).main?.lastRefreshError?.permanent, + ).toBe(true) + expect(tokenRefreshCalls).toBe(0) expect( ( await applyMenuAction(plugin, 'session-1', { @@ -11957,9 +12018,16 @@ describe('auth.loader', () => { }) ).ok, ).toBe(true) + expect( + (await readNativeRuntimeState()).main?.lastRefreshError, + ).toBeUndefined() for (const handler of intervalHandlers) handler() - await waitForMockCall(mockClient.auth.set) - + const refreshed = await waitForPoolCredential( + 'main', + 'sk-ant-oat01-refreshed-access', + ) + expect(refreshed.credential.refresh).toBe('refreshed-refresh') + expect(mockClient.auth.set).not.toHaveBeenCalled() expect(tokenRefreshCalls).toBe(1) expect( (await readAccountStorage())?.refresh?.mainLastRefreshError, @@ -11968,11 +12036,14 @@ describe('auth.loader', () => { test('background refresh uses a four-hour minimum window for main oauth', async () => { await useTempAccountFile( - createFallbackStorage({ - accounts: [], - quota: { enabled: false }, - refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, - }), + bindMainAccount( + createFallbackStorage({ + accounts: [], + quota: { enabled: false }, + refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, + }), + 'sk-ant-oat01-old-access', + ), { access: 'sk-ant-oat01-old-access', refresh: 'old-refresh', @@ -11985,22 +12056,20 @@ describe('auth.loader', () => { return { unref() {} } }) as unknown as typeof setInterval - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'early-refresh-new', - access_token: 'sk-ant-oat01-early-access-new', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + mainAccountIssues('sk-ant-oat01-early-access-new') + globalThis.fetch = mock( + async (input: Parameters[0], init?: RequestInit) => { + const admitted = await nativeBootstrapAnswer(input, init) + if (admitted) return admitted + if (extractUrl(input).includes('/v1/oauth/token')) + return Response.json({ + refresh_token: 'early-refresh-new', + access_token: 'sk-ant-oat01-early-access-new', + expires_in: 3600, + }) + return new Response(null, { status: 200 }) + }, + ) as unknown as typeof fetch const mockClient = createMockClient() const plugin = await getPlugin(mockClient, undefined, { @@ -12019,17 +12088,18 @@ describe('auth.loader', () => { ) for (const handler of intervalHandlers) handler() - await waitForMockCall(mockClient.auth.set) - - expect(mockClient.auth.set).toHaveBeenCalledWith({ - path: { id: 'anthropic' }, - body: { - type: 'oauth', - refresh: 'early-refresh-new', - access: 'sk-ant-oat01-early-access-new', - expires: expect.any(Number), - }, + const refreshed = await waitForPoolCredential( + 'main', + 'sk-ant-oat01-early-access-new', + ) + expect(refreshed.credential).toMatchObject({ + type: 'oauth', + refresh: 'early-refresh-new', + access: 'sk-ant-oat01-early-access-new', + expires: expect.any(Number), }) + expect(refreshed.subject.binding.identity).toBe(syntheticMainAccountUuid) + expect(mockClient.auth.set).not.toHaveBeenCalled() }) test('fetch wrapper backs off main oauth refresh after rate limits', async () => { From 515ca58a6d754b594bb17b2a2d73f895279df9f2 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:31:47 +0200 Subject: [PATCH 122/169] fix: preserve native refresh retries before arming backoff Keep the coordinator's three-attempt budget available after transient failures, while throttling 429 and permanent failures immediately. Retain safe first-request provider diagnostics and match subsequent backoff against the resolved account UUID. Verify exact successor dispatch, native error persistence, no host retry loop and the named early-backoff mutation guard. --- docs/testing/mutations.md | 5 +- mutations.toml | 16 +++ packages/core/src/native-account-runtime.ts | 14 ++- .../core/src/native-refresh-coordinator.ts | 6 +- .../src/tests/native-account-runtime.test.ts | 73 +++++++++++ packages/opencode/src/index.ts | 27 +++- packages/opencode/src/tests/index.test.ts | 117 ++++++++++++------ scripts/run-mutation-bun.ts | 1 + scripts/tests/run-mutation-bun.test.ts | 5 + 9 files changed, 216 insertions(+), 48 deletions(-) diff --git a/docs/testing/mutations.md b/docs/testing/mutations.md index ca6eeb81e..607803813 100644 --- a/docs/testing/mutations.md +++ b/docs/testing/mutations.md @@ -1,6 +1,6 @@ # Mutation proofs for request guards -`mutations.toml` records thirteen deliberately bounded runtime breaks against named +`mutations.toml` records fourteen deliberately bounded runtime breaks against named assertions. Run it only in an isolated checkout: `ckdev-mutate` edits its working checkout; it does **not** create a scratch worktree. Never reset/check out a target while replay is active, and never point builds at a live host installation. @@ -46,6 +46,7 @@ sibling repositories. | `profile-publication-refusal-is-not-io` | Label a rejected profile write as an I/O failure. | Stale-subject refusal must remain distinct from storage failure. | | `profile-current-credential-display` | Omit the current-credential display check. | A fetched tier must not cross a token rotation after its write failed. | | `runtime-owner-loss-is-publication-refusal` | Label runtime lease loss as storage I/O. | A failed ownership fence must never use the fresh-tier I/O exception. | +| `refresh-backoff-preserves-attempt-budget` | Start transient backoff after the first attempt. | A transient failure must leave the coordinator's remaining attempts available; 429 and permanent failures still back off immediately. | | `runtime-read-no-follow` | Follow a symlink installed between inspection and opening. | The runtime reader must reject the symlink. | | `runtime-read-opened-mode` | Ignore the opened file's permissions. | A replacement with mode 0644 must be rejected. | | `runtime-read-opened-owner` | Ignore the opened file's owner. | A foreign owner on the opened handle must be rejected. | @@ -75,7 +76,7 @@ bun scripts/run-mutation-bun.ts packages/core/src/tests/fast.test.ts \ 'fast mode eligibility for claude-opus-4-7[1m] is false' ``` -The wrapper accepts only the six reviewed owning test files. It rejects empty, +The wrapper accepts only the seven reviewed owning test files. It rejects empty, control-character and boundary-whitespace names and unsafe/unsupported paths. Bun prints nested scopes separated by ` > `, but its name filter joins those scopes with spaces. The wrapper applies that observed convention, escapes regex diff --git a/mutations.toml b/mutations.toml index 007847b36..c15466922 100644 --- a/mutations.toml +++ b/mutations.toml @@ -218,3 +218,19 @@ expect_message = "error: expect(received).toMatchObject(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] broad_report = "tmp/mutations/core.xml" broad_id = "{name}" + +[[control]] +id = "refresh-backoff-preserves-attempt-budget" +guards = "Failure bookkeeping must not block the coordinator's own remaining transient retries." +file = "packages/core/src/native-account-runtime.ts" +old = "nextRetryAt: retrying ? policy.checkedAt : policy.nextRetryAt," +new = "nextRetryAt: retrying ? policy.nextRetryAt : policy.nextRetryAt," +test_file = "packages/core/src/tests/native-account-runtime.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-account-runtime.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["native refresh keeps bounded provider attempts before backoff (status=500, failures=1)"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index b5bd7f451..84a448dc3 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -55,6 +55,7 @@ import type { NativeRefreshResult, NativeRefreshSubject, } from './native-refresh-coordinator.ts' +import { NATIVE_REFRESH_MAX_ATTEMPTS } from './native-refresh-coordinator.ts' import { type NativeRuntimeEntry, NativeRuntimeError, @@ -707,9 +708,20 @@ export function createNativeAccountRuntime( retryCount: Math.max(0, input.observation.attempt - 1), }, }) + // The coordinator owns up to three physical attempts. Recording + // a deadline after its first transient failure would block its own + // next attempt. A 429 or permanent error starts backoff immediately. + const retrying = + input.observation.status === 'failed' && + failure?.kind === 'provider' && + failure.classification === 'transient' && + failure.status !== 429 && + !input.observation.persisted && + !input.observation.committed && + input.observation.attempt < NATIVE_REFRESH_MAX_ATTEMPTS return { checkedAt: policy.checkedAt, - nextRetryAt: policy.nextRetryAt, + nextRetryAt: retrying ? policy.checkedAt : policy.nextRetryAt, retryCount: policy.retryCount, } }), diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index f53fa67c8..47d01f99e 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -38,6 +38,8 @@ import { } from './pool-store.ts' import { tokenFingerprint } from './token-fingerprint.ts' +export const NATIVE_REFRESH_MAX_ATTEMPTS = 3 + /** * Jobs distinguish credential replacements. This lock must still serialize * all refreshes for the same account. @@ -735,7 +737,7 @@ export function createNativeRefreshCoordinator( } try { - for (attempt = 1; attempt <= 3; attempt++) { + for (attempt = 1; attempt <= NATIVE_REFRESH_MAX_ATTEMPTS; attempt++) { // Clear the previous attempt's UUID, refresh lineage, access version and // runtime context before awaiting another row read. If the next policy // read fails, its failed observation must not reuse earlier reset values. @@ -1148,7 +1150,7 @@ export function createNativeRefreshCoordinator( failure.kind === 'provider' && failure.classification === 'transient' && failure.status !== 429 && - attempt < 3 + attempt < NATIVE_REFRESH_MAX_ATTEMPTS ) { await releaseHandoff() if (aliasKey) unregister(job, aliasKey) diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts index b0e73851d..7bf141a77 100644 --- a/packages/core/src/tests/native-account-runtime.test.ts +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -2,6 +2,7 @@ import { expect, spyOn } from 'bun:test' import * as filesystem from 'node:fs/promises' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { ClaudeOAuthRefreshError } from '../auth.ts' import type { ProviderAccountUuid } from '../claude-code.ts' import { __setLogTestSink, @@ -1303,3 +1304,75 @@ test('late profile publication refusal cannot authorize display for rotated cred ?.profile, ).toBeUndefined() }) + +for (const [status, failCount, expectedAttempts, expectedStatus] of [ + [500, 1, 2, 'usable'], + [500, 3, 3, 'failed'], + [429, 3, 1, 'failed'], + [403, 3, 1, 'failed'], +] as const) { + test(`native refresh keeps bounded provider attempts before backoff (status=${status}, failures=${failCount})`, async () => { + let attempts = 0 + const f = await fixture({ + refreshToken: async (input) => { + expect(input.maxRetries).toBe(0) + attempts++ + if (attempts <= failCount) + throw new ClaudeOAuthRefreshError( + status, + 'Synthetic provider failure', + ) + return { + access: 'sk-ant-oat01-retry-successor', + refresh: 'retry-successor-refresh', + expires: now + 8_000_000, + expiresIn: 8000, + } + }, + }) + const result = await f.runtime.authorizeLocal('main', { intent: 'refresh' }) + expect(attempts).toBe(expectedAttempts) + expect(result.status).toBe(expectedStatus) + const state = await f.runtime.read() + const error = state.accounts.find( + (row) => row.id === 'main', + )?.lastRefreshError + if (expectedStatus === 'usable') { + if (result.status !== 'usable') + throw new Error('Expected usable successor') + expect(result.access).toBe('sk-ant-oat01-retry-successor') + expect(error).toBeUndefined() + } else { + expect(error?.nextRetryAt).toBeGreaterThan(now) + expect(error?.retryCount).toBe(expectedAttempts) + await f.runtime.authorizeLocal('main', { intent: 'refresh' }) + expect(attempts).toBe(expectedAttempts) + } + }) +} + +test('native network refresh failure permits one retry and clears its transient error on success', async () => { + let attempts = 0 + const f = await fixture({ + refreshToken: async (input) => { + expect(input.maxRetries).toBe(0) + if (++attempts === 1) + throw Object.assign(new Error('Synthetic socket reset'), { + code: 'ECONNRESET', + }) + return { + access: 'sk-ant-oat01-network-successor', + refresh: 'network-successor-refresh', + expires: now + 8_000_000, + expiresIn: 8000, + } + }, + }) + const result = await f.runtime.authorizeLocal('main', { intent: 'refresh' }) + expect(attempts).toBe(2) + expect(result.status).toBe('usable') + expect( + (await f.runtime.read()).accounts.find((row) => row.id === 'main') + ?.lastRefreshError, + ).toBeUndefined() +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 229e64420..a64263109 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import type { NativeRefreshFailure } from '@cortexkit/anthropic-auth-core' import { type AccountCommandStatusProjection, type AccountStorage, @@ -1548,8 +1549,13 @@ const anthropicAuthPlugin = async ( readonly account: NativeAccountView, status: string, readonly tokenRefreshFailed = false, + readonly failure?: NativeRefreshFailure, ) { - super(`Native OAuth authorization refused: ${status}`) + super( + failure?.kind === 'provider' && failure.status !== undefined + ? `Claude OAuth refresh failed: ${failure.status} — ${failure.classification === 'invalid-grant' ? 'invalid_grant' : 'OAuth operation failed'}` + : `Native OAuth authorization refused: ${status}`, + ) } } async function authorizeOAuth( @@ -1623,6 +1629,7 @@ const anthropicAuthPlugin = async ( authorization.status, authorization.status === 'failed' && authorization.failure.kind === 'provider', + authorization.status === 'failed' ? authorization.failure : undefined, ) result = { accessToken: authorization.access, @@ -5746,6 +5753,7 @@ const anthropicAuthPlugin = async ( nativeScopedAttempt: undefined, modelDenied: false, credentialUnavailable: false, + credentialFailure: undefined, deferred: false, } if (!isCustodyTombstoneOAuth(currentActivation, 'anthropic')) { @@ -5831,12 +5839,14 @@ const anthropicAuthPlugin = async ( nativeScopedAttempt: undefined, modelDenied: false, credentialUnavailable: false, + credentialFailure: undefined, deferred: true, } } let credential: NativeOAuthAuthorization | undefined let denied: NativeAccountView | undefined let unavailable: NativeAccountView | undefined + let credentialFailure: NativeCredentialUnavailableError | undefined try { credential = await authorizeOAuth( 'main', @@ -5881,12 +5891,15 @@ const anthropicAuthPlugin = async ( denied = retryError.account else if ( retryError instanceof NativeCredentialUnavailableError - ) + ) { unavailable = retryError.account + credentialFailure = retryError + } } } } else if (error instanceof NativeCredentialUnavailableError) { unavailable = error.account + credentialFailure = error } } signal?.throwIfAborted() @@ -5909,6 +5922,7 @@ const anthropicAuthPlugin = async ( unavailable?.accountIdentity, modelDenied: Boolean(denied), credentialUnavailable: Boolean(unavailable), + credentialFailure, deferred: false, nativeLocalSource: credential?.localSource, nativeScopedAttempt: credential?.scopedAttempt, @@ -8795,6 +8809,11 @@ const anthropicAuthPlugin = async ( throw error } } + // Report the refresh failure that happened in this request, + // rather than replacing it with its newly recorded backoff. + // Later requests still check the persisted retry deadline. + if (auth.credentialFailure?.tokenRefreshFailed) + throw auth.credentialFailure // Check backoff before attempting refresh — avoids noisy // per-request retries during prolonged rate limits const refreshStorage = await loadAccounts() @@ -8804,7 +8823,9 @@ const anthropicAuthPlugin = async ( mainRefreshError && refreshBackoffActive( mainRefreshError, - mainAccountId ?? refreshStorage?.mainAccountId, + auth.nativeAccountIdentity ?? + mainAccountId ?? + refreshStorage?.mainAccountId, Date.now(), auth.refresh ? tokenFingerprint(auth.refresh) : undefined, ) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 4f53fda42..e50c799ba 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -12104,11 +12104,14 @@ describe('auth.loader', () => { test('fetch wrapper backs off main oauth refresh after rate limits', async () => { await useTempAccountFile( - createFallbackStorage({ - accounts: [], - quota: { enabled: false }, - refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, - }), + bindMainAccount( + createFallbackStorage({ + accounts: [], + quota: { enabled: false }, + refresh: { enabled: true, refreshBeforeExpiryMinutes: 30 }, + }), + 'sk-ant-oat01-expired', + ), { access: 'sk-ant-oat01-expired', refresh: 'refresh-token', @@ -12158,14 +12161,27 @@ describe('auth.loader', () => { ).rejects.toThrow('Claude OAuth refresh is backed off') expect(tokenRefreshCalls).toBe(1) - const savedConfig = JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) - expect(savedConfig.refresh?.mainLastRefreshError).toBeUndefined() - const savedState = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) - expect(savedState.main.lastRefreshError.nextRetryAt).toBeGreaterThan( + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const savedConfig = await createNativePoolStore({ + paths: pool.paths, + quota: nativeQuotaCodec, + }).readSettings() + expect(savedConfig.status).toBe('ready') + if (savedConfig.status !== 'ready') + throw new Error('Expected ready native pool') + expect(JSON.stringify(savedConfig.settings)).not.toContain( + 'lastRefreshError', + ) + const savedState = await readNativeRuntimeState() + expect(savedState.main?.lastRefreshError?.nextRetryAt).toBeGreaterThan( Date.now(), ) + expect(savedState.main?.lastRefreshError?.status).toBe(429) + expect(savedState.main?.accountIdentity).toBe(syntheticMainAccountUuid) + await expect(readFile(getAccountStatePath(), 'utf8')).rejects.toMatchObject( + { code: 'ENOENT' }, + ) }) test('successful re-login clears a live stale main refresh backoff', async () => { @@ -12480,43 +12496,58 @@ describe('auth.loader', () => { }) test('fetch wrapper retries transient token refresh failures', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] }), { - access: 'sk-ant-oat01-expired', - refresh: 'refresh', - expires: Date.now() - 1000, - }) + await useTempAccountFile( + bindMainAccount( + createFallbackStorage({ accounts: [], quota: { enabled: false } }), + 'sk-ant-oat01-expired', + ), + { + access: 'sk-ant-oat01-expired', + refresh: 'refresh', + expires: Date.now() - 1000, + }, + ) let tokenRefreshCalls = 0 const setTimeoutMock = mock((handler: () => unknown) => { handler() return 0 as unknown as ReturnType }) as unknown as typeof setTimeout - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) + mainAccountIssues('sk-ant-oat01-new-access') + const modelAuthorizations: Array = [] + globalThis.fetch = mock( + async (input: Parameters[0], init?: RequestInit) => { + const admitted = await nativeBootstrapAnswer(input, init) + if (admitted) return admitted + const url = extractUrl(input) + if (url.includes('/v1/messages')) + modelAuthorizations.push( + new Headers(init?.headers).get('authorization'), + ) + if (url.includes('/v1/oauth/token')) { + tokenRefreshCalls += 1 - if (url.includes('/v1/oauth/token')) { - tokenRefreshCalls += 1 + if (tokenRefreshCalls === 1) { + return Promise.resolve( + new Response('Temporary failure', { status: 500 }), + ) + } - if (tokenRefreshCalls === 1) { return Promise.resolve( - new Response('Temporary failure', { status: 500 }), + new Response( + JSON.stringify({ + refresh_token: 'new-refresh', + access_token: 'sk-ant-oat01-new-access', + expires_in: 3600, + }), + { status: 200 }, + ), ) } - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'new-refresh', - access_token: 'sk-ant-oat01-new-access', - expires_in: 3600, - }), - { status: 200 }, - ), - ) - } - - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + return Promise.resolve(new Response(null, { status: 200 })) + }, + ) as unknown as typeof fetch const mockClient = createMockClient() const plugin = await getPlugin(mockClient, undefined, { @@ -12539,9 +12570,15 @@ describe('auth.loader', () => { }) expect(tokenRefreshCalls).toBe(2) - expect(setTimeoutMock).toHaveBeenCalledTimes(1) - expect(setTimeoutMock).toHaveBeenCalledWith(expect.any(Function), 500) - expect(mockClient.auth.set).toHaveBeenCalledTimes(1) + expect(modelAuthorizations).toEqual(['Bearer sk-ant-oat01-new-access']) + expect( + (await waitForPoolCredential('main', 'sk-ant-oat01-new-access')) + .credential.refresh, + ).toBe('new-refresh') + // Retry belongs to the shared coordinator. The host must not add another + // retry loop or write the resulting secret into its activation slot. + expect(setTimeoutMock).not.toHaveBeenCalled() + expect(mockClient.auth.set).not.toHaveBeenCalled() }) test('fetch wrapper keeps main oauth retry count bounded when helper also supports retries', async () => { @@ -12620,7 +12657,7 @@ describe('auth.loader', () => { { models: {} }, ) - expect( + await expect( result.fetch('https://api.anthropic.com/v1/messages', { method: 'POST', body: '{}', diff --git a/scripts/run-mutation-bun.ts b/scripts/run-mutation-bun.ts index 02fdd3dc3..4e02edd2c 100644 --- a/scripts/run-mutation-bun.ts +++ b/scripts/run-mutation-bun.ts @@ -87,6 +87,7 @@ export function selectionFor(file: string, name: string) { 'packages/core/src/tests/fast.test.ts', 'packages/core/src/tests/native-runtime.test.ts', 'packages/core/src/tests/native-display-profile.test.ts', + 'packages/core/src/tests/native-account-runtime.test.ts', 'packages/opencode/src/tests/index.test.ts', 'packages/pi/src/tests/convert.test.ts', ].includes(file) diff --git a/scripts/tests/run-mutation-bun.test.ts b/scripts/tests/run-mutation-bun.test.ts index 9587aee25..0c3585f69 100644 --- a/scripts/tests/run-mutation-bun.test.ts +++ b/scripts/tests/run-mutation-bun.test.ts @@ -742,6 +742,11 @@ test('native guard selection uses the actual owning package and reviewed preload 'packages/core', 'src/tests/native-display-profile.test.ts', ], + [ + 'packages/core/src/tests/native-account-runtime.test.ts', + 'packages/core', + 'src/tests/native-account-runtime.test.ts', + ], [ 'packages/opencode/src/tests/index.test.ts', 'packages/opencode', From 2e98871a5083b0a0b08539a3aa6b7bca27856ecf Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:40:44 +0200 Subject: [PATCH 123/169] test: verify native re-login clears only credential-bound restrictions --- packages/opencode/src/tests/index.test.ts | 271 ++++++++++------------ 1 file changed, 120 insertions(+), 151 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index e50c799ba..c1e635197 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -12184,165 +12184,134 @@ describe('auth.loader', () => { ) }) - test('successful re-login clears a live stale main refresh backoff', async () => { - const now = Date.now() - await useTempAccountFile( - createFallbackStorage({ - accounts: [], - mainAccountId: 'main-account-id', - quota: { - enabled: false, - mainLastQuotaApiError: { - message: 'stale quota failure', - checkedAt: now - 1_000, - nextRetryAt: now + 60_000, - retryCount: 1, - }, - }, - refresh: { - enabled: true, - mainLastRefreshError: { - message: 'stale refresh failure', - checkedAt: now - 1_000, - nextRetryAt: now + 60_000, - retryCount: 1, - accountIdentity: 'relogged-main-refresh', - }, - }, - }), - { - access: 'sk-ant-oat01-relogged-main-access', - refresh: 'relogged-main-refresh', - expires: now + 60 * 60_000, - }, - ) - let tokenRefreshCalls = 0 - globalThis.fetch = mock((input: any) => { - const url = extractUrl(input) - if (url.includes('/v1/oauth/token')) { - tokenRefreshCalls += 1 - return Promise.resolve( - new Response( - JSON.stringify({ - refresh_token: 'relogged-main-refresh-new', - access_token: 'sk-ant-oat01-relogged-main-access', - expires_in: 3600, + for (const sameAccount of [false, true]) { + test( + sameAccount + ? 'successful re-login preserves the current account main quota backoff' + : 'successful re-login clears a live stale main refresh backoff', + async () => { + const now = Date.now() + const prior = { + access: 'sk-ant-oat01-prior-login', + refresh: 'prior-login-refresh', + expires: now + 8 * 60 * 60_000, + } + const quotaRetryAt = now + 60_000 + await useTempAccountFile( + bindMainAccount( + createFallbackStorage({ + accounts: [], + mainAccountId: 'main-account-id', + quota: { + enabled: false, + mainLastQuotaApiError: { + message: 'Prior quota failure', + status: 429, + checkedAt: now - 1000, + nextRetryAt: quotaRetryAt, + retryCount: 1, + accountIdentity: syntheticMainAccountUuid, + }, + }, + refresh: { + enabled: true, + mainLastRefreshError: { + message: 'Claude OAuth refresh failed: 400 — invalid_grant', + status: 400, + checkedAt: now - 1000, + nextRetryAt: now + 24 * 60 * 60_000, + retryCount: 1, + permanent: true, + accountIdentity: syntheticMainAccountUuid, + tokenHash: hashRefreshToken(prior.refresh), + }, + }, }), - { status: 200 }, + prior.access, ), + prior, ) - } - if (url.includes('/v1/messages')) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch - - const plugin = await getPlugin(createMockClient()) - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, + const successor = { access: 'sk-ant-oat01-relogged-main-access', refresh: 'relogged-main-refresh', - expires: now + 60 * 60_000, - }), - { models: {} }, - ) - const preflight = await readAccountStorage() - expect(preflight?.refresh?.mainLastRefreshError).toEqual( - expect.objectContaining({ - accountIdentity: 'relogged-main-refresh', - nextRetryAt: expect.any(Number), - }), - ) - - const response = await result.fetch(MESSAGES_URL, { - method: 'POST', - body: '{}', - }) - - expect(response.status).toBe(200) - expect(tokenRefreshCalls).toBe(0) - const savedState = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) - expect(savedState.main.lastRefreshError).toBeUndefined() - const savedConfig = await readAccountStorage() - expect(savedConfig?.quota?.mainLastQuotaApiError).toBeUndefined() - }) - - test('successful re-login preserves the current account main quota backoff', async () => { - const now = Date.now() - await useTempAccountFile( - createFallbackStorage({ - accounts: [], - mainAccountId: 'main-account-id', - quota: { - enabled: false, - mainLastQuotaApiError: { - message: 'current quota failure', - checkedAt: now - 1_000, - nextRetryAt: now + 60_000, - retryCount: 1, - accountIdentity: 'main-account-id', - }, - }, - refresh: { - enabled: true, - mainLastRefreshError: { - message: 'stale refresh failure', - checkedAt: now - 1_000, - nextRetryAt: now + 60_000, - retryCount: 1, - accountIdentity: 'relogged-main-refresh', + expires: now + 8 * 60 * 60_000, + } + const identity = sameAccount + ? syntheticMainAccountUuid + : ('88888888-2222-4333-8444-555555555555' as ProviderAccountUuid) + successorAccess.set(successor.access, identity) + let tokenRefreshCalls = 0 + const modelAuthorizations: Array = [] + globalThis.fetch = mock( + async (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if ( + url.includes('/api/claude_cli/bootstrap') && + new Headers(init?.headers).get('authorization') === + `Bearer ${successor.access}` + ) + return Response.json({ + oauth_account: { account_uuid: identity }, + }) + const admitted = await nativeBootstrapAnswer(input, init) + if (admitted) return admitted + if (url.includes('/v1/oauth/token')) { + tokenRefreshCalls++ + throw new Error( + 'Unexpected token refresh during known-account re-login', + ) + } + if (url.includes('/v1/messages')) + modelAuthorizations.push( + new Headers(init?.headers).get('authorization'), + ) + return Response.json({}) }, - }, - }), - { - access: 'sk-ant-oat01-relogged-main-access', - refresh: 'relogged-main-refresh', - expires: now + 60 * 60_000, + ) as unknown as typeof fetch + const mockClient = createMockClient() + const plugin = await getPlugin(mockClient) + const result = await plugin.auth.loader( + () => Promise.resolve({ type: 'oauth', ...prior }), + { models: {} }, + ) + const before = await readNativeRuntimeState() + expect(before.main?.lastRefreshError?.permanent).toBe(true) + expect(before.main?.lastRefreshError?.nextRetryAt).toBeGreaterThan(now) + expect(before.main?.lastQuotaApiError?.nextRetryAt).toBe(quotaRetryAt) + await replacePoolMainLogin(successor, { + sameAccount, + accountIdentity: identity, + }) + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + body: '{}', + }) + expect(response.status).toBe(200) + expect(modelAuthorizations).toEqual([`Bearer ${successor.access}`]) + expect(tokenRefreshCalls).toBe(0) + const after = await readNativeRuntimeState() + expect(after.main?.accountIdentity).toBe(identity) + expect(after.main?.lastRefreshError).toBeUndefined() + if (sameAccount) { + // A refresh restriction belongs to the replaced credentials. Quota + // backoff belongs to the account UUID and must keep its live deadline. + expect(after.main?.lastQuotaApiError).toMatchObject({ + accountIdentity: identity, + status: 429, + nextRetryAt: quotaRetryAt, + }) + expect(after.main?.lastQuotaApiError?.nextRetryAt).toBeGreaterThan( + Date.now(), + ) + } else expect(after.main?.lastQuotaApiError).toBeUndefined() + expect( + (await waitForPoolCredential('main', successor.access)).credential + .refresh, + ).toBe(successor.refresh) + expect(mockClient.auth.set).not.toHaveBeenCalled() }, ) - globalThis.fetch = mock( - withNativeAdmission((input: any) => { - if (extractUrl(input).includes('/v1/messages')) - return Promise.resolve(new Response('{}', { status: 200 })) - return Promise.resolve(new Response(null, { status: 200 })) - }), - ) as unknown as typeof fetch - - const plugin = await getPlugin(createMockClient()) - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: 'sk-ant-oat01-relogged-main-access', - refresh: 'relogged-main-refresh', - expires: now + 60 * 60_000, - }), - { models: {} }, - ) - - const response = await result.fetch(MESSAGES_URL, { - method: 'POST', - body: '{}', - }) - - expect(response.status).toBe(200) - const savedConfig = await readAccountStorage() - // The entry surviving is not the property under test — a backoff whose - // retry time has been zeroed is no longer restricting anything. - expect(savedConfig?.quota?.mainLastQuotaApiError).toEqual( - expect.objectContaining({ - accountIdentity: 'main-account-id', - message: 'current quota failure', - nextRetryAt: now + 60_000, - }), - ) - expect( - savedConfig?.quota?.mainLastQuotaApiError?.nextRetryAt, - ).toBeGreaterThan(Date.now()) - }) + } test('fallback-first uses stale passing fallback quota while quota refresh is in progress even when main refresh is backed off', async () => { const now = Date.now() From 6080b548e909b2d8d637ffa6756370c85301f10e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:57:48 +0200 Subject: [PATCH 124/169] test: exercise stale account writes against native authority --- packages/opencode/src/tests/index.test.ts | 152 +++++++++++++--------- 1 file changed, 94 insertions(+), 58 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index c1e635197..5cd779098 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -6969,35 +6969,37 @@ describe('auth.loader', () => { test('stale writer does not resurrect a deleted active account', async () => { await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - quota: { enabled: false }, - accounts: [ - { - id: 'work-deleted', - type: 'oauth', - access: 'sk-ant-oat01-work-deleted-access', - refresh: 'work-deleted-refresh', - expires: Date.now() + 100000, - }, - { - id: 'work-witness', - type: 'oauth', - access: 'sk-ant-oat01-work-witness-stale-access', - refresh: 'work-witness-stale-refresh', - expires: Date.now() + 100000, - lastRefreshedAt: 100, - }, - ], - }), + bindPoolAccounts( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { enabled: false }, + accounts: [ + { + id: 'work-deleted', + type: 'oauth', + access: 'sk-ant-oat01-work-deleted-access', + refresh: 'work-deleted-refresh', + expires: Date.now() - 1000, + }, + { + id: 'work-witness', + type: 'oauth', + access: 'sk-ant-oat01-work-witness-stale-access', + refresh: 'work-witness-stale-refresh', + expires: Date.now() - 1000, + lastRefreshedAt: 100, + }, + ], + }), + ), ) - const refreshStarted = deferred() - const releaseRefresh = deferred() - globalThis.fetch = mock((input: any) => { + const refreshStarted = bodyLifetime().gate() + const releaseRefresh = bodyLifetime().gate() + globalThis.fetch = mock((input: Parameters[0]) => { const url = extractUrl(input) if (url === TOKEN_URL) { - refreshStarted.resolve() - return releaseRefresh.promise.then( + refreshStarted.open() + return releaseRefresh.wait.then( () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400, @@ -7029,38 +7031,62 @@ describe('auth.loader', () => { expect(backgroundRefreshReady).toBeInstanceOf(Promise) try { await withDeadlockGuard( - refreshStarted.promise, + refreshStarted.wait, 4_000, 'fallback refresh never reached the token stub; the eager refresh did not start', ) - // v1.16.0's mergeAccountsForSave unions existing+incoming accounts, so a - // deletion must be declared explicitly via removedAccountIds — a plain - // save without the account no longer removes it. - await saveAccounts( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - quota: { enabled: false }, - accounts: [ - { - id: 'work-current', - type: 'oauth', - access: 'sk-ant-oat01-work-current-access', - refresh: 'work-current-refresh', - expires: Date.now() + 100000, - }, - { - id: 'work-witness', - type: 'oauth', - access: 'sk-ant-oat01-work-witness-current-access', - refresh: 'work-witness-current-refresh', - expires: Date.now() + 100000, - lastRefreshedAt: 200, - }, - ], - }), - undefined, - { removedAccountIds: ['work-deleted'] }, - ) + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const peer = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + try { + const witness = (await peer.read()).accounts.find( + (row) => row.id === 'work-witness', + ) + if (!witness?.accountIdentity) + throw new Error('Expected known witness UUID') + const staleSubject = requireKnownPoolSubject( + await peer.captureLocalSubject('work-deleted'), + ) + // Removal shares the refresh lock, so queue it before releasing the + // provider rather than waiting for removal while holding that lock. + const removing = peer.remove('work-deleted') + bodyLifetime().trackDetached(Promise.allSettled([removing])) + releaseRefresh.open() + await removing + expect( + await peer.publishLocal(staleSubject, { lastUsed: Date.now() }), + ).toBe(false) + await peer.loginOAuth({ + routeId: 'work-current', + replace: false, + accountIdentity: '99999999-2222-4333-8444-555555555555', + credential: { + access: 'sk-ant-oat01-work-current-access', + refresh: 'work-current-refresh', + expires: Date.now() + 100000, + }, + }) + await peer.loginOAuth({ + routeId: 'work-witness', + replace: true, + accountIdentity: witness.accountIdentity, + credential: { + access: 'sk-ant-oat01-work-witness-current-access', + refresh: 'work-witness-current-refresh', + expires: Date.now() - 1000, + }, + }) + await peer.reorder(['main', 'work-current', 'work-witness']) + const witnessFailure = await peer.authorizeLocal('work-witness', { + intent: 'refresh', + }) + expect(witnessFailure.status).toBe('failed') + } finally { + peer.close() + } await seedSidebarRouting('work-deleted', 'fallback-first', Date.now()) await plugin.auth.loader( @@ -7073,7 +7099,7 @@ describe('auth.loader', () => { }), { models: {} }, ) - releaseRefresh.resolve() + releaseRefresh.open() await withDeadlockGuard( backgroundRefreshReady!, 4_000, @@ -7084,8 +7110,18 @@ describe('auth.loader', () => { 'work-current', 'work-witness', ]) + const poolRead = await createNativePoolStore({ + paths: pool.paths, + quota: nativeQuotaCodec, + }).read() + expect(poolRead.status).toBe('ready') + if (poolRead.status !== 'ready') + throw new Error('Expected ready native pool') + expect( + poolRead.rows.find((row) => row.id === 'work-deleted'), + ).toBeUndefined() expect( - disk?.accounts.find((account) => account.id === 'work-current'), + poolRead.rows.find((row) => row.id === 'work-current')?.credential, ).toMatchObject({ access: 'sk-ant-oat01-work-current-access', refresh: 'work-current-refresh', @@ -7104,7 +7140,7 @@ describe('auth.loader', () => { 'work-witness', ]) } finally { - releaseRefresh.resolve() + releaseRefresh.open() } }) From 869b187c7540943565ae516d18a778ca38d7fa72 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:16:22 +0200 Subject: [PATCH 125/169] test: verify native quota menu status and explicit refresh --- packages/opencode/src/tests/index.test.ts | 125 +++++++++++++++------- 1 file changed, 87 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 5cd779098..f9aeb51a0 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -9762,7 +9762,33 @@ describe('auth.loader', () => { expect(result.text).toContain('Mode updated to `hybrid`.') }) - test('handles /claude-quota before auth loader has run', async () => { + test('native quota status works before the host auth loader has run', async () => { + const checkedAt = Date.now() + await useTempAccountFile( + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + quota: { + enabled: false, + mainQuota: { + accountIdentity: syntheticMainAccountUuid, + checkedAt, + five_hour: { usedPercent: 25, remainingPercent: 75, checkedAt }, + seven_day: { usedPercent: 50, remainingPercent: 50, checkedAt }, + }, + mainQuotaCheckedAt: checkedAt, + }, + }), + ), + ) + let fetchCalls = 0 + globalThis.fetch = Object.assign( + mock(async () => { + fetchCalls++ + throw new Error('Unexpected network call during cached native status') + }), + { preconnect() {} }, + ) const mockClient = createMockClient() const plugin = await getPlugin(mockClient) @@ -9782,39 +9808,43 @@ describe('auth.loader', () => { { type: 'text', ignored: true, - text: expect.stringContaining('auth loader has not run yet'), + text: expect.stringContaining('5h: 75% remaining'), }, ], }, }) + expect(fetchCalls).toBe(0) }) - test('/claude-quota shows live main and fallback quotas', async () => { + test('native quota refresh shows live main and fallback quotas', async () => { await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - id: 'fallback-1', - label: 'fallback personal', - type: 'oauth', - access: 'sk-ant-oat01-fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 99, - remainingPercent: 1, - checkedAt: 1, - }, - seven_day: { - usedPercent: 99, - remainingPercent: 1, - checkedAt: 1, + bindPoolAccounts( + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: 'fallback-1', + label: 'fallback personal', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 99, + remainingPercent: 1, + checkedAt: 1, + }, + seven_day: { + usedPercent: 99, + remainingPercent: 1, + checkedAt: 1, + }, }, }, - }, - ], - }), + ], + }), + ), ) const mockClient = createMockClient() const seenTokens: string[] = [] @@ -9834,7 +9864,12 @@ describe('auth.loader', () => { new Response( JSON.stringify({ five_hour: { utilization }, - seven_day: { utilization: utilization + 10 }, + seven_day: { + utilization: + authorization === 'Bearer sk-ant-oat01-main-access' + ? 50 + : 60, + }, }), { status: 200 }, ), @@ -9846,7 +9881,7 @@ describe('auth.loader', () => { ), ) as unknown as typeof fetch - const plugin = await getPlugin(mockClient) + const plugin = await getPlugin(mockClient, tempConfigDir) await plugin.auth.loader( () => Promise.resolve({ @@ -9866,18 +9901,32 @@ describe('auth.loader', () => { }), ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - expect(seenTokens).toContain('Bearer sk-ant-oat01-main-access') + const refreshed = await applyMenuAction(plugin, 'session-1', { + sectionId: 'Quota', + actionId: 'quota-refresh', + }) + expect(refreshed.ok).toBe(true) + const freshState = await readNativeRuntimeState() + expect(freshState.main?.quota?.five_hour?.usedPercent).toBe(25) expect( - seenTokens.filter( - (token) => token === 'Bearer sk-ant-oat01-fallback-access', - ).length, - ).toBeGreaterThanOrEqual(1) - const promptCalls = ( - mockClient.session.promptAsync as unknown as { - mock: { calls: Array<[{ body: { parts: Array<{ text: string }> } }]> } - } - ).mock.calls - const text = promptCalls.at(-1)?.[0]?.body.parts[0]?.text + freshState.accounts['fallback-1']?.quota?.five_hour?.usedPercent, + ).toBe(40) + await expect( + plugin['command.execute.before']({ + command: 'claude', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + expect(seenTokens.toSorted()).toEqual([ + 'Bearer sk-ant-oat01-fallback-access', + 'Bearer sk-ant-oat01-main-access', + ]) + // applyMenuAction opens a TUI-connected menu. Inspect its current + // payload, not the earlier headless notice sent before the refresh. + const currentMenu = await openClaudeMenu(plugin, 'session-1') + const text = JSON.stringify(currentMenu) + expect(refreshed.text).toContain('5h: 75% remaining') expect(text).toContain('## Claude Quotas') expect(text).toContain('### OpenCode anthropic (main)') expect(text).toContain('### fallback personal (fallback)') From 6fa519eed36e350d471dc0c24a4b988e892bd08f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:01:19 +0200 Subject: [PATCH 126/169] fix: recheck native activation around credential authorization --- docs/testing/mutations.md | 14 +- mutations.toml | 42 ++ packages/opencode/src/index.ts | 25 +- packages/opencode/src/tests/index.test.ts | 479 ++++++++++++++++------ 4 files changed, 425 insertions(+), 135 deletions(-) diff --git a/docs/testing/mutations.md b/docs/testing/mutations.md index 607803813..8fb3eb83a 100644 --- a/docs/testing/mutations.md +++ b/docs/testing/mutations.md @@ -1,9 +1,13 @@ # Mutation proofs for request guards -`mutations.toml` records fourteen deliberately bounded runtime breaks against named -assertions. Run it only in an isolated checkout: `ckdev-mutate` edits its working -checkout; it does **not** create a scratch worktree. Never reset/check out a target -while replay is active, and never point builds at a live host installation. +`mutations.toml` records sixteen edits, each replacing one specified source block +with a broken safeguard. Named test assertions must detect each break. + +Run only from an isolated clone or worktree. `ckdev-mutate` is the mutation test +runner: it applies the configured edits and runs tests in that checkout, without +creating a scratch worktree. Do not reset or check out a target file during replay: +that can replace the mutant with healthy code mid-test and invalidate the result. +Never point builds at a live host installation. ## Toolchain and scope @@ -47,6 +51,8 @@ sibling repositories. | `profile-current-credential-display` | Omit the current-credential display check. | A fetched tier must not cross a token rotation after its write failed. | | `runtime-owner-loss-is-publication-refusal` | Label runtime lease loss as storage I/O. | A failed ownership fence must never use the fresh-tier I/O exception. | | `refresh-backoff-preserves-attempt-budget` | Start transient backoff after the first attempt. | A transient failure must leave the coordinator's remaining attempts available; 429 and permanent failures still back off immediately. | +| `native-activation-before-401-refresh` | Remove the activation check before transport reauthorization. | A revoked native host activation must trigger no token exchange. | +| `native-activation-after-authorization` | Remove the activation check after transport authorization. | An activation revoked during fresh token validation must prevent model dispatch. | | `runtime-read-no-follow` | Follow a symlink installed between inspection and opening. | The runtime reader must reject the symlink. | | `runtime-read-opened-mode` | Ignore the opened file's permissions. | A replacement with mode 0644 must be rejected. | | `runtime-read-opened-owner` | Ignore the opened file's owner. | A foreign owner on the opened handle must be rejected. | diff --git a/mutations.toml b/mutations.toml index c15466922..3a6784947 100644 --- a/mutations.toml +++ b/mutations.toml @@ -234,3 +234,45 @@ expect_message = "error: expect(received).toBe(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] broad_report = "tmp/mutations/core.xml" broad_id = "{name}" + +[[control]] +id = "native-activation-before-401-refresh" +guards = "A revoked host activation must not trigger a credential refresh." +file = "packages/opencode/src/index.ts" +old = ''' requireNativeActivation(await hostGetAuth()) + init?.signal?.throwIfAborted() + const current = await authorizeOAuth(''' +new = ''' init?.signal?.throwIfAborted() + const current = await authorizeOAuth(''' +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +prebuild = ["bun", "run", "--cwd", "packages/core", "build"] +expect_red = ["auth.loader > sticky 401 retry refuses a foreign-provider tombstone before the token endpoint"] +expect_message = "error: expect(received).toEqual(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" + +[[control]] +id = "native-activation-after-authorization" +guards = "Activation revocation during physical credential authorization must prevent model dispatch." +file = "packages/opencode/src/index.ts" +old = ''' oauthAccountId === 'main' ? 'last-main' : 'serve', + ) + requireNativeActivation(await hostGetAuth()) + init?.signal?.throwIfAborted()''' +new = ''' oauthAccountId === 'main' ? 'last-main' : 'serve', + ) + init?.signal?.throwIfAborted()''' +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +prebuild = ["bun", "run", "--cwd", "packages/core", "build"] +expect_red = ["auth.loader > physical OAuth authorization refuses host revocation during fresh token validation"] +expect_message = "error: expect(received).toEqual(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index a64263109..c2aded432 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -5735,6 +5735,12 @@ const anthropicAuthPlugin = async ( profilePublications.add(hydration) void hydration.then(() => profilePublications.delete(hydration)) } + function requireNativeActivation(currentActivation: unknown) { + if (!isCustodyTombstoneOAuth(currentActivation, 'anthropic')) + throw new Error( + 'Native OAuth requires inert OpenCode activation; run offline setup', + ) + } const getAuth = async ( modelId?: string, signal?: AbortSignal, @@ -5756,11 +5762,7 @@ const anthropicAuthPlugin = async ( credentialFailure: undefined, deferred: false, } - if (!isCustodyTombstoneOAuth(currentActivation, 'anthropic')) { - throw new Error( - 'Native OAuth requires inert OpenCode activation; run offline setup', - ) - } + requireNativeActivation(currentActivation) const primarySnapshot = await nativeAccounts.read() headerRoutingPolicyStorage = primarySnapshot.policyStorage const primaryAccount = primarySnapshot.accounts.find( @@ -6011,6 +6013,11 @@ const anthropicAuthPlugin = async ( modelId?: string, signal?: AbortSignal, ) { + // A provider's 401 cannot authorize refresh after OpenCode's + // native activation marker is removed or changed. Recheck the + // host auth slot before and after awaiting the credentials. + requireNativeActivation(await hostGetAuth()) + signal?.throwIfAborted() const credential = await authorizeOAuth( 'main', signal, @@ -6018,6 +6025,8 @@ const anthropicAuthPlugin = async ( modelId, 'refresh', ) + requireNativeActivation(await hostGetAuth()) + signal?.throwIfAborted() return credential.accessToken } @@ -6749,6 +6758,10 @@ const anthropicAuthPlugin = async ( const requestAuthority = nativeAuthorization let retryFromVault: NativeCustodyReceipt | undefined const authorizePhysical = async (rejectedAccessToken?: string) => { + // Every direct or relay attempt still needs the native host + // activation, including retries after an upstream 401. + requireNativeActivation(await hostGetAuth()) + init?.signal?.throwIfAborted() const current = await authorizeOAuth( oauthAccountId, init?.signal ?? undefined, @@ -6756,6 +6769,8 @@ const anthropicAuthPlugin = async ( modelForIdentity, oauthAccountId === 'main' ? 'last-main' : 'serve', ) + requireNativeActivation(await hostGetAuth()) + init?.signal?.throwIfAborted() if ( current.accountIdentity !== requestAuthority.accountIdentity || Boolean(current.scopedAttempt) !== diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index f9aeb51a0..ac0f90cf0 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -34,7 +34,6 @@ import { ClaudeOAuthRefreshError, type CommandApplyRequest, type CommandApplyResult, - CustodyTombstoneRefreshError, createNativeAccountRuntime, createNativePoolStore, custodyTombstoneOAuth, @@ -9099,83 +9098,118 @@ describe('auth.loader', () => { }) }) - test('fallback-first adopts legacy persisted main quota for sidebar without refetching', async () => { - await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { usedPercent: 6, remainingPercent: 94 }, - seven_day: { usedPercent: 75, remainingPercent: 25 }, + for (const bound of [true, false]) { + test( + bound + ? 'fallback-first reuses migrated account-bound main quota for sidebar without refetching' + : 'fallback-first refuses legacy main quota attributed only to an old access token', + async () => { + const checkedAt = Date.now() + const storage = createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { usedPercent: 6, remainingPercent: 94, checkedAt }, + seven_day: { usedPercent: 75, remainingPercent: 25, checkedAt }, + checkedAt, + }, + mainQuotaCheckedAt: checkedAt, }, - mainQuotaCheckedAt: Date.now(), - mainQuotaToken: tokenFingerprint('old-main-access'), - } as AccountStorage['quota'], - }), - ) - - const authorizations: string[] = [] - let mainQuotaCalls = 0 - globalThis.fetch = mock( - withNativeAdmission((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - mainQuotaCalls++ - expect(new Headers(init?.headers).get('authorization')).toBe( - 'Bearer sk-ant-oat01-main-access', - ) - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 12 }, - seven_day: { utilization: 34 }, - }), - { status: 200 }, - ), - ) + }) + for (const account of storage.accounts) { + if (account.type !== 'oauth') continue + account.expires = checkedAt + 8 * 60 * 60_000 + account.quota = { + checkedAt, + five_hour: { usedPercent: 30, remainingPercent: 70, checkedAt }, + seven_day: { usedPercent: 40, remainingPercent: 60, checkedAt }, + } + } + bindPoolAccounts(storage) + if (!bound && storage.quota?.mainQuota) { + storage.quota.mainQuota.accountIdentity = undefined + storage.quota.mainQuotaToken = tokenFingerprint('old-main-access') } + await useTempAccountFile(storage) + const imported = await readNativeRuntimeState() + expect(imported.main?.accountIdentity).toBe(syntheticMainAccountUuid) + if (bound) + expect(imported.main?.quota).toMatchObject({ + accountIdentity: syntheticMainAccountUuid, + five_hour: { usedPercent: 6 }, + seven_day: { usedPercent: 75 }, + }) + else expect(imported.main?.quota).toBeUndefined() - authorizations.push( - new Headers(init?.headers).get('authorization') ?? '', - ) - return Promise.resolve(new Response(null, { status: 200 })) - }), - ) as unknown as typeof fetch + const authorizations: string[] = [] + let mainQuotaCalls = 0 + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + mainQuotaCalls++ + expect(new Headers(init?.headers).get('authorization')).toBe( + 'Bearer sk-ant-oat01-main-access', + ) + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 12 }, + seven_day: { utilization: 34 }, + }), + { status: 200 }, + ), + ) + } - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'sk-ant-oat01-main-access', - refresh: 'main-refresh', - expires: Date.now() + 100000, - }), - { models: {} }, - ) + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + return Promise.resolve(new Response(null, { status: 200 })) + }, + ), + ) as unknown as typeof fetch - await result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ - model: 'claude-opus-4-8', - messages: [{ role: 'user', content: 'hello' }], - }), - }) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'sk-ant-oat01-main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) - const state = await waitForSidebarState( - (candidate) => - candidate.activeId === 'fallback-1' && - candidate.main.quota?.five_hour?.usedPercent === 6, + await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-opus-4-8', + messages: [{ role: 'user', content: 'hello' }], + }), + }) + + const state = await waitForSidebarState( + (candidate) => + candidate.activeId === 'fallback-1' && + (bound + ? candidate.main.quota?.five_hour?.usedPercent === 6 + : candidate.main.quota === null), + ) + expect(state.route).toBe('fallback-first') + if (bound) expect(state.main.quota?.seven_day?.usedPercent).toBe(75) + else expect(state.main.quota).toBeNull() + expect(mainQuotaCalls).toBe(0) + expect(authorizations[0]).toBe('Bearer sk-ant-oat01-fallback-access') + }, ) - expect(state.route).toBe('fallback-first') - expect(state.main.quota?.seven_day?.usedPercent).toBe(75) - expect(mainQuotaCalls).toBe(0) - expect(authorizations[0]).toBe('Bearer sk-ant-oat01-fallback-access') - }) + } test('fetch wrapper sets OAuth headers and prefixes tools', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] }), { @@ -12890,77 +12924,270 @@ describe('auth.loader', () => { ) }) - test('sticky 401 retry refuses a foreign-provider tombstone before the token endpoint', async () => { + for (const duringRefresh of [false, true]) { + test( + duringRefresh + ? 'sticky 401 retry persists consumed successor but refuses it after host activation changes' + : 'sticky 401 retry refuses a foreign-provider tombstone before the token endpoint', + async () => { + const checkedAt = Date.now() + const login = { + access: 'sk-ant-oat01-live-main-access', + refresh: 'live-main-refresh', + expires: checkedAt + 8 * 60 * 60_000, + } + await useTempAccountFile( + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: { + checkedAt, + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt, + }, + }, + mainQuotaCheckedAt: checkedAt, + }, + }), + login.access, + ), + login, + ) + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const hostAuthPath = pool.hostAuthPath + const messageAuthorizations: string[] = [] + const tokenEndpointCalls: string[] = [] + const successor = { + access: 'sk-ant-oat01-consumed-revoked', + refresh: 'consumed-revoked-refresh', + } + mainAccountIssues(successor.access) + async function revokeActivation() { + const authFile = JSON.parse(await readFile(hostAuthPath, 'utf8')) + authFile.anthropic = { + ...custodyTombstoneOAuth('openai'), + access: 'claustrum-tombstone:v1:openai', + expires: 0, + } + await writeFile(hostAuthPath, JSON.stringify(authFile), { + mode: 0o600, + }) + } + globalThis.fetch = mock( + withNativeBootstrap( + async (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url === TOKEN_URL) { + tokenEndpointCalls.push(url) + if (duringRefresh) { + await revokeActivation() + return Response.json({ + access_token: successor.access, + refresh_token: successor.refresh, + expires_in: 8 * 60 * 60, + }) + } + return Promise.resolve( + new Response('unexpected', { status: 200 }), + ) + } + if (url.includes('/v1/messages')) { + messageAuthorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + if (!duringRefresh) await revokeActivation() + return new Response('unauthorized', { status: 401 }) + } + return Promise.resolve(new Response('{}', { status: 200 })) + }, + ), + ) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => Promise.resolve({ type: 'oauth', ...login }), + { models: {} }, + ) + + let refusal: unknown + try { + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'tombstone-sticky-401' }, + body: JSON.stringify({ + model: 'claude-opus-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + } catch (error) { + refusal = error + } + expect(messageAuthorizations).toEqual([ + 'Bearer sk-ant-oat01-live-main-access', + ]) + expect(tokenEndpointCalls).toEqual(duringRefresh ? [TOKEN_URL] : []) + if (duringRefresh) { + // The provider already issued replacement credentials, so losing + // them could leave only an invalid old refresh token. Removing + // OpenCode's activation blocks model sends, not credential storage. + expect( + (await waitForPoolCredential('main', successor.access)).credential + .refresh, + ).toBe(successor.refresh) + } + expect( + (await readNativeRuntimeState()).main?.lastRefreshError, + ).toBeUndefined() + expect(refusal).toBeInstanceOf(Error) + expect( + refusal instanceof Error ? refusal.message : undefined, + ).toContain('Native OAuth requires inert OpenCode activation') + await plugin.dispose?.() + }, + ) + } + + test('physical OAuth authorization refuses host revocation during fresh token validation', async () => { const checkedAt = Date.now() + const login = { + access: 'sk-ant-oat01-validation-initial', + refresh: 'validation-refresh', + expires: checkedAt + 8 * 60 * 60_000, + } + const successorAccess = 'sk-ant-oat01-validation-rotated' await useTempAccountFile( - createFallbackStorage({ - accounts: [], - routing: { mode: 'sticky-balanced' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 1, seven_day: 1 }, - failClosedOnUnknownQuota: true, - mainQuota: { - checkedAt, - five_hour: { usedPercent: 10, remainingPercent: 90, checkedAt }, - seven_day: { usedPercent: 10, remainingPercent: 90, checkedAt }, + bindMainQuotaToAccount( + createFallbackStorage({ + accounts: [], + routing: { mode: 'sticky-balanced' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + failClosedOnUnknownQuota: true, + mainQuota: { + checkedAt, + five_hour: { usedPercent: 10, remainingPercent: 90, checkedAt }, + seven_day: { usedPercent: 10, remainingPercent: 90, checkedAt }, + }, + mainQuotaCheckedAt: checkedAt, }, - mainQuotaCheckedAt: checkedAt, - mainQuotaToken: tokenFingerprint('sk-ant-oat01-live-main-access'), - }, - }), + }), + login.access, + ), + login, ) - let currentAuth: Record = { - type: 'oauth', - access: 'sk-ant-oat01-live-main-access', - refresh: 'live-main-refresh', - expires: checkedAt + 8 * 60 * 60_000, - } - const messageAuthorizations: string[] = [] - const tokenEndpointCalls: string[] = [] - globalThis.fetch = mock((input: unknown, init?: RequestInit) => { - const url = extractUrl(input as string | URL | Request) - if (url === TOKEN_URL) { - tokenEndpointCalls.push(url) - return Promise.resolve(new Response('unexpected', { status: 200 })) - } - if (url.includes('/v1/messages')) { - messageAuthorizations.push( - new Headers(init?.headers).get('authorization') ?? '', - ) - currentAuth = { - ...custodyTombstoneOAuth('openai'), - access: 'claustrum-tombstone:v1:openai', - expires: 0, + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const store = createNativePoolStore({ + paths: pool.paths, + quota: nativeQuotaCodec, + }) + let initialBootstrapDone = false + let rotated = false + const bootstraps: Array = [] + const modelAuthorizations: Array = [] + let tokenCalls = 0 + globalThis.fetch = Object.assign( + mock(async (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + const bearer = new Headers(init?.headers).get('authorization') + if (url.includes('/api/claude_cli/bootstrap')) { + bootstraps.push(bearer) + if (bearer === `Bearer ${login.access}`) initialBootstrapDone = true + else if (bearer === `Bearer ${successorAccess}`) { + const authFile = JSON.parse( + await readFile(pool.hostAuthPath, 'utf8'), + ) + authFile.anthropic = { + ...custodyTombstoneOAuth('openai'), + access: 'claustrum-tombstone:v1:openai', + expires: 0, + } + await writeFile(pool.hostAuthPath, JSON.stringify(authFile), { + mode: 0o600, + }) + } else throw new Error('Unexpected bootstrap credential') + return Response.json({ + oauth_account: { account_uuid: syntheticMainAccountUuid }, + }) } - return Promise.resolve(new Response('unauthorized', { status: 401 })) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch - + if (url === TOKEN_URL) tokenCalls++ + if (url.includes('/v1/messages')) modelAuthorizations.push(bearer) + return Response.json({}) + }), + { preconnect() {} }, + ) const plugin = await getPlugin() const result = await plugin.auth.loader( - () => Promise.resolve(currentAuth as never), + async () => { + if (initialBootstrapDone && !rotated) { + rotated = true + const settings = await store.readSettings() + if ( + settings.status !== 'ready' || + typeof settings.settings.mainAccountId !== 'string' + ) + throw new Error('Expected primary row') + // Simulate another process rotating the pool token after initial + // validation. The new token's bootstrap mock then replaces OpenCode's + // activation slot before that authorization returns. + await store.rotate(settings.settings.mainAccountId, { + type: 'oauth', + access: successorAccess, + refresh: login.refresh, + expires: login.expires + 1000, + }) + } + return { type: 'oauth', ...login } + }, { models: {} }, ) - - await expect( - result.fetch(MESSAGES_URL, { + let refusal: unknown + try { + await result.fetch(MESSAGES_URL, { method: 'POST', - headers: { 'x-session-affinity': 'tombstone-sticky-401' }, + headers: { 'x-session-affinity': 'revoked-token-validation' }, body: JSON.stringify({ model: 'claude-opus-5', max_tokens: 1, messages: [{ role: 'user', content: 'hello' }], }), - }), - ).rejects.toBeInstanceOf(CustodyTombstoneRefreshError) - expect(messageAuthorizations).toEqual([ - 'Bearer sk-ant-oat01-live-main-access', + }) + } catch (error) { + refusal = error + } + expect(rotated).toBe(true) + expect(bootstraps).toEqual([ + `Bearer ${login.access}`, + `Bearer ${successorAccess}`, ]) - expect(tokenEndpointCalls).toEqual([]) - await plugin.dispose?.() + expect(modelAuthorizations).toEqual([]) + expect(tokenCalls).toBe(0) + expect(refusal instanceof Error ? refusal.message : undefined).toContain( + 'Native OAuth requires inert OpenCode activation', + ) + expect( + (await waitForPoolCredential('main', successorAccess)).credential.refresh, + ).toBe(login.refresh) + expect( + (await readNativeRuntimeState()).main?.lastRefreshError, + ).toBeUndefined() }) test('sticky 401 retries with a concurrently rotated main access token', async () => { From 9196f72336c81b1c99738164eb56f018fb4cccd0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:13:29 +0200 Subject: [PATCH 127/169] fix(opencode): enforce fallback quota preflight without credential display reads --- docs/testing/mutations.md | 4 +- mutations.toml | 38 +- packages/opencode/src/index.ts | 106 +++-- packages/opencode/src/tests/index.test.ts | 464 +++++++++++++--------- 4 files changed, 365 insertions(+), 247 deletions(-) diff --git a/docs/testing/mutations.md b/docs/testing/mutations.md index 8fb3eb83a..ff72013ac 100644 --- a/docs/testing/mutations.md +++ b/docs/testing/mutations.md @@ -1,6 +1,6 @@ # Mutation proofs for request guards -`mutations.toml` records sixteen edits, each replacing one specified source block +`mutations.toml` records eighteen edits, each replacing one specified source block with a broken safeguard. Named test assertions must detect each break. Run only from an isolated clone or worktree. `ckdev-mutate` is the mutation test @@ -53,6 +53,8 @@ sibling repositories. | `refresh-backoff-preserves-attempt-budget` | Start transient backoff after the first attempt. | A transient failure must leave the coordinator's remaining attempts available; 429 and permanent failures still back off immediately. | | `native-activation-before-401-refresh` | Remove the activation check before transport reauthorization. | A revoked native host activation must trigger no token exchange. | | `native-activation-after-authorization` | Remove the activation check after transport authorization. | An activation revoked during fresh token validation must prevent model dispatch. | +| `fallback-killswitch-polls-before-selection` | Skip the due-quota check before fallback-first selection. | A fallback below its hard limit must not serve. | +| `fallback-quota-sweeps-do-not-overlap` | Run startup and request quota sweeps concurrently. | Two requests reuse one current poll per account. | | `runtime-read-no-follow` | Follow a symlink installed between inspection and opening. | The runtime reader must reject the symlink. | | `runtime-read-opened-mode` | Ignore the opened file's permissions. | A replacement with mode 0644 must be rejected. | | `runtime-read-opened-owner` | Ignore the opened file's owner. | A foreign owner on the opened handle must be rejected. | diff --git a/mutations.toml b/mutations.toml index 3a6784947..a7881da38 100644 --- a/mutations.toml +++ b/mutations.toml @@ -248,7 +248,6 @@ test_file = "packages/opencode/src/tests/index.test.ts" runner = "command" command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] test_count_pattern = "CKDEV executed {count} tests" -prebuild = ["bun", "run", "--cwd", "packages/core", "build"] expect_red = ["auth.loader > sticky 401 retry refuses a foreign-provider tombstone before the token endpoint"] expect_message = "error: expect(received).toEqual(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] @@ -270,9 +269,44 @@ test_file = "packages/opencode/src/tests/index.test.ts" runner = "command" command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] test_count_pattern = "CKDEV executed {count} tests" -prebuild = ["bun", "run", "--cwd", "packages/core", "build"] expect_red = ["auth.loader > physical OAuth authorization refuses host revocation during fresh token validation"] expect_message = "error: expect(received).toEqual(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] broad_report = "tmp/mutations/opencode.xml" broad_id = "{name}" + +[[control]] +id = "fallback-killswitch-polls-before-selection" +guards = "Fallback-first routing must learn due quota before allowing an account to bypass its killswitch." +file = "packages/opencode/src/index.ts" +old = ''' await refreshFallbackQuotas(false, true) + // Polling can also bind''' +new = ''' await Promise.resolve() + // Polling can also bind''' +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["killswitch fetch gate > fallback-first routing does not serve from a killswitch-killed fallback"] +expect_message = "error: expect(received).toContain(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" + +[[control]] +id = "fallback-quota-sweeps-do-not-overlap" +guards = "Startup and request quota sweeps must not independently decide to poll the same still-unknown account." +file = "packages/opencode/src/index.ts" +old = ''' const operation = fallbackQuotaRefreshQueue.then(() => + pollFallbackQuotas(force, requiredByPolicy), + )''' +new = ''' const operation = pollFallbackQuotas(force, requiredByPolicy)''' +test_file = "packages/opencode/src/tests/index.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/index.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["killswitch fetch gate > fallback-first routing does not serve from a killswitch-killed fallback"] +expect_message = "error: expect(received).toEqual(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c2aded432..50f6d14a6 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1102,9 +1102,6 @@ type PluginRuntimeOverrides = Partial<{ cacheKeepAggregateRefreshIntervalMs: number }> -// Keep boot above the resident IPC fast path, but never let a stale-marked -// refresh turn a vault treadmill into a seconds-long plugin-start delay. - function getConfiguredClaustrumConnectionFile(): string | undefined { const configured = process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE?.trim() || @@ -2356,7 +2353,7 @@ const anthropicAuthPlugin = async ( ): Promise { if (!quotaHeaderFeedRegistry) return if (served.accountId === 'main' && !served.mainQuotaIdentity?.quotaKey) { - // Observed-at staleness exposes silence; an unknown key must not replace verified data. + // An unresolved main account must not overwrite verified account-bound quota. return } const storage = @@ -2587,9 +2584,25 @@ const anthropicAuthPlugin = async ( } } - async function refreshFallbackQuotas(force = false) { + let fallbackQuotaRefreshQueue: Promise = Promise.resolve() + function refreshFallbackQuotas(force = false, requiredByPolicy = false) { + // Startup and request preflight can both observe missing quota before + // either poll finishes. Serialize the read/check/poll sequence so the + // next sweep sees the preceding result. Explicit refresh still polls. + const operation = fallbackQuotaRefreshQueue.then(() => + pollFallbackQuotas(force, requiredByPolicy), + ) + fallbackQuotaRefreshQueue = operation.catch(() => {}) + return operation + } + async function pollFallbackQuotas(force: boolean, requiredByPolicy: boolean) { const snapshot = await nativeAccounts.read() - if (!force && snapshot.policyStorage.quota?.enabled === false) return + if ( + !force && + !requiredByPolicy && + snapshot.policyStorage.quota?.enabled === false + ) + return for (const account of snapshot.accounts) { if (account.id === 'main' || account.type !== 'oauth' || !account.enabled) continue @@ -2616,6 +2629,9 @@ const anthropicAuthPlugin = async ( // Keep any separate refresh-token rejection; a usage poll cannot fix it. } } + // Poll results and their backoff are already stored. Publish them without + // waiting for sidebar I/O or changing the active request's route. + void refreshSidebarQuota().catch(() => {}) } const fallbackManager = { refreshQuotaForDueAccounts: () => refreshFallbackQuotas(), @@ -3877,24 +3893,15 @@ const anthropicAuthPlugin = async ( ) } - // Re-write the sidebar using the LAST known routing decision, refreshing only - // the quota numbers. Used by async quota refreshes (main + background fallback) - // so they never clobber the active account back to 'main'. async function resolveSidebarQuotaAccess() { + // Read saved account quota and profile data without requesting an OAuth + // token. Acquiring credentials can refresh main merely to redraw the sidebar. const storage = await loadAccounts(accountStoragePath) - let access: string | undefined - if (latestGetAuth) { - try { - const auth = await latestGetAuth() - access = getClaustrumMode(storage) === 'local' ? auth.access : undefined - } catch { - // best-effort - } - } - access ??= mainServedAccessToken - return { storage, access } + return { storage, access: undefined } } + // Quota timers do not choose the model request's account. Reuse the last + // routing decision so a display-only write cannot select main instead. async function refreshSidebarQuota() { // Rebuild the cross-process CacheKeep aggregate first: sibling plugin // instances (other project directories) may track sessions this instance @@ -4026,21 +4033,12 @@ const anthropicAuthPlugin = async ( async function refreshSidebarAfterMutation( updatedStorage: AccountStorage | null, ) { - if (latestGetAuth) { - try { - await getAllTrackedCacheKeepSessions().catch(() => {}) - const auth = await latestGetAuth() - await writeSidebarState(updatedStorage, { - activeId: lastSidebarRouting.activeId, - route: lastSidebarRouting.route, - mainAccessToken: auth.access, - mainRefreshToken: auth.refresh, - routingAuthoritative: false, - }) - } catch { - // auth not yet available — sidebar will refresh on next request - } - } + await getAllTrackedCacheKeepSessions().catch(() => {}) + await writeSidebarState(updatedStorage, { + activeId: lastSidebarRouting.activeId, + route: lastSidebarRouting.route, + routingAuthoritative: false, + }) } function publishFableRecoveryNotice( @@ -4528,11 +4526,8 @@ const anthropicAuthPlugin = async ( } } - // Publish the new prime section to the sidebar file (M7) so the - // expanded Prime row appears on `on` and disappears on `off` without - // waiting for the next quota refresh path. The post-mutation storage - // IS authoritative; we re-read it so the section payload reflects - // the freshly persisted flag. + // After toggling quota priming, update its sidebar status immediately. + // Read storage again so the display uses the newly saved enabled flag. if (action.type === 'enable' || action.type === 'disable') { const reloaded = await loadAccounts(accountStoragePath) if (reloaded && latestGetAuth) { @@ -5226,7 +5221,9 @@ const anthropicAuthPlugin = async ( throw new Error(`Unsupported native menu action: ${unsupported}`) } } - await refreshSidebarAfterMutation(await loadAccounts()) + // Account and setting changes are committed before this display update. + // A held sidebar writer must not delay the menu's action result. + void refreshSidebarAfterMutation(await loadAccounts()).catch(() => {}) return { ok: true, text: text @@ -7182,6 +7179,15 @@ const anthropicAuthPlugin = async ( storageArg: Awaited>, options: { includeApiRoutes?: boolean; modelId?: string } = {}, ): Promise> { + // Fallback-first can return before the main quota gate. Learn due + // fallback quotas before applying hard limits, without acquiring + // main credentials or polling accounts whose quotas are fresh. + if (isKillswitchEnabled(storageArg)) { + await refreshFallbackQuotas(false, true) + // Polling can also bind a newly discovered account UUID. Filter + // the published quotas, not the request's earlier account view. + storageArg = await loadAccounts(accountStoragePath) + } const usableOAuth = await fallbackManager.getUsableFallbackAccounts( storageArg, { modelId: options.modelId }, @@ -9106,25 +9112,15 @@ const anthropicAuthPlugin = async ( ) if (needsRefresh) { try { - const fallbackAccts = (storage?.accounts ?? []).filter( - (a): a is OAuthAccount => - a.enabled !== false && - isOAuthAccount(a) && - (isScopedCustodyActive(storage) - ? isFallbackAccountVaultServed(a.id, storage) - : Boolean(a.access)), - ) await Promise.all([ quotaManager.refreshMain( requestMainQuotaIdentity?.quotaKey, auth.access, requestMainQuotaIdentity?.generation, ), - quotaManager.refreshAllFallbacks( - fallbackAccts, - (account) => - isScopedCustodyActive(storage) ? '' : account.access, - ), + // Fallback-first may have just polled these accounts. + // Keep those fresh readings rather than polling twice. + refreshFallbackQuotas(false, true), ]) } catch (error) { log('[quota] killswitch refresh failed', { @@ -9224,7 +9220,7 @@ const anthropicAuthPlugin = async ( // Decide whether the block is scoped-driven (request's // model matches a scoped window that is at/below the scoped // threshold) vs a whole-account 5h/7d-driven block. A - // healthy Fable window + 5h/7d breach is NOT scoped-driven. + // healthy model-specific window + 5h/7d breach is NOT scoped-driven. const scoped = resolveScopedDrivenBlock({ mainQuota, requestModelId, diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index ac0f90cf0..327a5607a 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -967,13 +967,13 @@ async function nativeTokenAnswer( * answered here. */ function withNativeTokenExchange( - handler: (input: Input, init: Init) => unknown, -): (input: Input, init: Init) => Promise { + handler: (input: Input, init: Init) => Response | Promise, +): (input: Input, init: Init) => Promise { return async (input, init) => (await nativeTokenAnswer( input as Parameters[0], init as RequestInit | undefined, - )) ?? handler(input, init) + )) ?? (await handler(input, init)) } /** @@ -1048,8 +1048,8 @@ function withNativeAdmission( * Let the test's handler control these endpoints for its fallback accounts. */ function withMainAdmission( - handler: (input: Input, init: Init) => unknown, -): (input: Input, init: Init) => Promise { + handler: (input: Input, init: Init) => Response | Promise, +): (input: Input, init: Init) => Promise { return async (input, init) => { const main = poolLogins.slice(0, 1) const request = input as Parameters[0] @@ -1434,7 +1434,7 @@ function mainOnlyVault(): NativeCustodyClient { } /** - * Run setup migration for accounts whose credentials remain in Claustrum. + * Run setup migration for accounts whose credentials remain in the vault. * The fixture's enrollment token permits listing vault accounts for this host. * Plugin startup then reads the committed, credential-free account inventory. */ @@ -4758,7 +4758,7 @@ describe('quota header feed extended integration', () => { expect(await readFeedEntries()).toEqual([]) }) - test('sidebar quota refresh uses the rotated main host token', async () => { + test('sidebar quota refresh preserves rotated account metadata without acquiring credentials', async () => { await useTempAccountFile( createFallbackStorage({ accounts: [], @@ -4772,7 +4772,7 @@ describe('quota header feed extended integration', () => { }, ) const currentAccess = 'sk-ant-oat01-main-access-before-rotation' - globalThis.fetch = mock( + const fetchSpy = mock( withNativeTokenExchange((input: unknown, init?: RequestInit) => { const url = extractUrl(input as string | URL | Request) if (url.startsWith(MESSAGES_URL)) { @@ -4794,7 +4794,10 @@ describe('quota header feed extended integration', () => { } return Promise.reject(new Error(`Unexpected test fetch: ${url}`)) }), - ) as unknown as typeof fetch + ) + globalThis.fetch = Object.assign(fetchSpy, { + preconnect: globalThis.fetch.preconnect, + }) const plugin = await getPlugin() const result = await plugin.auth.loader( () => @@ -4818,14 +4821,27 @@ describe('quota header feed extended integration', () => { expires: Date.now() + 100_000, }) - const resolved = await ( - plugin as { - __resolveSidebarQuotaAccessForTest: () => Promise<{ - access: string | undefined - }> - } - ).__resolveSidebarQuotaAccessForTest() - expect(resolved.access).toBe('sk-ant-oat01-main-access-after-rotation') + const pool = migratedPool + if (!pool) throw new Error('Expected migrated pool') + const reader = createNativeAccountRuntime({ + paths: pool.paths, + host: 'opencode', + }) + bodyLifetime().deferCleanup(() => reader.close()) + expect( + (await reader.captureLocalSubject('main')).version?.accessFingerprint, + ).toBe(tokenFingerprint('sk-ant-oat01-main-access-after-rotation')) + await waitForAccountStorage( + (storage) => storage?.quota?.mainQuota?.five_hour?.usedPercent === 25, + ) + const requestsBeforeDisplay = fetchSpy.mock.calls.length + const resolved = await plugin.__resolveSidebarQuotaAccessForTest() + expect(resolved.access).toBeUndefined() + expect(resolved.storage?.quota?.mainQuota?.accountIdentity).toBe( + '44444444-4444-4444-4444-444444444444', + ) + expect(resolved.storage?.quota?.mainQuota?.five_hour?.usedPercent).toBe(25) + expect(fetchSpy.mock.calls.length).toBe(requestsBeforeDisplay) await plugin.dispose?.() }) @@ -7216,7 +7232,9 @@ describe('auth.loader', () => { clearExistingRouting: boolean, ) { await useTempAccountFile( - createFallbackStorage({ routing: { mode: 'fallback-first' } }), + bindPoolAccounts( + createFallbackStorage({ routing: { mode: 'fallback-first' } }), + ), ) globalThis.fetch = mock( withNativeAdmission((input: string | URL | Request) => { @@ -7235,8 +7253,8 @@ describe('auth.loader', () => { }), ) as unknown as typeof fetch - const plugin = await getPlugin() - await plugin.auth.loader( + const plugin = await getPlugin(createMockClient(), tempConfigDir) + const loaded = await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', @@ -7246,7 +7264,11 @@ describe('auth.loader', () => { }), { models: {} }, ) + // Establish the plugin's actual route before testing a quota-only write. + const response = await loaded.fetch(MESSAGES_URL, EMPTY_POST) + expect(response.status).toBe(200) await drainSidebarWrites() + expect((await getSidebarState()).activeId).toBe('fallback-1') if (clearExistingRouting) { const current = await getSidebarState() await setSidebarState({ @@ -7280,13 +7302,11 @@ describe('auth.loader', () => { const blockingWrite = setSidebarState(await getSidebarState()) await blocked try { - await expectHandledCommandResponse( - plugin['command.execute.before']({ - command: 'claude', - arguments: '', - sessionID: 'session-1', - }), - ) + const refreshed = await applyMenuAction(plugin, 'session-1', { + sectionId: 'Quota', + actionId: 'quota-refresh', + }) + expect(refreshed.ok).toBe(true) // Replace the shared pool config JSON file with a directory so later // account reads fail instead of returning an outdated snapshot. const accountFile = migratedPool?.paths.config @@ -10111,8 +10131,8 @@ describe('auth.loader', () => { } const locked = bodyLifetime().gate() const release = bodyLifetime().gate() - // Block the actual native profile destination, not the retired config - // file. Credential validation completed before acquiring this writer. + // Hold the profile's runtime-file lock after credential validation. + // Status must display the fetched tier without waiting for this writer. const writer = withLock( pool.paths.runtime, { @@ -12850,11 +12870,9 @@ describe('auth.loader', () => { ) mainAccountIssues('sk-ant-oat01-new-access') let tokenRefreshCount = 0 - // The first plugin's token exchange is held open, so the second plugin's - // request arrives while that refresh is still in progress. OpenCode's - // auth store no longer receives refreshed tokens (the pool persists - // them), so the delayed persistence the legacy test held is now the - // in-flight exchange itself. + // Hold the first plugin's exchange open while the second requests + // credentials. Both must join one refresh, and only the pool stores + // the returned tokens; OpenCode's activation slot stays unchanged. const tokenRequestStarted = lifetimeSignal() const tokenResponse = heldResponse() @@ -14028,7 +14046,7 @@ describe('auth.loader', () => { }) test('successful fallback-first request advances the every-N counter and refreshes the served fallback', async () => { - // Regression: the request counter must increment before the fallback-first + // The request counter must increment before the fallback-first // early return, so a served fallback's active-route every-N refresh fires. await useTempAccountFile( createFallbackStorage({ @@ -14585,7 +14603,7 @@ describe('auth.loader', () => { ) expect(state.route).toBe('fallback') - // REGRESSION: pre-fix the async callback rewrites activeId to 'main'. + // The callback must preserve the active fallback instead of selecting main. const after = await getSidebarState() expect(after.activeId).toBe('fallback-1') }) @@ -16404,7 +16422,7 @@ describe('auth.loader', () => { }, }) - // Reproduce the host race from issue #162: an idle probe starts, then a new + // Reproduce the host race: an idle probe starts, then a new // prompt marks the session busy before the asynchronous status response // arrives with its now-stale idle snapshot. The notice must remain queued; // otherwise OpenCode can adopt that ignored user message as the active retry @@ -16991,7 +17009,7 @@ describe('auth.loader', () => { }), } - // First request hits the refusal. After the fix, onContentFilter fires + // First request hits the refusal. onContentFilter fires // causing the stream to reject with a ContentFilterError. const filtered = await result.fetch(MESSAGES_URL, request) await expect(filtered.text()).rejects.toThrow() @@ -17083,39 +17101,16 @@ describe('auth.loader', () => { test('warms Fable with the OAuth account that was filtered when Opus routes elsewhere', async () => { const now = Date.now() await useTempAccountFile( - createFallbackStorage({ - claudeCache: { enabled: true, mode: 'hybrid' }, - cacheKeep: { enabled: false }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: true, - mainQuota: { - five_hour: { usedPercent: 0, remainingPercent: 100 }, - seven_day: { usedPercent: 0, remainingPercent: 100 }, - scoped: [ - { - id: 'claude-weekly-scoped-fable', - title: 'Fable only', - modelName: 'Fable', - usedPercent: 100, - remainingPercent: 0, - checkedAt: now, - }, - ], - }, - mainQuotaCheckedAt: now, - mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), - } as AccountStorage['quota'], - accounts: [ - { - id: 'fable-fallback', - type: 'oauth', - access: 'sk-ant-oat01-fallback-access', - refresh: 'fallback-refresh', - expires: now + 5 * 60 * 60 * 1000, - quota: { + bindPoolAccounts( + createFallbackStorage({ + claudeCache: { enabled: true, mode: 'hybrid' }, + cacheKeep: { enabled: false }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { five_hour: { usedPercent: 0, remainingPercent: 100, @@ -17126,20 +17121,54 @@ describe('auth.loader', () => { remainingPercent: 100, checkedAt: now, }, + checkedAt: now, scoped: [ { id: 'claude-weekly-scoped-fable', title: 'Fable only', modelName: 'Fable', - usedPercent: 25, - remainingPercent: 75, + usedPercent: 100, + remainingPercent: 0, checkedAt: now, }, ], }, - }, - ], - }), + mainQuotaCheckedAt: now, + mainQuotaToken: tokenFingerprint('sk-ant-oat01-main-access'), + } as AccountStorage['quota'], + accounts: [ + { + id: 'fable-fallback', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: now + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: now, + }, + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: now, + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 25, + remainingPercent: 75, + checkedAt: now, + }, + ], + }, + }, + ], + }), + ), ) const calls: Array<{ model: string; auth: string; warm: boolean }> = [] let firstFable = true @@ -17148,34 +17177,38 @@ describe('auth.loader', () => { const refusal = 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"refusal"}}\n\n' - 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 }, - ), - ) - } - const body = JSON.parse(String(init?.body)) as { - model: string - max_tokens?: number - } - const auth = new Headers(init?.headers).get('authorization') ?? '' - calls.push({ model: body.model, auth, warm: body.max_tokens === 0 }) - if (body.max_tokens === 0) { - return Promise.resolve(new Response('{}', { status: 200 })) - } - if (body.model === 'claude-fable-5' && firstFable) { - firstFable = false - return Promise.resolve(new Response(refusal, { status: 200 })) - } - return Promise.resolve(new Response(success, { status: 200 })) - }) as unknown as typeof fetch + globalThis.fetch = mock( + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + 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 }, + ), + ) + } + const body = JSON.parse(String(init?.body)) as { + model: string + max_tokens?: number + } + const auth = new Headers(init?.headers).get('authorization') ?? '' + calls.push({ model: body.model, auth, warm: body.max_tokens === 0 }) + if (body.max_tokens === 0) { + return Promise.resolve(new Response('{}', { status: 200 })) + } + if (body.model === 'claude-fable-5' && firstFable) { + firstFable = false + return Promise.resolve(new Response(refusal, { status: 200 })) + } + return Promise.resolve(new Response(success, { status: 200 })) + }, + ), + ) as unknown as typeof fetch const plugin = await getPlugin() const result = await plugin.auth.loader( @@ -17230,49 +17263,86 @@ describe('auth.loader', () => { ]) }) - test('background fallback refresh updates the sidebar without a request', async () => { + test('sidebar refresh reads metadata without authorizing expired main credentials', async () => { + const main = { ...syntheticMainHostAuth(), expires: Date.now() - 1_000 } await useTempAccountFile( createFallbackStorage({ - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - access: 'sk-ant-oat01-fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - quota: { - // Stale (old checkedAt) → background pass will refresh it. - five_hour: { - usedPercent: 0, - remainingPercent: 100, - checkedAt: 1, - }, - seven_day: { - usedPercent: 0, - remainingPercent: 100, - checkedAt: 1, + accounts: [], + quota: { enabled: false }, + refresh: { enabled: false }, + }), + main, + ) + const requests: string[] = [] + globalThis.fetch = Object.assign( + mock(async (input: Parameters[0], init?: RequestInit) => { + requests.push(extractUrl(input)) + return (await nativeAdmissionAnswer(input, init)) ?? Response.json({}) + }), + { preconnect: globalThis.fetch.preconnect }, + ) + const plugin = await getPlugin() + await plugin.auth.loader( + () => Promise.resolve({ type: 'oauth', ...main }), + { models: {} }, + ) + const display = await plugin.__resolveSidebarQuotaAccessForTest() + expect(display.storage?.quota?.enabled).toBe(false) + expect(display.access).toBeUndefined() + expect(requests).toEqual([]) + }) + + test('background fallback refresh updates the sidebar without a request', async () => { + await useTempAccountFile( + bindPoolAccounts( + createFallbackStorage({ + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + // Plugin startup polls this OAuth account's usage because its saved reading is old. + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, }, }, - }, - ], - }), + ], + }), + ), ) + const usageAuthorizations: Array = [] globalThis.fetch = mock( - withNativeAdmission((input: any) => { - if (extractUrl(input).includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0.42 }, - seven_day: { utilization: 0.1 }, - }), - { status: 200 }, - ), - ) - } - return Promise.resolve(new Response('ok', { status: 200 })) - }), + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + usageAuthorizations.push( + new Headers(init?.headers).get('authorization'), + ) + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.42 }, + seven_day: { utilization: 0.1 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('ok', { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -17288,6 +17358,12 @@ describe('auth.loader', () => { { models: {} }, ) + await plugin.__fallbackRefreshReady + expect(usageAuthorizations).toContain('Bearer sk-ant-oat01-fallback-access') + expect( + (await readNativeRuntimeState()).accounts['fallback-1']?.quota?.five_hour + ?.usedPercent, + ).toBe(0.42) // The background pass refreshes the stale fallback and the hook re-writes the // sidebar — without any request to the messages endpoint. // utilization: 0.42 → usedPercent: 0.42 (stored as-is, not multiplied by 100) @@ -17699,13 +17775,11 @@ describe('auth.loader', () => { ) } - // Harvested quota is published to the native pool; the legacy state file - // these tests once read is retired by migration. + // Read the account runtime state that stores response-header quota. async function waitForState(predicate: (state: any) => boolean) { let lastState: unknown for (let attempt = 0; attempt < 200; attempt++) { - // Typed loosely, as the parsed legacy file was: each test asserts the - // fields it expects to be present. + // Each test checks its required fields in the persisted projection. const state: any = await readNativeRuntimeState() lastState = state if (predicate(state)) return state @@ -17732,8 +17806,7 @@ describe('auth.loader', () => { (value) => value.main?.quota?.source === 'headers', ) expect(state.main.quota.five_hour.usedPercent).toBe(78) - // Header quota is bound to the main account, never to the access token - // that happened to carry it (the legacy store's quotaToken). + // Header quota belongs to the account UUID, not its rotating token. expect(state.main.quota.accountIdentity).toBe(poolMainIdentity()) }) @@ -17873,8 +17946,7 @@ describe('auth.loader', () => { (value) => value.main?.quota?.source === 'headers', ) expect(state.main.quota.five_hour.usedPercent).toBe(78) - // Header quota is bound to the main account, never to the access token - // that happened to carry it (the legacy store's quotaToken). + // Header quota belongs to the account UUID, not its rotating token. expect(state.main.quota.accountIdentity).toBe(poolMainIdentity()) }) @@ -20048,10 +20120,12 @@ describe('killswitch fetch gate', () => { } }) - afterEach(() => { - globalThis.fetch = originalFetch - pluginRuntimeOverrides = {} - delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + afterEach(async () => { + await drainBeforeReset(testLifetime, () => { + globalThis.fetch = originalFetch + pluginRuntimeOverrides = {} + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + }) }) const oauthLoader = () => @@ -20254,28 +20328,34 @@ describe('killswitch fetch gate', () => { ) let servedAuth: string | undefined + const quotaAuthorizations: string[] = [] + const modelAuthorizations: string[] = [] globalThis.fetch = mock( - withNativeAdmission((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - const authorization = - new Headers(init?.headers).get('authorization') ?? '' - const isMain = authorization.includes('sk-ant-oat01-main-access') - // Main has 80% remaining and can serve. The fallback's 30% passes - // the 10% routing minimum but fails its 50% hard-block threshold. - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: isMain ? 20 : 70 }, - seven_day: { utilization: isMain ? 20 : 50 }, - }), - { status: 200 }, - ), - ) - } - servedAuth = new Headers(init?.headers).get('authorization') ?? '' - return Promise.resolve(new Response('message-ok', { status: 200 })) - }), + withNativeAdmission( + (input: Parameters[0], init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + quotaAuthorizations.push(authorization) + const isMain = authorization.includes('sk-ant-oat01-main-access') + // Main has 80% remaining and can serve. The fallback's 30% passes + // the 10% routing minimum but fails its 50% hard-block threshold. + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: isMain ? 20 : 70 }, + seven_day: { utilization: isMain ? 20 : 50 }, + }), + { status: 200 }, + ), + ) + } + servedAuth = new Headers(init?.headers).get('authorization') ?? '' + modelAuthorizations.push(servedAuth) + return Promise.resolve(new Response('message-ok', { status: 200 })) + }, + ), ) as unknown as typeof fetch const plugin = await getPlugin() @@ -20285,6 +20365,16 @@ describe('killswitch fetch gate', () => { expect(response.status).toBe(200) expect(servedAuth).toContain('sk-ant-oat01-main-access') expect(servedAuth).not.toContain('sk-ant-oat01-fallback-access') + const again = await result.fetch(MESSAGES_URL, EMPTY_POST) + expect(again.status).toBe(200) + expect(modelAuthorizations).toEqual([ + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-main-access', + ]) + expect(quotaAuthorizations).toEqual([ + 'Bearer sk-ant-oat01-fallback-access', + 'Bearer sk-ant-oat01-main-access', + ]) }) test('fail-closed killswitch blocks the first request when main quota is unknown', async () => { @@ -21595,8 +21685,8 @@ describe('claude-prime sidebar on toggle', () => { }), { models: {} }, ) - // Prime a baseline sidebar write by issuing `/claude-prime on` (the - // mutation path publishes the sidebar section per M7). + // Enable priming and confirm its sidebar row exists before disabling it. + // Otherwise an absent row after disable could make this test pass vacuously. expect( ( await applyMenuAction(plugin, 'ses_test', { @@ -21624,10 +21714,9 @@ describe('claude-prime sidebar on toggle', () => { }) describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { - // R1: only snapshots stamped during the current refresh call are fresh. - // R2: refreshPrimeFallbackQuota must make exactly ONE usage-API call - // (the refreshAccountQuota path), not two. The second quotaManager. - // refreshFallback call is redundant — its result is ignored. + // Before starting an account's five-hour window, PrimeManager must obtain + // its quota from one current usage request. Cached quota cannot authorize + // that request, and the fallback quota check must not poll twice. const originalFetch = globalThis.fetch @@ -21791,11 +21880,9 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { test('R2: refreshPrimeFallbackQuota makes exactly one usage-API call per tick', async () => { const now = Date.now() - 60_000 const past = now - 120_000 - // Each test runs from a fresh temp dir but the prime marker dir is - // shared across processes. Sweep any leftover marker for this reset - // epoch so a prior suite's fire doesn't suppress the R2 claim AND - // doesn't trigger the manager's `refreshPrimeFallbackQuota` twice - // (once for the cached account, once for the due account). + // A marker records that an account's five-hour window was already started. + // Remove markers from this sandbox so a previous run cannot suppress + // this test's request or make it check a different reset window. await rm(join(tmpdir(), 'opencode-anthropic-auth', 'prime'), { recursive: true, force: true, @@ -22104,11 +22191,10 @@ async function primeWarningsAfterQuotaCheck( } describe('claude-prime — warn dedup (R3)', () => { - // R3: on a fresh-check-ok-but-fire-time-token-refresh-fails path, - // both the adapter-side `prime fire failed` warn (index.ts main - // catch) and the manager-side warn emit the same message. Only the - // manager should log — the adapter must surface the error to the - // manager as a non-ok result and not log itself. + // PrimeManager sends a minimal model request to start a five-hour window. + // If refreshing its token fails after quota checking, the adapter must + // return that failure to the manager. Only the manager logs the warning, + // so one failed request produces one warning rather than duplicate alerts. const originalFetch = globalThis.fetch From 5eb4d9fb4d1e83775cedbb34e0ece0e433eccead Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:37:49 +0200 Subject: [PATCH 128/169] fix(opencode): strip host session metadata from model transport --- packages/opencode/src/index.ts | 4 + packages/opencode/src/tests/index.test.ts | 311 ++++++++++++---------- 2 files changed, 173 insertions(+), 142 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 50f6d14a6..d18504721 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -6335,6 +6335,8 @@ const anthropicAuthPlugin = async ( requestHeaders.delete('x-parent-session-id') requestHeaders.delete('x-session-affinity') requestHeaders.delete('x-opencode-session') + requestHeaders.delete('x-opencode-session-id') + requestHeaders.delete('x-opencode-parent-session-id') requestHeaders.delete(EFFORT_PLAN_REQUEST_HEADER) requestHeaders.delete(BILLING_LINEAGE_REQUEST_HEADER) let body = await fetchBody(input, init) @@ -6511,6 +6513,8 @@ const anthropicAuthPlugin = async ( requestHeaders.delete('x-parent-session-id') requestHeaders.delete('x-session-affinity') requestHeaders.delete('x-opencode-session') + requestHeaders.delete('x-opencode-session-id') + requestHeaders.delete('x-opencode-parent-session-id') requestHeaders.delete(EFFORT_PLAN_REQUEST_HEADER) requestHeaders.delete(BILLING_LINEAGE_REQUEST_HEADER) let body = await fetchBody(input, init) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 327a5607a..a3025e30b 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -9231,163 +9231,190 @@ describe('auth.loader', () => { ) } - test('fetch wrapper sets OAuth headers and prefixes tools', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] }), { - access: 'sk-ant-oat01-my-access-token', - refresh: 'refresh', - expires: Date.now() + 100000, - }) - - let capturedHeaders: Headers | undefined - let capturedBody: string | undefined + test.each(['url', 'request'] as const)( + 'fetch wrapper sets OAuth headers and prefixes tools for %s input', + async (kind) => { + await useTempAccountFile(createFallbackStorage({ accounts: [] }), { + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }) - globalThis.fetch = mock( - withNativeAdmission((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 - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'sk-ant-oat01-my-access-token', - refresh: 'refresh', - expires: Date.now() + 100000, + globalThis.fetch = mock( + withNativeAdmission((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 })) }), - { models: {} }, - ) - - const body = JSON.stringify({ - tools: [{ name: 'bash', type: 'function' }], - messages: [{ role: 'user', content: 'hello world test message' }], - system: 'You are a helpful assistant.', - }) - - await result.fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { 'x-session-affinity': 'session-abc' }, - body, - }) + ) as unknown as typeof fetch - expect(capturedHeaders).toBeDefined() - expect(capturedHeaders!.get('authorization')).toBe( - 'Bearer sk-ant-oat01-my-access-token', - ) - expect(capturedHeaders!.get('x-api-key')).toBeNull() - expect(capturedHeaders!.get('x-session-affinity')).toBeNull() - expect(capturedHeaders!.get('x-opencode-session')).toBeNull() - expect(capturedHeaders!.get('anthropic-beta')).toContain('oauth-2025-04-20') + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) - const parsedBody = JSON.parse(capturedBody!) - // Tool name should be prefixed - expect(parsedBody.tools[0].name).toBe('mcp_Bash') - // Three-block layout: billing header, identity, rest - expect(parsedBody.system).toHaveLength(3) - expect(parsedBody.system[0].text).toContain('x-anthropic-billing-header') - expect(parsedBody.system[1].text).toBe( - "You are Claude Code, Anthropic's official CLI for Claude.", - ) - expect(parsedBody.system[2].text).toBe('You are a helpful assistant.') - // User message is untouched - expect(parsedBody.messages[0].content).toBe('hello world test message') - }) + const body = JSON.stringify({ + tools: [{ name: 'bash', type: 'function' }], + messages: [{ role: 'user', content: 'hello world test message' }], + system: 'You are a helpful assistant.', + }) - test('uses configured relay instead of uploading full body directly', async () => { - await useTempAccountFile( - createFallbackStorage({ - accounts: [], - relay: { - enabled: true, - url: 'https://relay.example.test', - token: 'relay-token', - fallbackToDirect: true, - transport: 'http', + const requestInit = { + method: 'POST', + headers: { + 'x-session-affinity': `session-abc-${kind}`, + 'x-opencode-session-id': `session-abc-${kind}`, + 'x-opencode-parent-session-id': 'parent-abc', }, - }), - { - access: 'sk-ant-oat01-my-access-token', - refresh: 'refresh', - expires: Date.now() + 100000, - }, - ) + body, + } + if (kind === 'request') + await result.fetch(new Request(MESSAGES_URL, requestInit)) + else await result.fetch(MESSAGES_URL, requestInit) - let capturedUrl: string | undefined - let capturedBody: string | undefined - let capturedHeaders: Headers | undefined - globalThis.fetch = mock( - withNativeAdmission((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 }, - ), - ) - } - capturedUrl = url - capturedBody = init?.body - capturedHeaders = new Headers(init?.headers) - return Promise.resolve( - new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), - ) - }), - ) as unknown as typeof fetch + expect(capturedHeaders).toBeDefined() + expect(capturedHeaders!.get('authorization')).toBe( + 'Bearer sk-ant-oat01-my-access-token', + ) + expect(capturedHeaders!.get('x-api-key')).toBeNull() + expect(capturedHeaders!.get('x-session-affinity')).toBeNull() + expect(capturedHeaders!.get('x-opencode-session')).toBeNull() + expect(capturedHeaders!.get('x-opencode-session-id')).toBeNull() + expect(capturedHeaders!.get('x-opencode-parent-session-id')).toBeNull() + expect(capturedHeaders!.get('anthropic-beta')).toContain( + 'oauth-2025-04-20', + ) - const plugin = await getPlugin() - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', + const parsedBody = JSON.parse(capturedBody!) + expect(parsedBody.tools[0].name).toBe('mcp_Bash') + // Three-block layout: billing header, identity, rest + expect(parsedBody.system).toHaveLength(3) + expect(parsedBody.system[0].text).toContain('x-anthropic-billing-header') + expect(parsedBody.system[1].text).toBe( + "You are Claude Code, Anthropic's official CLI for Claude.", + ) + expect(parsedBody.system[2].text).toBe('You are a helpful assistant.') + // User message is untouched + expect(parsedBody.messages[0].content).toBe('hello world test message') + }, + ) + + test.each(['url', 'request'] as const)( + 'uses configured relay instead of uploading full body directly for %s input', + async (kind) => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + relay: { + enabled: true, + url: 'https://relay.example.test', + token: 'relay-token', + fallbackToDirect: true, + transport: 'http', + }, + }), + { access: 'sk-ant-oat01-my-access-token', refresh: 'refresh', expires: Date.now() + 100000, + }, + ) + + let capturedUrl: string | undefined + let capturedBody: string | undefined + let capturedHeaders: Headers | undefined + globalThis.fetch = mock( + withNativeAdmission((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 }, + ), + ) + } + capturedUrl = url + capturedBody = init?.body + capturedHeaders = new Headers(init?.headers) + return Promise.resolve( + new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), + ) }), - { models: {} }, - ) + ) as unknown as typeof fetch - await result.fetch(MESSAGES_URL, { - method: 'POST', - headers: { 'x-session-affinity': 'session-abc' }, - body: JSON.stringify({ - messages: [{ role: 'user', content: 'hello' }], - system: 'system', - }), - }) + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'sk-ant-oat01-my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) - expect(capturedUrl).toBe('https://relay.example.test/') - expect(capturedHeaders?.get('x-relay-token')).toBe('relay-token') - const payload = JSON.parse(capturedBody!) - expect(payload).toMatchObject({ - mode: 'full_sync', - affinity: 'session-abc', - upstream: { - url: 'https://api.anthropic.com/v1/messages?beta=true', - }, - }) - expect(payload.upstream.headers['x-session-affinity']).toBeUndefined() - expect(payload.upstream.headers['x-opencode-session']).toBeUndefined() - expect(payload.body.length).toBeGreaterThan(0) - }) + const requestInit = { + method: 'POST', + headers: { + 'x-session-affinity': `session-abc-${kind}`, + 'x-opencode-session-id': `session-abc-${kind}`, + 'x-opencode-parent-session-id': 'parent-abc', + }, + body: JSON.stringify({ + messages: [{ role: 'user', content: 'hello' }], + system: 'system', + }), + } + if (kind === 'request') + await result.fetch(new Request(MESSAGES_URL, requestInit)) + else await result.fetch(MESSAGES_URL, requestInit) + + expect(capturedUrl).toBe('https://relay.example.test/') + expect(capturedHeaders?.get('x-relay-token')).toBe('relay-token') + const payload = JSON.parse(capturedBody!) + expect(payload).toMatchObject({ + mode: 'full_sync', + affinity: `session-abc-${kind}`, + upstream: { + url: 'https://api.anthropic.com/v1/messages?beta=true', + }, + }) + expect(payload.upstream.headers['x-session-affinity']).toBeUndefined() + expect(payload.upstream.headers['x-opencode-session']).toBeUndefined() + expect(payload.upstream.headers['x-opencode-session-id']).toBeUndefined() + expect( + payload.upstream.headers['x-opencode-parent-session-id'], + ).toBeUndefined() + expect(payload.body.length).toBeGreaterThan(0) + }, + ) test('reloads relay config from sidecar after plugin startup', async () => { await useTempAccountFile(createFallbackStorage({ accounts: [] }), { From e5a45b4670492250440efe2b19cc42f3c3223122 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:51:11 +0200 Subject: [PATCH 129/169] test(opencode): migrate CLI API and relay fixtures to native storage --- packages/opencode/src/tests/cli.test.ts | 226 ++++++++++++++++-------- 1 file changed, 150 insertions(+), 76 deletions(-) diff --git a/packages/opencode/src/tests/cli.test.ts b/packages/opencode/src/tests/cli.test.ts index a0178c631..3568d3c0b 100644 --- a/packages/opencode/src/tests/cli.test.ts +++ b/packages/opencode/src/tests/cli.test.ts @@ -1,35 +1,67 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { describe, expect } from 'bun:test' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { addAccountPersistent, + createNativeAccountRuntime, + createNativePoolStore, getAccountStatePath, loadAccounts, saveAccounts, } from '@cortexkit/anthropic-auth-core' +import { nativeQuotaCodec } from '../../../core/src/native-quota-codec' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime' import { addApiRoute, login, relaySetup } from '../cli' +import { migrateNativeOpencodeFixture } from './native-fixture' -let tempDir: string +const lifetime = createTestLifetimeSuite() +const test = lifetime.test -beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'anthropic-cli-test-')) -}) +async function createRoot() { + const root = await mkdtemp(join(tmpdir(), 'anthropic-cli-test-')) + lifetime.deferCleanup(() => rm(root, { recursive: true, force: true })) + return root +} -afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }) -}) +async function readStoredBytes(path: string) { + try { + return await readFile(path) + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) + return undefined + throw error + } +} + +async function readNativeRows( + fixture: Awaited>, +) { + const view = await createNativePoolStore({ + paths: fixture.paths, + quota: nativeQuotaCodec, + }).read() + expect(view.status).toBe('ready') + if (view.status !== 'ready') throw new Error('Native API pool did not load') + return view.rows +} + +async function nativeFixture(root: string) { + return migrateNativeOpencodeFixture({ + root, + lifetime, + legacyConfig: { version: 1, accounts: [] }, + }) +} -// addApiRoute / login are exercised IN-PROCESS with injected deps rather than -// via `bun [--preload] src/cli.ts ...`. The subprocess form (Bun.spawn + -// optional --preload fetch stub + stdin pipe) hung indefinitely in CI -// (proc.exited never resolving, 5000ms timeout) while passing locally — the -// failure lived in that harness, not in the command logic, and was -// unreproducible locally. Calling the commands directly with injected prompt -// (and, for login, authorize/exchange) removes the entire fragile surface -// (no spawn, no preload, no readline/stdin race, no real network) while still -// exercising the real logic and asserting the same persisted outcomes. +// Inject prompts and OAuth responses so command tests never open a browser +// or use interactive stdin. Storage assertions still read the actual files. // Run a command body against a temp account file, restoring any env we touch. async function withAccountEnv( @@ -55,12 +87,15 @@ async function withAccountEnv( } describe('CLI api add', () => { - test('saves API route config and stores API key in runtime state', async () => { - const accountPath = join(tempDir, 'anthropic-auth.json') + test('saves API route config and stores API key only in private pool state', async () => { + const tempDir = await createRoot() + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE await withAccountEnv( accountPath, { + ...fixture.env, OPENCODE_ANTHROPIC_AUTH_API_BASE_URL: 'https://api.kie.ai/claude', OPENCODE_ANTHROPIC_AUTH_API_KEY: 'kie-key', OPENCODE_ANTHROPIC_AUTH_API_AUTH_HEADER: 'authorization-bearer', @@ -73,29 +108,41 @@ describe('CLI api add', () => { }), ) - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.accounts[0]).toMatchObject({ + const rows = await readNativeRows(fixture) + const account = rows.find((row) => row.id === 'kie-opus') + expect(account).toMatchObject({ id: 'kie-opus', label: 'kie-opus', type: 'api', enabled: true, - baseURL: 'https://api.kie.ai/claude', - authHeader: 'authorization-bearer', + stamp: 'bound', + credential: { + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', + }, }) - expect(storage.accounts[0].apiKey).toBeUndefined() - - const runtimeState = JSON.parse( - await readFile(getAccountStatePath(accountPath), 'utf8'), + expect(await readFile(fixture.paths.config, 'utf8')).not.toContain( + 'kie-key', ) - expect(runtimeState.accounts['kie-opus'].apiKey).toBe('kie-key') + expect(await readFile(fixture.paths.state, 'utf8')).toContain('kie-key') + await expect(readFile(accountPath, 'utf8')).rejects.toThrow() }) test('rejects invalid API base URL before saving route state', async () => { - const accountPath = join(tempDir, 'anthropic-auth.json') + const tempDir = await createRoot() + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + const before = await Promise.all([ + readStoredBytes(fixture.paths.config), + readStoredBytes(fixture.paths.state), + ]) const promise = withAccountEnv( accountPath, { + ...fixture.env, OPENCODE_ANTHROPIC_AUTH_API_BASE_URL: 'https://secret@example.com/v1', OPENCODE_ANTHROPIC_AUTH_API_KEY: 'kie-key', OPENCODE_ANTHROPIC_AUTH_API_AUTH_HEADER: 'authorization-bearer', @@ -110,13 +157,20 @@ describe('CLI api add', () => { 'API fallback base URL must be an http(s) URL', ) - // Nothing should have been persisted for the rejected route. + expect( + await Promise.all([ + readStoredBytes(fixture.paths.config), + readStoredBytes(fixture.paths.state), + ]), + ).toEqual(before) + expect(await readNativeRows(fixture)).toEqual([]) await expect(readFile(accountPath, 'utf8')).rejects.toThrow() }) }) describe('CLI login', () => { test('refuses before authorization while global Claustrum custody is committed', async () => { + const tempDir = await createRoot() const accountPath = join(tempDir, 'anthropic-auth.json') await saveAccounts( { @@ -148,6 +202,7 @@ describe('CLI login', () => { }) test('continues from label prompt to OAuth callback prompt and saves account', async () => { + const tempDir = await createRoot() const accountPath = join(tempDir, 'anthropic-auth.json') // Real authorize() (exercises PKCE + state + the printed URL); exchange() @@ -234,6 +289,7 @@ describe('CLI login', () => { }) test('allows human-readable local OAuth labels without the obsolete manifest label restriction', async () => { + const tempDir = await createRoot() const accountPath = join(tempDir, 'anthropic-auth.json') await withAccountEnv(accountPath, {}, () => @@ -255,6 +311,7 @@ describe('CLI login', () => { }) test('preserves an account committed while the interactive OAuth flow is open', async () => { + const tempDir = await createRoot() const accountPath = join(tempDir, 'anthropic-auth.json') const now = Date.now() const exchange = async (): Promise<{ @@ -305,6 +362,7 @@ describe('CLI login', () => { }) test('re-login with same label clears stale errors and quota', async () => { + const tempDir = await createRoot() const accountPath = join(tempDir, 'anthropic-auth.json') await writeFile( @@ -398,6 +456,7 @@ describe('CLI login', () => { }) test('re-login with same label replaces split runtime state and clears stale reauth', async () => { + const tempDir = await createRoot() const accountPath = join(tempDir, 'anthropic-auth.json') const statePath = getAccountStatePath(accountPath) const oldRefreshedAt = Date.now() - 60_000 @@ -495,18 +554,12 @@ describe('CLI login', () => { }) describe('CLI relay setup', () => { - // relaySetup is exercised IN-PROCESS with an injected fetch + prompt rather - // than via `bun --preload ... src/cli.ts relay setup`. The old subprocess - // form hung indefinitely in CI (timed out at 5000ms with proc.exited never - // resolving) — the failure lived in the subprocess+--preload+readline/stdin - // harness, not in relaySetup's logic, and was unreproducible locally. Calling - // relaySetup directly with injected deps removes that entire fragile surface - // (no spawn, no preload-stub-application risk, no real network, no stdin/pipe - // race) while still exercising the real setup logic: KV create, worker - // upload, enable workers.dev, subdomain lookup, token generation, and config - // save — the same four Cloudflare calls and the same persisted relay config. + // Cloudflare requests use a synthetic transport. The command still executes + // provisioning in order and saves its private relay token in native storage. test('deploys worker resources and saves relay config', async () => { - const accountPath = join(tempDir, 'anthropic-auth.json') + const tempDir = await createRoot() + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE const calls: Array<{ url: string; method?: string }> = [] let accountAddedDuringProvisioning = false @@ -518,15 +571,24 @@ describe('CLI relay setup', () => { calls.push({ url, method: init?.method }) if (!accountAddedDuringProvisioning) { accountAddedDuringProvisioning = true - await addAccountPersistent( - { - id: 'added-during-relay', + const concurrent = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await concurrent.putAccount({ + routeId: 'added-during-relay', label: 'added-during-relay', - type: 'oauth', - refresh: 'relay-race-refresh', - }, - accountPath, - ) + credential: { + type: 'oauth', + access: 'sk-ant-oat01-relay-race-access', + refresh: 'relay-race-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + }, + }) + } finally { + concurrent.close() + } } if (url.includes('/storage/kv/namespaces')) return Response.json({ success: true, result: { id: 'kv-id' } }) @@ -556,38 +618,50 @@ describe('CLI relay setup', () => { return promptAnswers[message] ?? '' } - const prevFile = process.env.OPENCODE_ANTHROPIC_AUTH_FILE - const prevToken = process.env.CLOUDFLARE_API_TOKEN - const prevAccount = process.env.CLOUDFLARE_ACCOUNT_ID - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath - process.env.CLOUDFLARE_API_TOKEN = 'cf-token' - process.env.CLOUDFLARE_ACCOUNT_ID = 'account-id' - try { - await relaySetup({ fetchImpl, prompt }) - } finally { - if (prevFile === undefined) - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - else process.env.OPENCODE_ANTHROPIC_AUTH_FILE = prevFile - if (prevToken === undefined) delete process.env.CLOUDFLARE_API_TOKEN - else process.env.CLOUDFLARE_API_TOKEN = prevToken - if (prevAccount === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID - else process.env.CLOUDFLARE_ACCOUNT_ID = prevAccount - } + await withAccountEnv( + accountPath, + { + ...fixture.env, + CLOUDFLARE_API_TOKEN: 'cf-token', + CLOUDFLARE_ACCOUNT_ID: 'account-id', + }, + () => relaySetup({ fetchImpl, prompt }), + ) // Token + account come from env, so the only prompt is the worker name. expect(askedPrompts).toEqual(['Worker name [opencode-anthropic-relay]: ']) - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.relay).toMatchObject({ - enabled: true, - url: 'https://opencode-anthropic-relay.user-subdomain.workers.dev', - fallbackToDirect: true, - transport: 'http', + const reader = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', }) - expect(storage.relay.token).toBeString() - expect( - storage.accounts.map((account: { id: string }) => account.id), - ).toEqual(['added-during-relay']) + try { + const relay = await reader.getRelayConfig() + expect(relay).toMatchObject({ + enabled: true, + url: 'https://opencode-anthropic-relay.user-subdomain.workers.dev', + fallbackToDirect: true, + transport: 'http', + }) + expect(relay?.token).toBeString() + if (!relay?.token) throw new Error('Relay setup did not save a token') + const snapshot = await reader.read() + expect(snapshot.accounts.map((account) => account.id)).toEqual([ + 'added-during-relay', + ]) + expect(snapshot.policyStorage.relay?.token).toBeUndefined() + expect(await readFile(fixture.paths.config, 'utf8')).not.toContain( + relay.token, + ) + expect((await readNativeRows(fixture))[0]?.credential).toMatchObject({ + type: 'oauth', + access: 'sk-ant-oat01-relay-race-access', + refresh: 'relay-race-refresh', + }) + await expect(readFile(accountPath, 'utf8')).rejects.toThrow() + } finally { + reader.close() + } expect(calls).toHaveLength(4) expect(calls.map((c) => `${c.method ?? 'GET'} ${c.url}`)).toEqual([ From 7280520d06ae0ea20c689b155bfb8ef8de632fa6 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:50:21 +0200 Subject: [PATCH 130/169] build(core): adopt verified common-auth 0.13.0 --- bun.lock | 4 ++-- packages/core/package.json | 2 +- packages/core/scripts/check-native-type-closure.test.ts | 4 ++-- packages/core/scripts/check-native-type-closure.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index f63cd28ce..1431770c1 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "0.11.7", + "@cortexkit/common-auth": "0.13.0", }, }, "packages/e2e-tests": { @@ -237,7 +237,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.11.7","",{"dependencies":{"jsonc-parser":"^3.3.1"},"peerDependencies":{"solid-js":"1.9.12","@opentui/core":">=0.5.11","@opentui/solid":">=0.5.11","@opencode/plugin":">=2.0.21","@cortexkit/claustrum-client":">=0.6.2"},"optionalPeers":["solid-js","@opentui/core","@opentui/solid","@opencode/plugin","@cortexkit/claustrum-client"]},"sha512-dUvexlN0Jx/Q1dZWhVBI+cDJPLIg/nEmBlh6P4tqjTqSR4H2GxwmWKuJJ4PdClQLeFMitUWLAllSEcEi/iL8rg=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.13.0", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-U4rVLk18gd576nPWUnucx+0ZbNXgJhumw76oxvq+9hpd6fnU2h9V/1VPsBtd47g2kkunRDxpB9byoDSZOjCo6A=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], diff --git a/packages/core/package.json b/packages/core/package.json index 2b1ef1fa5..012217059 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "0.11.7" + "@cortexkit/common-auth": "0.13.0" }, "dependencies": { "@cortexkit/claustrum-client": "0.6.2", diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index b8575d5a9..1e356d078 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -98,7 +98,7 @@ test('rejects unsupported published producer versions before copying declaration ) await expect(closeNativeTypeDeclarations(dist, producer)).rejects.toThrow( - 'Expected published common-auth 0.11.7, got 0.9.0', + 'Expected published common-auth 0.13.0, got 0.9.0', ) expect(await readdir(dist)).not.toContain('internal-types') }) @@ -111,7 +111,7 @@ test('copies the complete published relative declaration graph including cycles producer, 'package.json', JSON.stringify({ - version: '0.11.7', + version: '0.13.0', exports: { './store': { types: './dist/store/index.d.ts' } }, }), ) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts index 9d51f322d..7133d425b 100644 --- a/packages/core/scripts/check-native-type-closure.ts +++ b/packages/core/scripts/check-native-type-closure.ts @@ -185,9 +185,9 @@ export async function closeNativeTypeDeclarations( version: string exports: Record } - if (manifest.version !== '0.11.7') + if (manifest.version !== '0.13.0') throw new Error( - `Expected published common-auth 0.11.7, got ${manifest.version}`, + `Expected published common-auth 0.13.0, got ${manifest.version}`, ) const producerDist = join(producerRoot, 'dist') const assets = join(distRoot, TYPE_ASSETS) From 450222b986d0915d4189d0c21c1ed078c579f554 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:53:06 +0200 Subject: [PATCH 131/169] fix(core): verify OAuth login identity and isolate vault account policy --- packages/core/src/native-account-runtime.ts | 67 ++++++- packages/core/src/native-account-view.ts | 4 +- .../src/tests/native-account-runtime.test.ts | 188 ++++++++++++++++-- .../src/tests/native-account-view.test.ts | 46 +++++ 4 files changed, 278 insertions(+), 27 deletions(-) diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index 84a448dc3..389c89a95 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -17,6 +17,7 @@ import { quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, } from './accounts.ts' +import { resolveClaudeCodeIdentity } from './claude-code.ts' import type { LogLevel } from './logger.ts' import { applyNativeMetadataPatch, @@ -90,7 +91,7 @@ export type { export interface NativeAccountRuntimeOptions { paths: NativePoolPaths host: 'opencode' | 'pi' - /** The setup CLI checks that OpenCode and Pi are still stopped before locked credential-file writes. */ + /** The opencode-anthropic-auth setup command rechecks that hosts are stopped before credential-file writes, so migration cannot change credentials used by a running host. */ beforePoolWrite?: () => Promise local?: Partial< Pick< @@ -428,15 +429,21 @@ export function createNativeAccountRuntime( ) return entry // A new credentialEpoch identifies replacement credentials, not an ordinary - // token refresh. Drop old validation, token backoff and the key that deduplicates - // Prime requests, so replacement credentials cannot inherit old permission - // or claims. Retain counters and profile only for the same account UUID. + // token refresh. Discard old identity validation and token backoff. Give OAuth + // a new Prime deduplication key so a previous credential's quota-start claim + // cannot suppress the replacement's request. Retain counters and profile + // only for the same account UUID. const sameAccount = old?.kind === 'local' && old.rowId === binding.rowId && binding.identity !== undefined && old.identity === binding.identity - const next: NativeRuntimeEntry = { binding } + const next: NativeRuntimeEntry = { + binding, + ...(row.credential?.type === 'oauth' + ? { authLineageId: randomUUID() } + : {}), + } if (sameAccount && previous) { for (const key of [ 'prime', @@ -757,6 +764,18 @@ export function createNativeAccountRuntime( }, async authorizeApi(routeId, signal) { await authority(signal) + const requireLocalMode = async () => { + const settings = await store.readSettings() + check(signal) + if (settings.status !== 'ready') + throw new NativeRuntimeError('publication-refused') + if ( + (settings.settings.claustrum as { mode?: unknown } | undefined) + ?.mode === 'claustrum' + ) + throw new NativeCustodyError('route-unavailable') + } + await requireLocalMode() const row = await poolRow(await physicalId(routeId)) check(signal) if ( @@ -766,6 +785,9 @@ export function createNativeAccountRuntime( row.credential?.type !== 'api' ) throw new NativeCustodyError('route-unavailable') + // Reading the credential yields to other processes. Recheck custody + // before returning a local key that vault mode would exclude. + await requireLocalMode() return { ...row.credential, subject: { @@ -781,12 +803,37 @@ export function createNativeAccountRuntime( check(signal) return vault.authorize(id, signal) }, - loginOAuth: (input) => - runtime.putAccount({ + async loginOAuth(input) { + const snapshot = await runtime.read() + if (snapshot.mode !== 'local') + throw new NativeCustodyError('route-unavailable') + let accountIdentity: string | undefined + try { + if (!input.credential.access.startsWith('sk-ant-oat')) + throw new Error('Unsupported OAuth credential') + accountIdentity = ( + await (options.local?.resolveIdentity ?? resolveClaudeCodeIdentity)( + input.credential.access, + undefined, + undefined, + ) + ).accountUuid + if (!accountIdentity?.trim()) + throw new Error('Missing account identity') + } catch { + throw new Error( + 'Claude account identity could not be verified; retry sign-in. No account changes were saved.', + ) + } + // Verify the exchanged token before replacing any stored credential. + // putAccount checks authority and custody again after the lookup awaits. + return runtime.putAccount({ ...input, + accountIdentity, replace: input.replace ?? true, credential: { type: 'oauth', ...input.credential }, - }), + }) + }, async putAccount(input) { await authority() const snapshot = await runtime.read() @@ -917,8 +964,8 @@ export function createNativeAccountRuntime( } const id = await physicalId(routeId) const operation = enabled ? 'enable' : 'disable' - // common-auth does not report whether enable or disable changed the - // account's enabled flag. Capture that flag immediately before each + // Credential-pool enable/disable results identify the account but omit whether + // its enabled flag changed. Capture that flag immediately before each // credential-pool configuration write, while its locks are held. // An interrupted credential replacement may need repair before the // requested toggle. Use the last captured flag, which the pure reader diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts index 71cd561d4..4ff57b13c 100644 --- a/packages/core/src/native-account-view.ts +++ b/packages/core/src/native-account-view.ts @@ -472,7 +472,9 @@ export function projectNativeAccountViews(input: { : 'local' const accounts: NativeAccountView[] = [] for (const row of input.rows) { - if (mode === 'claustrum' && row.type === 'oauth') continue + // Vault mode owns the entire usable account set, including paid routes. + // Keep local rows stored, but exclude them until the host disconnects. + if (mode === 'claustrum') continue const proposed: NativeLocalPoolBinding | undefined = row.credentialEpoch !== undefined ? { diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts index 7bf141a77..48d7278aa 100644 --- a/packages/core/src/tests/native-account-runtime.test.ts +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -405,15 +405,21 @@ test('normal local refresh preserves Prime lineage and counters but relogin star expires: now + 4_000_000, }, }) + const afterLogin = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (afterLogin.status !== 'ready') + throw new Error('Login metadata is missing') + const nextLineage = afterLogin.state.accounts['imported-main']?.authLineageId + expect(nextLineage).toMatch(/^[a-f0-9-]{36}$/) + expect(nextLineage).not.toBe(lineage) const replaced = await f.runtime.authorizeLocal('main') if (replaced.status !== 'usable') throw new Error('Fixture relogin not admitted') expect(replaced.binding.credentialEpoch).toBeGreaterThan( first.binding.credentialEpoch, ) - expect( - await f.runtime.getOrCreateAuthLineage('main', replaced.subject), - ).not.toBe(lineage) + expect(await f.runtime.getOrCreateAuthLineage('main', replaced.subject)).toBe( + nextLineage, + ) expect((await f.runtime.read()).accounts[0]?.prime?.count).toBe(1) }) @@ -670,7 +676,9 @@ test('cross-process remove and readd same native route and UUID creates new Prim identity: uuid, }) expect(entry?.credentialValidation).toBeUndefined() - expect(f.counts.lookups).toBe(1) + // Login verifies the successor before saving it. Serving still requires a + // positive proof bound to the newly committed epoch, not the parent's proof. + expect(f.counts.lookups).toBe(2) let staleCalls = 0 const service = createNativeLocalCredentialService({ paths: f.paths, @@ -716,7 +724,7 @@ test('cross-process remove and readd same native route and UUID creates new Prim expect(readded.binding.credentialEpoch).toBeGreaterThan( first.binding.credentialEpoch, ) - expect(f.counts.lookups).toBe(2) + expect(f.counts.lookups).toBe(3) expect( await f.runtime.getOrCreateAuthLineage('main', readded.subject), ).not.toBe(lineage) @@ -1178,14 +1186,15 @@ test('local enable and disable log only a flag change made by their own write', } const logs = captureCommandLogs() - // Repairing a torn, already-enabled row is the only config write; the - // enable itself changes nothing. + // The interrupted replacement saved credentials but not the matching config. + // Repair writes that config; the account was already enabled, so no toggle occurs. await tear('fallback-a') await f.runtime.setEnabled('fallback-a', true) expect((await poolRow('fallback-a')).torn).toBeUndefined() expect(logs.take()).toEqual([]) - // A torn, disabled row is repaired and then enabled: one change. + // After repairing the interrupted replacement, enabling a disabled account + // changes its flag once and must produce exactly one change log. await f.runtime.setEnabled('fallback-a', false) logs.take() await tear('fallback-a') @@ -1199,9 +1208,8 @@ test('local enable and disable log only a flag change made by their own write', }, ]) - // An enabled row that still carries a disabled reason: the enable writes - // the config to clear the reason, but the enabled flag does not change. - // The reason lives in the row's pool entry beside the roster row. + // Enabling clears a leftover disabled reason so it no longer describes the + // active account. This maintenance write does not change the enabled flag. const config = JSON.parse(await readFile(f.paths.config, 'utf8')) config.commonAuthPool.rows['fallback-a'].disabledReason = 'synthetic-reason' await writeFile(f.paths.config, JSON.stringify(config)) @@ -1210,8 +1218,8 @@ test('local enable and disable log only a flag change made by their own write', expect((await poolRow('fallback-a')).disabledReason).toBeUndefined() expect(logs.take()).toEqual([]) - // An OAuth row holding the main account's identity is stored disabled, and - // the store refuses to enable it: nothing is logged. + // Duplicate OAuth identities cannot be active twice in the pool. A fallback + // matching the active main account stays disabled and produces no change log. await f.store.add({ id: 'fallback-dup', identity: uuid, @@ -1234,9 +1242,9 @@ for (const rotate of [false, true]) { const before = await readFile(f.paths.runtime) const original = filesystem.rename let failedWrites = 0 - // Fail the rename after the account, token and write locks are checked. - // Failing those checks would mean changed credentials or lost ownership, - // not a storage error. + // Account/token checks reject superseded credentials, and lock checks reject + // a superseded writer. Inject a failed rename only after those checks pass + // to isolate a storage failure from a refused publication. const writer = spyOn(filesystem, 'rename').mockImplementation( async (from, to) => { if (to === f.paths.runtime) { @@ -1376,3 +1384,151 @@ test('native network refresh failure permits one retry and clears its transient ?.lastRefreshError, ).toBeUndefined() }) + +test('vault mode refuses direct local API authorization without changing stored rows and disconnect restores it', async () => { + const f = await fixture() + await f.runtime.addApi({ + routeId: 'local-api', + apiKey: 'synthetic-local-api-secret', + }) + await f.store.updateSettings((settings) => { + settings.claustrum = { mode: 'claustrum' } + }) + const before = await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]) + await expect(f.runtime.authorizeApi('local-api')).rejects.toMatchObject({ + code: 'route-unavailable', + }) + expect( + await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]), + ).toEqual(before) + expect(f.counts.lookups + f.counts.refresh).toBe(0) + await f.store.updateSettings((settings) => { + settings.claustrum = { mode: 'local' } + }) + expect((await f.runtime.authorizeApi('local-api')).apiKey).toBe( + 'synthetic-local-api-secret', + ) +}) + +test('OAuth login validates fresh identity before replacing the pool credential', async () => { + const presented: string[] = [] + const f = await fixture({ + resolveIdentity: async (access) => { + presented.push(access) + return { + deviceId: 'login-device', + sessionId: 'login-session', + accountUuid: uuid, + } + }, + }) + await f.runtime.loginOAuth({ + routeId: 'main', + credential: { + access: 'sk-ant-oat01-login-successor', + refresh: 'login-successor-refresh', + expires: now + 18_000_000, + }, + }) + expect(presented).toEqual(['sk-ant-oat01-login-successor']) + const saved = await f.store.read() + if (saved.status !== 'ready') throw new Error('Login pool was not readable') + expect(saved.rows.find((row) => row.id === 'imported-main')).toMatchObject({ + identity: uuid, + credential: { + type: 'oauth', + access: 'sk-ant-oat01-login-successor', + refresh: 'login-successor-refresh', + }, + }) +}) + +async function loginStoredBytes( + paths: Awaited>['paths'], +) { + return Promise.all( + [paths.config, paths.state, paths.runtime].map(async (path) => { + try { + return await readFile(path) + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) + return undefined + throw error + } + }), + ) +} + +for (const failure of ['missing-identity', 'lookup-error'] as const) { + test(`OAuth login leaves existing pool and metadata byte-identical on ${failure}`, async () => { + const f = await fixture({ + resolveIdentity: async () => { + if (failure === 'lookup-error') + throw new Error('synthetic-login-secret-must-not-escape') + return { deviceId: 'login-device', sessionId: 'login-session' } + }, + }) + const before = await loginStoredBytes(f.paths) + await expect( + f.runtime.loginOAuth({ + routeId: 'main', + credential: { + access: 'sk-ant-oat01-login-successor', + refresh: 'login-successor-refresh', + expires: now + 18_000_000, + }, + }), + ).rejects.toThrow('Claude account identity could not be verified') + expect(await loginStoredBytes(f.paths)).toEqual(before) + }) +} + +test('OAuth login rechecks migration authority after delayed identity validation', async () => { + const started = gate() + const release = gate() + const f = await fixture({ + resolveIdentity: async () => { + started.open() + await release.wait + return { + deviceId: 'login-device', + sessionId: 'login-session', + accountUuid: uuid, + } + }, + }) + const before = await loginStoredBytes(f.paths) + const login = f.runtime.loginOAuth({ + routeId: 'main', + credential: { + access: 'sk-ant-oat01-login-successor', + refresh: 'login-successor-refresh', + expires: now + 18_000_000, + }, + }) + const outcome = login.then( + () => ({ status: 'resolved' as const }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ) + trackDetached(outcome) + await started.wait + await rm(f.paths.journal) + release.open() + const result = await outcome + expect(result.status).toBe('rejected') + if (result.status !== 'rejected') + throw new Error('Revoked migration authority still allowed login') + expect(result.error).toHaveProperty('code', 'migration-required') + expect(await loginStoredBytes(f.paths)).toEqual(before) +}) diff --git a/packages/core/src/tests/native-account-view.test.ts b/packages/core/src/tests/native-account-view.test.ts index 7335f7c87..4ef011799 100644 --- a/packages/core/src/tests/native-account-view.test.ts +++ b/packages/core/src/tests/native-account-view.test.ts @@ -324,3 +324,49 @@ for (const access of ['sk-ant-oat01-synthetic', 'synthetic-api-key', '']) { expect(JSON.stringify(snapshot)).not.toContain('synthetic-refresh') }) } + +for (const roster of [ + undefined, + { version: 1 as const, complete: false, rows: [], declined: [] }, + { version: 1 as const, complete: true, rows: [], declined: [] }, +]) { + test(`vault projection excludes every local account with ${roster === undefined ? 'missing' : roster.complete ? 'empty' : 'incomplete'} roster and restores them in local mode`, () => { + const rows: PoolRow[] = [ + row, + { + ...row, + id: 'local-api', + type: 'api', + credential: { + type: 'api', + apiKey: 'synthetic-local-api-secret', + baseURL: 'https://example.test', + }, + }, + ] + const before = JSON.stringify(rows) + const settings = { mainAccountId: row.id, claustrum: { mode: 'claustrum' } } + const snapshot = projectNativeAccountViews({ + paths, + rows, + settings, + runtime, + roster, + }) + expect(snapshot.accounts).toEqual([]) + expect(snapshot.policyStorage.accounts).toEqual([]) + expect(JSON.stringify(snapshot)).not.toContain('local-api') + expect(JSON.stringify(rows)).toBe(before) + const disconnected = projectNativeAccountViews({ + paths, + rows, + settings: { ...settings, claustrum: { mode: 'local' } }, + runtime, + }) + expect(disconnected.accounts.map((account) => account.id)).toEqual([ + 'main', + 'local-api', + ]) + expect(JSON.stringify(rows)).toBe(before) + }) +} From 95921f15b9c9956c1c43a15666540d3cf4033476 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:47:18 +0200 Subject: [PATCH 132/169] feat(core): expose enrollment authority and isolated vault paths --- packages/core/src/index.ts | 1 + .../core/src/native-enrollment-authority.ts | 17 ++ .../core/src/native-refresh-coordinator.ts | 6 +- packages/core/src/native-vault-paths.ts | 79 +++++++++ packages/core/src/pool-store.ts | 14 +- .../tests/native-enrollment-authority.test.ts | 150 ++++++++++++++++++ 6 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/native-enrollment-authority.ts create mode 100644 packages/core/src/native-vault-paths.ts create mode 100644 packages/core/src/tests/native-enrollment-authority.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c628f15c0..f3a8acac2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -64,6 +64,7 @@ export { NativeCustodyActivationError, runNativeCustodyActivation, } from './native-custody-activation.ts' +export { readNativeEnrollmentMode } from './native-enrollment-authority.ts' export { inspectNativeHostAuthEntry, NativeHostAuthError, diff --git a/packages/core/src/native-enrollment-authority.ts b/packages/core/src/native-enrollment-authority.ts new file mode 100644 index 000000000..e79fce84c --- /dev/null +++ b/packages/core/src/native-enrollment-authority.ts @@ -0,0 +1,17 @@ +import { enrollmentAuthority } from '@cortexkit/common-auth/claustrum' + +import { getHostClaustrumEnrollmentPaths } from './claustrum-enrollment.ts' +import { NativeCustodyError } from './native-custody.ts' + +/** Enrollment files, not account settings, choose the host's credential source. */ +export async function readNativeEnrollmentMode( + host: 'opencode' | 'pi', + env: NodeJS.ProcessEnv = process.env, +): Promise<'local' | 'claustrum'> { + const authority = await enrollmentAuthority( + getHostClaustrumEnrollmentPaths(host, env), + `anthropic-auth-${host}`, + ) + if (authority === 'unreadable') throw new NativeCustodyError('invalid-token') + return authority === 'vault' ? 'claustrum' : 'local' +} diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index 47d01f99e..90be2ab16 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -68,8 +68,9 @@ export type NativeRefreshRestriction = status: 'blocked' reason: 'refresh-backoff' | 'invalid-grant' /** - * Identifies the stored credential through fingerprintOf; never put a - * bearer token or an access-token hash here. + * A one-way refresh-credential fingerprint ties this backoff to the stored + * OAuth lineage. Bearer values are secret, and an access-token fingerprint + * would identify a rotating access token rather than the failed refresh credential. */ credentialFingerprint: string } @@ -264,6 +265,7 @@ export interface NativeRefreshHooks { export interface NativeRefreshCoordinatorOptions extends NativePoolStoreOptions, NativeRefreshHooks { + paths: NativePoolPaths refreshToken?: typeof refreshClaudeOAuthToken resolveIdentity?: typeof resolveClaudeCodeIdentity } diff --git a/packages/core/src/native-vault-paths.ts b/packages/core/src/native-vault-paths.ts new file mode 100644 index 000000000..a4fc6bd7e --- /dev/null +++ b/packages/core/src/native-vault-paths.ts @@ -0,0 +1,79 @@ +import { createHash } from 'node:crypto' +import { basename, dirname, join } from 'node:path' + +import type { NativePoolPaths } from './pool-paths.ts' + +/** Vault files have no migration journal: enrollment alone authorizes their use. */ +export interface NativeVaultPaths { + config: string + state: string + runtime: string + roster: string + routing: string + storageId: string +} + +function sibling(path: string, expected: string, name: string, suffix: string) { + return basename(path) === expected + ? join(dirname(path), name) + : path.replace(/(?:\.json)?$/, `.${suffix}.json`) +} + +/** Keep vault settings, metadata and affinity outside every local recovery file. */ +export function nativeVaultPaths(local: NativePoolPaths): NativeVaultPaths { + const config = sibling( + local.legacyConfig, + 'anthropic-auth.json', + 'anthropic-auth-vault-settings.json', + 'vault-settings', + ) + const state = sibling( + local.legacyState, + 'anthropic-auth-state.json', + 'anthropic-auth-vault-store-state.json', + 'vault-store', + ) + const runtime = sibling( + local.legacyState, + 'anthropic-auth-state.json', + 'anthropic-auth-vault-state.json', + 'vault-native', + ) + const roster = sibling( + local.legacyState, + 'anthropic-auth-state.json', + 'anthropic-auth-vault-roster.json', + 'vault-roster', + ) + const routing = sibling( + local.legacyState, + 'anthropic-auth-state.json', + 'anthropic-auth-vault-routing-state.json', + 'vault-routing', + ) + const destinations = [config, state, runtime, roster, routing] + const localFiles = [ + local.legacyConfig, + local.legacyState, + local.config, + local.state, + local.runtime, + local.journal, + local.roster, + ] + if ( + new Set([...localFiles, ...destinations]).size !== + localFiles.length + destinations.length + ) + throw new Error('Vault storage overlaps local account storage') + return { + config, + state, + runtime, + roster, + routing, + storageId: createHash('sha256') + .update(JSON.stringify(['anthropic-vault', config, state])) + .digest('hex'), + } +} diff --git a/packages/core/src/pool-store.ts b/packages/core/src/pool-store.ts index ff373ed68..a37191831 100644 --- a/packages/core/src/pool-store.ts +++ b/packages/core/src/pool-store.ts @@ -16,21 +16,23 @@ interface NativeQuotaCodec { merge(stored: unknown | undefined, observation: unknown): unknown } +export type NativePoolStorePaths = Pick + export interface NativePoolStoreOptions extends Pick< OpenPoolStoreOptions, 'now' | 'onStep' | 'hold' | 'logger' | 'onLockStep' > { - paths: NativePoolPaths + paths: NativePoolStorePaths quota: NativeQuotaCodec onLockEvent?: (event: NativeLockEvent) => void } /** - * Pool and native-runtime writers share these locks when they reconcile an - * account binding. The new namespace has no legacy credential writer. + * These config/state locks also fence local account metadata against concurrent + * credential changes. Legacy sidecar writers use different files and locks. */ -export function nativePoolStoreLocks(paths: NativePoolPaths) { +export function nativePoolStoreLocks(paths: NativePoolStorePaths) { return [ { path: paths.config, name: 'pool-config' }, { path: paths.state, name: 'pool-state' }, @@ -38,8 +40,8 @@ export function nativePoolStoreLocks(paths: NativePoolPaths) { } /** - * Internal storage machinery, not a startup or migration authorization. Native - * dispatch also requires the offline migration's committed authority journal. + * Storage machinery only. Callers must separately establish local migration + * authority or vault enrollment authority before using these files. */ export function createNativePoolStore(options: NativePoolStoreOptions) { return openPoolStore({ diff --git a/packages/core/src/tests/native-enrollment-authority.test.ts b/packages/core/src/tests/native-enrollment-authority.test.ts new file mode 100644 index 000000000..98e8526a4 --- /dev/null +++ b/packages/core/src/tests/native-enrollment-authority.test.ts @@ -0,0 +1,150 @@ +import { expect } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { disconnectClaustrumEnrollment } from '@cortexkit/common-auth/claustrum' + +import { getHostClaustrumEnrollmentPaths } from '../claustrum-enrollment.ts' +import { readNativeEnrollmentMode } from '../native-enrollment-authority.ts' +import { nativeVaultPaths } from '../native-vault-paths.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const lifetime = createTestLifetimeSuite() +const test = lifetime.test + +async function fixture(host: 'opencode' | 'pi') { + const root = await mkdtemp(join(tmpdir(), 'native-enrollment-authority-')) + lifetime.deferCleanup(() => rm(root, { recursive: true, force: true })) + const env = { + [`${host.toUpperCase()}_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE`]: join( + root, + `${host}-enrollment.json`, + ), + } + const enrollment = getHostClaustrumEnrollmentPaths(host, env) + const local = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + const files = [ + local.legacyConfig, + local.legacyState, + local.config, + local.state, + local.runtime, + local.journal, + local.roster, + ] + for (const file of files) + await writeFile(file, JSON.stringify({ file }), { mode: 0o600 }) + const bytes = () => Promise.all(files.map((file) => readFile(file))) + const before = await bytes() + return { root, env, enrollment, local, before, bytes } +} + +for (const host of ['opencode', 'pi'] as const) { + test(`${host} without enrollment stays local and does not read or write account authority`, async () => { + const f = await fixture(host) + expect(await readNativeEnrollmentMode(host, f.env)).toBe('local') + expect(await f.bytes()).toEqual(f.before) + }) + + test(`${host} recovers a durable token before ceremony state without changing any local file`, async () => { + const f = await fixture(host) + await writeFile( + f.enrollment.tokenPath, + JSON.stringify({ token: 'd'.repeat(64), token_generation: 1 }), + { mode: 0o600 }, + ) + expect(await readNativeEnrollmentMode(host, f.env)).toBe('claustrum') + expect(await f.bytes()).toEqual(f.before) + expect( + await disconnectClaustrumEnrollment( + f.enrollment, + `anthropic-auth-${host}`, + ), + ).toBe('disconnected') + expect(await readNativeEnrollmentMode(host, f.env)).toBe('local') + expect(await f.bytes()).toEqual(f.before) + }) + + test(`${host} approved enrollment missing its token keeps vault authority instead of selecting local`, async () => { + const f = await fixture(host) + await writeFile( + f.enrollment.statePath, + JSON.stringify({ + version: 1, + phase: 'approved', + proposedName: `anthropic-auth-${host}`, + tokenGeneration: 1, + updatedAt: 1, + }), + { mode: 0o600 }, + ) + expect(await readNativeEnrollmentMode(host, f.env)).toBe('claustrum') + expect(await f.bytes()).toEqual(f.before) + }) + + test(`${host} malformed token refuses with a fixed error and leaves local files untouched`, async () => { + const f = await fixture(host) + await writeFile(f.enrollment.tokenPath, 'synthetic-secret-not-json', { + mode: 0o600, + }) + await expect(readNativeEnrollmentMode(host, f.env)).rejects.toMatchObject({ + code: 'invalid-token', + message: 'Native custody refused: invalid-token', + }) + expect(await f.bytes()).toEqual(f.before) + }) +} + +test('one host enrollment cannot activate the other host', async () => { + const f = await fixture('opencode') + await writeFile( + f.enrollment.tokenPath, + JSON.stringify({ token: 'd'.repeat(64), token_generation: 1 }), + { mode: 0o600 }, + ) + const env = { + ...f.env, + PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + f.root, + 'pi-enrollment.json', + ), + } + expect(await readNativeEnrollmentMode('opencode', env)).toBe('claustrum') + expect(await readNativeEnrollmentMode('pi', env)).toBe('local') + expect(await f.bytes()).toEqual(f.before) +}) + +test('vault file identity is stable and disjoint from every local recovery file', async () => { + const f = await fixture('opencode') + const vault = nativeVaultPaths(f.local) + expect(nativeVaultPaths(f.local)).toEqual(vault) + expect(vault.storageId).not.toBe(f.local.storageId) + expect(vault.config).toBe(join(f.root, 'anthropic-auth-vault-settings.json')) + expect(vault.runtime).toBe(join(f.root, 'anthropic-auth-vault-state.json')) + expect(vault.routing).toBe( + join(f.root, 'anthropic-auth-vault-routing-state.json'), + ) + const local = [ + f.local.legacyConfig, + f.local.legacyState, + f.local.config, + f.local.state, + f.local.runtime, + f.local.journal, + f.local.roster, + ] + for (const file of [ + vault.config, + vault.state, + vault.runtime, + vault.roster, + vault.routing, + ]) + expect(local).not.toContain(file) + expect(vault).not.toHaveProperty('journal') + expect(await f.bytes()).toEqual(f.before) +}) From 0a2d25ada9f867dc3facfa2250d3aa662aa9e505 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:24:18 +0200 Subject: [PATCH 133/169] fix(core): isolate vault custody from local account recovery --- packages/core/src/native-account-runtime.ts | 243 +++++----- packages/core/src/native-account-view.ts | 4 +- packages/core/src/native-vault-runtime.ts | 95 +++- .../src/tests/native-account-runtime.test.ts | 68 ++- .../src/tests/native-vault-runtime.test.ts | 418 ++++++++++++++---- packages/pi/src/native.ts | 12 +- 6 files changed, 600 insertions(+), 240 deletions(-) diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index 389c89a95..207c8de17 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto' -import { mutateVaultRoster } from '@cortexkit/common-auth/claustrum' import { withLock } from '@cortexkit/common-auth/fs' import { fingerprintOf, @@ -28,8 +27,6 @@ import { type NativeAccountView, nativeAccountReadError, nativeQuotaFailure, - nativeVaultPrimaryPin, - nativeVaultPrimaryRow, projectNativeAccountViews, } from './native-account-view.ts' import { @@ -45,6 +42,7 @@ import { NativeCustodyError, type NativeCustodyReceipt, } from './native-custody.ts' +import { readNativeEnrollmentMode } from './native-enrollment-authority.ts' import { createNativeLocalCredentialService, type NativeLocalCredentialServiceOptions, @@ -63,6 +61,7 @@ import { readNativeRuntime, updateNativeRuntime, } from './native-runtime.ts' +import { nativeVaultPaths } from './native-vault-paths.ts' import { acquireNativeVaultRuntime, type NativeDisplayProfile, @@ -256,23 +255,37 @@ export function nativeAccountPolicy( return { status: 'allowed' } } -/** The migration journal records the offline move from host auth and sidecars to the credential pool. Only its committed state permits this service to use the pool and separate runtime metadata. */ +/** The local migration journal records completed transfer from host credentials and sidecars to the credential pool. Local use refuses without that record. Host vault enrollment instead selects independent settings and metadata, leaving local credentials, journal and metadata untouched. */ export function createNativeAccountRuntime( options: NativeAccountRuntimeOptions, ): NativeAccountRuntime { const paths = Object.freeze({ ...options.paths }) const now = options.local?.now ?? options.vault?.now ?? Date.now + const env = { ...(options.vault?.env ?? process.env) } + const vaultPaths = nativeVaultPaths(paths) + const vaultStore = createNativePoolStore({ + paths: vaultPaths, + quota: nativeQuotaCodec, + now, + onStep: async (point) => { + if (point === 'before-config-write' || point === 'before-state-write') + await requireMode('claustrum') + }, + }) const shutdown = new AbortController() const store = createNativePoolStore({ paths, quota: nativeQuotaCodec, now, - onStep: options.beforePoolWrite - ? async (point) => { - if (point === 'before-config-write' || point === 'before-state-write') - await options.beforePoolWrite?.() + onStep: async (point) => { + if (point === 'before-config-write' || point === 'before-state-write') { + if (options.beforePoolWrite) { + await localAuthority() + await options.beforePoolWrite() } - : undefined, + await localAuthority() + } + }, }) const vault = acquireNativeVaultRuntime({ ...options.vault, @@ -285,8 +298,28 @@ export function createNativeAccountRuntime( } async function authority(signal?: AbortSignal) { check(signal) - await requireNativePoolAuthority(paths) + const mode = await readNativeEnrollmentMode(options.host, env) + if (mode === 'local') await requireNativePoolAuthority(paths) check(signal) + return mode + } + async function requireMode( + mode: 'local' | 'claustrum', + signal?: AbortSignal, + ) { + if ((await authority(signal)) !== mode) + throw new NativeCustodyError('route-unavailable') + } + async function localAuthority(signal?: AbortSignal) { + await requireMode('local', signal) + } + async function selectedStorage() { + const mode = await authority() + return { + mode, + paths: mode === 'claustrum' ? vaultPaths : paths, + store: mode === 'claustrum' ? vaultStore : store, + } } async function poolRow(id: string) { const read = await store.read() @@ -312,7 +345,7 @@ export function createNativeAccountRuntime( ) => NativeRuntimeEntry, exactAccess: boolean | 'binding' = true, ): Promise { - await authority() + if ((await authority()) !== 'local') return false const binding = subject.binding const matches = async () => { const row = await poolRow(binding.rowId) @@ -372,7 +405,7 @@ export function createNativeAccountRuntime( }, { beforeRename: async () => { - await authority() + await localAuthority() if (!(await matches())) throw new NativeRuntimeError('publication-refused') await configLease.assertOwned() @@ -386,8 +419,10 @@ export function createNativeAccountRuntime( ) } catch (error) { if ( - error instanceof NativeRuntimeError && - error.code === 'runtime-conflict' + (error instanceof NativeRuntimeError && + error.code === 'runtime-conflict') || + (error instanceof NativeCustodyError && + error.code === 'route-unavailable') ) return false throw error @@ -466,7 +501,7 @@ export function createNativeAccountRuntime( const runtime: NativeAccountRuntime = { vault, async getRelayConfig() { - await authority() + const { mode, paths, store } = await selectedStorage() return withLock( paths.config, { ...POOL_LOCK_DEFAULTS, name: 'native-relay' }, @@ -478,7 +513,7 @@ export function createNativeAccountRuntime( const read = await readNativeRuntime(paths.runtime, paths.storageId) const token = read.status === 'ready' ? read.state.relay?.token : undefined - await authority() + await requireMode(mode) await lease.assertOwned() if (!flags.enabled || !flags.url || !token) return null return { ...flags, token } @@ -521,7 +556,7 @@ export function createNativeAccountRuntime( /\p{Cc}/u.test(patch.token)) ) throw new NativeRuntimeError('publication-refused') - await authority() + const { mode, paths, store } = await selectedStorage() await withLock( paths.config, { ...POOL_LOCK_DEFAULTS, name: 'native-relay' }, @@ -552,16 +587,16 @@ export function createNativeAccountRuntime( throw new NativeRuntimeError('publication-refused') async function writeFlags(flags: Omit) { await store.updateSettings(async (settings) => { - await authority() + await requireMode(mode) await lease.assertOwned() settings.relay = flags }) } // Relay flags and its token live in separate files. Disable first so a crash - // cannot send the new token to the old endpoint. PoolStore checks its - // config/state locks; immediately before replacing the token file, - // also verify the lock that serializes relay readers and writers, and that - // the journal still authorizes using the pool instead of host credentials. + // cannot send the new token to the old endpoint. Credential-store locks + // protect the flags, and the relay lock ties them to the private token. + // Before publishing that token, recheck both relay ownership and that + // the selected local or vault credential source has not changed. await writeFlags({ ...previous, enabled: false }) if (patch.token !== undefined) await updateNativeRuntime( @@ -574,7 +609,7 @@ export function createNativeAccountRuntime( }, { beforeRename: async () => { - await authority() + await requireMode(mode) await lease.assertOwned() }, }, @@ -584,30 +619,39 @@ export function createNativeAccountRuntime( ) }, async read() { - await authority() + const mode = await authority() + const selectedPaths = mode === 'claustrum' ? vaultPaths : paths + const selectedStore = mode === 'claustrum' ? vaultStore : store const [read, settings, metadata] = await Promise.all([ - store.read(), - store.readSettings(), - readNativeRuntime(paths.runtime, paths.storageId), + selectedStore.read(), + selectedStore.readSettings(), + readNativeRuntime(selectedPaths.runtime, selectedPaths.storageId), ]) if (read.status !== 'ready' || settings.status !== 'ready') throw new NativeRuntimeError('publication-refused') - const mode = ( - settings.settings.claustrum as { mode?: unknown } | undefined - )?.mode // Status reads use stored account metadata without fetching tokens or // requesting vault access. Refresh, or first authorization with no stored // account list, discovers authorized vault accounts. Only offline setup // may enroll the host and request its grant. const roster = mode === 'claustrum' ? await vault.read() : undefined check() - return projectNativeAccountViews({ - paths, + const snapshot = projectNativeAccountViews({ + paths: { ...paths, ...selectedPaths }, rows: read.rows, - settings: settings.settings, + settings: { + ...settings.settings, + claustrum: { + ...(settings.settings.claustrum as + | Record + | undefined), + mode, + }, + }, runtime: metadata.status === 'ready' ? metadata.state : undefined, roster, }) + if (mode === 'claustrum') snapshot.routingPath = vaultPaths.routing + return snapshot }, async authorizeLocal(routeId, request = {}) { await authority(request.signal) @@ -763,19 +807,7 @@ export function createNativeAccountRuntime( return result }, async authorizeApi(routeId, signal) { - await authority(signal) - const requireLocalMode = async () => { - const settings = await store.readSettings() - check(signal) - if (settings.status !== 'ready') - throw new NativeRuntimeError('publication-refused') - if ( - (settings.settings.claustrum as { mode?: unknown } | undefined) - ?.mode === 'claustrum' - ) - throw new NativeCustodyError('route-unavailable') - } - await requireLocalMode() + await localAuthority(signal) const row = await poolRow(await physicalId(routeId)) check(signal) if ( @@ -787,7 +819,7 @@ export function createNativeAccountRuntime( throw new NativeCustodyError('route-unavailable') // Reading the credential yields to other processes. Recheck custody // before returning a local key that vault mode would exclude. - await requireLocalMode() + await localAuthority(signal) return { ...row.credential, subject: { @@ -835,9 +867,9 @@ export function createNativeAccountRuntime( }) }, async putAccount(input) { - await authority() + await localAuthority() const snapshot = await runtime.read() - if (input.credential.type === 'oauth' && snapshot.mode !== 'local') + if (snapshot.mode !== 'local') throw new NativeCustodyError('route-unavailable') const id = input.routeId === 'main' @@ -903,11 +935,11 @@ export function createNativeAccountRuntime( }, }), async updateSettings(mutator) { - await authority() + const selected = await selectedStorage() // Filled from the copy the committed attempt started from; effects run // only after the write has been committed and changed the settings. const captured: { before?: NativeSettingsEffectFields } = {} - const result = await store.updateSettings(async (settings) => { + const result = await selected.store.updateSettings(async (settings) => { function activation(value: Record) { const custody = value.claustrum as Record | undefined return [ @@ -957,11 +989,14 @@ export function createNativeAccountRuntime( const row = current.accounts.find((row) => row.id === routeId) const account = { id: routeId, label: row?.label } const change = enabled ? 'account enabled' : 'account disabled' - if (row?.source === 'vault') { + if (current.mode === 'claustrum') { + if (row?.source !== 'vault') + throw new NativeCustodyError('route-unavailable') if (await vault.setEnabled(routeId, enabled)) logCommittedNativeAccountChange(change, account) return } + await localAuthority() const id = await physicalId(routeId) const operation = enabled ? 'enable' : 'disable' // Credential-pool enable/disable results identify the account but omit whether @@ -977,6 +1012,8 @@ export function createNativeAccountRuntime( quota: nativeQuotaCodec, now, onStep: async (step, info) => { + if (step === 'before-config-write' || step === 'before-state-write') + await localAuthority() if ( step !== 'before-config-write' || info.operation !== operation || @@ -991,6 +1028,7 @@ export function createNativeAccountRuntime( } catch { // An unreadable pool leaves the change unknown, so nothing is logged. } + await localAuthority() }, }) if (enabled) await transition.enable(id) @@ -999,103 +1037,41 @@ export function createNativeAccountRuntime( logCommittedNativeAccountChange(change, account) }, async remove(routeId) { + await localAuthority() const current = await runtime.read() + if (current.mode !== 'local') + throw new NativeCustodyError('route-unavailable') const row = current.accounts.find((row) => row.id === routeId) - const account = { id: routeId, label: row?.label } - if (row?.source === 'vault') { - // This call cannot remove an account from the vault. Disable its use - // by this plugin instead, and log only that committed change. - if (await vault.setEnabled(routeId, false)) - logCommittedNativeAccountChange('account disabled', account) - return - } - // 'removed' means this call dropped the account's roster row. const removed = await store.remove(await physicalId(routeId)) if (removed.outcome === 'removed') - logCommittedNativeAccountChange('account removed', account) + logCommittedNativeAccountChange('account removed', { + id: routeId, + label: row?.label, + }) }, async reorder(routeIds, movedRouteId) { - await authority() + await localAuthority() const snapshot = await runtime.read() + if (snapshot.mode !== 'local') + throw new NativeCustodyError('route-unavailable') const read = await store.read() if (read.status !== 'ready') throw new NativeRuntimeError('publication-refused') - const logReordered = () => + const ids = await Promise.all(routeIds.map(physicalId)) + const main = read.rows.find( + (row) => row.id === snapshot.settings.mainAccountId, + ) + if (main && !ids.includes(main.id)) ids.unshift(main.id) + if ((await store.reorder(ids)).outcome === 'reordered') logCommittedNativeAccountChange( 'account reordered', snapshot.accounts.find((row) => row.id === movedRouteId) ?? { id: movedRouteId, }, ) - if (snapshot.mode === 'claustrum') { - const order = [...routeIds] - if ( - snapshot.accounts.some((row) => row.id === 'main') && - !order.includes('main') - ) - order.unshift('main') - if ( - new Set(order).size !== order.length || - order.length !== snapshot.accounts.length || - order.some((id) => !snapshot.accounts.some((row) => row.id === id)) - ) - throw new NativeCustodyError('invalid-state') - const rosterReordered = await mutateVaultRoster( - paths.roster, - (roster) => { - if (!roster) throw new NativeCustodyError('route-unavailable') - const primary = nativeVaultPrimaryRow( - roster, - nativeVaultPrimaryPin(snapshot.settings), - ) - const ids = order - .filter( - (id) => - snapshot.accounts.find((row) => row.id === id)?.source === - 'vault', - ) - .map((id) => (id === 'main' ? primary?.routeId : id)) - if ( - ids.length !== roster.rows.length || - ids.some((id) => !roster.rows.some((row) => row.routeId === id)) - ) - throw new NativeCustodyError('identity-changed') - return { - next: { - ...roster, - rows: ids.map((id) => { - const row = roster.rows.find((row) => row.routeId === id) - if (!row) throw new NativeCustodyError('identity-changed') - return row - }), - }, - // Compare the requested order with the account rows read under - // the roster lock, so an unchanged order produces no change log. - result: ids.some( - (id, index) => roster.rows[index]?.routeId !== id, - ), - } - }, - ) - // Ordering spans the pool account config and the native vault roster. - // Their writes are not a two-file atomic transaction: an interruption - // can leave mixed presentation order, but neither roster loses a member. - const settingsWrite = await store.updateSettings((settings) => { - settings.nativeAccountOrder = order - }) - if (rosterReordered || settingsWrite.outcome === 'updated') - logReordered() - return - } - const ids = await Promise.all(routeIds.map(physicalId)) - const main = read.rows.find( - (row) => row.id === snapshot.settings.mainAccountId, - ) - if (main && !ids.includes(main.id)) ids.unshift(main.id) - if ((await store.reorder(ids)).outcome === 'reordered') logReordered() }, async captureLocalSubject(routeId) { - await authority() + await localAuthority() const row = await poolRow(await physicalId(routeId)) if (row?.credential?.type !== 'oauth' || row.stamp !== 'bound') throw new NativeCustodyError('route-unavailable') @@ -1127,7 +1103,7 @@ export function createNativeAccountRuntime( }, async publishLocal(subject, rawPatch) { const patch = captureNativeMetadataPatch(rawPatch) - await authority() + if ((await authority()) !== 'local') return false const { quota, ...metadata } = patch const quotaOnly = quota !== undefined && Object.keys(metadata).length === 0 @@ -1173,6 +1149,7 @@ export function createNativeAccountRuntime( })), async resetBackoff(routeId, fence, kind = 'all') { if ('accessToken' in fence) return vault.resetBackoff(fence, kind) + if ((await authority()) !== 'local') return false if ((await physicalId(routeId)) !== fence.binding.rowId) return false return localWrite( fence, @@ -1202,6 +1179,7 @@ export function createNativeAccountRuntime( }, async getOrCreateAuthLineage(routeId, fence) { if ('accessToken' in fence) return vault.getOrCreateAuthLineage(fence) + if ((await authority()) !== 'local') return undefined if ((await physicalId(routeId)) !== fence.binding.rowId) return undefined let lineage: string | undefined const kept = await localWrite(fence, (old) => { @@ -1217,6 +1195,7 @@ export function createNativeAccountRuntime( }, async incrementPrimeUsage(routeId, fence, delta = {}) { if ('accessToken' in fence) return vault.incrementPrimeUsage(fence, delta) + if ((await authority()) !== 'local') return false if ((await physicalId(routeId)) !== fence.binding.rowId) return false const captured = primeDelta(delta) return localWrite(fence, (old) => ({ diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts index 4ff57b13c..79a98e936 100644 --- a/packages/core/src/native-account-view.ts +++ b/packages/core/src/native-account-view.ts @@ -59,6 +59,8 @@ export interface NativeAccountSnapshot { mode: 'local' | 'claustrum' settings: Record accounts: NativeAccountView[] + /** Vault session-to-account assignments use this separate file. Local mode keeps the assignment file at the destination recorded by migration. */ + routingPath?: string /** AccountStorage-shaped input for pure policy predicates, never a credential or persistence authority. */ policyStorage: AccountStorage } @@ -281,7 +283,7 @@ export interface NativeVaultPrimaryPin { accountIdentity: string } -/** Offline setup records the primary account UUID and credential ID. Later vault discovery must not replace that selection. */ +/** Vault discovery saves the default credential ID, route ID and verified account UUID in vault settings. Readers compare that UUID to the current roster so a replaced default cannot inherit another account's metadata. */ export function nativeVaultPrimaryPin( settings: Record, ): NativeVaultPrimaryPin | undefined { diff --git a/packages/core/src/native-vault-runtime.ts b/packages/core/src/native-vault-runtime.ts index 26d922fd8..1635f7203 100644 --- a/packages/core/src/native-vault-runtime.ts +++ b/packages/core/src/native-vault-runtime.ts @@ -21,6 +21,7 @@ import { applyNativeMetadataPatch, captureNativeMetadataPatch, captureNativePlainData, + NATIVE_PRIMARY_CREDENTIAL_ID, type NativeAccountMetadataPatch, nativeAccountReadError, nativeQuotaFailure, @@ -36,6 +37,7 @@ import { type NativeCustodyReporterSource, type NativeCustodyRetry, } from './native-custody.ts' +import { readNativeEnrollmentMode } from './native-enrollment-authority.ts' import { nativeQuotaCodec } from './native-quota-codec.ts' import { buildNativeVaultRosterSeed, @@ -52,11 +54,10 @@ import { readNativeRuntime, updateNativeRuntime, } from './native-runtime.ts' +import type { NativeVaultPaths } from './native-vault-paths.ts' +import { nativeVaultPaths } from './native-vault-paths.ts' import { fetchOAuthAccountProfile } from './oauth-profile.ts' -import { - NativeAuthorityError, - requireNativePoolAuthority, -} from './pool-authority.ts' +import { NativeAuthorityError } from './pool-authority.ts' import type { NativePoolPaths } from './pool-paths.ts' import { createNativePoolStore } from './pool-store.ts' @@ -207,7 +208,7 @@ export interface NativeVaultRuntime { /** Publish initial account metadata only while the migration controller holds its journal lock and verifies that the host is stopped. */ export async function publishNativeVaultRosterSeed( - paths: NativePoolPaths, + paths: Pick, input: NativeRosterSeedInput, options: { assertOwned?: () => Promise } = {}, ): Promise { @@ -230,7 +231,7 @@ export async function publishNativeVaultRosterSeed( /** Bind imported metadata to the vault account list. The migration controller supplies journal-lock and stopped-host checks; this writer owns neither. */ export async function publishNativeVaultRuntimeSeed( - paths: NativePoolPaths, + paths: Pick, state: NativeRuntimeState, options: { assertOwned?: () => Promise } = {}, ): Promise { @@ -290,7 +291,7 @@ export async function discoverNativeVaultInventory( export function createNativeVaultRuntime( options: NativeVaultRuntimeOptions, ): NativeVaultRuntime { - const paths = Object.freeze({ ...options.paths }) + const paths = Object.freeze(nativeVaultPaths(options.paths)) const env = { ...(options.env ?? process.env) } const shutdown = new AbortController() const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) @@ -312,7 +313,7 @@ export function createNativeVaultRuntime( >() const retryUsed = new WeakSet() const versions = new Map() - let reservedRouteIds = new Set(['main']) + const reservedRouteIds = new Set(['main']) let refreshing: Promise | undefined function check(signal?: AbortSignal) { @@ -325,22 +326,72 @@ export function createNativeVaultRuntime( } async function active() { check() - await requireNativePoolAuthority(paths) - const read = await store.readSettings() - if (read.status !== 'ready') throw new NativeCustodyError('invalid-state') - const rows = await store.read() - if (rows.status !== 'ready') throw new NativeCustodyError('invalid-state') - reservedRouteIds = new Set(['main', ...rows.rows.map((row) => row.id)]) - return ( - (read.settings.claustrum as { mode?: unknown } | undefined)?.mode === - 'claustrum' - ) + const mode = await readNativeEnrollmentMode(options.host, env) + check() + return mode === 'claustrum' } async function requireActive(signal?: AbortSignal) { check(signal) if (!(await active())) throw new NativeCustodyError('route-unavailable') check(signal) } + async function publishPrimaryPin(signal?: AbortSignal) { + await requireActive(signal) + await mutateVaultRoster(paths.roster, async (roster, lease) => { + const primary = roster?.rows.find( + (row) => + (row.credentialId === NATIVE_PRIMARY_CREDENTIAL_ID || + row.aliases?.includes(NATIVE_PRIMARY_CREDENTIAL_ID)) && + row.credentialType === 'oauth' && + row.state === 'active' && + !row.stale && + !row.unclaimed && + typeof row.accountIdentity === 'string' && + row.accountIdentity.length > 0, + ) + const read = await store.readSettings() + if (read.status !== 'ready') throw new NativeCustodyError('invalid-state') + const previous = nativeVaultPrimaryPin(read.settings) + if ( + (!primary && !previous) || + (primary && + previous?.routeId === primary.routeId && + previous.credentialId === NATIVE_PRIMARY_CREDENTIAL_ID && + previous.accountIdentity === primary.accountIdentity) + ) + return { result: undefined } + const writer = createNativePoolStore({ + paths, + quota: nativeQuotaCodec, + onStep: async (point) => { + if ( + point === 'before-config-write' || + point === 'before-state-write' + ) { + await requireActive(signal) + await lease.assertOwned() + } + }, + }) + await writer.updateSettings((settings) => { + if (primary) { + settings.mainAccountId = primary.routeId + settings.claustrum = { + primaryAccount: { + credentialId: NATIVE_PRIMARY_CREDENTIAL_ID, + accountId: primary.accountIdentity, + }, + } + } else { + delete settings.mainAccountId + delete settings.claustrum + } + return settings + }) + return { result: undefined } + }) + } + async function rowFor( roster: NativeVaultRosterDocument | undefined, routeId: string, @@ -366,7 +417,9 @@ export function createNativeVaultRuntime( check() return roster }, - refresh(signal) { + async refresh(signal) { + check(signal) + if (!(await active())) return undefined check(signal) // Cancellation belongs to this waiter, not to a peer using shared discovery. refreshing ??= refreshVaultRoster({ @@ -378,7 +431,9 @@ export function createNativeVaultRuntime( }).finally(() => { refreshing = undefined }) - return waitFor(refreshing, signalFor(signal)) + const roster = await waitFor(refreshing, signalFor(signal)) + await publishPrimaryPin(signal) + return roster }, async authorize(logicalId, signal) { const combined = signalFor(signal) diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts index 48d7278aa..a652b51ff 100644 --- a/packages/core/src/tests/native-account-runtime.test.ts +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -2,8 +2,10 @@ import { expect, spyOn } from 'bun:test' import * as filesystem from 'node:fs/promises' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { disconnectClaustrumEnrollment } from '@cortexkit/common-auth/claustrum' import { ClaudeOAuthRefreshError } from '../auth.ts' import type { ProviderAccountUuid } from '../claude-code.ts' +import { getHostClaustrumEnrollmentPaths } from '../claustrum-enrollment.ts' import { __setLogTestSink, getLogLevel, @@ -61,9 +63,16 @@ async function fixture( settings.mainAccountId = 'imported-main' }) const counts = { lookups: 0, refresh: 0 } + const env = { + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + root, + 'opencode-enrollment.json', + ), + } const runtime = createNativeAccountRuntime({ paths, host: 'opencode', + vault: { env }, local: { now: () => now, resolveIdentity: async () => { @@ -87,7 +96,7 @@ async function fixture( }, }) deferCleanup(() => runtime.close()) - return { paths, runtime, store, counts } + return { paths, runtime, store, counts, env } } test('native facade reads no legacy authority and does not import legacy persistence', async () => { @@ -1089,7 +1098,11 @@ test('two writers enabling the same disabled account log one change, judged by t await f.runtime.setEnabled('fallback-a', false) // A second account runtime shares the same credential/configuration files, // representing another process. - const other = createNativeAccountRuntime({ paths: f.paths, host: 'opencode' }) + const other = createNativeAccountRuntime({ + paths: f.paths, + host: 'opencode', + vault: { env: f.env }, + }) deferCleanup(() => other.close()) const logs = captureCommandLogs() @@ -1391,9 +1404,12 @@ test('vault mode refuses direct local API authorization without changing stored routeId: 'local-api', apiKey: 'synthetic-local-api-secret', }) - await f.store.updateSettings((settings) => { - settings.claustrum = { mode: 'claustrum' } - }) + const enrollment = getHostClaustrumEnrollmentPaths('opencode', f.env) + await writeFile( + enrollment.tokenPath, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) const before = await Promise.all([ readFile(f.paths.config, 'utf8'), readFile(f.paths.state, 'utf8'), @@ -1408,9 +1424,9 @@ test('vault mode refuses direct local API authorization without changing stored ]), ).toEqual(before) expect(f.counts.lookups + f.counts.refresh).toBe(0) - await f.store.updateSettings((settings) => { - settings.claustrum = { mode: 'local' } - }) + expect( + await disconnectClaustrumEnrollment(enrollment, 'anthropic-auth-opencode'), + ).toBe('disconnected') expect((await f.runtime.authorizeApi('local-api')).apiKey).toBe( 'synthetic-local-api-secret', ) @@ -1532,3 +1548,39 @@ test('OAuth login rechecks migration authority after delayed identity validation expect(result.error).toHaveProperty('code', 'migration-required') expect(await loginStoredBytes(f.paths)).toEqual(before) }) + +test('enrollment rejects captured local metadata and Prime writes before reopening local recovery files', async () => { + const f = await fixture() + const prior = await f.runtime.authorizeLocal('main') + if (prior.status !== 'usable') + throw new Error('Local subject was not admitted') + await writeFile( + getHostClaustrumEnrollmentPaths('opencode', f.env).tokenPath, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const files = [ + f.paths.config, + f.paths.state, + f.paths.runtime, + f.paths.journal, + ] + for (const file of files) + await writeFile(file, 'synthetic-unreadable-local-state', { mode: 0o600 }) + const before = await Promise.all(files.map((path) => readFile(path))) + expect( + await f.runtime.publishLocal(prior.subject, { + quota: { + accountIdentity: uuid, + checkedAt: now, + five_hour: { usedPercent: 99, remainingPercent: 1, checkedAt: now }, + }, + }), + ).toBe(false) + expect(await f.runtime.resetBackoff('main', prior.subject)).toBe(false) + expect( + await f.runtime.getOrCreateAuthLineage('main', prior.subject), + ).toBeUndefined() + expect(await f.runtime.incrementPrimeUsage('main', prior.subject)).toBe(false) + expect(await Promise.all(files.map((path) => readFile(path)))).toEqual(before) +}) diff --git a/packages/core/src/tests/native-vault-runtime.test.ts b/packages/core/src/tests/native-vault-runtime.test.ts index 4a9c087e4..912a74a16 100644 --- a/packages/core/src/tests/native-vault-runtime.test.ts +++ b/packages/core/src/tests/native-vault-runtime.test.ts @@ -1,12 +1,14 @@ import { expect, spyOn } from 'bun:test' import * as filesystem from 'node:fs/promises' -import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { mutateVaultRoster, projectVaultRoster, readVaultRoster, } from '@cortexkit/common-auth/claustrum' +import { getHostClaustrumEnrollmentPaths } from '../claustrum-enrollment.ts' import { __setLogTestSink, getLogLevel, @@ -17,6 +19,7 @@ import { createNativeAccountRuntime } from '../native-account-runtime.ts' import type { NativeCustodyClient } from '../native-custody.ts' import { nativeQuotaCodec } from '../native-quota-codec.ts' import { readNativeRuntime, updateNativeRuntime } from '../native-runtime.ts' +import { nativeVaultPaths } from '../native-vault-paths.ts' import { acquireNativeVaultRuntime, createNativeVaultRuntime, @@ -51,16 +54,26 @@ const listed: Row = { } async function fixture() { - const parent = new URL( - '../../../../node_modules/.cache/native-vault-runtime/', - import.meta.url, - ).pathname - await mkdir(parent, { recursive: true }) - const root = await mkdtemp(join(parent, 'fixture-')) + const root = await mkdtemp(join(tmpdir(), 'native-vault-runtime-')) deferCleanup(() => rm(root, { recursive: true, force: true })) const paths = await resolveNativePoolPaths(join(root, 'anthropic-auth.json')) await initializeNativeTestAuthority(paths) - const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + const vaultPaths = nativeVaultPaths(paths) + const env = { + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + root, + 'opencode-enrollment.json', + ), + } + await writeFile( + env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const store = createNativePoolStore({ + paths: vaultPaths, + quota: nativeQuotaCodec, + }) await store.initialize() const projected = projectVaultRoster( undefined, @@ -83,7 +96,6 @@ async function fixture() { await store.updateSettings((settings) => { settings.mainAccountId = primaryRouteId settings.claustrum = { - mode: 'claustrum', primaryAccount: { credentialId: id, accountId: uuid, state: 'active' }, } }) @@ -119,6 +131,7 @@ async function fixture() { const options = { paths, host: 'opencode' as const, + env, now: () => now, connect: async () => { counts.connects++ @@ -130,6 +143,8 @@ async function fixture() { deferCleanup(() => runtime.close()) return { paths, + vaultPaths, + env, options, runtime, counts, @@ -192,7 +207,7 @@ test('delayed vault fallback GET rejects a newly expired or revoked roster row b } }) -test('vault main authorization preserves offline primary credential UUID and seeded route pin', async () => { +test('vault main follows the verified default but rejects a tampered current route binding', async () => { const f = await fixture() await f.runtime.refresh() f.rows([{ ...listed, accountId: uuid2 }]) @@ -202,12 +217,12 @@ test('vault main authorization preserves offline primary credential UUID and see http++ return receipt }) - await expect(pending).rejects.toMatchObject({ code: 'route-unavailable' }) - expect(f.counts.gets).toBe(0) - expect(http).toBe(0) - // Reusing the setup-selected primary route must not authorize a different - // account UUID or vault credential than setup recorded. - await mutateVaultRoster(f.paths.roster, (roster) => { + expect((await pending).accountIdentity).toBe(uuid2) + expect(f.counts.gets).toBe(1) + expect(http).toBe(1) + // Once discovery pins the verified default, changing its route, UUID or + // credential in the roster must not authorize a different account. + await mutateVaultRoster(f.vaultPaths.roster, (roster) => { if (!roster?.rows[0]) throw new Error('Missing fixture roster') roster.rows[0].routeId = f.primaryRouteId return { next: roster, result: undefined } @@ -215,7 +230,7 @@ test('vault main authorization preserves offline primary credential UUID and see await expect(f.runtime.authorize('main')).rejects.toMatchObject({ code: 'route-unavailable', }) - await mutateVaultRoster(f.paths.roster, (roster) => { + await mutateVaultRoster(f.vaultPaths.roster, (roster) => { if (!roster?.rows[0]) throw new Error('Missing fixture roster') roster.rows[0].accountIdentity = uuid roster.rows[0].credentialId = 'oauth:anthropic:other' @@ -224,7 +239,7 @@ test('vault main authorization preserves offline primary credential UUID and see await expect(f.runtime.authorize('main')).rejects.toMatchObject({ code: 'route-unavailable', }) - expect(f.counts.gets).toBe(0) + expect(f.counts.gets).toBe(1) }) test('persisted vault read never connects while first authorization discovers and every attempt gets a fresh receipt', async () => { @@ -330,7 +345,7 @@ test('closing a pending vault list cannot publish its late result', async () => await expect(pending).rejects.toMatchObject({ code: 'closed' }) release.open() await settled.wait - expect(await readVaultRoster(f.paths.roster)).toBeUndefined() + expect(await readVaultRoster(f.vaultPaths.roster)).toBeUndefined() }) test('vault background 401 reports only the final rejected receipt and retries only once on strict newer version', async () => { @@ -496,7 +511,10 @@ test('vault receipt ownership and version fences reject copied, replaced and del await expect( f.runtime.publish({ ...second }, { lastUsed: now }), ).rejects.toMatchObject({ code: 'no-receipt' }) - const quota = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const quota = await readNativeRuntime( + f.vaultPaths.runtime, + f.vaultPaths.storageId, + ) expect( quota.status === 'ready' ? quota.state.accounts[(await f.runtime.read())!.rows[0]!.routeId] @@ -540,13 +558,16 @@ test('vault quota/profile and atomic Prime lineage remain secret-free in durable expect( await f.runtime.incrementPrimeUsage(receipt, { outputTokens: 3 }), ).toBe(true) - const state = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const state = await readNativeRuntime( + f.vaultPaths.runtime, + f.vaultPaths.storageId, + ) expect( state.status === 'ready' ? Object.values(state.state.accounts)[0]?.prime : undefined, ).toEqual({ count: 2, inputTokens: 2, outputTokens: 3, since: now }) - expect(await readFile(f.paths.runtime, 'utf8')).not.toContain( + expect(await readFile(f.vaultPaths.runtime, 'utf8')).not.toContain( 'synthetic-bearer', ) }) @@ -574,7 +595,7 @@ test('seed publication outer ownership is checked again at actual producer renam } let assertions = 0 await expect( - publishNativeVaultRosterSeed(f.paths, input, { + publishNativeVaultRosterSeed(f.vaultPaths, input, { assertOwned: async () => { if (++assertions === 2) throw new Error('synthetic outer lease lost at rename') @@ -582,8 +603,8 @@ test('seed publication outer ownership is checked again at actual producer renam }), ).rejects.toThrow('synthetic outer lease lost at rename') expect(assertions).toBe(2) - expect(await readVaultRoster(f.paths.roster)).toBeUndefined() - const seed = await publishNativeVaultRosterSeed(f.paths, input) + expect(await readVaultRoster(f.vaultPaths.roster)).toBeUndefined() + const seed = await publishNativeVaultRosterSeed(f.vaultPaths, input) expect(seed.rows[0]?.routeId).toBe('imported-main') }) @@ -591,15 +612,20 @@ test('custody runtime seed rejects unowned publication and descriptor callbacks const f = await fixture() const roster = await f.runtime.refresh() const routeId = roster!.rows[0]!.routeId - const before = await readFile(f.paths.runtime) + await updateNativeRuntime( + f.vaultPaths.runtime, + f.vaultPaths.storageId, + (current) => current, + ) + const before = await readFile(f.vaultPaths.runtime) const state = { version: 1 as const, - storageId: f.paths.storageId, + storageId: f.vaultPaths.storageId, accounts: { [routeId]: { binding: { kind: 'custody' as const, - storageId: f.paths.storageId, + storageId: f.vaultPaths.storageId, routeId, credentialId: id, accountIdentity: uuid, @@ -610,13 +636,13 @@ test('custody runtime seed rejects unowned publication and descriptor callbacks }, } await expect( - publishNativeVaultRuntimeSeed(f.paths, state, { + publishNativeVaultRuntimeSeed(f.vaultPaths, state, { assertOwned: async () => { throw new Error('lost outer lease') }, }), ).rejects.toMatchObject({ code: 'publication-refused' }) - expect(await readFile(f.paths.runtime)).toEqual(before) + expect(await readFile(f.vaultPaths.runtime)).toEqual(before) let reads = 0 Object.defineProperty(state.accounts[routeId], 'lastUsed', { get: () => { @@ -626,10 +652,10 @@ test('custody runtime seed rejects unowned publication and descriptor callbacks enumerable: true, }) await expect( - publishNativeVaultRuntimeSeed(f.paths, state), + publishNativeVaultRuntimeSeed(f.vaultPaths, state), ).rejects.toMatchObject({ code: 'publication-refused' }) expect(reads).toBe(0) - expect(await readFile(f.paths.runtime)).toEqual(before) + expect(await readFile(f.vaultPaths.runtime)).toEqual(before) }) test('vault route disable persists decline and a held roster prevents replaced-account runtime publication', async () => { @@ -640,7 +666,7 @@ test('vault route disable persists decline and a held roster prevents replaced-a await expect(f.runtime.authorize('main')).rejects.toMatchObject({ code: 'route-declined', }) - await mutateVaultRoster(f.paths.roster, (current) => { + await mutateVaultRoster(f.vaultPaths.roster, (current) => { if (!current) throw new Error('Missing fixture roster') current.rows[0]!.accountIdentity = uuid2 return { next: current, result: undefined } @@ -658,7 +684,10 @@ test('vault quota auth failures never arm quota backoff while 429 still does', a await expect(f.runtime.fetchQuota('main', transport)).rejects.toMatchObject( { status }, ) - const state = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const state = await readNativeRuntime( + f.vaultPaths.runtime, + f.vaultPaths.storageId, + ) const error = state.status === 'ready' ? state.state.accounts[f.primaryRouteId]?.lastQuotaRefreshError @@ -734,20 +763,24 @@ test('vault quota success and failure cannot inherit another account runtime cle for (const status of [200, 429]) { const f = await fixture() await f.runtime.refresh() - await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { - current.accounts[f.primaryRouteId] = { - binding: { - kind: 'custody', - storageId: f.paths.storageId, - routeId: f.primaryRouteId, - credentialId: id, - accountIdentity: uuid2, - recordVersion: 7, - }, - quotaErrorClearedAt: now + 1_000_000, - } - return current - }) + await updateNativeRuntime( + f.vaultPaths.runtime, + f.vaultPaths.storageId, + (current) => { + current.accounts[f.primaryRouteId] = { + binding: { + kind: 'custody', + storageId: f.vaultPaths.storageId, + routeId: f.primaryRouteId, + credentialId: id, + accountIdentity: uuid2, + recordVersion: 7, + }, + quotaErrorClearedAt: now + 1_000_000, + } + return current + }, + ) const transport: typeof fetch = Object.assign( async () => new Response('{}', { status }), { preconnect: fetch.preconnect }, @@ -757,7 +790,10 @@ test('vault quota success and failure cannot inherit another account runtime cle await expect( f.runtime.fetchQuota('main', transport), ).rejects.toMatchObject({ status: 429 }) - const state = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + const state = await readNativeRuntime( + f.vaultPaths.runtime, + f.vaultPaths.storageId, + ) const entry = state.status === 'ready' ? state.state.accounts[f.primaryRouteId] @@ -803,11 +839,7 @@ function accountRuntime(f: Awaited>) { const runtime = createNativeAccountRuntime({ paths: f.paths, host: 'opencode', - vault: { - now: f.options.now, - connect: f.options.connect, - readToken: f.options.readToken, - }, + vault: f.options, }) deferCleanup(() => runtime.close()) return runtime @@ -816,19 +848,19 @@ function accountRuntime(f: Awaited>) { test('vault setEnabled reports whether the route flag changed, judged on the roster under its lock', async () => { const f = await fixture() await f.runtime.refresh() - const row = async () => (await readVaultRoster(f.paths.roster))?.rows[0] + const row = async () => (await readVaultRoster(f.vaultPaths.roster))?.rows[0] expect(await f.runtime.setEnabled('main', false)).toBe(true) expect(await f.runtime.setEnabled('main', false)).toBe(false) expect((await row())?.enabled).toBe(false) - expect((await readVaultRoster(f.paths.roster))?.declined).toEqual([ + expect((await readVaultRoster(f.vaultPaths.roster))?.declined).toEqual([ { credentialId: id, accountIdentity: uuid }, ]) expect(await f.runtime.setEnabled('main', true)).toBe(true) expect(await f.runtime.setEnabled('main', true)).toBe(false) expect((await row())?.enabled).toBe(true) - expect((await readVaultRoster(f.paths.roster))?.declined).toEqual([]) + expect((await readVaultRoster(f.vaultPaths.roster))?.declined).toEqual([]) await expect( f.runtime.setEnabled('missing-route', false), @@ -869,11 +901,14 @@ test('two native runtimes disabling the same vault account log one change, judge }, ]) - // Removing a vault account declines it: the change is logged as a disable, - // once, and removing it again changes nothing. + // Explicit enable/disable changes only this plugin's use of the vault account. + // Removing credentials is not a substitute for declining their use. await a.setEnabled('main', true) - await a.remove('main') - await a.remove('main') + await expect(a.remove('main')).rejects.toMatchObject({ + code: 'route-unavailable', + }) + await a.setEnabled('main', false) + await a.setEnabled('main', false) expect(logs.take()).toEqual([ { message: 'account enabled', @@ -886,7 +921,7 @@ test('two native runtimes disabling the same vault account log one change, judge ]) }) -test('vault account reorder logs once when the locked roster or stored order changes', async () => { +test('vault account reorder refuses without changing roster, settings, or local state', async () => { const f = await fixture() f.rows([ listed, @@ -907,29 +942,46 @@ test('vault account reorder logs once when the locked roster or stored order cha if (!first || !second) throw new Error('Missing fixture fallback routes') const logs = captureCommandLogs() - await a.reorder([second, first], second) - expect(await fallbacks()).toEqual([second, first]) - // Asking for the order already stored writes nothing new, so only the - // reorder above is logged. - await a.reorder([second, first], second) - expect(logs.take()).toEqual([ - { - message: 'account reordered', - payload: expect.objectContaining({ id: second }), - }, - ]) + const files = [ + f.vaultPaths.roster, + f.vaultPaths.config, + f.paths.config, + f.paths.state, + f.paths.journal, + ] + const readBytes = async (path: string) => + readFile(path).catch((error: unknown) => { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) + return undefined + throw error + }) + const before = await Promise.all(files.map(readBytes)) + await expect(a.reorder([second, first], second)).rejects.toMatchObject({ + code: 'route-unavailable', + }) + expect(await fallbacks()).toEqual([first, second]) + await expect(a.reorder([first, second], first)).rejects.toMatchObject({ + code: 'route-unavailable', + }) + expect(await Promise.all(files.map(readBytes))).toEqual(before) + expect(logs.take()).toEqual([]) }) test('vault profile I/O failure rechecks non-secret provenance without another GET', async () => { const f = await fixture() const initial = await f.runtime.authorize('main') expect(await f.runtime.publish(initial, { lastUsed: now })).toBe(true) - const before = await readFile(f.paths.runtime) + const before = await readFile(f.vaultPaths.runtime) const originalRename = filesystem.rename let failedWrites = 0 const writer = spyOn(filesystem, 'rename').mockImplementation( async (from, to) => { - if (to === f.paths.runtime) { + if (to === f.vaultPaths.runtime) { failedWrites++ throw Object.assign( new Error('Synthetic vault profile rename failure'), @@ -953,7 +1005,7 @@ test('vault profile I/O failure rechecks non-secret provenance without another G const result = await f.runtime.fetchProfileForDisplay('main', transport) expect(await result.persisted).toBe('failed') expect(failedWrites).toBe(1) - expect(await readFile(f.paths.runtime)).toEqual(before) + expect(await readFile(f.vaultPaths.runtime)).toEqual(before) const gets = f.counts.gets expect(await result.isCurrent()).toBe(true) expect(f.counts.gets).toBe(gets) @@ -1005,3 +1057,217 @@ test('a newer vault receipt refuses stale profile publication and display', asyn expect(await result.persisted).toBe('refused') expect(await result.isCurrent()).toBe(false) }) + +async function preservedLocalFiles(f: Awaited>) { + const files = [ + f.paths.legacyConfig, + f.paths.legacyState, + f.paths.config, + f.paths.state, + f.paths.runtime, + f.paths.journal, + f.paths.roster, + ] + for (const file of files) + await writeFile(file, 'synthetic-unreadable-local-state', { mode: 0o600 }) + const before = await Promise.all(files.map((file) => readFile(file))) + return async () => + expect(await Promise.all(files.map((file) => readFile(file)))).toEqual( + before, + ) +} + +test('approved vault recovery discovers its own main without reading local credentials or migration authority', async () => { + const f = await fixture() + const unchanged = await preservedLocalFiles(f) + await f.store.updateSettings((settings) => { + delete settings.mainAccountId + delete settings.claustrum + }) + const facade = accountRuntime(f) + await facade.vault.refresh() + const snapshot = await facade.read() + expect(snapshot.mode).toBe('claustrum') + expect(snapshot.routingPath).toBe(f.vaultPaths.routing) + expect(snapshot.accounts).toHaveLength(1) + expect(snapshot.accounts[0]).toMatchObject({ + id: 'main', + source: 'vault', + accountIdentity: uuid, + }) + const saved = await f.store.readSettings() + if (saved.status !== 'ready') throw new Error('Vault settings are missing') + expect(saved.settings.mainAccountId).toBe(f.primaryRouteId) + expect(saved.settings.claustrum).toEqual({ + primaryAccount: { credentialId: id, accountId: uuid }, + }) + expect(f.counts.gets).toBe(0) + await unchanged() +}) + +test('vault-only settings, relay and account controls preserve every local file', async () => { + const f = await fixture() + const unchanged = await preservedLocalFiles(f) + const facade = accountRuntime(f) + await facade.vault.refresh() + await facade.updateSettings((settings) => { + settings.routing = { mode: 'fallback-first' } + }) + await facade.updateRelay({ + enabled: true, + url: 'https://relay.example.invalid', + token: 'synthetic-private-relay', + transport: 'http', + }) + expect(await facade.getRelayConfig()).toMatchObject({ + enabled: true, + token: 'synthetic-private-relay', + }) + const receipt = await facade.authorizeVault('main') + expect( + await facade.vault.publish(receipt, { + lastQuotaRefreshError: { + message: 'synthetic quota failure', + checkedAt: now, + accountIdentity: uuid, + }, + }), + ).toBe(true) + expect(await facade.resetBackoff('main', receipt, 'quota')).toBe(true) + await facade.setEnabled('main', false) + expect((await facade.read()).accounts[0]?.enabled).toBe(false) + await facade.setEnabled('main', true) + expect((await facade.read()).accounts[0]?.enabled).toBe(true) + const saved = await f.store.readSettings() + if (saved.status !== 'ready') throw new Error('Vault settings are missing') + expect(saved.settings.routing).toEqual({ mode: 'fallback-first' }) + expect(JSON.stringify(saved.settings)).not.toContain( + 'synthetic-private-relay', + ) + const metadata = await readNativeRuntime( + f.vaultPaths.runtime, + f.vaultPaths.storageId, + ) + if (metadata.status !== 'ready') throw new Error('Vault metadata is missing') + expect(metadata.state.relay?.token).toBe('synthetic-private-relay') + expect( + metadata.state.accounts[f.primaryRouteId]?.lastQuotaRefreshError, + ).toBeUndefined() + await unchanged() +}) + +test('stale local account actions refuse in vault mode before reading or writing the local pool', async () => { + const f = await fixture() + const unchanged = await preservedLocalFiles(f) + const facade = accountRuntime(f) + await expect( + facade.addApi({ + routeId: 'stale', + apiKey: 'synthetic-private-key', + baseURL: 'https://api.example.invalid', + }), + ).rejects.toMatchObject({ code: 'route-unavailable' }) + await expect(facade.remove('stale')).rejects.toMatchObject({ + code: 'route-unavailable', + }) + await expect(facade.reorder(['stale'])).rejects.toMatchObject({ + code: 'route-unavailable', + }) + await expect(facade.captureLocalSubject('main')).rejects.toMatchObject({ + code: 'route-unavailable', + }) + await expect( + facade.loginOAuth({ + routeId: 'stale', + credential: { + access: 'sk-ant-oat01-synthetic-local', + refresh: 'synthetic-local-refresh', + expires: now + 3_600_000, + }, + }), + ).rejects.toMatchObject({ code: 'route-unavailable' }) + expect(f.counts.gets).toBe(0) + await unchanged() +}) + +test('replacing the verified vault default re-pins a new account without quota or Prime inheritance', async () => { + const f = await fixture() + const unchanged = await preservedLocalFiles(f) + const facade = accountRuntime(f) + await facade.vault.refresh() + const old = await facade.authorizeVault('main') + expect( + await facade.vault.publish(old, { + quota: { + accountIdentity: uuid, + checkedAt: now, + five_hour: { usedPercent: 99, remainingPercent: 1, checkedAt: now }, + }, + }), + ).toBe(true) + const oldLineage = await facade.getOrCreateAuthLineage('main', old) + expect(oldLineage).toMatch(/^[a-f0-9-]{36}$/) + expect( + await facade.incrementPrimeUsage('main', old, { inputTokens: 2 }), + ).toBe(true) + f.rows([{ ...listed, accountId: uuid2 }]) + await facade.vault.refresh() + const snapshot = await facade.read() + const main = snapshot.accounts.find((row) => row.id === 'main') + expect(main?.accountIdentity).toBe(uuid2) + expect(main?.quota).toBeUndefined() + expect(main?.prime).toBeUndefined() + expect(snapshot.settings.mainAccountId).not.toBe(f.primaryRouteId) + expect(await facade.vault.publish(old, { lastUsed: now })).toBe(false) + const current = await facade.authorizeVault('main') + expect(current.accountIdentity).toBe(uuid2) + expect(await facade.getOrCreateAuthLineage('main', current)).not.toBe( + oldLineage, + ) + await unchanged() +}) + +test('approved enrollment missing its token refuses vault credentials and never opens local recovery files', async () => { + const f = await fixture() + const unchanged = await preservedLocalFiles(f) + const enrollment = getHostClaustrumEnrollmentPaths('opencode', f.env) + await writeFile( + enrollment.statePath, + JSON.stringify({ + version: 1, + phase: 'approved', + proposedName: 'anthropic-auth-opencode', + tokenGeneration: 1, + updatedAt: now, + }), + { mode: 0o600 }, + ) + await rm(enrollment.tokenPath) + const facade = createNativeAccountRuntime({ + paths: f.paths, + host: 'opencode', + vault: { ...f.options, readToken: undefined }, + }) + deferCleanup(() => facade.close()) + expect((await facade.read()).mode).toBe('claustrum') + await expect(facade.authorizeVault('main')).rejects.toBeInstanceOf(Error) + expect(f.counts.gets).toBe(0) + await unchanged() +}) + +test('unverified default discovery removes main admission without inheriting a local account identity', async () => { + const f = await fixture() + const unchanged = await preservedLocalFiles(f) + const facade = accountRuntime(f) + await facade.vault.refresh() + f.rows([{ ...listed, accountId: undefined }]) + await facade.vault.refresh() + const snapshot = await facade.read() + expect(snapshot.mode).toBe('claustrum') + expect(snapshot.accounts.find((row) => row.id === 'main')).toBeUndefined() + await expect(facade.authorizeVault('main')).rejects.toMatchObject({ + code: 'route-unavailable', + }) + expect(f.counts.gets).toBe(0) + await unchanged() +}) diff --git a/packages/pi/src/native.ts b/packages/pi/src/native.ts index 1f5342977..8809ac51c 100644 --- a/packages/pi/src/native.ts +++ b/packages/pi/src/native.ts @@ -123,9 +123,15 @@ export class PiNativeRuntime { } async routingPath(): Promise { - // Read the recorded destination of the session-to-account routing file. - // A guessed path would lose the account assignments preserved by migration. - await this.service() + const runtime = await this.service() + const snapshot = await runtime.read() + if (snapshot.mode === 'claustrum') { + if (!snapshot.routingPath) + throw new Error('Vault routing storage is unavailable') + return snapshot.routingPath + } + // Keep local session-to-account assignments at the exact file destination + // recorded by migration; guessing a new path would lose those assignments. const journal = await readNativeMigrationJournal(await this.paths) if ( !journal || From 15e327e4190eea4516dff3253cc65150f1ccff6d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:32:58 +0200 Subject: [PATCH 134/169] test(core): isolate native profile enrollment and vault metadata --- .../src/tests/native-status-profile.test.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tests/native-status-profile.test.ts b/packages/core/src/tests/native-status-profile.test.ts index 26629801c..cd9600fa5 100644 --- a/packages/core/src/tests/native-status-profile.test.ts +++ b/packages/core/src/tests/native-status-profile.test.ts @@ -9,6 +9,7 @@ import type { ProviderAccountUuid } from '../claude-code.ts' import { createNativeAccountRuntime } from '../native-account-runtime.ts' import type { NativeCustodyClient } from '../native-custody.ts' import { nativeQuotaCodec } from '../native-quota-codec.ts' +import { nativeVaultPaths } from '../native-vault-paths.ts' import { createNativeVaultRuntime } from '../native-vault-runtime.ts' import { resolveNativePoolPaths } from '../pool-paths.ts' import { createNativePoolStore, nativePoolStoreLocks } from '../pool-store.ts' @@ -61,6 +62,14 @@ async function localFixture() { const runtime = createNativeAccountRuntime({ paths, host: 'opencode', + vault: { + env: { + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + dirname(paths.config), + 'opencode-enrollment.json', + ), + }, + }, local: { now: () => now, resolveIdentity: async () => ({ @@ -80,7 +89,22 @@ async function localFixture() { async function vaultFixture() { const paths = await fixtureRoot('native-status-profile-vault') await initializeNativeTestAuthority(paths) - const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + const vaultPaths = nativeVaultPaths(paths) + const env = { + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + dirname(paths.config), + 'opencode-enrollment.json', + ), + } + await writeFile( + env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const store = createNativePoolStore({ + paths: vaultPaths, + quota: nativeQuotaCodec, + }) await store.initialize() const credentialId = 'oauth:anthropic' const projected = projectVaultRoster( @@ -104,7 +128,6 @@ async function vaultFixture() { await store.updateSettings((settings) => { settings.mainAccountId = primaryRouteId settings.claustrum = { - mode: 'claustrum', primaryAccount: { credentialId, accountId: uuid, state: 'active' }, } }) @@ -140,12 +163,13 @@ async function vaultFixture() { const runtime = createNativeVaultRuntime({ paths, host: 'opencode', + env, now: () => now, connect: async () => client, readToken: async () => ({ token: '01'.repeat(32), token_generation: 1 }), }) deferCleanup(() => runtime.close()) - return { paths, runtime } + return { paths: { ...paths, ...vaultPaths }, runtime } } function profileTransport( From 52efdb8244902f3b241956c9a887da3961a215af Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:21:32 +0200 Subject: [PATCH 135/169] fix(setup): isolate vault enrollment from local credential recovery --- packages/core/src/native-vault-runtime.ts | 33 +- .../src/tests/native-vault-runtime.test.ts | 34 ++ packages/opencode/src/setup/command.ts | 47 ++- .../src/setup/native-activation.test.ts | 347 ++++++++++++------ .../opencode/src/setup/native-activation.ts | 106 +++--- packages/opencode/src/setup/native-local.ts | 24 +- 6 files changed, 377 insertions(+), 214 deletions(-) diff --git a/packages/core/src/native-vault-runtime.ts b/packages/core/src/native-vault-runtime.ts index 1635f7203..422801075 100644 --- a/packages/core/src/native-vault-runtime.ts +++ b/packages/core/src/native-vault-runtime.ts @@ -65,6 +65,8 @@ export interface NativeVaultRuntimeOptions { paths: NativePoolPaths host: 'opencode' | 'pi' connect?: () => Promise + /** Reuse the inventory verifier supplied by offline setup without changing per-send scoped authorization. */ + discover?: () => Promise readToken?: () => Promise<{ token: string; token_generation: number }> env?: NodeJS.ProcessEnv now?: () => number @@ -156,11 +158,11 @@ export interface NativeVaultRuntime { source: NativeCustodyReporterSource, ): Promise /** - * Re-authorize after a 401 and admit one replay only for a strictly newer - * version of the same credential and account. With a `site`, the decision is - * logged as 'scoped 401 re-authorized' (versions and reason only, never - * credential material). It never reports the 401: the caller reports only - * the receipt whose response is final. + * After a 401, acquire a fresh receipt for the same credential and account. + * Permit one replay only if its recordVersion exceeds the rejected receipt's + * version. The optional site is a diagnostic call-site label: model requests, + * quota, profile or cache warming. Logs contain versions, never credentials. + * The caller reports only the receipt rejected by its final HTTP attempt. */ prepareRetry( receipt: NativeCustodyReceipt, @@ -181,7 +183,7 @@ export interface NativeVaultRuntime { fetchImpl?: typeof fetch, signal?: AbortSignal, ): Promise - /** Like fetchProfile, but returns before the profile is saved; see NativeDisplayProfile. */ + /** Return the fetched account profile without waiting for its metadata write. NativeDisplayProfile.persisted reports whether that asynchronous write succeeded, failed or was refused. */ fetchProfileForDisplay( routeId: string, fetchImpl?: typeof fetch, @@ -206,7 +208,7 @@ export interface NativeVaultRuntime { close(): void } -/** Publish initial account metadata only while the migration controller holds its journal lock and verifies that the host is stopped. */ +/** Publish the initial roster from validated account inventory. A caller importing captured state must fence that state through assertOwned, which runs again at the roster's actual publication. */ export async function publishNativeVaultRosterSeed( paths: Pick, input: NativeRosterSeedInput, @@ -229,7 +231,7 @@ export async function publishNativeVaultRosterSeed( ) } -/** Bind imported metadata to the vault account list. The migration controller supplies journal-lock and stopped-host checks; this writer owns neither. */ +/** Match imported metadata to the held vault roster before publishing it. The caller's assertOwned fences its captured input; the roster lease separately prevents account replacement during the metadata write. */ export async function publishNativeVaultRuntimeSeed( paths: Pick, state: NativeRuntimeState, @@ -262,7 +264,7 @@ export async function publishNativeVaultRuntimeSeed( }) } -/** Read inventory for offline setup; a list neither authorizes serving nor proves completeness without the controller's checks. */ +/** List accounts readable by this host's enrollment for offline setup. An inventory grants no bearer authority; every outbound request still needs a fresh scoped receipt. */ export async function discoverNativeVaultInventory( options: NativeVaultRuntimeOptions, signal?: AbortSignal, @@ -421,10 +423,11 @@ export function createNativeVaultRuntime( check(signal) if (!(await active())) return undefined check(signal) - // Cancellation belongs to this waiter, not to a peer using shared discovery. + // Abort this caller's wait, not the discovery operation: another caller + // may still need the result of that shared operation. refreshing ??= refreshVaultRoster({ path: paths.roster, - custody, + custody: options.discover ? { discover: options.discover } : custody, signal: shutdown.signal, isActive: active, projection: () => ({ reservedRouteIds }), @@ -485,7 +488,8 @@ export function createNativeVaultRuntime( }, async prepareRetry(served, signal, site) { const route = requireIssued(served) - // The re-authorized receipt, kept beside the decision for diagnostics. + // Return the retry decision with any fresh receipt, including a refused + // one, so diagnostics can record the newly observed recordVersion. const decide = async (): Promise< [NativeCustodyRetry, NativeCustodyReceipt | undefined] > => { @@ -665,8 +669,9 @@ export function createNativeVaultRuntime( if (!row) throw new NativeCustodyError('route-unavailable') // Update acceptance and the enabled flag together under the roster write // lock. Compare that held flag before changing it so repeated enable or - // disable calls report no change. It makes the same roster change as - // common-auth's acceptVaultRoute and declineVaultRoute. + // disable calls report no change. Enabling removes this credential/account + // pair from declined entries; disabling adds it so discovery cannot enable + // it again. return mutateVaultRoster(paths.roster, (current) => { const held = current?.rows.find( (entry) => entry.routeId === row.routeId, diff --git a/packages/core/src/tests/native-vault-runtime.test.ts b/packages/core/src/tests/native-vault-runtime.test.ts index 912a74a16..8333c639a 100644 --- a/packages/core/src/tests/native-vault-runtime.test.ts +++ b/packages/core/src/tests/native-vault-runtime.test.ts @@ -1271,3 +1271,37 @@ test('unverified default discovery removes main admission without inheriting a l expect(f.counts.gets).toBe(0) await unchanged() }) + +test('offline setup inventory populates only vault files and cannot replace per-send scoped authorization', async () => { + const f = await fixture() + const unchanged = await preservedLocalFiles(f) + let discoveries = 0 + const runtime = createNativeVaultRuntime({ + ...f.options, + discover: async () => { + discoveries++ + return { + view: 'offline-verified-inventory', + credentials: [ + { + credentialId: id, + credentialType: 'oauth', + accountIdentity: uuid, + state: 'active', + }, + ], + skipped: [], + } + }, + }) + deferCleanup(() => runtime.close()) + await runtime.refresh() + expect(discoveries).toBe(1) + expect(f.counts.connects).toBe(0) + expect(f.counts.gets).toBe(0) + const receipt = await runtime.authorize('main') + expect(receipt.accountIdentity).toBe(uuid) + expect(f.counts.connects).toBe(1) + expect(f.counts.gets).toBe(1) + await unchanged() +}) diff --git a/packages/opencode/src/setup/command.ts b/packages/opencode/src/setup/command.ts index bb1d28a30..f010d790c 100644 --- a/packages/opencode/src/setup/command.ts +++ b/packages/opencode/src/setup/command.ts @@ -8,6 +8,7 @@ import { outro, spinner, } from '@clack/prompts' +import { readNativeEnrollmentMode } from '@cortexkit/anthropic-auth-core' import { defaultCommandRunner } from './command-runner.ts' import { detectAll } from './detect.ts' import { activateNativeVaultForHost } from './native-activation.ts' @@ -35,7 +36,7 @@ export interface SetupCommandOptions { fence?: ProcessFence env?: NodeJS.ProcessEnv isInteractive?: boolean - /** Test seams for host detection and per-host vault activation. */ + /** Let tests substitute host detection and per-host vault activation. */ detect?: typeof detectAll activate?: typeof activateNativeVaultForHost migrateLocal?: typeof migrateNativeLocalForHost @@ -62,6 +63,13 @@ export async function runSetupCommand( ? (options.localLogin ?? loginNativeOffline) : undefined + if (explicitLogin && explicitClaustrum) { + log.error( + 'Local sign-in and vault setup are two separate choices. Use --login or --claustrum, not both.', + ) + return 1 + } + intro('CortexKit Anthropic Auth Setup') // 1. Detect environment @@ -153,7 +161,7 @@ export async function runSetupCommand( } else if (!nonInteractive) { const claustrumChoice = await confirm({ message: - 'Claustrum daemon is running. Switch Anthropic accounts to Claustrum vault custody? Local OAuth logins are removed once their vault accounts are verified.', + 'Use Claude accounts from the Claustrum vault? Local account files stay unchanged and are not used while connected.', initialValue: false, }) @@ -166,14 +174,32 @@ export async function runSetupCommand( } } - // Pi's stored OAuth takes precedence over the extension's account pool. + if (localLogin && useClaustrum) { + log.error( + 'Local sign-in and vault setup are two separate choices. Use --login or --claustrum, not both.', + ) + return 1 + } + if (localLogin || !useClaustrum) { + for (const host of selectedHosts) { + if ((await readNativeEnrollmentMode(host, env)) !== 'local') { + log.error('Disconnect from the vault first.') + return 1 + } + } + } + + // Pi chooses its stored Anthropic login before the extension's account pool. + // Moving it aside requires separate consent; ordinary --yes is insufficient. let removePiLocalAuth = false if (selectedHosts.includes('pi') && detection.pi.hasLocalAuth) { if (nonInteractive) { - // Deleting a stored login needs its own flag; --yes is not consent. + // Moving a stored login needs its own flag; --yes is not consent. if (!explicitRemovePiAuth) { log.error( - 'Pi has a stored Anthropic login. Rerun with --remove-pi-auth to move it into the native account pool.', + useClaustrum + ? 'Pi has stored Anthropic authentication. Rerun with --remove-pi-auth to preserve it privately during vault use and restore it on disconnect.' + : 'Pi has a stored Anthropic login. Rerun with --remove-pi-auth to move it into the native account pool.', ) outro('Setup aborted.') return 1 @@ -181,11 +207,14 @@ export async function runSetupCommand( removePiLocalAuth = true } else { log.warn( - 'Pi has a stored Anthropic OAuth token. Native account-pool setup moves credentials out of the host auth store.', + useClaustrum + ? 'Pi has stored Anthropic authentication. Vault setup must preserve it privately and restore it on disconnect.' + : 'Pi has a stored Anthropic OAuth token. Native account-pool setup moves credentials out of the host auth store.', ) const consent = await confirm({ - message: - 'Move the Pi Anthropic OAuth login into the native account pool and remove its stored copy?', + message: useClaustrum + ? 'Move Pi authentication aside privately for vault use, then restore it on disconnect?' + : 'Move the Pi Anthropic OAuth login into the native account pool and remove its stored copy?', initialValue: false, }) @@ -201,7 +230,7 @@ export async function runSetupCommand( } } - // 5. Verify host quiescence before any changes + // 5. Verify all hosts are stopped before making changes try { await assertHostsStopped(fence) } catch (error) { diff --git a/packages/opencode/src/setup/native-activation.test.ts b/packages/opencode/src/setup/native-activation.test.ts index 57d8ae7b8..f8d3b942d 100644 --- a/packages/opencode/src/setup/native-activation.test.ts +++ b/packages/opencode/src/setup/native-activation.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test' +import { expect } from 'bun:test' import { mkdir, mkdtemp, @@ -10,19 +10,23 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { - custodyTombstoneOAuth, + createNativeAccountRuntime, type NativeCustodyInventory, nativeMigrationAuthorityPhase, readNativeMigrationJournal, runNativeMigration, } from '@cortexkit/anthropic-auth-core' -import { readVaultRoster } from '@cortexkit/common-auth/claustrum' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { runSetupCommand } from './command.ts' import { activateNativeVaultForHost } from './native-activation.ts' +import { migrateNativeLocalForHost } from './native-local.ts' import { resolveNativeSetupPaths } from './native-paths.ts' import type { CommandRunner, HarnessKind, ProcessFence } from './types.ts' -const MAIN_IDENTITY = 'account-main-uuid' +const { test, deferCleanup } = createTestLifetimeSuite() + +const MAIN_IDENTITY = '11111111-2222-4333-8444-555555555555' const OTHER_PROVIDER = { type: 'api', key: 'synthetic-unrelated-provider' } const INVENTORY: NativeCustodyInventory = { view: 'synthetic-view', @@ -44,12 +48,18 @@ const INVENTORY: NativeCustodyInventory = { */ async function fixture(host: HarnessKind, running: string[] = []) { const root = await mkdtemp(join(tmpdir(), 'native-setup-activation-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) const env: NodeJS.ProcessEnv = { HOME: root, XDG_CONFIG_HOME: join(root, '.config'), XDG_DATA_HOME: join(root, '.local', 'share'), XDG_STATE_HOME: join(root, '.local', 'state'), PI_CODING_AGENT_DIR: join(root, '.pi', 'agent'), + [`${host.toUpperCase()}_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE`]: join( + root, + 'enrollment', + `${host}.json`, + ), } const paths = await resolveNativeSetupPaths(host, env) const calls: string[] = [] @@ -117,68 +127,81 @@ async function writeHostAuth(path: string, anthropic?: unknown) { ) } -test('a fresh OpenCode install is enrolled, migrated with a vault request and activated', async () => { +test('a fresh OpenCode install enrolls and discovers vault accounts without creating local authority or changing host authentication', async () => { const f = await fixture('opencode') - try { - await writeHostAuth(f.paths.hostAuthPath) - expect(await activateNativeVaultForHost('opencode', f.options)).toBe( - 'committed', - ) - expect(f.calls).toEqual([ - 'propose', - 'poll', - 'auth enroll approve', - 'poll', - 'auth grant --principal', - ]) - const journal = await readNativeMigrationJournal(f.paths.paths) - expect(nativeMigrationAuthorityPhase(journal)).toBe('retired') - expect(journal?.activation).toMatchObject({ - kind: 'activation', - phase: 'committed', - }) - expect((await readVaultRoster(f.paths.paths.roster))?.rows).toMatchObject([ - { credentialId: 'oauth:anthropic', accountIdentity: MAIN_IDENTITY }, - ]) - expect(JSON.parse(await readFile(f.paths.hostAuthPath, 'utf8'))).toEqual({ - anthropic: custodyTombstoneOAuth('anthropic'), - other: OTHER_PROVIDER, - }) - } finally { - await f.cleanup() - } + await writeHostAuth(f.paths.hostAuthPath) + expect(await activateNativeVaultForHost('opencode', f.options)).toBe( + 'committed', + ) + expect(f.calls).toEqual([ + 'propose', + 'poll', + 'auth enroll approve', + 'poll', + 'auth grant --principal', + ]) + expect(await readNativeMigrationJournal(f.paths.paths)).toBeUndefined() + const runtime = createNativeAccountRuntime({ + paths: f.paths.paths, + host: 'opencode', + vault: { env: f.options.env }, + }) + deferCleanup(() => runtime.close()) + expect((await runtime.read()).accounts).toMatchObject([ + { id: 'main', source: 'vault', accountIdentity: MAIN_IDENTITY }, + ]) + expect(JSON.parse(await readFile(f.paths.hostAuthPath, 'utf8'))).toEqual({ + other: OTHER_PROVIDER, + }) }) test('a retired local pool is activated without rerunning its migration', async () => { const f = await fixture('opencode') - try { - await writeHostAuth(f.paths.hostAuthPath) - await runNativeMigration({ - paths: f.paths.paths, - legacyConfigPath: f.paths.legacyConfigPath, - legacyStatePath: f.paths.legacyStatePath, - host: 'opencode', - hostAuthPath: f.paths.hostAuthPath, - routingSourcePath: f.paths.routingSourcePath, - routingDestinationPath: f.paths.routingDestinationPath, - env: {}, - processFence: async () => {}, - removePiAnthropicAuth: false, - }) - expect( - nativeMigrationAuthorityPhase( - await readNativeMigrationJournal(f.paths.paths), + await writeHostAuth(f.paths.hostAuthPath) + await runNativeMigration({ + paths: f.paths.paths, + legacyConfigPath: f.paths.legacyConfigPath, + legacyStatePath: f.paths.legacyStatePath, + host: 'opencode', + hostAuthPath: f.paths.hostAuthPath, + routingSourcePath: f.paths.routingSourcePath, + routingDestinationPath: f.paths.routingDestinationPath, + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: false, + }) + expect( + nativeMigrationAuthorityPhase( + await readNativeMigrationJournal(f.paths.paths), + ), + ).toBe('retired') + const files = [ + f.paths.paths.config, + f.paths.paths.state, + f.paths.paths.runtime, + f.paths.paths.journal, + f.paths.hostAuthPath, + ] + const bytes = async () => + Promise.all( + files.map((path) => + readFile(path).catch((error: unknown) => { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) + return undefined + throw error + }), ), - ).toBe('retired') - expect(await activateNativeVaultForHost('opencode', f.options)).toBe( - 'committed', ) - expect( - (await readNativeMigrationJournal(f.paths.paths))?.activation, - ).toMatchObject({ phase: 'committed' }) - } finally { - await f.cleanup() - } + const before = await bytes() + expect(await activateNativeVaultForHost('opencode', f.options)).toBe( + 'committed', + ) + expect(await bytes()).toEqual(before) }) test('environment, running-host and Pi consent fences refuse before enrollment or any file write', async () => { @@ -204,7 +227,7 @@ test('environment, running-host and Pi consent fences refuse before enrollment o host: 'pi', anthropic: { type: 'api_key', key: 'synthetic-pi-api-key' }, consent: true, - error: { code: 'primary-conflict' }, + error: { code: 'pi-slot-helper-required' }, }, { host: 'pi', @@ -219,65 +242,165 @@ test('environment, running-host and Pi consent fences refuse before enrollment o ] for (const item of cases) { const f = await fixture(item.host, item.running) - try { - await writeHostAuth(f.paths.hostAuthPath, item.anthropic) - const host = await readFile(f.paths.hostAuthPath, 'utf8') - await expect( - activateNativeVaultForHost(item.host, { - ...f.options, - env: { ...f.options.env, ...item.env }, - removePiAnthropicAuth: item.consent ?? false, - }), - ).rejects.toMatchObject(item.error) - expect(f.calls).toEqual([]) - expect(await readFile(f.paths.hostAuthPath, 'utf8')).toBe(host) - expect(await readNativeMigrationJournal(f.paths.paths)).toBeUndefined() - const entries = await readdir(f.root) - expect(entries.includes('enrollment')).toBe(false) - } finally { - await f.cleanup() - } + await writeHostAuth(f.paths.hostAuthPath, item.anthropic) + const host = await readFile(f.paths.hostAuthPath, 'utf8') + await expect( + activateNativeVaultForHost(item.host, { + ...f.options, + env: { ...f.options.env, ...item.env }, + removePiAnthropicAuth: item.consent ?? false, + }), + ).rejects.toMatchObject(item.error) + expect(f.calls).toEqual([]) + expect(await readFile(f.paths.hostAuthPath, 'utf8')).toBe(host) + expect(await readNativeMigrationJournal(f.paths.paths)).toBeUndefined() + const entries = await readdir(f.root) + expect(entries.includes('enrollment')).toBe(false) } }) -test('an unverified legacy main leaves credentials and migration authority unchanged', async () => { +test('vault enrollment ignores an unverified legacy main and preserves all legacy credential bytes', async () => { const f = await fixture('opencode') - try { - await mkdir(dirname(f.paths.legacyConfigPath), { - recursive: true, - mode: 0o700, - }) - const config = JSON.stringify({ - version: 1, - accounts: [], - mainAccountId: 'legacy-main', - }) - const state = JSON.stringify({ main: {} }) - await writeFile(f.paths.legacyConfigPath, config, { mode: 0o600 }) - await writeFile(f.paths.legacyStatePath, state, { mode: 0o600 }) - await writeHostAuth(f.paths.hostAuthPath, { - type: 'oauth', - access: 'synthetic-unverified-main-access', - refresh: 'synthetic-unverified-main-refresh', - expires: Date.now() + 3_600_000, - }) - const auth = await readFile(f.paths.hostAuthPath, 'utf8') - await expect( - activateNativeVaultForHost('opencode', f.options), - ).rejects.toThrow() - expect(await readNativeMigrationJournal(f.paths.paths)).toBeUndefined() - expect(await readFile(f.paths.legacyConfigPath, 'utf8')).toBe(config) - expect(await readFile(f.paths.legacyStatePath, 'utf8')).toBe(state) - expect(await readFile(f.paths.hostAuthPath, 'utf8')).toBe(auth) - for (const path of [ + await mkdir(dirname(f.paths.legacyConfigPath), { + recursive: true, + mode: 0o700, + }) + const config = JSON.stringify({ + version: 1, + accounts: [], + mainAccountId: 'legacy-main', + }) + const state = JSON.stringify({ main: {} }) + await writeFile(f.paths.legacyConfigPath, config, { mode: 0o600 }) + await writeFile(f.paths.legacyStatePath, state, { mode: 0o600 }) + await writeHostAuth(f.paths.hostAuthPath, { + type: 'oauth', + access: 'synthetic-unverified-main-access', + refresh: 'synthetic-unverified-main-refresh', + expires: Date.now() + 3_600_000, + }) + const auth = await readFile(f.paths.hostAuthPath, 'utf8') + expect(await activateNativeVaultForHost('opencode', f.options)).toBe( + 'committed', + ) + expect(await readNativeMigrationJournal(f.paths.paths)).toBeUndefined() + expect(await readFile(f.paths.legacyConfigPath, 'utf8')).toBe(config) + expect(await readFile(f.paths.legacyStatePath, 'utf8')).toBe(state) + expect(await readFile(f.paths.hostAuthPath, 'utf8')).toBe(auth) + for (const path of [ + f.paths.paths.config, + f.paths.paths.state, + f.paths.paths.runtime, + f.paths.paths.roster, + ]) { + await expect(readFile(path)).rejects.toMatchObject({ code: 'ENOENT' }) + } +}) + +test('vault setup never reads invalid local recovery files and never changes an existing host login', async () => { + const f = await fixture('opencode') + await writeHostAuth(f.paths.hostAuthPath, { + type: 'api', + key: 'synthetic-existing-host-key', + }) + const files = [ + ...new Set([ + f.paths.legacyConfigPath, + f.paths.legacyStatePath, f.paths.paths.config, f.paths.paths.state, f.paths.paths.runtime, + f.paths.paths.journal, f.paths.paths.roster, - ]) { - await expect(readFile(path)).rejects.toMatchObject({ code: 'ENOENT' }) - } - } finally { - await f.cleanup() + f.paths.routingDestinationPath, + ]), + ] + for (const path of files) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile(path, 'synthetic-invalid-local-recovery-data', { + mode: 0o600, + }) + } + const bytes = () => + Promise.all([...files, f.paths.hostAuthPath].map((path) => readFile(path))) + const before = await bytes() + expect(await activateNativeVaultForHost('opencode', f.options)).toBe( + 'committed', + ) + expect(await bytes()).toEqual(before) + expect(await activateNativeVaultForHost('opencode', f.options)).toBe( + 'already-vault', + ) + expect(await bytes()).toEqual(before) +}) + +test('combined local-login and vault flags refuse before detection or any file change', async () => { + const f = await fixture('opencode') + await writeHostAuth(f.paths.hostAuthPath, { + type: 'oauth', + access: 'synthetic-host-access', + refresh: 'synthetic-host-refresh', + expires: 1, + }) + const before = await readFile(f.paths.hostAuthPath) + let detections = 0 + expect( + await runSetupCommand(['--yes', '--login', '--claustrum'], { + env: f.options.env, + detect: async () => { + detections++ + throw new Error('Detection must not run') + }, + }), + ).toBe(1) + expect(detections).toBe(0) + expect(await readFile(f.paths.hostAuthPath)).toEqual(before) + expect(await readdir(f.root)).not.toContain('enrollment') +}) + +test('an enrolled host refuses local recovery before reading files or starting login', async () => { + const f = await fixture('opencode') + const token = f.options.enrollment.paths.tokenPath + await mkdir(dirname(token), { recursive: true, mode: 0o700 }) + await writeFile( + token, + JSON.stringify({ token: 'c'.repeat(64), token_generation: 1 }), + { mode: 0o600 }, + ) + const files = [ + ...new Set([ + f.paths.paths.config, + f.paths.paths.state, + f.paths.paths.runtime, + f.paths.paths.journal, + f.paths.legacyConfigPath, + f.paths.legacyStatePath, + f.paths.hostAuthPath, + token, + ]), + ] + for (const path of files.filter((path) => path !== token)) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile(path, 'synthetic-invalid-local-recovery-data', { + mode: 0o600, + }) + } + const bytes = () => Promise.all(files.map((path) => readFile(path))) + const before = await bytes() + let logins = 0 + const login = async () => { + logins++ + throw new Error('Login must not run') + } + for (const includeLogin of [false, true]) { + await expect( + migrateNativeLocalForHost('opencode', { + ...f.options, + ...(includeLogin ? { login } : {}), + }), + ).rejects.toThrow('Disconnect from the vault first.') + expect(await bytes()).toEqual(before) } + expect(logins).toBe(0) + expect(f.calls).toEqual([]) }) diff --git a/packages/opencode/src/setup/native-activation.ts b/packages/opencode/src/setup/native-activation.ts index fdebfe006..9c8149fb1 100644 --- a/packages/opencode/src/setup/native-activation.ts +++ b/packages/opencode/src/setup/native-activation.ts @@ -1,15 +1,12 @@ import { readFile } from 'node:fs/promises' import { + createNativeAccountRuntime, discoverNativeVaultInventory, inspectNativeHostAuthEntry, type NativeCustodyActivationHooks, type NativeCustodyInventory, - publishNativeVaultRosterSeed, - publishNativeVaultRuntimeSeed, - readNativeMigrationJournal, + readNativeEnrollmentMode, requireNoSupervisedAuthContentSnapshot, - runNativeCustodyActivation, - runNativeMigration, } from '@cortexkit/anthropic-auth-core' import { @@ -29,7 +26,9 @@ export class NativeVaultActivationError extends Error { | 'auth-content-refused' | 'host-auth-invalid' | 'primary-conflict' - | 'consent-required', + | 'consent-required' + | 'pi-slot-helper-required' + | 'primary-unverified', ) { super( { @@ -39,7 +38,11 @@ export class NativeVaultActivationError extends Error { 'primary-conflict': 'Pi has a stored Anthropic API key, which would take precedence over the vault account. Remove it in Pi, then rerun setup.', 'consent-required': - 'Pi has a stored Anthropic OAuth login. Rerun setup and agree to remove it so the vault account serves requests.', + 'Pi has stored Anthropic authentication. Explicit consent is required to move it aside for vault use.', + 'pi-slot-helper-required': + 'Pi stored authentication requires the shared vault move-aside helper. No stored authentication was changed.', + 'primary-unverified': + 'The vault has no verified default Claude account. Sign in through the vault and rerun setup.', }[code], ) this.name = 'NativeVaultActivationError' @@ -50,7 +53,7 @@ export interface NativeVaultActivationOptions { env: NodeJS.ProcessEnv runner: CommandRunner fence: ProcessFence - /** The user's explicit consent to delete Pi's stored Anthropic OAuth login. */ + /** Consent to move Pi's stored authentication aside; never consent to delete it. */ removePiAnthropicAuth: boolean /** * Tests override the host paths, enrollment client and vault listing; @@ -75,9 +78,9 @@ async function readHostAuth(path: string): Promise { } /** - * Explicit offline switch of one host to vault custody: enroll this host, - * finish (or start) its migration, then activate vault custody. Each host is - * enrolled and activated on its own; nothing here runs at host startup. + * Enroll this stopped host, discover its verified default vault account and + * leave the host auth slot untouched. Local credentials, migration journal, + * account metadata and routing assignments are not inputs to this operation. */ export async function activateNativeVaultForHost( host: HarnessKind, @@ -96,22 +99,20 @@ export async function activateNativeVaultForHost( } await processFence() const plan = options.paths ?? (await resolveNativeSetupPaths(host, env)) - let entry: ReturnType - try { - entry = inspectNativeHostAuthEntry( - host, + if (host === 'pi') { + const entry = inspectNativeHostAuthEntry( + 'pi', await readHostAuth(plan.hostAuthPath), ) - } catch { - throw new NativeVaultActivationError('host-auth-invalid') - } - if (host === 'pi') { - // Consent to remove an OAuth login never covers a Pi API key. - if (entry.kind === 'api_key') - throw new NativeVaultActivationError('primary-conflict') - if (entry.kind !== 'absent' && !options.removePiAnthropicAuth) - throw new NativeVaultActivationError('consent-required') + if (entry.kind !== 'absent') { + if (!options.removePiAnthropicAuth) + throw new NativeVaultActivationError('consent-required') + // Pi's stored authentication must remain restorable after disconnect. + // Refuse before enrollment until its private move-aside support is available. + throw new NativeVaultActivationError('pi-slot-helper-required') + } } + const priorMode = await readNativeEnrollmentMode(host, env) await enrollNativeVaultForHost(host, { env, runner: options.runner, @@ -133,40 +134,25 @@ export async function activateNativeVaultForHost( } : env, })) - const journal = await readNativeMigrationJournal(plan.paths) - // A retired (fully finished) migration is not rerun, because rerunning it - // would repair host auth that the activation now checks. Otherwise finish - // or start the migration; a first migration records that vault custody was - // requested, which keeps serving refused until the activation commits. - if (journal?.phase !== 'retired') - await runNativeMigration({ - paths: plan.paths, - legacyConfigPath: plan.legacyConfigPath, - legacyStatePath: plan.legacyStatePath, - host, - hostAuthPath: plan.hostAuthPath, - routingSourcePath: plan.routingSourcePath, - routingDestinationPath: plan.routingDestinationPath, - env, - processFence, - removePiAnthropicAuth: options.removePiAnthropicAuth, - custody: { - discover, - publishSeed: publishNativeVaultRosterSeed, - publishRuntimeSeed: publishNativeVaultRuntimeSeed, - }, - ...(journal ? {} : { requestVaultActivation: true }), - }) - const result = await runNativeCustodyActivation( - { - paths: plan.paths, - host, - env, - processFence, - removePiAnthropicAuth: options.removePiAnthropicAuth, - discover, - }, - options.hooks, - ) - return result.status + const runtime = createNativeAccountRuntime({ + paths: plan.paths, + host, + vault: { env, discover }, + }) + try { + await processFence() + await runtime.vault.refresh() + const snapshot = await runtime.read() + const main = snapshot.accounts.find((row) => row.id === 'main') + if ( + snapshot.mode !== 'claustrum' || + !main?.accountIdentity || + main.source !== 'vault' + ) + throw new NativeVaultActivationError('primary-unverified') + await processFence() + return priorMode === 'claustrum' ? 'already-vault' : 'committed' + } finally { + runtime.close() + } } diff --git a/packages/opencode/src/setup/native-local.ts b/packages/opencode/src/setup/native-local.ts index c008d48a8..5cf7845d0 100644 --- a/packages/opencode/src/setup/native-local.ts +++ b/packages/opencode/src/setup/native-local.ts @@ -1,6 +1,6 @@ import { createNativeAccountRuntime, - readNativeMigrationJournal, + readNativeEnrollmentMode, requireNoSupervisedAuthContentSnapshot, runNativeMigration, } from '@cortexkit/anthropic-auth-core' @@ -35,24 +35,11 @@ export async function migrateNativeLocalForHost( const processFence = async () => { requireNoSupervisedAuthContentSnapshot(env) await assertHostsStopped(options.fence) + if ((await readNativeEnrollmentMode(host, env)) !== 'local') + throw new Error('Disconnect from the vault first.') } await processFence() const plan = options.paths ?? (await resolveNativeSetupPaths(host, env)) - const journal = await readNativeMigrationJournal(plan.paths) - if ( - options.login && - (journal?.phase === 'retired' || journal?.phase === 'committed') - ) { - const current = createNativeAccountRuntime({ paths: plan.paths, host }) - try { - if ((await current.read()).mode !== 'local') - throw new Error( - 'Exit vault custody with offline setup before local sign-in', - ) - } finally { - current.close() - } - } const login = await options.login?.(host) if ( login && @@ -78,14 +65,13 @@ export async function migrateNativeLocalForHost( const runtime = createNativeAccountRuntime({ paths: plan.paths, host, + vault: { env }, beforePoolWrite: processFence, }) try { const snapshot = await runtime.read() if (snapshot.mode !== 'local') - throw new Error( - 'Exit vault custody with offline setup before local sign-in', - ) + throw new Error('Disconnect from the vault first.') await processFence() await runtime.loginOAuth({ routeId: 'main', replace: true, ...login }) } finally { From 7715bdfbcfdbdcffe61dd825b8ece4cd2bf08a78 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:02:56 +0200 Subject: [PATCH 136/169] fix(routing): bind vault session pins to verified account UUIDs --- mutations.toml | 48 ++++ packages/core/src/sticky-routing.ts | 49 +++- .../src/tests/native-vault-affinity.test.ts | 270 ++++++++++++++++++ 3 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/tests/native-vault-affinity.test.ts diff --git a/mutations.toml b/mutations.toml index a7881da38..adbe01065 100644 --- a/mutations.toml +++ b/mutations.toml @@ -310,3 +310,51 @@ expect_message = "error: expect(received).toEqual(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] broad_report = "tmp/mutations/opencode.xml" broad_id = "{name}" + +[[control]] +id = "vault-affinity-fast-uuid-fence" +guards = "A cached vault session pin cannot follow a replaced default account UUID." +file = "packages/core/src/sticky-routing.ts" +old = "matchesAccountIdentity(cached) &&" +new = "true && /* NON-VACUITY BREAK: retain a replaced vault UUID */" +test_file = "packages/core/src/tests/native-vault-affinity.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-vault-affinity.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["replacing the vault default UUID re-places only its session while an unrelated fallback pin survives byte-identical"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "vault-affinity-locked-uuid-fence" +guards = "The locked routing reread must reject a pin bound to the previous vault UUID." +file = "packages/core/src/sticky-routing.ts" +old = "matchesAccountIdentity(current) &&" +new = "true && /* NON-VACUITY BREAK: let the locked path retain the old UUID */" +test_file = "packages/core/src/tests/native-vault-affinity.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-vault-affinity.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["a replaced vault default using the same logical main id gets a new UUID-bound pin and no old preference or timestamps"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "vault-affinity-requires-verified-uuid" +guards = "A vault account without a verified UUID cannot receive a new session pin." +file = "packages/core/src/sticky-routing.ts" +old = "identityForAccount(candidate.accountId) !== undefined" +new = "true /* NON-VACUITY BREAK: allocate an unverified vault route */" +test_file = "packages/core/src/tests/native-vault-affinity.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-vault-affinity.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["a vault route without a verified UUID is never pinned, including retained logical main and warm-cache preference"] +expect_message = "error: expect(received).toBeNull()" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" diff --git a/packages/core/src/sticky-routing.ts b/packages/core/src/sticky-routing.ts index 39741e641..7bf4d0374 100644 --- a/packages/core/src/sticky-routing.ts +++ b/packages/core/src/sticky-routing.ts @@ -46,6 +46,8 @@ export type StickyRouteCandidate = { export type StickyRouteAssignment = { accountId: string + /** Verified account UUID for a credential held by the vault. Pins using credentials stored by this host omit the field. */ + accountIdentity?: string family: StickyRouteFamily affinityModelId?: string assignedAt: number @@ -195,8 +197,18 @@ function normalizeAssignment( ) { return } + if ( + Object.hasOwn(value, 'accountIdentity') && + (typeof value.accountIdentity !== 'string' || + value.accountIdentity.length === 0 || + value.accountIdentity.trim() !== value.accountIdentity) + ) + return return { accountId: value.accountId, + ...(typeof value.accountIdentity === 'string' + ? { accountIdentity: value.accountIdentity } + : {}), family: value.family as StickyRouteFamily, affinityModelId: typeof value.affinityModelId === 'string' @@ -726,6 +738,8 @@ export class StickySessionRouter { affinityModelId?: string candidates: readonly StickyRouteCandidate[] retainAccountIds: ReadonlySet + /** Verified UUIDs for every vault account the host retains, even when quota prevents selection. Omit when credentials are stored by the host instead of the vault. */ + accountIdentities?: ReadonlyMap storage: AccountStorage | null inputBytes: number preferredAccountId?: string @@ -739,11 +753,25 @@ export class StickySessionRouter { affinityModelId !== undefined ? assignment.affinityModelId === affinityModelId : assignment.family === input.family + const identityForAccount = (accountId: string) => { + const identity = input.accountIdentities?.get(accountId) + return typeof identity === 'string' && + identity.length > 0 && + identity.trim() === identity + ? identity + : undefined + } + const matchesAccountIdentity = (assignment: StickyRouteAssignment) => { + if (!input.accountIdentities) return true + const identity = identityForAccount(assignment.accountId) + return identity !== undefined && assignment.accountIdentity === identity + } const cached = this.state.assignments[key] if ( cached && this.assignmentIsActive(cached) && matchesAffinity(cached) && + matchesAccountIdentity(cached) && input.retainAccountIds.has(cached.accountId) && !input.excludeAccountIds?.has(cached.accountId) ) { @@ -767,6 +795,7 @@ export class StickySessionRouter { if ( current && matchesAffinity(current) && + matchesAccountIdentity(current) && input.retainAccountIds.has(current.accountId) && !input.excludeAccountIds?.has(current.accountId) ) { @@ -780,14 +809,20 @@ export class StickySessionRouter { } } const affinityChanged = Boolean(current && !matchesAffinity(current)) - if (affinityChanged) { + const identityChanged = Boolean( + current && !matchesAccountIdentity(current), + ) + if (affinityChanged || identityChanged) { delete state.assignments[key] } const candidates = input.candidates.filter( - (candidate) => !input.excludeAccountIds?.has(candidate.accountId), + (candidate) => + !input.excludeAccountIds?.has(candidate.accountId) && + (!input.accountIdentities || + identityForAccount(candidate.accountId) !== undefined), ) const preferred = - !affinityChanged && input.preferredAccountId + !affinityChanged && !identityChanged && input.preferredAccountId ? candidates.find( (candidate) => candidate.accountId === input.preferredAccountId && @@ -813,6 +848,9 @@ export class StickySessionRouter { const selectedQuota = knownQuota(selected.quota) const assignment: StickyRouteAssignment = { accountId: selected.accountId, + ...(input.accountIdentities + ? { accountIdentity: identityForAccount(selected.accountId) } + : {}), family: input.family, affinityModelId, assignedAt: now, @@ -826,7 +864,10 @@ export class StickySessionRouter { accountId: selected.accountId, assignment: { ...assignment }, created: !current, - migrated: Boolean(current && current.accountId !== selected.accountId), + migrated: Boolean( + current && + (identityChanged || current.accountId !== selected.accountId), + ), } } finally { await lock.release() diff --git a/packages/core/src/tests/native-vault-affinity.test.ts b/packages/core/src/tests/native-vault-affinity.test.ts new file mode 100644 index 000000000..6c1c240df --- /dev/null +++ b/packages/core/src/tests/native-vault-affinity.test.ts @@ -0,0 +1,270 @@ +import { expect } from 'bun:test' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { OAuthQuotaSnapshot } from '../accounts.ts' +import { + migrateStickyRoutingState, + type StickyRouteCandidate, + StickySessionRouter, +} from '../sticky-routing.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() +const originalUuid = '11111111-2222-4333-8444-555555555555' +const replacedUuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const fallbackUuid = '12345678-1234-4234-8234-123456789abc' +const model = 'claude-sonnet-5-5' + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'native-vault-affinity-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'routing.json') + let now = 2_000_000_000_000 + const router = new StickySessionRouter({ path, now: () => now }) + const quota = (identity: string): OAuthQuotaSnapshot => ({ + accountIdentity: identity, + checkedAt: now, + five_hour: { + usedPercent: 1, + remainingPercent: 99, + checkedAt: now, + resetsAt: new Date(now + 3_600_000).toISOString(), + }, + seven_day: { + usedPercent: 1, + remainingPercent: 99, + checkedAt: now, + resetsAt: new Date(now + 86_400_000).toISOString(), + }, + }) + const candidate = (id: string, identity: string): StickyRouteCandidate => ({ + accountId: id, + quota: quota(identity), + order: id === 'main' ? 0 : 1, + }) + const resolve = ( + sessionId: string, + candidates: StickyRouteCandidate[], + identities?: ReadonlyMap, + preferredAccountId?: string, + ) => + router.resolve({ + sessionId, + family: 'general', + modelId: model, + affinityModelId: model, + candidates, + retainAccountIds: new Set(['main', 'fallback']), + accountIdentities: identities, + storage: null, + inputBytes: 77, + preferredAccountId, + }) + return { + path, + router, + resolve, + candidate, + advance: () => { + now += 1 + }, + now: () => now, + } +} + +function rawPin(text: string, sessionId: string) { + const key = createHash('sha256').update(sessionId).digest('hex') + // These fixture pins contain only scalar fields and brace-free strings, so + // preserve the exact JSON object bytes without depending on indentation. + const match = text.match(new RegExp(`"${key}":\\s*(\\{[^}]*\\})`)) + if (!match?.[1]) throw new Error('Expected pin is missing') + return match[1] +} + +test('replacing the vault default UUID re-places only its session while an unrelated fallback pin survives byte-identical', async () => { + const f = await fixture() + const old = new Map([ + ['main', originalUuid], + ['fallback', fallbackUuid], + ]) + expect( + (await f.resolve('main-session', [f.candidate('main', originalUuid)], old)) + ?.accountId, + ).toBe('main') + expect( + ( + await f.resolve( + 'fallback-session', + [f.candidate('fallback', fallbackUuid)], + old, + ) + )?.accountId, + ).toBe('fallback') + const fallbackPin = rawPin(await readFile(f.path, 'utf8'), 'fallback-session') + f.advance() + const next = new Map([ + ['main', replacedUuid], + ['fallback', fallbackUuid], + ]) + const result = await f.resolve( + 'main-session', + [ + { accountId: 'main', quota: { kind: 'unknown' }, order: 0 }, + f.candidate('fallback', fallbackUuid), + ], + next, + 'main', + ) + expect(result?.accountId).toBe('fallback') + expect(result?.assignment.accountIdentity).toBe(fallbackUuid) + expect(result?.migrated).toBe(true) + expect(rawPin(await readFile(f.path, 'utf8'), 'fallback-session')).toBe( + fallbackPin, + ) +}) + +test('a replaced vault default using the same logical main id gets a new UUID-bound pin and no old preference or timestamps', async () => { + const f = await fixture() + const first = await f.resolve( + 'session', + [f.candidate('main', originalUuid)], + new Map([['main', originalUuid]]), + ) + expect(first?.assignment.accountIdentity).toBe(originalUuid) + f.advance() + const next = await f.resolve( + 'session', + [f.candidate('main', replacedUuid)], + new Map([['main', replacedUuid]]), + ) + expect(next?.assignment.accountIdentity).toBe(replacedUuid) + expect(next?.assignment.assignedAt).toBe(f.now()) + expect(next?.migrated).toBe(true) +}) + +test('a vault route without a verified UUID is never pinned, including retained logical main and warm-cache preference', async () => { + const f = await fixture() + const candidates = [f.candidate('main', originalUuid)] + expect( + (await f.resolve('session', candidates, new Map([['main', originalUuid]]))) + ?.accountId, + ).toBe('main') + for (const identities of [ + new Map(), + new Map([['main', '']]), + new Map([['main', ' ']]), + ]) { + expect( + await f.resolve('session', candidates, identities, 'main'), + ).toBeNull() + } +}) + +test('vault UUID changes refuse old retention even while the quota candidate list is empty', async () => { + const f = await fixture() + expect( + ( + await f.resolve( + 'session', + [f.candidate('main', originalUuid)], + new Map([['main', originalUuid]]), + ) + )?.accountId, + ).toBe('main') + expect( + await f.resolve('session', [], new Map([['main', replacedUuid]])), + ).toBeNull() +}) + +test('local retention ignores account UUID changes and leaves existing routing bytes untouched', async () => { + const f = await fixture() + expect( + (await f.resolve('session', [f.candidate('main', originalUuid)])) + ?.accountId, + ).toBe('main') + const before = await readFile(f.path) + const result = await f.resolve('session', [f.candidate('main', replacedUuid)]) + expect(result?.accountId).toBe('main') + expect(result?.assignment).not.toHaveProperty('accountIdentity') + expect(await readFile(f.path)).toEqual(before) +}) + +test('migration preserves optional vault UUID metadata without adding any identity to legacy local assignments', async () => { + const f = await fixture() + await f.resolve('local', [f.candidate('main', originalUuid)]) + const localSource = JSON.parse(await readFile(f.path, 'utf8')) + const local = migrateStickyRoutingState( + localSource, + new Map([['main', 'imported-main']]), + ) + expect(local).not.toContain('accountIdentity') + await f.resolve( + 'vault', + [f.candidate('fallback', fallbackUuid)], + new Map([['fallback', fallbackUuid]]), + ) + const source = JSON.parse(await readFile(f.path, 'utf8')) + const migrated = migrateStickyRoutingState( + source, + new Map([ + ['main', 'imported-main'], + ['fallback', 'imported-fallback'], + ]), + ) + expect(migrated).toContain(`"accountIdentity": "${fallbackUuid}"`) + expect(migrated).toContain('"accountId": "imported-main"') +}) + +test('a legacy vault pin without UUID is re-placed instead of inheriting its logical main id', async () => { + const f = await fixture() + await f.resolve('session', [f.candidate('main', originalUuid)]) + const next = await f.resolve( + 'session', + [ + { accountId: 'main', quota: { kind: 'unknown' }, order: 0 }, + f.candidate('fallback', fallbackUuid), + ], + new Map([ + ['main', originalUuid], + ['fallback', fallbackUuid], + ]), + ) + expect(next?.accountId).toBe('fallback') + expect(next?.assignment.accountIdentity).toBe(fallbackUuid) + expect(next?.migrated).toBe(true) +}) + +test('a replaced vault UUID cannot inherit a warm-cache preference for logical main', async () => { + const f = await fixture() + await f.resolve( + 'session', + [f.candidate('main', originalUuid)], + new Map([['main', originalUuid]]), + ) + const main = f.candidate('main', replacedUuid) + if (!('five_hour' in main.quota)) throw new Error('Expected a known quota') + main.quota.five_hour = { + checkedAt: f.now(), + usedPercent: 99, + remainingPercent: 1, + resetsAt: new Date(f.now() + 3_600_000).toISOString(), + } + main.quota.seven_day = { + checkedAt: f.now(), + usedPercent: 99, + remainingPercent: 1, + resetsAt: new Date(f.now() + 86_400_000).toISOString(), + } + const next = await f.resolve( + 'session', + [main, f.candidate('fallback', fallbackUuid)], + new Map([ + ['main', replacedUuid], + ['fallback', fallbackUuid], + ]), + 'main', + ) + expect(next?.accountId).toBe('fallback') +}) From 45c2e28d782e08b62f2a1d13ce9a7fb2589b72bd Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:13:10 +0200 Subject: [PATCH 137/169] feat(core): preserve Pi auth slots with shared vault disconnect --- bun.lock | 31 ++- mutations.toml | 16 ++ package.json | 1 + packages/core/package.json | 2 +- .../scripts/check-native-type-closure.test.ts | 4 +- .../core/scripts/check-native-type-closure.ts | 4 +- packages/core/src/index.ts | 14 ++ packages/core/src/native-vault-connection.ts | 64 +++++ .../src/tests/native-vault-connection.test.ts | 237 ++++++++++++++++++ .../src/setup/native-activation.test.ts | 88 ++++++- .../opencode/src/setup/native-activation.ts | 29 ++- packages/opencode/src/setup/paths.ts | 12 +- .../src/tests/pi-auth-path-contract.test.ts | 64 +++++ 13 files changed, 546 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/native-vault-connection.ts create mode 100644 packages/core/src/tests/native-vault-connection.test.ts create mode 100644 packages/opencode/src/tests/pi-auth-path-contract.test.ts diff --git a/bun.lock b/bun.lock index 1431770c1..8112e7e7f 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "jsonc-parser": "^3.3.1", "lefthook": "2.1.14", "miniflare": "^4.20260730.0", + "pi-coding-agent-1": "npm:@earendil-works/pi-coding-agent@1.0.4", "typescript": "7.0.2", }, }, @@ -29,7 +30,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "0.13.0", + "@cortexkit/common-auth": "0.14.1", }, }, "packages/e2e-tests": { @@ -59,7 +60,11 @@ }, "peerDependencies": { "@opencode-ai/plugin": "*", + "@opencode/plugin": ">=2.0.21", }, + "optionalPeers": [ + "@opencode/plugin", + ], }, "packages/pi": { "name": "@cortexkit/pi-anthropic-auth", @@ -237,7 +242,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.13.0", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-U4rVLk18gd576nPWUnucx+0ZbNXgJhumw76oxvq+9hpd6fnU2h9V/1VPsBtd47g2kkunRDxpB9byoDSZOjCo6A=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.14.1", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-QNoECbEdYHMx6tv6SK2FFOq+lnweB6Krr+1SDrOT3jCntfSMs4JpuCDYixnprsbnVmfqjezc4UmbkgoQpWd3LQ=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], @@ -253,8 +258,12 @@ "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.86.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^0.86.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-1XHhI6D/fyQdsBieHC/E/4zGKVOoGe4yDyX67VXvzoYkFsX/qE7NpZE7E1RC8e6Bz8B9oG/P+MQFXikv2/BGEg=="], + "@earendil-works/pi-codemode": ["@earendil-works/pi-codemode@1.1.0", "", { "dependencies": { "quickjs-wasi": "3.6.2" } }, "sha512-8Asc2AzhoNaXS1snmRFa96OhcWIlFM5k1Kuiz6p2DvIYgi5QHQccRAmDVY9oO92TzWrizpvQpconZGQ7JhlI7Q=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.86.1", "", { "dependencies": { "@earendil-works/chord": "^0.86.1", "@earendil-works/pi-agent-core": "^0.86.1", "@earendil-works/pi-ai": "^0.86.1", "@earendil-works/pi-tui": "^0.86.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "minimatch": "10.2.6", "proper-lockfile": "4.1.2", "semver": "7.8.5", "typebox": "1.3.27", "undici": "8.10.2", "yaml": "2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-vZBuNfJnruxZyemZ3O05V0S/Ylze08ahFTIQ1Mik++gVdOevPl89gt/Uv0U97BPAJaj9cj6Vf9rcIgKtUrd0BA=="], + "@earendil-works/pi-mcp": ["@earendil-works/pi-mcp@1.1.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-xGKwvu3SvVTeoIx8Z7UGoJprB4VK20wKORMhufcWGx61fIauhu3J+UqfB5eeoF1oE/geYBA598K6nh1SdABQkg=="], + "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.86.1", "", {}, "sha512-SOcEqOS3oVGgKeahs2jHB906d8hFjuLP+RBee8xKYMRgw5KAeWHNg+YABfL0ALlp3Bt6tW4b632MLghc3vnTog=="], "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.86.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-FU/zU/zG4RWokcZt+BVXXcieWi5ggvYnWP2kkB5XXjMaHRoy5BDhcZJ9JAnLTN9MwrCRoXgPQxOI0bFqwYeZkQ=="], @@ -975,6 +984,8 @@ "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "pi-coding-agent-1": ["@earendil-works/pi-coding-agent@1.0.4", "", { "dependencies": { "@earendil-works/chord": "^1.0.4", "@earendil-works/pi-agent-core": "^1.0.4", "@earendil-works/pi-ai": "^1.0.4", "@earendil-works/pi-codemode": "^1.0.4", "@earendil-works/pi-mcp": "^1.0.4", "@earendil-works/pi-tui": "^1.0.4", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "minimatch": "10.2.6", "proper-lockfile": "4.1.2", "quickjs-wasi": "3.6.2", "semver": "7.8.5", "typebox": "1.3.27", "undici": "8.10.2", "yaml": "2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-+956nfMFHr5lDUVY/2Q4k+YzojzBuCaBXFgj0eSlXVGr7QVliVddKdc1Pz6yVg1dOlJQmb67doOVrlMsIcIdaw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -1001,6 +1012,8 @@ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "quickjs-wasi": ["quickjs-wasi@3.6.2", "", {}, "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg=="], + "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], "redis": ["redis@6.3.0", "", { "dependencies": { "@redis/bloom": "6.3.0", "@redis/client": "6.3.0", "@redis/json": "6.3.0", "@redis/search": "6.3.0", "@redis/time-series": "6.3.0" } }, "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw=="], @@ -1223,6 +1236,14 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "pi-coding-agent-1/@earendil-works/chord": ["@earendil-works/chord@1.1.0", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og=="], + + "pi-coding-agent-1/@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@1.1.0", "", { "dependencies": { "@earendil-works/pi-ai": "^1.1.0", "typebox": "1.3.27" } }, "sha512-aX1KZomNCPmwYnXa3OivF3VYLJ+WPUkIJlEIZTgwdOZdY/+oToWTQ334WYpQeOz9POpiYFNMJLPwIhXQ4e48kg=="], + + "pi-coding-agent-1/@earendil-works/pi-ai": ["@earendil-works/pi-ai@1.1.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^1.1.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw=="], + + "pi-coding-agent-1/@earendil-works/pi-tui": ["@earendil-works/pi-tui@1.1.0", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-v7wkS0y2ErZvZkSfemqd9RrBZJ5x6p8Ujsv7NdJj01IXJyFM0kNL6rMdcKcvTe7EVTd6ugvDB9VPZSoaBF9QxQ=="], + "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -1293,6 +1314,12 @@ "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + "pi-coding-agent-1/@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.129.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw=="], + + "pi-coding-agent-1/@earendil-works/pi-ai/@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@1.1.0", "", {}, "sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw=="], + + "pi-coding-agent-1/@earendil-works/pi-ai/openai": ["openai@7.19.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "undici": ">=5 <9", "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "undici", "ws", "zod"] }, "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ=="], + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.41", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ=="], "babel-plugin-module-resolver/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], diff --git a/mutations.toml b/mutations.toml index adbe01065..bf0d4390c 100644 --- a/mutations.toml +++ b/mutations.toml @@ -358,3 +358,19 @@ expect_message = "error: expect(received).toBeNull()" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] broad_report = "tmp/mutations/core.xml" broad_id = "{name}" + +[[control]] +id = "pi-auth-slot-uses-sdk-directory" +guards = "Offline vault setup protects Pi's actual auth file, not an extension-only directory override." +file = "packages/opencode/src/setup/paths.ts" +old = "const configured = env.PI_CODING_AGENT_DIR\n" +new = "const configured = env.PI_CODING_AGENT_DIR || env.PI_AGENT_DIR\n" +test_file = "packages/opencode/src/tests/pi-auth-path-contract.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/pi-auth-path-contract.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["offline Pi auth path matches @earendil-works/pi-coding-agent with directory undefined"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" diff --git a/package.json b/package.json index 4655a5bbf..65c665ef0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "jsonc-parser": "^3.3.1", "lefthook": "2.1.14", "miniflare": "^4.20260730.0", + "pi-coding-agent-1": "npm:@earendil-works/pi-coding-agent@1.0.4", "typescript": "7.0.2" } } diff --git a/packages/core/package.json b/packages/core/package.json index 012217059..52581f103 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "0.13.0" + "@cortexkit/common-auth": "0.14.1" }, "dependencies": { "@cortexkit/claustrum-client": "0.6.2", diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index 1e356d078..d2c376d60 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -98,7 +98,7 @@ test('rejects unsupported published producer versions before copying declaration ) await expect(closeNativeTypeDeclarations(dist, producer)).rejects.toThrow( - 'Expected published common-auth 0.13.0, got 0.9.0', + 'Expected published common-auth 0.14.1, got 0.9.0', ) expect(await readdir(dist)).not.toContain('internal-types') }) @@ -111,7 +111,7 @@ test('copies the complete published relative declaration graph including cycles producer, 'package.json', JSON.stringify({ - version: '0.13.0', + version: '0.14.1', exports: { './store': { types: './dist/store/index.d.ts' } }, }), ) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts index 7133d425b..6c831cdbd 100644 --- a/packages/core/scripts/check-native-type-closure.ts +++ b/packages/core/scripts/check-native-type-closure.ts @@ -185,9 +185,9 @@ export async function closeNativeTypeDeclarations( version: string exports: Record } - if (manifest.version !== '0.13.0') + if (manifest.version !== '0.14.1') throw new Error( - `Expected published common-auth 0.13.0, got ${manifest.version}`, + `Expected published common-auth 0.14.1, got ${manifest.version}`, ) const producerDist = join(producerRoot, 'dist') const assets = join(distRoot, TYPE_ASSETS) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3a8acac2..bcaa424e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -169,6 +169,20 @@ export { parseApplyRequest, runPiCommandMenu, } from './native-ui.ts' +export type { + NativePiAuthSlot, + NativePiAuthSlotErrorCode, + NativePiAuthSlotInspection, + NativePiAuthSlotOptions, + NativeVaultDisconnectOptions, + NativeVaultDisconnectResult, +} from './native-vault-connection.ts' +export { + disconnectNativeVaultForHost, + NativePiAuthSlotError, + nativePiAuthSlotPaths, + openNativePiAuthSlot, +} from './native-vault-connection.ts' export type { NativeVaultRuntime, NativeVaultRuntimeOptions, diff --git a/packages/core/src/native-vault-connection.ts b/packages/core/src/native-vault-connection.ts new file mode 100644 index 000000000..f8620aed0 --- /dev/null +++ b/packages/core/src/native-vault-connection.ts @@ -0,0 +1,64 @@ +import { dirname, join, resolve } from 'node:path' +import { disconnectClaustrumEnrollment } from '@cortexkit/common-auth/claustrum' +import { placeholderSecret } from '@cortexkit/common-auth/opencode2' +import { openPiSlot, type PiSlot } from '@cortexkit/common-auth/pi-slot' +import { getHostClaustrumEnrollmentPaths } from './claustrum-enrollment.ts' + +export type { + PiSlot as NativePiAuthSlot, + PiSlotErrorCode as NativePiAuthSlotErrorCode, + PiSlotInspection as NativePiAuthSlotInspection, +} from '@cortexkit/common-auth/pi-slot' +export { PiSlotError as NativePiAuthSlotError } from '@cortexkit/common-auth/pi-slot' + +export interface NativePiAuthSlotOptions { + /** The Pi adapter supplies its SDK-owned auth path; enrollment paths do not identify that file. */ + authPath: string + env?: NodeJS.ProcessEnv +} + +/** Setup and disconnect must use the same host auth file, private stash and inert API-key marker. */ +export function nativePiAuthSlotPaths(options: NativePiAuthSlotOptions) { + return { + authPath: resolve(options.authPath), + stashPath: join( + dirname(getHostClaustrumEnrollmentPaths('pi', options.env).tokenPath), + 'pi-auth-slot', + 'anthropic.json', + ), + provider: 'anthropic', + placeholderKey: placeholderSecret('anthropic'), + } +} + +/** Delegate private Pi-auth preservation to the shared locked writer; never create a second host-slot writer. */ +export function openNativePiAuthSlot(options: NativePiAuthSlotOptions): PiSlot { + return openPiSlot(nativePiAuthSlotPaths(options)) +} + +export type NativeVaultDisconnectOptions = + | { host: 'opencode'; env?: NodeJS.ProcessEnv } + | { host: 'pi'; env?: NodeJS.ProcessEnv; slot: Pick } + +export type NativeVaultDisconnectResult = + | 'disconnected' + | 'idle' + | 'busy' + | 'conflict' + +/** + * Restore Pi's stored authentication before removing this host's enrollment. + * A conflicting replacement belongs to the user: leave both it and enrollment + * intact rather than selecting local custody with unresolved recovery data. + * OpenCode authentication is never changed by this operation. + */ +export async function disconnectNativeVaultForHost( + options: NativeVaultDisconnectOptions, +): Promise { + if (options.host === 'pi' && (await options.slot.exitVault()) === 'conflict') + return 'conflict' + return disconnectClaustrumEnrollment( + getHostClaustrumEnrollmentPaths(options.host, options.env), + `anthropic-auth-${options.host}`, + ) +} diff --git a/packages/core/src/tests/native-vault-connection.test.ts b/packages/core/src/tests/native-vault-connection.test.ts new file mode 100644 index 000000000..712f9f850 --- /dev/null +++ b/packages/core/src/tests/native-vault-connection.test.ts @@ -0,0 +1,237 @@ +import { expect } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { getHostClaustrumEnrollmentPaths } from '../claustrum-enrollment.ts' +import { readNativeEnrollmentMode } from '../native-enrollment-authority.ts' +import { + disconnectNativeVaultForHost, + NativePiAuthSlotError, + nativePiAuthSlotPaths, + openNativePiAuthSlot, +} from '../native-vault-connection.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() + +async function fixture(host: 'pi' | 'opencode') { + const root = await mkdtemp(join(tmpdir(), 'native-vault-disconnect-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const env = { + PI_CODING_AGENT_DIR: root, + PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join(root, 'enrollment.json'), + [`${host.toUpperCase()}_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE`]: join( + root, + 'enrollment.json', + ), + } + const enrollment = getHostClaustrumEnrollmentPaths(host, env) + await writeFile( + enrollment.tokenPath, + JSON.stringify({ token: 'd'.repeat(64), token_generation: 1 }), + { mode: 0o600 }, + ) + await writeFile( + enrollment.statePath, + JSON.stringify({ + version: 1, + phase: 'approved', + proposedName: `anthropic-auth-${host}`, + tokenGeneration: 1, + updatedAt: 1, + }), + { mode: 0o600 }, + ) + const localPaths = [ + 'local-pool.json', + 'local-credentials.json', + 'local-runtime.json', + 'local-journal.json', + 'local-routing.json', + ].map((name) => join(root, name)) + for (const path of localPaths) + await writeFile(path, 'synthetic-local-recovery-bytes', { mode: 0o600 }) + const localBytes = () => Promise.all(localPaths.map((path) => readFile(path))) + const beforeLocal = await localBytes() + const { authPath, stashPath } = nativePiAuthSlotPaths({ + env, + authPath: join(root, 'auth.json'), + }) + const slot = openNativePiAuthSlot({ env, authPath }) + return { + root, + env, + enrollment, + authPath, + stashPath, + slot, + localBytes, + beforeLocal, + } +} + +for (const entry of [ + '{"type":"oauth", "access":"synthetic-access", "refresh":"synthetic-refresh", "expires":1}', + '{ "type" : "api_key", "key" : "synthetic-key" }', +]) { + test(`Pi disconnect restores exact stored ${entry.includes('oauth') ? 'OAuth' : 'API-key'} bytes before returning to local authority`, async () => { + const f = await fixture('pi') + const original = `{\n "other":{"type":"api_key","key":"synthetic-unrelated"},\n "anthropic" : ${entry}\n}\n` + await writeFile(f.authPath, original, { mode: 0o600 }) + await f.slot.enterVault() + expect(await f.slot.inspect()).toEqual({ + slot: 'placeholder', + stash: 'valid', + }) + expect(await readNativeEnrollmentMode('pi', f.env)).toBe('claustrum') + const modesAtRestore: string[] = [] + expect( + await disconnectNativeVaultForHost({ + host: 'pi', + env: f.env, + slot: { + exitVault: async () => { + modesAtRestore.push(await readNativeEnrollmentMode('pi', f.env)) + return f.slot.exitVault() + }, + }, + }), + ).toBe('disconnected') + expect(modesAtRestore).toEqual(['claustrum']) + expect(await readFile(f.authPath, 'utf8')).toBe(original) + expect(await readNativeEnrollmentMode('pi', f.env)).toBe('local') + for (const path of [ + f.stashPath, + f.enrollment.tokenPath, + f.enrollment.statePath, + ]) + await expect(readFile(path)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await f.localBytes()).toEqual(f.beforeLocal) + }) +} + +test('a Pi slot conflict preserves foreign authentication, private recovery stash and both enrollment files', async () => { + const f = await fixture('pi') + await writeFile( + f.authPath, + '{"anthropic":{"type":"api_key","key":"synthetic-original"}}', + { mode: 0o600 }, + ) + await f.slot.enterVault() + await writeFile( + f.authPath, + '{"anthropic":{"type":"api_key","key":"synthetic-foreign"}}', + { mode: 0o600 }, + ) + const files = [ + f.authPath, + f.stashPath, + f.enrollment.tokenPath, + f.enrollment.statePath, + ] + const before = await Promise.all(files.map((path) => readFile(path))) + expect( + await disconnectNativeVaultForHost({ + host: 'pi', + env: f.env, + slot: f.slot, + }), + ).toBe('conflict') + expect(await Promise.all(files.map((path) => readFile(path)))).toEqual(before) + expect(await readNativeEnrollmentMode('pi', f.env)).toBe('claustrum') + expect(await f.localBytes()).toEqual(f.beforeLocal) +}) + +test('a Pi recovery-stash refusal cannot delete enrollment or expose parser input', async () => { + const f = await fixture('pi') + await writeFile( + f.authPath, + '{"anthropic":{"type":"api_key","key":"synthetic-original"}}', + { mode: 0o600 }, + ) + await f.slot.enterVault() + await writeFile(f.stashPath, 'synthetic-secret-invalid-json', { mode: 0o600 }) + const files = [ + f.authPath, + f.stashPath, + f.enrollment.tokenPath, + f.enrollment.statePath, + ] + const before = await Promise.all(files.map((path) => readFile(path))) + const result = await disconnectNativeVaultForHost({ + host: 'pi', + env: f.env, + slot: f.slot, + }).then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ) + if (!('error' in result)) throw new Error('Malformed stash was accepted') + expect(result.error).toBeInstanceOf(NativePiAuthSlotError) + expect(result.error).toHaveProperty('message', 'Pi slot: invalid-stash') + expect(await Promise.all(files.map((path) => readFile(path)))).toEqual(before) + expect(await f.localBytes()).toEqual(f.beforeLocal) +}) + +test('a Pi disconnect retry after exact authentication restoration still removes enrollment', async () => { + const f = await fixture('pi') + const original = + '{"anthropic":{"type":"oauth","access":"synthetic-access","refresh":"synthetic-refresh","expires":1}}' + await writeFile(f.authPath, original, { mode: 0o600 }) + await f.slot.enterVault() + await writeFile(f.authPath, original, { mode: 0o600 }) + expect( + await disconnectNativeVaultForHost({ + host: 'pi', + env: f.env, + slot: f.slot, + }), + ).toBe('disconnected') + expect(await readFile(f.authPath, 'utf8')).toBe(original) + expect(await readNativeEnrollmentMode('pi', f.env)).toBe('local') + expect(await f.localBytes()).toEqual(f.beforeLocal) +}) + +test('OpenCode disconnect never opens or changes host authentication or local recovery files', async () => { + const f = await fixture('opencode') + await mkdir(dirname(f.authPath), { recursive: true, mode: 0o700 }) + const original = 'synthetic-malformed-host-auth' + await writeFile(f.authPath, original, { mode: 0o600 }) + expect( + await disconnectNativeVaultForHost({ host: 'opencode', env: f.env }), + ).toBe('disconnected') + expect(await readFile(f.authPath, 'utf8')).toBe(original) + expect(await readNativeEnrollmentMode('opencode', f.env)).toBe('local') + expect(await f.localBytes()).toEqual(f.beforeLocal) +}) + +for (const host of ['opencode', 'pi'] as const) { + test(`${host} explicit disconnect recovers malformed enrollment without reading or changing local account files`, async () => { + const f = await fixture(host) + const original = + '{"anthropic":{"type":"api_key","key":"synthetic-original"}}' + await writeFile(f.authPath, original, { mode: 0o600 }) + if (host === 'pi') await f.slot.enterVault() + await writeFile( + f.enrollment.tokenPath, + 'synthetic-corrupt-enrollment-token', + { mode: 0o600 }, + ) + await writeFile( + f.enrollment.statePath, + 'synthetic-corrupt-enrollment-state', + { mode: 0o600 }, + ) + await expect(readNativeEnrollmentMode(host, f.env)).rejects.toMatchObject({ + code: 'invalid-token', + }) + const result = + host === 'pi' + ? await disconnectNativeVaultForHost({ host, env: f.env, slot: f.slot }) + : await disconnectNativeVaultForHost({ host, env: f.env }) + expect(result).toBe('disconnected') + expect(await readFile(f.authPath, 'utf8')).toBe(original) + expect(await readNativeEnrollmentMode(host, f.env)).toBe('local') + expect(await f.localBytes()).toEqual(f.beforeLocal) + }) +} diff --git a/packages/opencode/src/setup/native-activation.test.ts b/packages/opencode/src/setup/native-activation.test.ts index f8d3b942d..9b20d5128 100644 --- a/packages/opencode/src/setup/native-activation.test.ts +++ b/packages/opencode/src/setup/native-activation.test.ts @@ -11,8 +11,12 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { createNativeAccountRuntime, + disconnectNativeVaultForHost, type NativeCustodyInventory, nativeMigrationAuthorityPhase, + nativePiAuthSlotPaths, + openNativePiAuthSlot, + readNativeEnrollmentMode, readNativeMigrationJournal, runNativeMigration, } from '@cortexkit/anthropic-auth-core' @@ -226,8 +230,7 @@ test('environment, running-host and Pi consent fences refuse before enrollment o { host: 'pi', anthropic: { type: 'api_key', key: 'synthetic-pi-api-key' }, - consent: true, - error: { code: 'pi-slot-helper-required' }, + error: { code: 'consent-required' }, }, { host: 'pi', @@ -404,3 +407,84 @@ test('an enrolled host refuses local recovery before reading files or starting l expect(logins).toBe(0) expect(f.calls).toEqual([]) }) + +for (const anthropic of [ + { + type: 'oauth', + access: 'synthetic-host-access', + refresh: 'synthetic-host-refresh', + expires: 1, + }, + { type: 'api_key', key: 'synthetic-host-key' }, +]) { + test(`Pi vault setup preserves a consented stored ${anthropic.type} login privately and disconnect restores its exact bytes`, async () => { + const f = await fixture('pi') + await writeHostAuth(f.paths.hostAuthPath, anthropic) + const hostBytes = await readFile(f.paths.hostAuthPath) + const files = [ + ...new Set([ + f.paths.legacyConfigPath, + f.paths.legacyStatePath, + f.paths.paths.config, + f.paths.paths.state, + f.paths.paths.runtime, + f.paths.paths.journal, + f.paths.paths.roster, + f.paths.routingDestinationPath, + ]), + ] + for (const path of files) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile(path, 'synthetic-invalid-local-recovery-data', { + mode: 0o600, + }) + } + const bytes = () => Promise.all(files.map((path) => readFile(path))) + const before = await bytes() + expect( + await activateNativeVaultForHost('pi', { + ...f.options, + removePiAnthropicAuth: true, + }), + ).toBe('committed') + expect(await bytes()).toEqual(before) + const paths = nativePiAuthSlotPaths({ + env: f.options.env, + authPath: f.paths.hostAuthPath, + }) + expect(paths.authPath).toBe(f.paths.hostAuthPath) + expect(JSON.parse(await readFile(paths.authPath, 'utf8'))).toEqual({ + anthropic: { type: 'api_key', key: paths.placeholderKey }, + other: OTHER_PROVIDER, + }) + const slot = openNativePiAuthSlot({ + env: f.options.env, + authPath: f.paths.hostAuthPath, + }) + expect(await slot.inspect()).toEqual({ + slot: 'placeholder', + stash: 'valid', + }) + const activeHost = await readFile(paths.authPath) + const activeStash = await readFile(paths.stashPath) + expect(await activateNativeVaultForHost('pi', f.options)).toBe( + 'already-vault', + ) + expect(await readFile(paths.authPath)).toEqual(activeHost) + expect(await readFile(paths.stashPath)).toEqual(activeStash) + expect(await bytes()).toEqual(before) + expect(await readNativeEnrollmentMode('pi', f.options.env)).toBe( + 'claustrum', + ) + expect( + await disconnectNativeVaultForHost({ + host: 'pi', + env: f.options.env, + slot, + }), + ).toBe('disconnected') + expect(await readFile(f.paths.hostAuthPath)).toEqual(hostBytes) + expect(await bytes()).toEqual(before) + expect(await readNativeEnrollmentMode('pi', f.options.env)).toBe('local') + }) +} diff --git a/packages/opencode/src/setup/native-activation.ts b/packages/opencode/src/setup/native-activation.ts index 9c8149fb1..9db1a0cca 100644 --- a/packages/opencode/src/setup/native-activation.ts +++ b/packages/opencode/src/setup/native-activation.ts @@ -5,6 +5,8 @@ import { inspectNativeHostAuthEntry, type NativeCustodyActivationHooks, type NativeCustodyInventory, + NativePiAuthSlotError, + openNativePiAuthSlot, readNativeEnrollmentMode, requireNoSupervisedAuthContentSnapshot, } from '@cortexkit/anthropic-auth-core' @@ -27,7 +29,6 @@ export class NativeVaultActivationError extends Error { | 'host-auth-invalid' | 'primary-conflict' | 'consent-required' - | 'pi-slot-helper-required' | 'primary-unverified', ) { super( @@ -39,8 +40,6 @@ export class NativeVaultActivationError extends Error { 'Pi has a stored Anthropic API key, which would take precedence over the vault account. Remove it in Pi, then rerun setup.', 'consent-required': 'Pi has stored Anthropic authentication. Explicit consent is required to move it aside for vault use.', - 'pi-slot-helper-required': - 'Pi stored authentication requires the shared vault move-aside helper. No stored authentication was changed.', 'primary-unverified': 'The vault has no verified default Claude account. Sign in through the vault and rerun setup.', }[code], @@ -104,13 +103,18 @@ export async function activateNativeVaultForHost( 'pi', await readHostAuth(plan.hostAuthPath), ) - if (entry.kind !== 'absent') { - if (!options.removePiAnthropicAuth) - throw new NativeVaultActivationError('consent-required') - // Pi's stored authentication must remain restorable after disconnect. - // Refuse before enrollment until its private move-aside support is available. - throw new NativeVaultActivationError('pi-slot-helper-required') - } + const slot = await openNativePiAuthSlot({ + env, + authPath: plan.hostAuthPath, + }).inspect() + if (slot.slot === 'placeholder' && slot.stash === 'missing') + throw new NativePiAuthSlotError('missing-stash') + if ( + entry.kind !== 'absent' && + slot.slot !== 'placeholder' && + !options.removePiAnthropicAuth + ) + throw new NativeVaultActivationError('consent-required') } const priorMode = await readNativeEnrollmentMode(host, env) await enrollNativeVaultForHost(host, { @@ -151,6 +155,11 @@ export async function activateNativeVaultForHost( ) throw new NativeVaultActivationError('primary-unverified') await processFence() + if (host === 'pi') + await openNativePiAuthSlot({ + env, + authPath: plan.hostAuthPath, + }).enterVault() return priorMode === 'claustrum' ? 'already-vault' : 'committed' } finally { runtime.close() diff --git a/packages/opencode/src/setup/paths.ts b/packages/opencode/src/setup/paths.ts index 3bb4376a4..d5e895e54 100644 --- a/packages/opencode/src/setup/paths.ts +++ b/packages/opencode/src/setup/paths.ts @@ -37,7 +37,17 @@ export function getPiAgentDir(env: NodeJS.ProcessEnv = process.env): string { } export function getPiAuthPath(env: NodeJS.ProcessEnv = process.env): string { - return join(getPiAgentDir(env), 'auth.json') + // Match Pi's SDK directory rule, not the extension's optional storage override. + const configured = env.PI_CODING_AGENT_DIR + let agentDir = configured || join(homedir(), '.pi', 'agent') + if (agentDir === '~') agentDir = homedir() + else if ( + agentDir.startsWith('~/') || + (process.platform === 'win32' && agentDir.startsWith('~\\')) + ) + agentDir = join(homedir(), agentDir.slice(2)) + if (agentDir.startsWith('file://')) agentDir = fileURLToPath(agentDir) + return join(agentDir, 'auth.json') } export function opencodePluginEntry(version: string): string { diff --git a/packages/opencode/src/tests/pi-auth-path-contract.test.ts b/packages/opencode/src/tests/pi-auth-path-contract.test.ts new file mode 100644 index 000000000..9f1640f8b --- /dev/null +++ b/packages/opencode/src/tests/pi-auth-path-contract.test.ts @@ -0,0 +1,64 @@ +import { expect } from 'bun:test' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' + +const { test, gate } = createTestLifetimeSuite() + +for (const sdk of ['@earendil-works/pi-coding-agent', 'pi-coding-agent-1']) { + for (const value of [ + undefined, + '/tmp/synthetic-pi-agent', + '~/synthetic-pi-agent', + '~', + ' ', + 'file:///tmp/synthetic-pi-agent', + ]) { + test(`offline Pi auth path matches ${sdk} with directory ${JSON.stringify(value)}`, async () => { + const script = ` + import { getAgentDir } from ${JSON.stringify(sdk)}; + import { join } from 'node:path'; + import { getPiAuthPath } from './src/setup/paths.ts'; + console.log(JSON.stringify({ sdk: join(getAgentDir(), 'auth.json'), setup: getPiAuthPath(process.env) })); + ` + const env: NodeJS.ProcessEnv = { + ...process.env, + PI_AGENT_DIR: '/tmp/synthetic-extension-only-storage', + } + if (value === undefined) delete env.PI_CODING_AGENT_DIR + else env.PI_CODING_AGENT_DIR = value + // Both path implementations run in the same fresh Node process. This + // compares default home and tilde expansion without Bun's cached home. + const child = Bun.spawn( + ['node', '--input-type=module', '--eval', script], + { + cwd: new URL('../..', import.meta.url).pathname, + env, + stdout: 'pipe', + stderr: 'pipe', + }, + ) + const closing = gate() + void closing.wait.then(() => { + if (child.exitCode === null) child.kill() + }) + const [exit, output, errors] = await Promise.allSettled([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if ( + exit.status === 'rejected' || + output.status === 'rejected' || + errors.status === 'rejected' + ) + throw new Error( + 'Pi path-contract child did not complete its process and pipe joins', + ) + expect({ exit: exit.value, stderr: errors.value }).toEqual({ + exit: 0, + stderr: '', + }) + const paths = JSON.parse(output.value) + expect(paths.setup).toBe(paths.sdk) + }) + } +} From 643fdd52ca81dc8a55c56b9193c7d8384fee8ceb Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:16:25 +0200 Subject: [PATCH 138/169] fix(pi): fence vault session affinity by account UUID --- mutations.toml | 16 ++ packages/core/src/tests/test-lifetime.ts | 19 +- packages/pi/src/stream.ts | 31 ++- packages/pi/src/tests/scoped-stream.test.ts | 242 ++++++++++++++++---- 4 files changed, 240 insertions(+), 68 deletions(-) diff --git a/mutations.toml b/mutations.toml index bf0d4390c..7743bde12 100644 --- a/mutations.toml +++ b/mutations.toml @@ -374,3 +374,19 @@ expect_message = "error: expect(received).toBe(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] broad_report = "tmp/mutations/opencode.xml" broad_id = "{name}" + +[[control]] +id = "pi-vault-routing-passes-account-uuid" +guards = "Pi cannot reuse a vault session pin after the default Claude account UUID changes." +file = "packages/pi/src/stream.ts" +old = "candidates: incompleteQuotaPool ? [] : routes.candidates,\n retainAccountIds: routes.retainAccountIds,\n accountIdentities: vaultAccountIdentities()," +new = "candidates: incompleteQuotaPool ? [] : routes.candidates,\n retainAccountIds: routes.retainAccountIds," +test_file = "packages/pi/src/tests/scoped-stream.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/pi/src/tests/scoped-stream.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["Pi vault default UUID replacement re-places the old session without inheriting quota or changing an unrelated fallback pin"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/pi", "tmp/mutations/pi.xml"] +broad_report = "tmp/mutations/pi.xml" +broad_id = "{name}" diff --git a/packages/core/src/tests/test-lifetime.ts b/packages/core/src/tests/test-lifetime.ts index 6a9973822..062952679 100644 --- a/packages/core/src/tests/test-lifetime.ts +++ b/packages/core/src/tests/test-lifetime.ts @@ -126,17 +126,18 @@ export function createTestLifetimeSuite() { 'Test fixture teardown failed', ) }) + const wrapBody = + (body: (...args: Args) => unknown) => + (...args: Args) => { + const lifetime = new TestLifetime() + lifetimes.add(lifetime) + return context.run(lifetime, () => lifetime.runBody(() => body(...args))) + } return { + /** Keep native test.each argument expansion and names while giving each case its own fixture lifetime. */ + wrapBody, test(name: string, body: () => unknown, timeout?: number): void { - registerTest( - name, - () => { - const lifetime = new TestLifetime() - lifetimes.add(lifetime) - return context.run(lifetime, () => lifetime.runBody(body)) - }, - timeout, - ) + registerTest(name, wrapBody(body), timeout) }, deferCleanup: (cleanup: () => void | Promise) => current().deferCleanup(cleanup), diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index 664a0a336..9ee0357d3 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -327,7 +327,7 @@ export async function getPiTrackedCacheKeepSessions() { return getPiCacheKeepRegistry().list(cacheKeepManager.trackedSessions()) } -/** Exercise the real prewarm dispatch without wall-clock or timer mocks. */ +/** Call prewarmNow directly to exercise its provider request without clock or timer mocks. */ export function __prewarmPiCacheKeepForTest( input: Parameters[0], ) { @@ -502,8 +502,8 @@ async function sendAnthropicRequest(options: { if (!options.apiAccount && !verifiedUuid) { if (selected?.source === 'vault') throw new Error('Pi vault route identity is unasserted') - // The local service may learn a UUID by validating an unidentified row. - // This lookup supplies identity only; dispatch still authorizes afresh. + // Validation can learn the UUID of an account that has no verified identity. + // This lookup returns only that UUID; the model send authorizes the credential again. verifiedUuid = ( await native.authorize( routeId, @@ -745,7 +745,7 @@ async function sendAnthropicRequest(options: { onUpstreamStatus: (status) => { if (status === 401) { relay401Attempt = attempt - // A WebSocket status may arrive after the response has been returned. + // The relay can report an upstream 401 after returning the streaming response. relayReports.push(report(attempt, status, 'relay_status_field')) } }, @@ -771,8 +771,8 @@ async function sendAnthropicRequest(options: { if (rotated) { retried401 = true await response.body?.cancel().catch(() => {}) - // Use the replacement credential for the next request attempt only. A - // relay-to-direct fallback obtains a separate authorization. + // Use the replacement credential only for the retried relay request. + // A direct request after relay failure obtains its own authorization. nextAttempt = rotated relay401Attempt = undefined response = await sendRelayAttempt() @@ -834,6 +834,15 @@ async function executeWithFallback(options: { let storage = snapshot.policyStorage const accountView = (id: string) => snapshot.accounts.find((account) => account.id === id) + const vaultAccountIdentities = () => { + if (snapshot.mode !== 'claustrum') return undefined + const identities = new Map() + for (const account of snapshot.accounts) { + if (account.source === 'vault' && account.accountIdentity) + identities.set(account.id, account.accountIdentity) + } + return identities + } const nativeReady = (id: string) => { const account = accountView(id) return Boolean( @@ -871,8 +880,8 @@ async function executeWithFallback(options: { ): boolean { if (stickyQuotaSnapshotIsFresh(quota, storage, Date.now(), modelId)) return true - // A successful windowless usage response is not the same as a failed - // probe. Pi intentionally admits the former as unknown-capacity OAuth. + // A successful usage response without quota windows is not a failed poll. + // Pi can serve that OAuth account without a known remaining capacity. if ( !quota || quota.five_hour || @@ -1158,10 +1167,11 @@ async function executeWithFallback(options: { family: stickyRouteFamilyForModel(options.model.id), modelId: options.model.id, affinityModelId: options.model.id, - // Existing affinity can survive a transient probe failure, but an - // incomplete pool cannot create a new balanced assignment. + // Keep an existing route after a temporary quota poll failure, but do not + // choose a new balanced route until the account pool has current quota data. candidates: incompleteQuotaPool ? [] : routes.candidates, retainAccountIds: routes.retainAccountIds, + accountIdentities: vaultAccountIdentities(), storage, inputBytes: initialInputBytes, }) @@ -1328,6 +1338,7 @@ async function executeWithFallback(options: { modelId: options.model.id, candidates: routes.candidates, retainAccountIds: routes.retainAccountIds, + accountIdentities: vaultAccountIdentities(), storage, inputBytes: initialInputBytes, excludeAccountIds: new Set([failedRouteId]), diff --git a/packages/pi/src/tests/scoped-stream.test.ts b/packages/pi/src/tests/scoped-stream.test.ts index f779666e0..acdb4ab5f 100644 --- a/packages/pi/src/tests/scoped-stream.test.ts +++ b/packages/pi/src/tests/scoped-stream.test.ts @@ -1,4 +1,5 @@ -import { afterEach, expect, test } from 'bun:test' +import { expect, test as registerTest } from 'bun:test' +import { createHash } from 'node:crypto' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -7,6 +8,7 @@ import { type NativeCustodyClient as ClaustrumScopedClient, getLogLevel, type LogTestRecord, + resolveNativePoolPaths, setLogLevel, } from '@cortexkit/anthropic-auth-core' import { @@ -27,6 +29,7 @@ import { ModelRuntime, type ProviderConfig, } from '@earendil-works/pi-coding-agent' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import cortexKitPiAnthropicAuth from '../index.ts' import { closePiNativeRuntime, getPiNativeRuntime } from '../native.ts' import { @@ -37,24 +40,20 @@ import { fixtureAccountIdentity, readNativePiFixture, saveNativePiFixture, - setNativePiFixtureMode, } from './native-fixture.ts' import { trackPiTestBody } from './setup.ts' const originalFetch = globalThis.fetch +const lifetimes = createTestLifetimeSuite() +const { deferCleanup } = lifetimes +const wrapBody = (body: (...args: Args) => unknown) => + lifetimes.wrapBody((...args: Args) => + trackPiTestBody(Promise.resolve().then(() => body(...args))), + ) +const test = (name: string, body: () => unknown, timeout?: number) => + registerTest(name, wrapBody(body), timeout) let dir: string | undefined let path: string | undefined -afterEach(async () => { - if (path) closePiNativeRuntime(path) - globalThis.fetch = originalFetch - delete process.env.PI_ANTHROPIC_AUTH_FILE - delete process.env.PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE - delete process.env.PI_CODING_AGENT_DIR - delete process.env.PI_AGENT_DIR - if (dir) await rm(dir, { recursive: true, force: true }) - dir = undefined - path = undefined -}) const model: Model = { id: 'claude-fable-5-1', name: 'Fable', @@ -95,16 +94,34 @@ async function fixture( ) { dir = await mkdtemp(join(tmpdir(), 'pi-scoped-stream-')) path = join(dir, 'accounts.json') + const root = dir + const storagePath = path + const envKeys = [ + 'PI_CODING_AGENT_DIR', + 'PI_AGENT_DIR', + 'PI_ANTHROPIC_AUTH_FILE', + 'PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + ] + const previousEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + deferCleanup(() => { + closePiNativeRuntime(storagePath) + globalThis.fetch = originalFetch + for (const [key, value] of previousEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + if (path === storagePath) { + path = undefined + dir = undefined + } + }) + deferCleanup(() => rm(root, { recursive: true, force: true })) const tokenPath = join(dir, 'pi-token.json') process.env.PI_CODING_AGENT_DIR = dir process.env.PI_AGENT_DIR = dir process.env.PI_ANTHROPIC_AUTH_FILE = path process.env.PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = tokenPath - await writeFile( - tokenPath, - JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, - ) await saveNativePiFixture( { version: 1, @@ -132,13 +149,34 @@ async function fixture( path, { primary: false }, ) + const localPaths = await resolveNativePoolPaths( + storagePath, + join(root, 'anthropic-auth-state.json'), + ) + const localFiles = [ + localPaths.config, + localPaths.state, + localPaths.runtime, + localPaths.journal, + ] + const beforeLocal = await Promise.all( + localFiles.map((file) => readFile(file)), + ) + await writeFile( + tokenPath, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) const rows = [row('oauth:anthropic', fixtureAccountIdentity('main'))] const gets: Array[0]> = [] const reports: Array< Parameters[0] > = [] const client: ClaustrumScopedClient = { - listScoped: async () => ({ rows, view: `view-${rows.length}` }), + listScoped: async () => ({ + rows, + view: createHash('sha256').update(JSON.stringify(rows)).digest('hex'), + }), getScoped: async (input) => { gets.push(input) return { @@ -156,19 +194,26 @@ async function fixture( close: () => {}, } const native = getPiNativeRuntime(path, { connect: async () => client }) - await setNativePiFixtureMode(path, 'claustrum') - const roster = await (await native.service()).vault.refresh() + const service = await native.service() + const roster = await service.vault.refresh() + await service.updateSettings((settings) => ({ + ...settings, + routing: { mode }, + })) + if (relay) + await service.updateRelay({ + enabled: true, + url: 'https://relay.invalid', + token: 'relay-test-secret', + transport: 'http', + fallbackToDirect: true, + }) const primary = roster?.rows.find( (account) => account.credentialId === 'oauth:anthropic', ) if (!primary?.accountIdentity) throw new Error('Fixture vault primary identity is missing') - await setNativePiFixtureMode(path, 'claustrum', { - routeId: primary.routeId, - credentialId: primary.credentialId, - accountIdentity: primary.accountIdentity, - }) - return { rows, gets, reports, client, path } + return { rows, gets, reports, client, path, localFiles, beforeLocal, native } } test('Pi sends using scoped credentials without host auth, and never bootstraps with a cached token', async () => { @@ -278,9 +323,9 @@ test('fallback-first discovers a new account on the next turn and honors a live }) }) -test.each([500, 401])( +registerTest.each([500, 401])( 'relay status %i falls back direct with a fresh version-fenced credential', - async (relayStatus) => { + wrapBody(async (relayStatus) => { const f = await fixture('main-first', true) // Relay and direct use different records; no rotation occurs after the // direct send, so a genuine 401 must report its exact served version. @@ -331,7 +376,7 @@ test.each([500, 401])( reporterSource: 'direct', }, ]) - }, + }), ) test('scoped model switches reuse the existing sticky allocator and its Opus reserve preference', async () => { @@ -431,9 +476,9 @@ const hostCredentials: Array<{ name: string; credential?: Credential }> = [ }, ] -test.each(hostCredentials)( +registerTest.each(hostCredentials)( 'real Pi model runtime honors custody with $name', - async ({ credential }) => { + wrapBody(async ({ credential }) => { const f = await fixture() let writes = 0, sends = 0 @@ -549,12 +594,12 @@ test.each(hostCredentials)( { sessionManager: { getSessionId: () => undefined } }, ) } - }, + }), ) -test.each([200, 401])( +registerTest.each([200, 401])( 'Pi retries in-flight scoped 401 once and fences final status %i', - async (finalStatus) => { + wrapBody(async (finalStatus) => { const f = await fixture() let version = 1 const gets: number[] = [] @@ -625,12 +670,12 @@ test.each([200, 401])( reason: 'rotated', }), ) - }, + }), ) -test.each([200, 401])( +registerTest.each([200, 401])( 'Pi HTTP relay retries rotated scoped credential once; final status %i', - async (finalStatus) => { + wrapBody(async (finalStatus) => { const f = await fixture('main-first', true) let version = 1 const reports: number[] = [], @@ -681,12 +726,12 @@ test.each([200, 401])( expect(result.stopReason).toBe(finalStatus === 401 ? 'error' : 'stop') expect(relayTokens).toEqual(['Bearer relay-pi-1', 'Bearer relay-pi-2']) expect(reports).toEqual(finalStatus === 401 ? [1, 2] : [1]) - }, + }), ) -test.each([200, 401])( +registerTest.each([200, 401])( 'Pi CacheKeep retries a rotated scoped receipt once, final status %i', - async (finalStatus) => { + wrapBody(async (finalStatus) => { const f = await fixture() let version = 1 const sent: string[] = [] @@ -747,12 +792,12 @@ test.each([200, 401])( expect(f.reports.map((report) => report.recordVersion)).toEqual( finalStatus === 401 ? [1, 2] : [1], ) - }, + }), ) -test.each([200, 401])( +registerTest.each([200, 401])( 'Pi WebSocket relay retries a strict-newer native receipt once and reports every rejected version; final status %i', - async (finalStatus) => + wrapBody(async (finalStatus) => trackPiTestBody( (async () => { const f = await fixture() @@ -844,6 +889,7 @@ test.each([200, 401])( } })(), ), + ), ) test('Pi WebSocket reconnect authorizes the replacement physical attempt without reporting a transport failure', () => @@ -954,7 +1000,7 @@ test('Pi refuses an unchanged native vault version after 401 without local or pa expect(f.reports.map((report) => report.recordVersion)).toEqual([1]) }) -test.each([ +registerTest.each([ 'https://unapproved.invalid/v1', 'http://api.anthropic.com', 'https://user:password@api.anthropic.com/v1', @@ -963,7 +1009,7 @@ test.each([ 'not a URL', ])( 'Pi rejects native OAuth recipient %s before authorization or dispatch', - async (baseUrl) => { + wrapBody(async (baseUrl) => { const f = await fixture() let sends = 0 globalThis.fetch = Object.assign( @@ -981,7 +1027,7 @@ test.each([ expect(result.errorMessage).toContain('official Anthropic HTTPS origin') expect(f.gets).toEqual([]) expect(sends).toBe(0) - }, + }), ) test('Pi accepts the official versioned OAuth endpoint with redirects disabled', async () => { @@ -1041,12 +1087,12 @@ test('Pi refuses a foreign CacheKeep target before authorizing a native receipt' expect(sends).toBe(0) }) -test.each([ +registerTest.each([ { name: 'Fable', id: 'claude-fable-5-1', allowed: false }, { name: 'Sonnet', id: 'claude-sonnet-5-5', allowed: true }, ])( 'Pi final native vault admission rechecks the pending request model: $name', - (requested) => + wrapBody((requested) => trackPiTestBody( (async () => { const f = await fixture() @@ -1132,4 +1178,102 @@ test.each([ expect(f.reports).toEqual([]) })(), ), + ), ) + +test('Pi vault default UUID replacement re-places the old session without inheriting quota or changing an unrelated fallback pin', async () => { + const f = await fixture('sticky-balanced') + f.rows.push(row('oauth:anthropic:work', fixtureAccountIdentity('work'))) + const served: string[] = [] + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + const index = + Number(/vault-test-access-(\d+)$/.exec(authorization)?.[1]) - 1 + const credentialId = f.gets[index]?.credentialId + if (!credentialId) throw new Error('Request did not use a scoped receipt') + if (String(input).includes('/api/oauth/usage')) + return Response.json({ + five_hour: { + utilization: 20, + resets_at: new Date(Date.now() + 3_600_000).toISOString(), + }, + seven_day: { + utilization: 20, + resets_at: new Date(Date.now() + 86_400_000).toISOString(), + }, + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: credentialId === 'oauth:anthropic' ? 100 : 0, + scope: { model: { display_name: 'Fable' } }, + }, + ], + }) + if (!String(input).includes('/v1/messages')) + throw new Error('Unexpected outbound request') + served.push(credentialId) + return success() + }, + { preconnect: originalFetch.preconnect }, + ) + const mainSession = 'vault-default-session' + const fallbackSession = 'vault-fallback-session' + const opus = { ...model, id: 'claude-opus-5' } + expect( + ( + await streamCortexKitAnthropic(opus, context, { + sessionId: mainSession, + }).result() + ).stopReason, + ).toBe('stop') + expect( + ( + await streamCortexKitAnthropic(model, context, { + sessionId: fallbackSession, + }).result() + ).stopReason, + ).toBe('stop') + expect(served).toEqual(['oauth:anthropic', 'oauth:anthropic:work']) + const routingPath = await f.native.routingPath() + const fallbackKey = createHash('sha256').update(fallbackSession).digest('hex') + const mainKey = createHash('sha256').update(mainSession).digest('hex') + const beforeText = await readFile(routingPath, 'utf8') + const fallbackPinBytes = (text: string) => { + const match = text.match(new RegExp(`"${fallbackKey}":\\s*(\\{[^}]*\\})`)) + if (!match?.[1]) throw new Error('Expected fallback pin is missing') + return match[1] + } + const beforePin = fallbackPinBytes(beforeText) + expect(JSON.parse(beforeText).assignments[mainKey].accountIdentity).toBe( + fixtureAccountIdentity('main'), + ) + const nextUuid = fixtureAccountIdentity('replacement-main') + f.rows[0] = row('oauth:anthropic', nextUuid) + await (await f.native.service()).vault.refresh() + const replacement = (await f.native.view()).accounts.find( + (account) => account.id === 'main', + ) + expect(replacement?.accountIdentity).toBe(nextUuid) + expect(replacement?.quota).toBeUndefined() + expect( + ( + await streamCortexKitAnthropic(opus, context, { + sessionId: mainSession, + }).result() + ).stopReason, + ).toBe('stop') + const afterText = await readFile(routingPath, 'utf8') + expect(JSON.parse(afterText).assignments[mainKey].accountIdentity).toBe( + nextUuid, + ) + expect(fallbackPinBytes(afterText)).toBe(beforePin) + expect(await Promise.all(f.localFiles.map((file) => readFile(file)))).toEqual( + f.beforeLocal, + ) +}) From b808733ad5b3d131d69eb38657daa279a1f962f1 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:23:43 +0200 Subject: [PATCH 139/169] test(opencode): include native setup checks in the unit suite --- .../src/{setup => tests}/native-activation.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) rename packages/opencode/src/{setup => tests}/native-activation.test.ts (97%) diff --git a/packages/opencode/src/setup/native-activation.test.ts b/packages/opencode/src/tests/native-activation.test.ts similarity index 97% rename from packages/opencode/src/setup/native-activation.test.ts rename to packages/opencode/src/tests/native-activation.test.ts index 9b20d5128..9d4425a6d 100644 --- a/packages/opencode/src/setup/native-activation.test.ts +++ b/packages/opencode/src/tests/native-activation.test.ts @@ -22,11 +22,15 @@ import { } from '@cortexkit/anthropic-auth-core' import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' -import { runSetupCommand } from './command.ts' -import { activateNativeVaultForHost } from './native-activation.ts' -import { migrateNativeLocalForHost } from './native-local.ts' -import { resolveNativeSetupPaths } from './native-paths.ts' -import type { CommandRunner, HarnessKind, ProcessFence } from './types.ts' +import { runSetupCommand } from '../setup/command.ts' +import { activateNativeVaultForHost } from '../setup/native-activation.ts' +import { migrateNativeLocalForHost } from '../setup/native-local.ts' +import { resolveNativeSetupPaths } from '../setup/native-paths.ts' +import type { + CommandRunner, + HarnessKind, + ProcessFence, +} from '../setup/types.ts' const { test, deferCleanup } = createTestLifetimeSuite() From dcc18bb092dd8b620739e6939ca80b7bdd159f3b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:38:20 +0200 Subject: [PATCH 140/169] feat(core): expose a safe secret-free host login reader --- mutations.toml | 16 +++ packages/core/src/index.ts | 1 + packages/core/src/native-host-auth-read.ts | 31 ++++++ .../src/tests/native-host-auth-read.test.ts | 102 ++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 packages/core/src/native-host-auth-read.ts create mode 100644 packages/core/src/tests/native-host-auth-read.test.ts diff --git a/mutations.toml b/mutations.toml index 7743bde12..ca743855b 100644 --- a/mutations.toml +++ b/mutations.toml @@ -390,3 +390,19 @@ expect_message = "error: expect(received).toBe(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/pi", "tmp/mutations/pi.xml"] broad_report = "tmp/mutations/pi.xml" broad_id = "{name}" + +[[control]] +id = "host-auth-reader-rejects-shared-permissions" +guards = "A readable auth file with unsafe permissions cannot be mistaken for a safe empty login slot." +file = "packages/core/src/native-host-auth-read.ts" +old = "(snapshot.metadata.mode & 0o077) !== 0" +new = "false /* NON-VACUITY BREAK: admit shared host authentication */" +test_file = "packages/core/src/tests/native-host-auth-read.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-host-auth-read.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["group-readable authentication refuses even when the process can physically read it"] +expect_message = "Expected promise that rejects" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bcaa424e2..f00206206 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -70,6 +70,7 @@ export { NativeHostAuthError, requireNoSupervisedAuthContentSnapshot, } from './native-host-auth.ts' +export { readNativeHostAuthKind } from './native-host-auth-read.ts' export type { NativeLocalAttributedFailure, NativeLocalCredentialService, diff --git a/packages/core/src/native-host-auth-read.ts b/packages/core/src/native-host-auth-read.ts new file mode 100644 index 000000000..856dddb4a --- /dev/null +++ b/packages/core/src/native-host-auth-read.ts @@ -0,0 +1,31 @@ +import { readNativeMigrationSource } from './migration-source.ts' +import { + inspectNativeHostAuthEntry, + type NativeHostAuthEntryInspection, + NativeHostAuthError, +} from './native-host-auth.ts' + +/** Read only the host's login classification; credentials and source fingerprints never leave this boundary. */ +export async function readNativeHostAuthKind( + host: 'opencode' | 'pi', + path: string, +): Promise { + try { + if (host !== 'opencode' && host !== 'pi') + throw new NativeHostAuthError('invalid-source') + const snapshot = await readNativeMigrationSource('hostAuth', path) + if (snapshot.metadata) { + const uid = + typeof process.getuid === 'function' ? process.getuid() : undefined + if ( + (uid !== undefined && snapshot.metadata.uid !== uid) || + (snapshot.metadata.mode & 0o077) !== 0 + ) + throw new NativeHostAuthError('invalid-source') + } + return inspectNativeHostAuthEntry(host, snapshot.data).kind + } catch { + // A failed read must not look like an empty slot or expose another provider's credentials. + throw new NativeHostAuthError('invalid-source') + } +} diff --git a/packages/core/src/tests/native-host-auth-read.test.ts b/packages/core/src/tests/native-host-auth-read.test.ts new file mode 100644 index 000000000..b98198779 --- /dev/null +++ b/packages/core/src/tests/native-host-auth-read.test.ts @@ -0,0 +1,102 @@ +import { expect } from 'bun:test' +import { + chmod, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { custodyTombstoneOAuth } from '../claustrum.ts' +import { readNativeHostAuthKind } from '../native-host-auth-read.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'native-host-auth-read-')) + deferCleanup(() => rm(root, { recursive: true, force: true })) + return { root, path: join(root, 'auth.json') } +} + +test('an absent host auth file is absent without creating a file or local authority', async () => { + const f = await fixture() + expect(await readNativeHostAuthKind('opencode', f.path)).toBe('absent') + await expect(readFile(f.path)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +for (const [host, entry, kind] of [ + ['opencode', undefined, 'absent'], + ['opencode', { type: 'api', key: 'synthetic-private-key' }, 'api'], + ['pi', { type: 'api_key', key: 'synthetic-private-key' }, 'api_key'], + [ + 'opencode', + { + type: 'oauth', + access: 'synthetic-access', + refresh: 'synthetic-refresh', + expires: 1, + }, + 'oauth', + ], + ['opencode', custodyTombstoneOAuth('anthropic'), 'activation'], +] as const) { + test(`${host} host auth read returns only ${kind} and preserves every file byte`, async () => { + const f = await fixture() + const text = JSON.stringify({ + other: { type: 'api', key: 'synthetic-co-tenant-secret' }, + ...(entry && { anthropic: entry }), + }) + await writeFile(f.path, text, { mode: 0o600 }) + const result = await readNativeHostAuthKind(host, f.path) + expect(result).toBe(kind) + expect(typeof result).toBe('string') + expect(await readFile(f.path, 'utf8')).toBe(text) + }) +} + +for (const dangling of [false, true]) { + test(`a ${dangling ? 'dangling' : 'readable'} auth symlink refuses instead of admitting an empty slot`, async () => { + const f = await fixture() + const target = join(f.root, 'target.json') + if (!dangling) await writeFile(target, '{}', { mode: 0o600 }) + await symlink(target, f.path) + await expect( + readNativeHostAuthKind('opencode', f.path), + ).rejects.toMatchObject({ code: 'invalid-source' }) + if (!dangling) expect(await readFile(target, 'utf8')).toBe('{}') + }) +} + +for (const text of [ + '{"anthropic":"synthetic-secret"', + '{"anthropic":null}', + '{"anthropic":{"type":"oauth","access":"synthetic-secret"}}', +]) { + test(`malformed host auth refuses with a closed error and unchanged bytes: ${text.length}`, async () => { + const f = await fixture() + await writeFile(f.path, text, { mode: 0o600 }) + const outcome = await readNativeHostAuthKind('opencode', f.path).then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ) + expect(outcome.ok).toBe(false) + if (outcome.ok) + throw new Error('Malformed host authentication was admitted') + expect(outcome.error).toMatchObject({ code: 'invalid-source' }) + expect(String(outcome.error)).not.toContain('synthetic-secret') + expect(outcome.error).not.toHaveProperty('cause') + expect(await readFile(f.path, 'utf8')).toBe(text) + }) +} + +test('group-readable authentication refuses even when the process can physically read it', async () => { + const f = await fixture() + await writeFile(f.path, '{}', { mode: 0o600 }) + await chmod(f.path, 0o640) + await expect( + readNativeHostAuthKind('opencode', f.path), + ).rejects.toMatchObject({ code: 'invalid-source' }) + expect(await readFile(f.path, 'utf8')).toBe('{}') +}) From 235654676af0f75bc6dc81ff45650c7dc0842fb1 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:59:09 +0200 Subject: [PATCH 141/169] fix(quota): pace physical usage requests after credential admission --- mutations.toml | 48 ++++++ packages/core/src/native-account-runtime.ts | 41 ++++- packages/core/src/native-vault-runtime.ts | 45 ++++- packages/core/src/quota-manager.ts | 150 +++++++++++----- .../src/tests/native-vault-runtime.test.ts | 66 ++++++++ .../core/src/tests/quota-dispatch.test.ts | 160 ++++++++++++++++++ packages/opencode/src/index.ts | 4 +- 7 files changed, 455 insertions(+), 59 deletions(-) create mode 100644 packages/core/src/tests/quota-dispatch.test.ts diff --git a/mutations.toml b/mutations.toml index ca743855b..c1784ccb2 100644 --- a/mutations.toml +++ b/mutations.toml @@ -406,3 +406,51 @@ expect_message = "Expected promise that rejects" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] broad_report = "tmp/mutations/core.xml" broad_id = "{name}" + +[[control]] +id = "quota-spacing-counts-physical-dispatch-only" +guards = "Quota admission refusals and lock losers cannot postpone a usable account's first usage GET." +file = "packages/core/src/quota-manager.ts" +old = " return fn()\n }\n const queued = this.apiGate.then(gatedFn, gatedFn)" +new = " this.lastApiCallAt = this.now()\n return fn()\n }\n const queued = this.apiGate.then(gatedFn, gatedFn)" +test_file = "packages/core/src/tests/quota-dispatch.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/quota-dispatch.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["two unusable quota rows consume no dispatch spacing before the sole usable GET", "a quota lock loser neither sends a GET nor advances physical dispatch spacing"] +expect_message = "error: expect(received).toBeUndefined()" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "quota-physical-get-gap-includes-failures" +guards = "Every physical usage GET, including one after a provider failure, respects the one-second minimum start gap." +file = "packages/core/src/quota-manager.ts" +old = "if (remaining > 0)" +new = "if (false)" +test_file = "packages/core/src/tests/quota-dispatch.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/quota-dispatch.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["physical quota GET starts stay one second apart when the first GET fails"] +expect_message = "error: expect(received).toBeGreaterThanOrEqual(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "quota-rechecks-credential-after-spacing" +guards = "An enrollment or account decline during the quota pacing wait prevents dispatch and consumes no timestamp or 401 report." +file = "packages/core/src/quota-manager.ts" +old = "await revalidate()" +new = "// NON-VACUITY BREAK: skip post-wait credential eligibility" +test_file = "packages/core/src/tests/native-vault-runtime.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-vault-runtime.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["vault mode lost during quota spacing drops the attempt without a GET, spacing stamp or 401 report", "vault subject lost during quota spacing drops the attempt without a GET, spacing stamp or 401 report"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" diff --git a/packages/core/src/native-account-runtime.ts b/packages/core/src/native-account-runtime.ts index 207c8de17..0316d9722 100644 --- a/packages/core/src/native-account-runtime.ts +++ b/packages/core/src/native-account-runtime.ts @@ -78,6 +78,7 @@ import { } from './pool-binding.ts' import type { NativePoolPaths } from './pool-paths.ts' import { createNativePoolStore, nativePoolStoreLocks } from './pool-store.ts' +import type { QuotaRequestDispatch } from './quota-manager.ts' import type { RelayConfig } from './relay.ts' import { tokenFingerprint } from './token-fingerprint.ts' @@ -219,6 +220,7 @@ export interface NativeAccountRuntime { routeId: string, fetchImpl?: typeof fetch, signal?: AbortSignal, + dispatch?: QuotaRequestDispatch, ): Promise fetchProfile( routeId: string, @@ -1208,10 +1210,10 @@ export function createNativeAccountRuntime( }, })) }, - async fetchQuota(routeId, fetchImpl = fetch, signal) { + async fetchQuota(routeId, fetchImpl = fetch, signal, dispatch) { const snapshot = await runtime.read() if (snapshot.mode === 'claustrum') - return vault.fetchQuota(routeId, fetchImpl, signal) + return vault.fetchQuota(routeId, fetchImpl, signal, dispatch) const before = snapshot.accounts.find((row) => row.id === routeId) const result = await fetchReading( routeId, @@ -1243,6 +1245,7 @@ export function createNativeAccountRuntime( lastQuotaRefreshError: failure, }) }, + dispatch, ) const quota = { ...result.value, @@ -1339,6 +1342,7 @@ export function createNativeAccountRuntime( error: unknown, attempt: Extract, ) => Promise, + dispatch?: QuotaRequestDispatch, ) { const combined = AbortSignal.any([ shutdown.signal, @@ -1348,6 +1352,31 @@ export function createNativeAccountRuntime( if (first.status !== 'usable') throw new NativeCustodyError('route-unavailable') let served = first + const send = ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const physical = () => fetchImpl(input, { ...init, signal: combined }) + if (!dispatch) return physical() + return dispatch(physical, async () => { + await localAuthority(combined) + const row = await poolRow(served.binding.rowId) + if ( + !row?.enabled || + row.stamp !== 'bound' || + row.credential?.type !== 'oauth' || + !nativeLocalPoolBindingMatches(served.binding, paths, row) || + !nativeLocalCredentialValidationMatches( + served.subject, + served.binding, + row.credential, + ) || + (await physicalId(id)) !== served.binding.rowId + ) + throw new NativeCustodyError('identity-changed') + await localAuthority(combined) + }) + } let responseStatus: number | undefined const transport: typeof fetch = Object.assign( async ( @@ -1355,7 +1384,7 @@ export function createNativeAccountRuntime( init?: Parameters[1], ) => { responseStatus = undefined - let response = await fetchImpl(input, { ...init, signal: combined }) + let response = await send(input, init) responseStatus = response.status if (response.status === 401 && !combined.aborted) { await response.body?.cancel().catch(() => {}) @@ -1379,11 +1408,7 @@ export function createNativeAccountRuntime( const headers = new Headers(init?.headers) headers.set('authorization', `Bearer ${served.access}`) responseStatus = undefined - response = await fetchImpl(input, { - ...init, - headers, - signal: combined, - }) + response = await send(input, { ...init, headers }) responseStatus = response.status } } diff --git a/packages/core/src/native-vault-runtime.ts b/packages/core/src/native-vault-runtime.ts index 422801075..2ab609ed9 100644 --- a/packages/core/src/native-vault-runtime.ts +++ b/packages/core/src/native-vault-runtime.ts @@ -60,6 +60,7 @@ import { fetchOAuthAccountProfile } from './oauth-profile.ts' import { NativeAuthorityError } from './pool-authority.ts' import type { NativePoolPaths } from './pool-paths.ts' import { createNativePoolStore } from './pool-store.ts' +import type { QuotaRequestDispatch } from './quota-manager.ts' export interface NativeVaultRuntimeOptions { paths: NativePoolPaths @@ -177,6 +178,7 @@ export interface NativeVaultRuntime { routeId: string, fetchImpl?: typeof fetch, signal?: AbortSignal, + dispatch?: QuotaRequestDispatch, ): Promise fetchProfile( routeId: string, @@ -589,7 +591,7 @@ export function createNativeVaultRuntime( }, })) }, - async fetchQuota(routeId, fetchImpl = fetch, signal) { + async fetchQuota(routeId, fetchImpl = fetch, signal, dispatch) { const row = await rowFor(await runtime.read(), routeId) const state = await readNativeRuntime(paths.runtime, paths.storageId) const before = @@ -633,6 +635,7 @@ export function createNativeVaultRuntime( if (failure) await runtime.publish(receipt, { lastQuotaRefreshError: failure }) }, + dispatch, ) const quota = { ...result.value, @@ -851,9 +854,41 @@ export function createNativeVaultRuntime( error: unknown, receipt: NativeCustodyReceipt, ) => Promise, + dispatch?: QuotaRequestDispatch, ) { const combined = signalFor(signal) let served = await runtime.authorize(routeId, combined) + const send = ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const physical = () => fetchImpl(input, { ...init, signal: combined }) + if (!dispatch) return physical() + return dispatch(physical, async () => { + await requireActive(combined) + const route = requireIssued(served) + const current = await rowFor( + await readVaultRoster(paths.roster), + route.logicalId, + ) + const key = JSON.stringify([ + route.routeId, + served.credentialId, + served.assertedAccountIdentity, + ]) + if ( + !current?.enabled || + current.state !== 'active' || + current.routeId !== route.routeId || + current.accountIdentity !== served.assertedAccountIdentity || + (current.credentialId !== served.credentialId && + !current.aliases?.includes(served.credentialId)) || + (versions.get(key) ?? served.recordVersion) > served.recordVersion + ) + throw new NativeCustodyError('identity-changed') + await requireActive(combined) + }) + } let responseStatus: number | undefined const transport: typeof fetch = Object.assign( async ( @@ -861,7 +896,7 @@ export function createNativeVaultRuntime( init?: Parameters[1], ) => { responseStatus = undefined - let response = await fetchImpl(input, { ...init, signal: combined }) + let response = await send(input, init) responseStatus = response.status if (response.status === 401 && !combined.aborted) { // Body cleanup can await. Complete it before final retry admission @@ -896,11 +931,7 @@ export function createNativeVaultRuntime( responseStatus = undefined // A transport failure here propagates unreported: it is not evidence // that the newer version was rejected. - response = await fetchImpl(input, { - ...init, - headers, - signal: combined, - }) + response = await send(input, { ...init, headers }) responseStatus = response.status if (response.status === 401) await report(served) } diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts index fd879119d..0556200a6 100644 --- a/packages/core/src/quota-manager.ts +++ b/packages/core/src/quota-manager.ts @@ -54,19 +54,31 @@ export type QuotaRefreshResult = { fetched: boolean } +/** Pace a physical usage GET and verify the selected credential is still eligible before sending. */ +export type QuotaRequestDispatch = ( + send: () => Promise, + revalidate: () => Promise, +) => Promise + export type QuotaManagerOptions = { storage: AccountStorage | null fetchImpl?: typeof fetch /** * This callback checks or obtains credentials before its own HTTP request. * accessToken may be empty when the host uses a native pool or vault instead - * of keeping a token in its quota settings snapshot. + * of keeping a token in its quota settings snapshot. Pass dispatch into the + * native quota operation so every physical GET, including a retry, is paced + * only after admission and rechecks host custody and credential eligibility + * after the spacing wait. */ - fetchQuotaSnapshot?: (request: { - kind: 'main' | 'fallback' - accountId: string | undefined - accessToken: string - }) => Promise + fetchQuotaSnapshot?: ( + request: { + kind: 'main' | 'fallback' + accountId: string | undefined + accessToken: string + }, + dispatch: QuotaRequestDispatch, + ) => Promise now?: () => number onMainQuotaFetched?: ( quota: OAuthQuotaSnapshot, @@ -160,7 +172,7 @@ export class QuotaManager { // --- Serial API gate (prevents concurrent quota API calls) --- private apiGate: Promise = Promise.resolve() - private lastApiCallAt = 0 + private lastApiCallAt: number | undefined // --- Config --- private storage: AccountStorage | null @@ -910,24 +922,13 @@ export class QuotaManager { } /** - * Serialize API calls through a shared gate so only one - * quota API request runs at a time, with a minimum gap - * between calls. Prevents concurrent and rapid-fire calls - * from triggering Anthropic's rate limits. + * Serialize quota refresh work, including credential admission and locks. + * The transport separately paces physical GETs. A refresh refused before + * HTTP dispatch must not postpone the next usable account's poll. */ private _enqueueApiFetch(fn: () => Promise): Promise { const gatedFn = async (): Promise => { if (this.isClosed) throw new Error('QuotaManager is closed') - // Wait until minimum gap since last API call - const elapsed = this.now() - this.lastApiCallAt - if (elapsed < QuotaManager.API_CALL_GAP_MS) { - await new Promise((r) => { - const id = nativeSetTimeout(r, QuotaManager.API_CALL_GAP_MS - elapsed) - if (typeof id === 'object' && 'unref' in id) id.unref() - }) - } - if (this.isClosed) throw new Error('QuotaManager is closed') - this.lastApiCallAt = this.now() return fn() } const queued = this.apiGate.then(gatedFn, gatedFn) @@ -935,6 +936,53 @@ export class QuotaManager { return queued } + private _quotaDispatch( + onStart?: (at: number) => void, + requireOwnership?: () => Promise, + ): QuotaRequestDispatch { + return async (send, revalidate) => { + if (this.lastApiCallAt !== undefined) { + const remaining = + QuotaManager.API_CALL_GAP_MS - (this.now() - this.lastApiCallAt) + if (remaining > 0) + await new Promise((resolve) => { + const id = nativeSetTimeout(resolve, remaining) + if (typeof id === 'object' && 'unref' in id) id.unref() + }) + } + if (this.isClosed) throw new Error('QuotaManager is closed') + // Authorization can precede the pacing wait. If custody changed or the + // captured credential is no longer eligible, send nothing and record + // neither spacing nor a poll timestamp. + await revalidate() + await requireOwnership?.() + if (this.isClosed) throw new Error('QuotaManager is closed') + const at = this.now() + onStart?.(at) + try { + return send() + } finally { + // Capture the start after invoking the transport, not before its + // synchronous setup. A failed physical attempt still consumes spacing. + this.lastApiCallAt = this.now() + } + } + } + + private _quotaFetch(dispatch: QuotaRequestDispatch): typeof fetch { + return Object.assign( + ( + input: Parameters[0], + init?: Parameters[1], + ) => + dispatch( + () => this.fetchImpl(input, init), + async () => init?.signal?.throwIfAborted(), + ), + { preconnect: this.fetchImpl.preconnect }, + ) + } + private async _fetchMain( mainAccountId: string | undefined, accessToken: string, @@ -977,23 +1025,28 @@ export class QuotaManager { throw new Error('Quota refresh is already in progress') } 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, - } + let fetchStartedAt = this.now() + let dispatched = false + const dispatch = this._quotaDispatch( + (at) => { + if (!dispatched) fetchStartedAt = at + dispatched = true + this.lastMainPollAttempt = { accountId: mainAccountId, at } + }, + async () => { + await fileLock.assertOwned() + if (this.mainGeneration !== generation) + throw new Error('Main quota identity changed before dispatch') + }, + ) const quota = this.fetchQuotaSnapshot - ? await this.fetchQuotaSnapshot({ - kind: 'main', - accountId: mainAccountId, - accessToken, - }) + ? await this.fetchQuotaSnapshot( + { kind: 'main', accountId: mainAccountId, accessToken }, + dispatch, + ) : await fetchOAuthQuotaSnapshot({ accessToken, - fetchImpl: this.fetchImpl, + fetchImpl: this._quotaFetch(dispatch), now: this.now, }) const now = this.now() @@ -1097,16 +1150,29 @@ export class QuotaManager { throw new Error('Quota refresh is already in progress') } try { - const fetchStartedAt = this.now() + let fetchStartedAt = this.now() + let dispatched = false + const dispatch = this._quotaDispatch( + (at) => { + if (!dispatched) fetchStartedAt = at + dispatched = true + }, + async () => { + await fileLock.assertOwned() + if ((this.fallbackGenerations.get(accountId) ?? 0) !== generation) + throw new Error( + 'Fallback quota identity changed before dispatch', + ) + }, + ) const quota = this.fetchQuotaSnapshot - ? await this.fetchQuotaSnapshot({ - kind: 'fallback', - accountId, - accessToken, - }) + ? await this.fetchQuotaSnapshot( + { kind: 'fallback', accountId, accessToken }, + dispatch, + ) : await fetchOAuthQuotaSnapshot({ accessToken, - fetchImpl: this.fetchImpl, + fetchImpl: this._quotaFetch(dispatch), now: this.now, }) const now = this.now() diff --git a/packages/core/src/tests/native-vault-runtime.test.ts b/packages/core/src/tests/native-vault-runtime.test.ts index 8333c639a..afcf91d37 100644 --- a/packages/core/src/tests/native-vault-runtime.test.ts +++ b/packages/core/src/tests/native-vault-runtime.test.ts @@ -28,6 +28,7 @@ import { } from '../native-vault-runtime.ts' import { resolveNativePoolPaths } from '../pool-paths.ts' import { createNativePoolStore } from '../pool-store.ts' +import { QuotaManager } from '../quota-manager.ts' import { initializeNativeTestAuthority } from './native-authority-fixture.ts' import { createTestLifetimeSuite } from './test-lifetime.ts' @@ -1305,3 +1306,68 @@ test('offline setup inventory populates only vault files and cannot replace per- expect(f.counts.gets).toBe(1) await unchanged() }) + +for (const change of ['mode', 'subject'] as const) { + test(`vault ${change} lost during quota spacing drops the attempt without a GET, spacing stamp or 401 report`, async () => { + const f = await fixture() + await f.runtime.refresh() + let gets = 0 + let dispatches = 0 + let entered!: () => void + const waiting = new Promise((resolve) => { + entered = resolve + }) + const transport: typeof fetch = Object.assign( + async () => { + gets++ + return Response.json({ + five_hour: { utilization: 4 }, + seven_day: { utilization: 13 }, + }) + }, + { preconnect: fetch.preconnect }, + ) + const manager = new QuotaManager({ + storage: null, + fetchQuotaSnapshot: (_request, dispatch) => + f.runtime.fetchQuota( + 'main', + transport, + undefined, + (send, revalidate) => { + dispatches++ + if (dispatches === 2) entered() + return dispatch(send, revalidate) + }, + ), + }) + deferCleanup(() => manager.close()) + await manager.refreshMainWithMetadata(uuid, '') + const firstStamp: unknown = Object.getOwnPropertyDescriptor( + manager, + 'lastApiCallAt', + )?.value + expect(typeof firstStamp).toBe('number') + const pending = manager.refreshMainWithMetadata(uuid, '').then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ) + await waiting + if (change === 'mode') + await rm(f.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE) + else await f.runtime.setEnabled('main', false) + const result = await pending + expect(result.ok).toBe(false) + if (result.ok) + throw new Error('An unavailable vault quota attempt was admitted') + expect(result.error).toMatchObject({ + code: change === 'mode' ? 'route-unavailable' : 'identity-changed', + }) + expect(gets).toBe(1) + expect(f.counts.gets).toBe(2) + expect(f.reports).toEqual([]) + expect( + Object.getOwnPropertyDescriptor(manager, 'lastApiCallAt')?.value, + ).toBe(firstStamp) + }) +} diff --git a/packages/core/src/tests/quota-dispatch.test.ts b/packages/core/src/tests/quota-dispatch.test.ts new file mode 100644 index 000000000..ff309def0 --- /dev/null +++ b/packages/core/src/tests/quota-dispatch.test.ts @@ -0,0 +1,160 @@ +import { expect } from 'bun:test' +import { acquireRefreshFileLock, fetchOAuthQuotaSnapshot } from '../accounts.ts' +import { NativeCustodyError } from '../native-custody.ts' +import { QuotaManager } from '../quota-manager.ts' +import { createTestLifetimeSuite } from './test-lifetime.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() +const quotaResponse = { + five_hour: { utilization: 4 }, + seven_day: { utilization: 13 }, +} + +function lastDispatch(manager: QuotaManager): unknown { + return Object.getOwnPropertyDescriptor(manager, 'lastApiCallAt')?.value +} + +function ownManager(manager: QuotaManager) { + deferCleanup(() => manager.close()) + return manager +} + +test('two unusable quota rows consume no dispatch spacing before the sole usable GET', async () => { + let gets = 0 + const transport: typeof fetch = Object.assign( + async () => { + gets++ + return Response.json(quotaResponse) + }, + { preconnect: fetch.preconnect }, + ) + const manager = ownManager( + new QuotaManager({ + storage: null, + now: () => 1_000, + fetchQuotaSnapshot: (request, dispatch) => { + if (request.accountId !== 'usable') + throw new NativeCustodyError('route-unavailable') + return fetchOAuthQuotaSnapshot({ + accessToken: 'OAT-synthetic-usable', + fetchImpl: Object.assign( + ( + input: Parameters[0], + init?: Parameters[1], + ) => + dispatch( + () => transport(input, init), + async () => {}, + ), + { preconnect: transport.preconnect }, + ), + }) + }, + }), + ) + for (const id of ['unusable-one', 'unusable-two']) { + await expect( + manager.refreshFallbackWithMetadata(id, '', undefined), + ).rejects.toMatchObject({ code: 'route-unavailable' }) + expect(lastDispatch(manager)).toBeUndefined() + } + expect(gets).toBe(0) + const result = await manager.refreshFallbackWithMetadata( + 'usable', + '', + undefined, + ) + expect(result.fetched).toBe(true) + expect(gets).toBe(1) + expect(lastDispatch(manager)).toBe(1_000) +}) + +test('physical quota GET starts stay one second apart when the first GET fails', async () => { + const starts: number[] = [] + const methods: string[] = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch(request) { + methods.push(request.method) + return methods.length === 1 + ? new Response('provider unavailable', { status: 500 }) + : Response.json(quotaResponse) + }, + }) + deferCleanup(() => server.stop(true)) + const manager = ownManager( + new QuotaManager({ + storage: null, + fetchImpl: Object.assign( + async ( + _input: Parameters[0], + init?: Parameters[1], + ) => { + starts.push(Date.now()) + return fetch(server.url, init) + }, + { preconnect: fetch.preconnect }, + ), + }), + ) + await expect( + manager.refreshFallbackWithMetadata( + 'first', + 'OAT-synthetic-first', + undefined, + ), + ).rejects.toThrow() + expect(starts.length).toBe(1) + expect(lastDispatch(manager)).toBeGreaterThanOrEqual(starts[0]!) + const result = await manager.refreshFallbackWithMetadata( + 'second', + 'OAT-synthetic-second', + undefined, + ) + expect(result.fetched).toBe(true) + expect(starts.length).toBe(2) + expect(methods).toEqual(['GET', 'GET']) + expect(starts[1]! - starts[0]!).toBeGreaterThanOrEqual(1_000) +}) + +test('a quota lock loser neither sends a GET nor advances physical dispatch spacing', async () => { + const held = await acquireRefreshFileLock({ + name: 'opencode-fallback-quota-refresh-held', + ttlMs: 30_000, + }) + if (!held) throw new Error('Fixture failed to hold the quota lock') + deferCleanup(() => held.release()) + let gets = 0 + const manager = ownManager( + new QuotaManager({ + storage: null, + fetchImpl: Object.assign( + async () => { + gets++ + return Response.json(quotaResponse) + }, + { preconnect: fetch.preconnect }, + ), + }), + ) + await expect( + manager.refreshFallbackWithMetadata( + 'held', + 'OAT-synthetic-held', + undefined, + ), + ).rejects.toThrow('already in progress') + expect(gets).toBe(0) + expect(lastDispatch(manager)).toBeUndefined() + expect( + ( + await manager.refreshFallbackWithMetadata( + 'free', + 'OAT-synthetic-free', + undefined, + ) + ).fetched, + ).toBe(true) + expect(gets).toBe(1) +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index d18504721..69b8472b6 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1739,11 +1739,11 @@ const anthropicAuthPlugin = async ( // rejected refresh token became usable, so it must not clear refresh errors. const quotaManager = new QuotaManager({ storage: initialStorage, - fetchQuotaSnapshot: (request) => { + fetchQuotaSnapshot: (request, dispatch) => { assertNativeEnvironment() const routeId = request.kind === 'main' ? 'main' : request.accountId if (!routeId) throw new Error('Quota request has no account identity') - return nativeAccounts.fetchQuota(routeId) + return nativeAccounts.fetchQuota(routeId, undefined, undefined, dispatch) }, }) function quotaSnapshotIsExhausted( From 88f3b8cff7c4a6caac9d69cf33fbd6a8858dd91f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:05:11 +0200 Subject: [PATCH 142/169] test(opencode): migrate CLI and account fixtures to native custody --- packages/opencode/src/cli.ts | 24 +- .../src/tests/account-command.test.ts | 657 +++++++--- .../src/tests/add-account-flows.test.ts | 1148 +++++++++++------ packages/opencode/src/tests/cli.test.ts | 785 +++++++---- 4 files changed, 1756 insertions(+), 858 deletions(-) diff --git a/packages/opencode/src/cli.ts b/packages/opencode/src/cli.ts index 2e356a92a..4f096f4f5 100644 --- a/packages/opencode/src/cli.ts +++ b/packages/opencode/src/cli.ts @@ -10,6 +10,7 @@ import { getAccountStoragePath, isValidApiBaseURL, type NativeAccountRuntime, + readNativeEnrollmentMode, resolveNativePoolPaths, WORKER_SCRIPT, } from '@cortexkit/anthropic-auth-core' @@ -31,6 +32,19 @@ async function withNativeAccounts( } } +/** + * This host's Claustrum enrollment files, not account settings, decide whether + * credentials come from the vault. In vault mode the local account pool must + * not gain accounts, so commands that add local credentials refuse before + * reading the pool or asking for anything, and check again right before + * writing in case setup enrolled the host while a prompt was open. Unreadable + * enrollment state throws as well: it never counts as local mode. + */ +async function requireLocalCustody() { + if ((await readNativeEnrollmentMode('opencode', process.env)) !== 'local') + throw new Error('Disconnect from the vault first.') +} + function usage() { console.log(`Usage: opencode-anthropic-auth setup [--yes] [--dry-run] @@ -297,12 +311,9 @@ export async function login(labelArg?: string, deps: LoginDeps = {}) { const ask = deps.prompt ?? prompt const authorizeImpl = deps.authorize ?? authorize const exchangeImpl = deps.exchange ?? exchange + await requireLocalCustody() await withNativeAccounts(async (runtime) => { const snapshot = await runtime.read() - if (snapshot.mode !== 'local') - throw new Error( - 'Exit vault custody with offline setup before local login', - ) const label = labelArg?.trim() || (await ask('Fallback account label (optional): ')) const authorization = await authorizeImpl('max') @@ -324,6 +335,8 @@ export async function login(labelArg?: string, deps: LoginDeps = {}) { throw new Error( 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set', ) + // Sign-in can take minutes; refuse if setup enrolled the host meanwhile. + await requireLocalCustody() const routeId = label && label !== 'main' ? label : crypto.randomUUID() const replace = snapshot.accounts.some( (account) => account.id === routeId && account.source === 'local', @@ -352,6 +365,7 @@ export interface ApiAddDeps { } export async function addApiRoute(labelArg?: string, deps: ApiAddDeps = {}) { + await requireLocalCustody() await withNativeAccounts((runtime) => runtime.read()) const ask = deps.prompt ?? prompt const label = @@ -383,6 +397,8 @@ export async function addApiRoute(labelArg?: string, deps: ApiAddDeps = {}) { authHeaderInput === 'x-api-key' ? 'x-api-key' : 'authorization-bearer' await withNativeAccounts(async (runtime) => { + // The prompts above can take minutes; refuse if setup enrolled the host. + await requireLocalCustody() const snapshot = await runtime.read() const routeId = label && label !== 'main' ? label : crypto.randomUUID() const existing = snapshot.accounts.find((account) => account.id === routeId) diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index 9bdf5cd96..93f31b139 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -15,18 +15,28 @@ import { __setLogTestSink, type AccountStorage, buildAccountList, + type CommandApplyRequest, + type CommandApplyResult, + createNativeAccountRuntime, custodyStatusLabel, executeAccountCommand, type LogTestRecord, - loadAccounts, + type ProviderAccountUuid, parseAccountCommandAction, - removeAccountPersistent, - reorderAccountsPersistent, - saveAccounts, - setAccountEnabledPersistent, } from '@cortexkit/anthropic-auth-core' +import type { Hooks, PluginInput } from '@opencode-ai/plugin' +import { + type Auth, + createOpencodeClient, + type Provider, +} from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { AnthropicAuthPlugin } from '../index' import { drainNotifications } from '../rpc/notifications' +import { createRpcClient } from '../rpc/rpc-client' +import { drainSidebarWrites } from '../sidebar-state' +import { migrateNativeOpencodeFixture } from './native-fixture.ts' import { DEFAULT_FETCH_MOCK, installDefaultFetchMock } from './test-fetch' import { createTimerTracking, @@ -38,6 +48,21 @@ let accountPath: string const tempDirs = new Set() const originalFetch = globalThis.fetch const timerTracking = createTimerTracking() +// Environment variables a test sets to point the plugin at its migrated +// account pool; the test's cleanup puts back their previous values. +const nativeEnvKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', + 'OPENCODE_AUTH_CONTENT', +] as const const { activeIntervals, disabledPluginTimerOverrides, @@ -92,6 +117,116 @@ const baseStorage = (): AccountStorage => ({ ], }) +/** Account UUIDs are branded once a provider reports them; any non-empty fixture value stands in for one. */ +function isProviderAccountUuid(value: string): value is ProviderAccountUuid { + return value.length > 0 +} + +/** The Anthropic provider entry OpenCode passes the auth loader; no models are configured. */ +const anthropicProvider: Provider = { + id: 'anthropic', + name: 'Anthropic', + source: 'custom', + env: [], + options: {}, + models: {}, +} + +/** + * The context OpenCode passes a server plugin, with a real SDK client whose + * HTTP transport is answered in this process: message history is empty and + * prompts are accepted, so nothing leaves the test. + */ +function hostContext(directory: string): PluginInput { + const client = createOpencodeClient({ + baseUrl: 'http://opencode.invalid', + fetch: async (request) => { + const path = new URL(request.url).pathname + if (request.method === 'GET' && /^\/session\/[^/]+\/message$/.test(path)) + return Response.json([]) + if ( + request.method === 'POST' && + /^\/session\/[^/]+\/prompt_async$/.test(path) + ) + return new Response(null, { status: 204 }) + return Response.json({ name: 'NotFound' }, { status: 404 }) + }, + }) + return { + client, + project: { + id: 'account-command-fixture', + worktree: directory, + time: { created: Date.now() }, + }, + directory, + worktree: directory, + serverUrl: new URL('http://opencode.invalid'), + experimental_workspace: { register() {} }, + $, + } +} + +/** Plugin construction starts a fallback-account refresh and exposes its promise for tests. */ +function fallbackRefreshReady(hooks: Hooks): Promise { + const ready = + '__fallbackRefreshReady' in hooks ? hooks.__fallbackRefreshReady : undefined + if (!(ready instanceof Promise)) + throw new Error('Plugin did not expose its startup fallback refresh') + return ready +} + +// Synthetic OpenCode auth.json: the main OAuth login the offline migration +// moves into the native pool, beside an unrelated provider it must keep. +function mainHostAuth() { + return { + anthropic: { + type: 'oauth', + access: 'synthetic-main-access', + refresh: 'synthetic-main-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + other: { type: 'api', key: 'synthetic-unrelated-provider' }, + } +} + +// Tests of stored account changes import their accounts through the real +// offline migration into an isolated native pool, which is the only store the +// account commands change; the old account file is only a migration source. +const persistence = createTestLifetimeSuite() + +async function migratedAccounts(storage: AccountStorage) { + const root = await mkdtemp(join(tmpdir(), 'anthropic-auth-acct-pool-')) + // A fixture that refuses the directory never registers its removal. + persistence.deferCleanup(() => rm(root, { recursive: true, force: true })) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: persistence, + legacyConfig: { ...storage }, + hostAuth: mainHostAuth(), + }) + // The same runtime the plugin's enable, disable, remove and reorder + // commands write through. + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + persistence.deferCleanup(() => runtime.close()) + /** Every pool file's bytes, to show that a refused change wrote nothing. */ + const files = () => + Promise.all( + [ + fixture.paths.config, + fixture.paths.state, + fixture.paths.runtime, + fixture.paths.journal, + ].map((path) => readFile(path, 'utf8')), + ) + const fallbacks = async () => + (await runtime.read()).accounts.filter((account) => account.id !== 'main') + return { fixture, runtime, files, fallbacks } +} + beforeEach(async () => { installDefaultFetchMock() timerTracking.reset() @@ -160,17 +295,18 @@ describe('parseAccountCommandAction', () => { }) test('recognizes only global mode verbs and rejects retired custody vocabulary', async () => { - const cases = [ - ['claustrum', { type: 'claustrum-mode', mode: 'claustrum' }], - ['local', { type: 'claustrum-mode', mode: 'local' }], - ['custody work-alt on', { type: 'usage' }], - ['claustrum on', { type: 'usage' }], - ['on', { type: 'usage' }], - ['off', { type: 'usage' }], - ] - - for (const [input, expected] of cases as Array<[string, unknown]>) { - expect(parseAccountCommandAction(input)).toEqual(expected as never) + const cases: Array<[string, ReturnType]> = + [ + ['claustrum', { type: 'claustrum-mode', mode: 'claustrum' }], + ['local', { type: 'claustrum-mode', mode: 'local' }], + ['custody work-alt on', { type: 'usage' }], + ['claustrum on', { type: 'usage' }], + ['on', { type: 'usage' }], + ['off', { type: 'usage' }], + ] + + for (const [input, expected] of cases) { + expect(parseAccountCommandAction(input)).toEqual(expected) } }) @@ -255,10 +391,7 @@ describe('parseAccountCommandAction', () => { // --------------------------------------------------------------------------- describe('buildAccountList', () => { test('builds list with main first, then fallbacks in order', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) - const loaded = await loadAccounts(accountPath) - const list = buildAccountList(loaded!) + const list = buildAccountList(baseStorage()) expect(list).toHaveLength(4) expect(list[0]).toEqual({ @@ -345,12 +478,8 @@ describe('executeAccountCommand status', () => { expect(custodyStatusLabel('on-vault-served')).toBe('vault-served') expect(custodyStatusLabel('on-vault-reauth')).toBe('vault reauth') expect(custodyStatusLabel('on-cold')).toBe('vault cold') - expect(custodyStatusLabel('on-identity-mismatch' as never)).toBe( - 'identity mismatch', - ) - expect(custodyStatusLabel('on-corrupt-binding' as never)).toBe( - 'corrupt binding', - ) + expect(custodyStatusLabel('on-identity-mismatch')).toBe('identity mismatch') + expect(custodyStatusLabel('on-corrupt-binding')).toBe('corrupt binding') }) test('bare status returns account list in text', async () => { @@ -532,7 +661,10 @@ describe('executeAccountCommand status', () => { const account = storage.accounts[0] if (account?.type !== 'oauth') throw new Error('missing OAuth fixture') account.claustrumScopedCredentialId = 'oauth:anthropic:work' - account.anthropicAccountUuid = 'account-work' as never + const accountUuid = 'account-work' + if (!isProviderAccountUuid(accountUuid)) + throw new Error('missing account UUID fixture') + account.anthropicAccountUuid = accountUuid account.claustrumScopedState = 'active' const result = await executeAccountCommand({ argumentsText: '', storage }) @@ -562,19 +694,22 @@ describe('executeAccountCommand global Claustrum mode', () => { expect(result.text).toContain('/claude-account local') }) - test('refuses to bypass the coordinator when no mode transition is supplied', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) + persistence.test( + 'refuses to bypass the coordinator when no mode transition is supplied', + async () => { + const { runtime, files } = await migratedAccounts(baseStorage()) + const before = await files() - const result = await executeAccountCommand({ - argumentsText: 'claustrum', - storage, - path: accountPath, - }) + const result = await executeAccountCommand({ + argumentsText: 'claustrum', + storage: (await runtime.read()).policyStorage, + }) - expect(result.text).toBe('Claustrum mode transition is unavailable.') - expect((await loadAccounts(accountPath))?.claustrum?.mode).toBeUndefined() - }) + expect(result.text).toBe('Claustrum mode transition is unavailable.') + expect(await files()).toEqual(before) + expect((await runtime.read()).mode).toBe('local') + }, + ) test('routes a requested global mode through its transition seam', async () => { const modes: string[] = [] @@ -654,23 +789,27 @@ describe('executeAccountCommand enable/disable', () => { expect(result.updated).toBeUndefined() }) - test('persistent enable mutates store', async () => { + persistence.test('persistent enable mutates store', async () => { const storage = baseStorage() storage.accounts[2]!.enabled = false - await saveAccounts(storage, accountPath) + const { runtime, fallbacks } = await migratedAccounts(storage) + expect((await fallbacks())[2]?.enabled).toBe(false) - await setAccountEnabledPersistent('fallback-3', true, accountPath) - const loaded = await loadAccounts(accountPath) - expect(loaded?.accounts[2]?.enabled).toBe(true) + await runtime.setEnabled('fallback-3', true) + expect((await fallbacks())[2]).toMatchObject({ + id: 'fallback-3', + enabled: true, + }) }) - test('persistent disable mutates store', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) + persistence.test('persistent disable mutates store', async () => { + const { runtime, fallbacks } = await migratedAccounts(baseStorage()) - await setAccountEnabledPersistent('fallback-1', false, accountPath) - const loaded = await loadAccounts(accountPath) - expect(loaded?.accounts[0]?.enabled).toBe(false) + await runtime.setEnabled('fallback-1', false) + expect((await fallbacks())[0]).toMatchObject({ + id: 'fallback-1', + enabled: false, + }) }) }) @@ -729,24 +868,34 @@ describe('executeAccountCommand remove', () => { expect(result.updated).toBeUndefined() }) - test('persistent remove mutates store', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) + persistence.test('persistent remove mutates store', async () => { + const { runtime, fallbacks } = await migratedAccounts(baseStorage()) - const existed = await removeAccountPersistent('fallback-1', accountPath) - expect(existed).toBe(true) - const loaded = await loadAccounts(accountPath) - expect(loaded?.accounts).toHaveLength(2) - expect(loaded?.accounts[0]?.id).toBe('fallback-2') + await runtime.remove('fallback-1') + const remaining = await fallbacks() + expect(remaining).toHaveLength(2) + expect(remaining[0]?.id).toBe('fallback-2') }) - test('persistent remove non-existent returns false', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) + persistence.test( + 'persistent remove non-existent is refused and changes nothing', + async () => { + const { runtime, files, fallbacks } = await migratedAccounts( + baseStorage(), + ) + const before = await files() - const existed = await removeAccountPersistent('nonexistent', accountPath) - expect(existed).toBe(false) - }) + await expect(runtime.remove('nonexistent')).rejects.toMatchObject({ + kind: 'unknown-row', + }) + expect(await files()).toEqual(before) + expect((await fallbacks()).map((account) => account.id)).toEqual([ + 'fallback-1', + 'fallback-2', + 'fallback-3', + ]) + }, + ) }) // --------------------------------------------------------------------------- @@ -813,16 +962,11 @@ describe('executeAccountCommand reorder', () => { expect(result.updated).toBeUndefined() }) - test('persistent reorder mutates store', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) + persistence.test('persistent reorder mutates store', async () => { + const { runtime, fallbacks } = await migratedAccounts(baseStorage()) - await reorderAccountsPersistent( - ['fallback-2', 'fallback-1', 'fallback-3'], - accountPath, - ) - const loaded = await loadAccounts(accountPath) - expect(loaded?.accounts.map((a) => a.id)).toEqual([ + await runtime.reorder(['fallback-2', 'fallback-1', 'fallback-3']) + expect((await fallbacks()).map((a) => a.id)).toEqual([ 'fallback-2', 'fallback-1', 'fallback-3', @@ -831,9 +975,13 @@ describe('executeAccountCommand reorder', () => { }) // --------------------------------------------------------------------------- -// INFO log emission via plugin command.execute.before +// INFO log emission through the plugin's /claude menu after offline migration // --------------------------------------------------------------------------- describe('account command INFO logs (via plugin)', () => { + // Per-test lifetimes: fixture roots outlive the whole test body and any work + // the body registered, and are removed only after the plugins are disposed. + const lifetimes = createTestLifetimeSuite() + const test = lifetimes.test let capturedRecords: LogTestRecord[] beforeEach(() => { @@ -847,54 +995,153 @@ describe('account command INFO logs (via plugin)', () => { __setLogTestSink(null) }) - function createMockClient() { - return { - auth: { set: mock(() => Promise.resolve()) }, - session: { - promptAsync: mock(() => Promise.resolve()), - }, - } + // Synthetic OpenCode auth.json for the migration; see mainHostAuth. + const hostAuth = mainHostAuth + + type Migrated = Awaited> & { + getPlugin: ( + timerOverrides?: PluginTimerOverrides, + runtimeOverrides?: Record, + ) => Promise } - async function getPlugin( - timerOverrides?: PluginTimerOverrides, - runtimeOverrides: Record = {}, - ) { - const defaultTimerOverrides = disabledPluginTimerOverrides() - const plugin = (await ( - AnthropicAuthPlugin as unknown as ( - ctx: Parameters[0], - timers?: PluginTimerOverrides, - ) => ReturnType - )( - { - // @ts-expect-error: minimal mock for testing - client: createMockClient(), + /** + * Import `storage` through the real offline migration, point this process at + * the migrated pool, and return a plugin factory for that pool. Plugins are + * constructed only after the migration committed, as OpenCode would start + * them after offline setup. + */ + async function migrate(storage: AccountStorage): Promise { + const root = await mkdtemp(join(tmpdir(), 'anthropic-auth-acct-native-')) + const plugins: Hooks[] = [] + const savedEnv = new Map( + nativeEnvKeys.map((key) => [key, process.env[key]] as const), + ) + // Registered before the migration fixture takes ownership of its + // directory (and registers that directory's removal), so this runs first: + // plugins stop (closing their RPC servers) and pending sidebar writes land + // before any fixture file is removed. The environment is restored even when + // a dispose fails. + lifetimes.deferCleanup(async () => { + const results = await Promise.allSettled( + plugins + .splice(0) + .map((plugin) => Promise.resolve().then(() => plugin.dispose?.())), + ) + try { + await drainSidebarWrites() + } finally { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Plugin disposal failed', + ) + }) + // A fixture that refuses the directory never registers its removal, so + // remove it here as well. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: { ...storage }, + hostAuth: hostAuth(), + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + delete process.env.OPENCODE_AUTH_CONTENT + return { + ...fixture, + async getPlugin(timerOverrides, runtimeOverrides = {}) { + // A project directory starts the plugin's menu RPC server in the + // fixture's RPC directory. + const creation = AnthropicAuthPlugin(hostContext(fixture.root), { + ...disabledPluginTimerOverrides(), + ...timerOverrides, + ...runtimeOverrides, + }) + lifetimes.trackDetached(creation) + const plugin = await creation + plugins.push(plugin) + await fallbackRefreshReady(plugin) + return plugin }, - { ...defaultTimerOverrides, ...timerOverrides, ...runtimeOverrides }, - )) as any - await plugin.__fallbackRefreshReady - return plugin + } } - async function executeCommand( - plugin: any, - command: string, - args: string, - ): Promise { + /** Open /claude for a TUI-connected session, as the slash command does. */ + async function openClaudeMenu(plugin: Hooks, sessionId: string) { + // The plugin counts a session's TUI as connected only while that session + // has recently drained its RPC notification queue, which the TUI polls as a + // heartbeat. Draining here makes /claude queue its menu as a notification + // for the TUI instead of replying in the chat with text. + drainNotifications(0, sessionId) + const execute = plugin['command.execute.before'] + if (!execute) throw new Error('Plugin did not install its command hook') await expect( - plugin['command.execute.before']({ - command, - arguments: args, - sessionID: 'ses_test', - }), + execute( + { command: 'claude', arguments: '', sessionID: sessionId }, + { parts: [] }, + ), ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + const notices = drainNotifications(0, sessionId) + const notice = notices.at(-1) + if (notice?.type !== 'open-menu') + throw new Error('Expected the /claude menu notification') + drainNotifications(notice.id, sessionId) + return notice.payload } - async function readConfigFile(): Promise { - return JSON.parse( - await readFile(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), + /** + * Submit one Claude menu action through the plugin's RPC server, the way + * the TUI renderer does after the user picks it. + */ + async function applyMenuAction( + fixture: Migrated, + plugin: Hooks, + request: Omit, + sessionId = 'ses_test', + ): Promise { + await openClaudeMenu(plugin, sessionId) + // globalThis.fetch is currently a mock for provider (Anthropic) requests + // that refuses loopback URLs. Send only the call to the plugin's local RPC + // server (127.0.0.1) through the fetch captured when this file loaded, + // which the test preload limits to loopback hosts. + const providerFetch = globalThis.fetch + globalThis.fetch = Object.assign( + (input: Parameters[0], init?: RequestInit) => + new URL(input instanceof Request ? input.url : String(input)) + .hostname === '127.0.0.1' + ? originalFetch(input, init) + : providerFetch(input, init), + { preconnect: providerFetch.preconnect }, ) + try { + return await createRpcClient( + fixture.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR, + process.pid, + ).applyMenu({ command: 'claude', sessionId, ...request }) + } finally { + globalThis.fetch = providerFetch + } + } + + /** Accounts as the native runtime reads them back from the migrated pool. */ + async function readNativeAccounts(fixture: Migrated) { + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + return (await runtime.read()).accounts + } finally { + runtime.close() + } } function findCommandsLog(message: string): LogTestRecord | undefined { @@ -907,10 +1154,15 @@ describe('account command INFO logs (via plugin)', () => { test('enable emits INFO log and persists', async () => { const storage = baseStorage() storage.accounts[2]!.enabled = false - await saveAccounts(storage, accountPath) - const plugin = await getPlugin() + const fixture = await migrate(storage) + const plugin = await fixture.getPlugin() - await executeCommand(plugin, 'claude-account', 'enable fallback-3') + const result = await applyMenuAction(fixture, plugin, { + sectionId: 'Accounts', + actionId: 'enable', + values: { id: 'fallback-3' }, + }) + expect(result.ok).toBe(true) const rec = findCommandsLog('account enabled') expect(rec).toBeDefined() expect(rec!.payload).toBeDefined() @@ -923,31 +1175,40 @@ describe('account command INFO logs (via plugin)', () => { expect(payloadStr).not.toContain('access') expect(payloadStr).not.toContain('apiKey') // Persisted - const raw = await readConfigFile() - expect(raw.accounts[2].enabled).toBe(true) + const accounts = await readNativeAccounts(fixture) + expect(accounts.find((a) => a.id === 'fallback-3')?.enabled).toBe(true) }) test('disable emits INFO log and persists', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) - const plugin = await getPlugin() + const fixture = await migrate(baseStorage()) + const plugin = await fixture.getPlugin() - await executeCommand(plugin, 'claude-account', 'disable fallback-1') + const result = await applyMenuAction(fixture, plugin, { + sectionId: 'Accounts', + actionId: 'disable', + values: { id: 'fallback-1' }, + }) + expect(result.ok).toBe(true) const rec = findCommandsLog('account disabled') expect(rec).toBeDefined() expect(rec!.payload!.id).toBe('fallback-1') expect(rec!.payload!.enabled).toBe(false) // Persisted - const raw = await readConfigFile() - expect(raw.accounts[0].enabled).toBe(false) + const accounts = await readNativeAccounts(fixture) + expect(accounts.find((a) => a.id === 'fallback-1')?.enabled).toBe(false) }) test('remove emits INFO log and persists', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) - const plugin = await getPlugin() - - await executeCommand(plugin, 'claude-account', 'remove fallback-1') + const fixture = await migrate(baseStorage()) + const plugin = await fixture.getPlugin() + + const result = await applyMenuAction(fixture, plugin, { + sectionId: 'Accounts', + actionId: 'remove', + values: { id: 'fallback-1' }, + confirmed: true, + }) + expect(result.ok).toBe(true) const rec = findCommandsLog('account removed') expect(rec).toBeDefined() expect(rec!.payload!.id).toBe('fallback-1') @@ -956,17 +1217,23 @@ describe('account command INFO logs (via plugin)', () => { expect(payloadStr).not.toContain('refresh') expect(payloadStr).not.toContain('token') // Persisted - const raw = await readConfigFile() - expect(raw.accounts).toHaveLength(2) - expect(raw.accounts[0].id).toBe('fallback-2') + const fallbacks = (await readNativeAccounts(fixture)).filter( + (a) => a.id !== 'main', + ) + expect(fallbacks).toHaveLength(2) + expect(fallbacks[0]!.id).toBe('fallback-2') }) test('reorder emits INFO log and persists', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) - const plugin = await getPlugin() + const fixture = await migrate(baseStorage()) + const plugin = await fixture.getPlugin() - await executeCommand(plugin, 'claude-account', 'move-up fallback-2') + const result = await applyMenuAction(fixture, plugin, { + sectionId: 'Accounts', + actionId: 'move-up', + values: { id: 'fallback-2' }, + }) + expect(result.ok).toBe(true) const rec = findCommandsLog('account reordered') expect(rec).toBeDefined() expect(rec!.payload!.id).toBe('fallback-2') @@ -975,84 +1242,94 @@ describe('account command INFO logs (via plugin)', () => { expect(payloadStr).not.toContain('refresh') expect(payloadStr).not.toContain('token') // Persisted - const raw = await readConfigFile() - expect(raw.accounts.map((a: any) => a.id)).toEqual([ - 'fallback-2', - 'fallback-1', - 'fallback-3', - ]) + expect( + (await readNativeAccounts(fixture)) + .filter((a) => a.id !== 'main') + .map((a) => a.id), + ).toEqual(['fallback-2', 'fallback-1', 'fallback-3']) }) test('status emits no setting-change log', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) - const plugin = await getPlugin() + const fixture = await migrate(baseStorage()) + const plugin = await fixture.getPlugin() - await executeCommand(plugin, 'claude-account', '') + // Opening /claude reads account status for its Accounts section. + const payload = await openClaudeMenu(plugin, 'ses_test') + const accounts = payload.menu.sections.find( + (section) => section.id === 'Accounts', + ) + expect(accounts?.lines.join('\n')).toContain('Work account') expect( capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(0) }) - test('retired custody syntax returns usage without touching the account file', async () => { - const storage = baseStorage() - await saveAccounts(storage, accountPath) - const before = await readFile(accountPath, 'utf8') - const beforeMtime = (await fs.stat(accountPath)).mtimeMs - const plugin = await getPlugin() + test('retired custody syntax is not handled and leaves native account storage untouched', async () => { + const fixture = await migrate(baseStorage()) + const before = await Promise.all( + [fixture.paths.config, fixture.paths.state].map(async (path) => ({ + bytes: await readFile(path, 'utf8'), + mtimeMs: (await fs.stat(path)).mtimeMs, + })), + ) + const plugin = await fixture.getPlugin() drainNotifications(0, 'ses_test') + const execute = plugin['command.execute.before'] + if (!execute) throw new Error('Plugin did not install its command hook') - await executeCommand(plugin, 'claude-account', 'custody fallback-1 on') - - const notice = drainNotifications(0, 'ses_test').at(-1) - expect(notice).toBeDefined() - if ( - notice?.type !== 'open-dialog' || - notice.payload.command !== 'claude-account' + // The old /claude-account alias is no longer registered; the plugin + // passes it through without handling, notifying or mutating anything. + await expect( + execute( + { + command: 'claude-account', + arguments: 'custody fallback-1 on', + sessionID: 'ses_test', + }, + { parts: [] }, + ), + ).resolves.toBeUndefined() + + expect(drainNotifications(0, 'ses_test')).toEqual([]) + const after = await Promise.all( + [fixture.paths.config, fixture.paths.state].map(async (path) => ({ + bytes: await readFile(path, 'utf8'), + mtimeMs: (await fs.stat(path)).mtimeMs, + })), ) - throw new Error('Expected legacy account guidance payload') - const payload = notice.payload - expect(payload.text).toContain('/claude-account claustrum') - expect(payload.text).toContain('/claude-account local') - expect(await readFile(accountPath, 'utf8')).toBe(before) - expect((await fs.stat(accountPath)).mtimeMs).toBe(beforeMtime) + expect(after).toEqual(before) }) test('claustrum command gives scoped setup guidance without changing local storage', async () => { - await saveAccounts(baseStorage(), accountPath) - const plugin = await getPlugin() - await plugin.auth.loader( - async () => ({ - type: 'oauth', - access: 'real-main-access', - refresh: 'real-main-refresh', - expires: Date.now() + 60_000, - }), - { models: {} }, - ) - const before = await readFile(accountPath, 'utf8') - drainNotifications(0, 'ses_test') - await executeCommand(plugin, 'claude-account', 'claustrum') - const notice = drainNotifications(0, 'ses_test').at(-1) - expect(notice).toBeDefined() - if ( - notice?.type !== 'open-dialog' || - notice.payload.command !== 'claude-account' - ) - throw new Error('Expected legacy account guidance payload') - const payload = notice.payload - expect(payload.text).toContain('setup') - expect(await readFile(accountPath, 'utf8')).toBe(before) + const fixture = await migrate(baseStorage()) + const plugin = await fixture.getPlugin() + // The host passes the loader the Anthropic entry the migration left in + // OpenCode's auth.json: a placeholder login that holds no usable token. + const activation: Auth = JSON.parse( + await readFile(fixture.hostAuthPath, 'utf8'), + ).anthropic + const loader = plugin.auth?.loader + if (!loader) throw new Error('Plugin did not install its auth loader') + await loader(async () => activation, anthropicProvider) + const before = await readFile(fixture.paths.config, 'utf8') + const result = await applyMenuAction(fixture, plugin, { + sectionId: 'Accounts', + actionId: 'custody-mode', + values: { mode: 'claustrum' }, + }) + expect(result.ok).toBe(true) + expect(result.text).toContain('setup') + expect(await readFile(fixture.paths.config, 'utf8')).toBe(before) }) test('does not retain a background interval unless the helper opts in', async () => { - await saveAccounts(baseStorage(), accountPath) - await getPlugin() + const fixture = await migrate(baseStorage()) + await fixture.getPlugin() expect(timerTracking.disabledIntervalCalls).toBe(1) expect(activeIntervals.size).toBe(0) await timerTracking.withTrackedInterval(async () => { - await getPlugin({ + await fixture.getPlugin({ setInterval: trackedSetInterval, clearInterval: trackedClearInterval, }) diff --git a/packages/opencode/src/tests/add-account-flows.test.ts b/packages/opencode/src/tests/add-account-flows.test.ts index 163bcd1f4..1ea14e5b7 100644 --- a/packages/opencode/src/tests/add-account-flows.test.ts +++ b/packages/opencode/src/tests/add-account-flows.test.ts @@ -2,14 +2,29 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + type AccountStorage, + type CommandApplyRequest, + type CommandApplyResult, + createNativeAccountRuntime, + custodyTombstoneOAuth, + type NativeCustodyClient, +} from '@cortexkit/anthropic-auth-core' +import type { Hooks, PluginInput } from '@opencode-ai/plugin' +import { createOpencodeClient } from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { AnthropicAuthPlugin } from '../index' +import { drainNotifications } from '../rpc/notifications' +import { createRpcClient } from '../rpc/rpc-client' +import { drainSidebarWrites } from '../sidebar-state' +import { migrateNativeOpencodeFixture } from './native-fixture.ts' import { DEFAULT_FETCH_MOCK } from './test-fetch' import { createTimerTracking, type PluginTimerOverrides, } from './timer-tracking' -let tempDir: string -let accountPath: string const originalFetch = globalThis.fetch const timerTracking = createTimerTracking() const { @@ -18,62 +33,316 @@ const { trackedClearInterval, trackedSetInterval, } = timerTracking +// Plugin tests run in a per-test lifetime: fixture roots outlive the test body +// and any work it registered, and are removed only after plugins are disposed. +const lifetimes = createTestLifetimeSuite() +// Environment variables a test sets to point the plugin at its migrated +// account pool; the test's cleanup puts back their previous values. +const nativeEnvKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', + 'OPENCODE_AUTH_CONTENT', +] as const +let envSavedForTest = false +const mainIdentity = '11111111-1111-4111-8111-111111111111' + +/** The account store every add-account test starts from: a main login and no fallbacks. */ +function emptyStorage(): AccountStorage { + return { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [], + } +} -async function useTempAccountFile() { - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) +// Synthetic OpenCode auth.json: the main OAuth login that the migration moves +// into the native account pool, beside an unrelated provider it must keep. +function localHostAuth() { + return { + anthropic: { + type: 'oauth', + access: 'synthetic-main-access', + refresh: 'synthetic-main-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + other: { type: 'api', key: 'synthetic-unrelated-provider' }, } - tempDir = await mkdtemp(join(tmpdir(), 'anthropic-add-acct-')) - accountPath = join(tempDir, 'anthropic-auth.json') - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath - const { saveAccounts } = await import('@cortexkit/anthropic-auth-core') - await saveAccounts( - { - version: 1, - main: { type: 'opencode', provider: 'anthropic' }, - accounts: [], +} + +/** A synthetic vault whose listing holds only the main account's credential + * row; any attempt to read that credential's token makes the test fail. */ +function mainOnlyVault(): NativeCustodyClient { + return { + async listScoped() { + return { + view: 'synthetic-native-view', + rows: [ + { + id: 'oauth:anthropic', + accountId: mainIdentity, + credentialType: 'oauth', + state: 'active', + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + refreshAdapter: 'anthropic', + recordVersion: 1, + operations: ['read', 'invalidate'], + createdAtMs: 1, + }, + ], + } }, - accountPath, - ) + async getScoped() { + throw new Error('Add-account tests must not read vault credentials') + }, + async reportAuthFailureScoped() { + throw new Error('Add-account tests must not report vault failures') + }, + close() {}, + } } -function createMockClient() { +/** + * The context OpenCode passes a server plugin, with a real SDK client whose + * HTTP transport is answered in this process: message history is empty and + * prompts are accepted, so nothing leaves the test. + */ +function hostContext(directory: string): PluginInput { + const client = createOpencodeClient({ + baseUrl: 'http://opencode.invalid', + fetch: async (request) => { + const path = new URL(request.url).pathname + if (request.method === 'GET' && /^\/session\/[^/]+\/message$/.test(path)) + return Response.json([]) + if ( + request.method === 'POST' && + /^\/session\/[^/]+\/prompt_async$/.test(path) + ) + return new Response(null, { status: 204 }) + return Response.json({ name: 'NotFound' }, { status: 404 }) + }, + }) return { - auth: { set: mock(() => Promise.resolve()) }, - session: { promptAsync: mock(() => Promise.resolve()) }, + client, + project: { + id: 'add-account-flows-fixture', + worktree: directory, + time: { created: Date.now() }, + }, + directory, + worktree: directory, + serverUrl: new URL('http://opencode.invalid'), + experimental_workspace: { register() {} }, + $, } } -async function getPlugin(timerOverrides?: PluginTimerOverrides) { - const { AnthropicAuthPlugin } = await import('../index') - const defaultTimerOverrides = disabledPluginTimerOverrides() - return (await ( - AnthropicAuthPlugin as unknown as ( - ctx: Parameters[0], - timers?: PluginTimerOverrides, - ) => ReturnType - )( - { - // @ts-expect-error: minimal mock for testing - client: createMockClient(), +type Migrated = Awaited> & { + getPlugin: ( + timerOverrides?: PluginTimerOverrides & Record, + ) => Promise +} + +/** + * Import the account store through the real offline migration, point this + * process at the migrated pool, and return a plugin factory for that pool. + * Plugins are constructed only after the migration committed, as OpenCode + * would start them after offline setup. + */ +async function migrate( + storage: AccountStorage = emptyStorage(), + mode: 'local' | 'claustrum' = 'local', +): Promise { + const root = await mkdtemp(join(tmpdir(), 'anthropic-add-acct-native-')) + const plugins: Hooks[] = [] + // A test may migrate two pools. Cleanups run in registration order, so only + // the first migration of a test saves the environment, and its cleanup puts + // back the values from before the test. + const savedEnv = envSavedForTest + ? undefined + : new Map(nativeEnvKeys.map((key) => [key, process.env[key]] as const)) + if (savedEnv) envSavedForTest = true + // Registered before the migration fixture takes ownership of its directory + // (and registers that directory's removal), so this cleanup runs first: + // plugins stop (closing their RPC servers) and pending sidebar writes land + // before any fixture file is removed. The environment is restored even when + // a dispose fails. + lifetimes.deferCleanup(async () => { + const results = await Promise.allSettled( + plugins + .splice(0) + .map((plugin) => Promise.resolve().then(() => plugin.dispose?.())), + ) + try { + await drainSidebarWrites() + } finally { + if (savedEnv) { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + envSavedForTest = false + } + } + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Plugin disposal failed', + ) + }) + // A fixture that refuses the directory never registers its removal, so + // remove it here as well. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: + mode === 'claustrum' + ? { + ...storage, + mainAccountId: 'primary-route', + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: mainIdentity, + state: 'active', + }, + }, + } + : { ...storage }, + hostAuth: + mode === 'claustrum' + ? { + anthropic: custodyTombstoneOAuth('anthropic'), + other: { type: 'api', key: 'synthetic-unrelated-provider' }, + } + : localHostAuth(), + ...(mode === 'claustrum' && { + custody: { + connect: async () => mainOnlyVault(), + enrollment: { token: 'ab'.repeat(32), token_generation: 1 }, + }, + }), + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + delete process.env.OPENCODE_AUTH_CONTENT + return { + ...fixture, + async getPlugin(timerOverrides) { + // A project directory starts the plugin's menu RPC server in the + // fixture's RPC directory. + const creation = AnthropicAuthPlugin(hostContext(fixture.root), { + ...disabledPluginTimerOverrides(), + ...timerOverrides, + }) + lifetimes.trackDetached(creation) + const plugin = await creation + plugins.push(plugin) + return plugin }, - { ...defaultTimerOverrides, ...timerOverrides }, - )) as Promise + } } -async function executeCommand( - plugin: any, - command: string, - args: string, - sessionId?: string, -): Promise { +/** + * Open /claude for a TUI-connected session, then submit one Claude menu action + * through the plugin's RPC server, the way the TUI renderer does after the + * user picks it. + */ +async function applyMenuAction( + fixture: Migrated, + plugin: Hooks, + request: Omit, + sessionId = 'ses_test', +): Promise { + // The plugin counts a session's TUI as connected only while that session + // has recently drained its RPC notification queue, which the TUI polls as a + // heartbeat. Draining here makes /claude queue its menu as a notification + // for the TUI instead of replying in the chat with text. + drainNotifications(0, sessionId) + const execute = plugin['command.execute.before'] + if (!execute) throw new Error('Plugin did not install its command hook') await expect( - plugin['command.execute.before']({ - command, - arguments: args, - sessionID: sessionId ?? 'ses_test', - }), + execute( + { command: 'claude', arguments: '', sessionID: sessionId }, + { parts: [] }, + ), ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + const notice = drainNotifications(0, sessionId).at(-1) + if (notice?.type !== 'open-menu') + throw new Error('Expected the /claude menu notification') + drainNotifications(notice.id, sessionId) + // globalThis.fetch is currently a mock for provider (Anthropic) requests + // that refuses loopback URLs. Send only the call to the plugin's local RPC + // server (127.0.0.1) through the fetch captured when this file loaded, + // which the test preload limits to loopback hosts. + const providerFetch = globalThis.fetch + globalThis.fetch = Object.assign( + (input: Parameters[0], init?: RequestInit) => + new URL(input instanceof Request ? input.url : String(input)).hostname === + '127.0.0.1' + ? originalFetch(input, init) + : providerFetch(input, init), + { preconnect: providerFetch.preconnect }, + ) + try { + return await createRpcClient( + fixture.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR, + process.pid, + ).applyMenu({ + command: 'claude', + sectionId: 'Accounts', + sessionId, + ...request, + }) + } finally { + globalThis.fetch = providerFetch + } +} + +/** Return fallback accounts available when the primary account cannot serve a request. Read them from the native pool. */ +async function readFallbacks(fixture: Migrated) { + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + return (await runtime.read()).accounts.filter( + (account) => account.id !== 'main', + ) + } finally { + runtime.close() + } +} + +/** Return the stored API key, destination URL and auth header for the account identified by routeId. */ +async function readApiCredential(fixture: Migrated, routeId: string) { + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + return await runtime.authorizeApi(routeId) + } finally { + runtime.close() + } +} + +/** A plugin test whose body receives a pool migrated from `emptyStorage()`. */ +function pluginTest(name: string, body: (fixture: Migrated) => unknown) { + lifetimes.test(name, async () => body(await migrate())) } function findCommandsLog( @@ -128,7 +397,6 @@ beforeEach(async () => { __setLogTestSink((record) => { capturedRecords.push(record) }) - await useTempAccountFile() }) afterEach(async () => { @@ -143,10 +411,6 @@ afterEach(async () => { } const { __setLogTestSink } = await import('@cortexkit/anthropic-auth-core') __setLogTestSink(null) - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) - } mock.restore() } finally { // Assert last so a detected leak cannot abort the cleanup above. @@ -154,33 +418,40 @@ afterEach(async () => { } }) -test('does not retain a background interval unless the helper opts in', async () => { - await getPlugin() - expect(timerTracking.disabledIntervalCalls).toBe(1) - expect(activeIntervals.size).toBe(0) +pluginTest( + 'does not retain a background interval unless the helper opts in', + async (fixture) => { + await fixture.getPlugin() + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) - let thrown: unknown - try { - await timerTracking.withTrackedInterval(async () => { - await getPlugin({ - setInterval: trackedSetInterval, - clearInterval: trackedClearInterval, + let thrown: unknown + try { + await timerTracking.withTrackedInterval(async () => { + await fixture.getPlugin({ + setInterval: trackedSetInterval, + clearInterval: trackedClearInterval, + }) + expect(activeIntervals.size).toBe(1) + throw new Error('forced timer assertion failure') }) - expect(activeIntervals.size).toBe(1) - throw new Error('forced timer assertion failure') - }) - } catch (error) { - thrown = error - } - expect((thrown as Error).message).toBe('forced timer assertion failure') - expect(activeIntervals.size).toBe(0) -}) - -test('partial timer overrides retain disabled interval defaults', async () => { - await getPlugin({ clearInterval: trackedClearInterval }) - expect(timerTracking.disabledIntervalCalls).toBe(1) - expect(activeIntervals.size).toBe(0) -}) + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(Error) + expect(thrown).toMatchObject({ message: 'forced timer assertion failure' }) + expect(activeIntervals.size).toBe(0) + }, +) + +pluginTest( + 'partial timer overrides retain disabled interval defaults', + async (fixture) => { + await fixture.getPlugin({ clearInterval: trackedClearInterval }) + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) + }, +) // --------------------------------------------------------------------------- // parseAccountCommandAction — add actions (core) @@ -295,151 +566,164 @@ describe('parseAccountCommandAction — add actions', () => { }) // --------------------------------------------------------------------------- -// add-apikey — persistence + security (no mocks needed) +// add-apikey — persistence + security, through the /claude menu // --------------------------------------------------------------------------- describe('add-apikey flow', () => { - test('persists an API key account to the store', async () => { - const plugin = await getPlugin() - await executeCommand(plugin, 'claude-account', 'add-apikey sk-ant-test123') - - const { loadAccounts } = await import('@cortexkit/anthropic-auth-core') - const loaded = await loadAccounts(accountPath) - expect(loaded).not.toBeNull() - expect(loaded!.accounts).toHaveLength(1) - const account = loaded!.accounts[0]! + pluginTest('persists an API key account to the store', async (fixture) => { + const plugin = await fixture.getPlugin() + await applyMenuAction(fixture, plugin, { + actionId: 'add-apikey', + values: { apiKey: 'sk-ant-test123' }, + }) + + const fallbacks = await readFallbacks(fixture) + expect(fallbacks).toHaveLength(1) + const account = fallbacks[0]! expect(account.type).toBe('api') - if (account.type === 'api') { - expect(account.apiKey).toBe('sk-ant-test123') - expect(account.baseURL).toBe('https://api.kie.ai/claude') - expect(account.authHeader).toBe('authorization-bearer') - } + const credential = await readApiCredential(fixture, account.id) + expect(credential.apiKey).toBe('sk-ant-test123') + expect(credential.baseURL).toBe('https://api.kie.ai/claude') + expect(credential.authHeader).toBe('authorization-bearer') expect(account.enabled).toBe(true) }) - test('persists with a label', async () => { - const plugin = await getPlugin() - await executeCommand( - plugin, - 'claude-account', - 'add-apikey sk-ant-test456 Work API', - ) + pluginTest('persists with a label', async (fixture) => { + const plugin = await fixture.getPlugin() + await applyMenuAction(fixture, plugin, { + actionId: 'add-apikey', + values: { apiKey: 'sk-ant-test456', label: 'Work API' }, + }) - const { loadAccounts } = await import('@cortexkit/anthropic-auth-core') - const loaded = await loadAccounts(accountPath) - expect(loaded).not.toBeNull() - expect(loaded!.accounts).toHaveLength(1) - expect(loaded!.accounts[0]!.label).toBe('Work API') - expect(loaded!.accounts[0]!.id).toBe('Work API') + const fallbacks = await readFallbacks(fixture) + expect(fallbacks).toHaveLength(1) + expect(fallbacks[0]!.label).toBe('Work API') + expect(fallbacks[0]!.id).toBe('Work API') }) - test('emits INFO log with id/label/type but NO apiKey', async () => { - const plugin = await getPlugin() - await executeCommand( - plugin, - 'claude-account', - 'add-apikey sk-ant-secret123', - ) + pluginTest( + 'emits INFO log with id/label/type but NO apiKey', + async (fixture) => { + const plugin = await fixture.getPlugin() + await applyMenuAction(fixture, plugin, { + actionId: 'add-apikey', + values: { apiKey: 'sk-ant-secret123' }, + }) - const rec = findCommandsLog(capturedRecords, 'account added') - expect(rec).toBeDefined() - const payload = rec!.payload! - expect(payload.id).toBeDefined() - expect(payload.type).toBe('apikey') - - const payloadStr = JSON.stringify(payload) - expect(payloadStr).not.toContain('sk-ant-secret123') - expect(payloadStr).not.toContain('apiKey') - expect(payloadStr).not.toContain('sk-ant') - expect(payloadStr).not.toContain('secret') - }) + const rec = findCommandsLog(capturedRecords, 'account added') + expect(rec).toBeDefined() + const payload = rec!.payload! + expect(payload.id).toBeDefined() + expect(payload.type).toBe('apikey') + + const payloadStr = JSON.stringify(payload) + expect(payloadStr).not.toContain('sk-ant-secret123') + expect(payloadStr).not.toContain('apiKey') + expect(payloadStr).not.toContain('sk-ant') + expect(payloadStr).not.toContain('secret') + }, + ) - test('account list after add shows the new account', async () => { - const plugin = await getPlugin() - await executeCommand( - plugin, - 'claude-account', - 'add-apikey sk-ant-list TestList', - ) + pluginTest( + 'account list after add shows the new account', + async (fixture) => { + const plugin = await fixture.getPlugin() + await applyMenuAction(fixture, plugin, { + actionId: 'add-apikey', + values: { apiKey: 'sk-ant-list', label: 'TestList' }, + }) - const { buildAccountList, loadAccounts } = await import( - '@cortexkit/anthropic-auth-core' - ) - const storage = await loadAccounts(accountPath) - const list = buildAccountList(storage!) - expect(list).toHaveLength(2) // main + fallback - const fallback = list.find((a: { role: string }) => a.role === 'fallback') - expect(fallback).toBeDefined() - expect(fallback!.label).toBe('TestList') - }) + const { buildAccountList } = await import( + '@cortexkit/anthropic-auth-core' + ) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + let storage: AccountStorage + try { + // The runtime's account-store projection, which feeds the account list. + storage = (await runtime.read()).policyStorage + } finally { + runtime.close() + } + const list = buildAccountList(storage) + expect(list).toHaveLength(2) // main + fallback + const fallback = list.find((a: { role: string }) => a.role === 'fallback') + expect(fallback).toBeDefined() + expect(fallback!.label).toBe('TestList') + }, + ) - test('persists with custom baseURL and x-api-key authHeader', async () => { - const plugin = await getPlugin() - await executeCommand( - plugin, - 'claude-account', - 'add-apikey sk-ant-custom --base-url https://api.example.com/v1 --auth-header x-api-key --label CustomAPI', - ) + pluginTest( + 'persists with custom baseURL and x-api-key authHeader', + async (fixture) => { + const plugin = await fixture.getPlugin() + await applyMenuAction(fixture, plugin, { + actionId: 'add-apikey', + values: { + apiKey: 'sk-ant-custom', + baseURL: 'https://api.example.com/v1', + authHeader: 'x-api-key', + label: 'CustomAPI', + }, + }) - const { loadAccounts } = await import('@cortexkit/anthropic-auth-core') - const loaded = await loadAccounts(accountPath) - expect(loaded).not.toBeNull() - expect(loaded!.accounts).toHaveLength(1) - const account = loaded!.accounts[0]! - expect(account.type).toBe('api') - if (account.type === 'api') { - expect(account.apiKey).toBe('sk-ant-custom') - expect(account.baseURL).toBe('https://api.example.com/v1') - expect(account.authHeader).toBe('x-api-key') - } - expect(account.label).toBe('CustomAPI') - }) + const fallbacks = await readFallbacks(fixture) + expect(fallbacks).toHaveLength(1) + const account = fallbacks[0]! + expect(account.type).toBe('api') + const credential = await readApiCredential(fixture, account.id) + expect(credential.apiKey).toBe('sk-ant-custom') + expect(credential.baseURL).toBe('https://api.example.com/v1') + expect(credential.authHeader).toBe('x-api-key') + expect(account.label).toBe('CustomAPI') + }, + ) - test('rejects invalid baseURL with embedded credentials', async () => { - const plugin = await getPlugin() - await executeCommand( - plugin, - 'claude-account', - 'add-apikey sk-ant-bad --base-url https://user:pass@evil.com', - ) + pluginTest( + 'rejects invalid baseURL with embedded credentials', + async (fixture) => { + const plugin = await fixture.getPlugin() + await applyMenuAction(fixture, plugin, { + actionId: 'add-apikey', + values: { apiKey: 'sk-ant-bad', baseURL: 'https://user:pass@evil.com' }, + }) - const { loadAccounts } = await import('@cortexkit/anthropic-auth-core') - const loaded = await loadAccounts(accountPath) - expect(loaded!.accounts).toHaveLength(0) - }) + expect(await readFallbacks(fixture)).toHaveLength(0) + }, + ) - test('rejects non-http baseURL', async () => { - const plugin = await getPlugin() - await executeCommand( - plugin, - 'claude-account', - 'add-apikey sk-ant-bad2 --base-url file:///etc/passwd', - ) + pluginTest('rejects non-http baseURL', async (fixture) => { + const plugin = await fixture.getPlugin() + await applyMenuAction(fixture, plugin, { + actionId: 'add-apikey', + values: { apiKey: 'sk-ant-bad2', baseURL: 'file:///etc/passwd' }, + }) - const { loadAccounts } = await import('@cortexkit/anthropic-auth-core') - const loaded = await loadAccounts(accountPath) - expect(loaded!.accounts).toHaveLength(0) + expect(await readFallbacks(fixture)).toHaveLength(0) }) }) // --------------------------------------------------------------------------- -// add-oauth — error paths (no mocks needed) +// add-oauth — error paths // --------------------------------------------------------------------------- describe('add-oauth error paths', () => { - test('add-oauth-finish with no pending state gives graceful error', async () => { - const plugin = await getPlugin() - capturedRecords = [] - - await executeCommand( - plugin, - 'claude-account', - 'add-oauth-finish somecode', - 'ses_no_pending', - ) - - const { loadAccounts } = await import('@cortexkit/anthropic-auth-core') - const loaded = await loadAccounts(accountPath) - expect(loaded!.accounts).toHaveLength(0) - }) + pluginTest( + 'add-oauth-finish with no pending state gives graceful error', + async (fixture) => { + const plugin = await fixture.getPlugin() + capturedRecords = [] + + await applyMenuAction( + fixture, + plugin, + { actionId: 'add-oauth-finish', values: { code: 'somecode' } }, + 'ses_no_pending', + ) + + expect(await readFallbacks(fixture)).toHaveLength(0) + }, + ) test('add-oauth-start stores pending and returns url', async () => { const { parseAccountCommandAction } = await import( @@ -449,132 +733,100 @@ describe('add-oauth error paths', () => { expect(parsed.type).toBe('add-oauth-start') }) - test('add-oauth-start refuses Claustrum mode before authorization', async () => { - const { loadAccounts, saveAccounts, setClaustrumModePersistent } = - await import('@cortexkit/anthropic-auth-core') - const storage = (await loadAccounts(accountPath))! - await saveAccounts( - { ...storage, claustrum: { mode: 'claustrum' } }, - accountPath, - ) - const authorize = mock(() => - Promise.resolve({ - url: 'https://example.test/authorize', - redirectUri: 'http://localhost/callback', - state: 'state', - verifier: 'verifier', - }), - ) - const plugin = await getPlugin({ authorize } as any) - - await expect( - plugin['command.execute.before']({ - command: 'claude-account', - arguments: 'add-oauth-start', - sessionID: 'ses_claustrum_add', - }), - ).rejects.toThrow('Exit Claustrum mode first: /claude-account local') - expect(authorize).not.toHaveBeenCalled() - - await setClaustrumModePersistent('local', accountPath) - await executeCommand( - plugin, - 'claude-account', - 'add-oauth-start', - 'ses_local_add', - ) - expect(authorize).toHaveBeenCalledTimes(1) - await plugin.dispose?.() - }) - - test('pending entry is always cleared after finish (even on failure)', async () => { - // This validates the finally-block behavior: - // After calling add-oauth-finish with a real pending entry (from a real - // add-oauth-start), the pending is consumed. A second finish call with the - // same session ID must receive 'expired / no pending', proving the finally - // block cleared it. We use the error-path here since exchange will fail - // with the bad code, but the pending is still deleted by finally. - // - // We call add-oauth-start (real authorize, creates pending entry), - // then add-oauth-finish with a garbage code (exchange returns failed). - // Then a second add-oauth-finish must get 'expired'. - const plugin = await getPlugin() - - // Start OAuth — this will make a real HTTP call to authorize() - await executeCommand( - plugin, - 'claude-account', - 'add-oauth-start', - 'ses_cleanup_test', - ) - - // Finish with garbage code — exchange will fail, but pending MUST be cleared - await executeCommand( - plugin, - 'claude-account', - 'add-oauth-finish garbage-code', - 'ses_cleanup_test', - ) - - // Second finish with same session — must get 'expired' (pending cleared) - capturedRecords = [] - await executeCommand( - plugin, - 'claude-account', - 'add-oauth-finish another-code', - 'ses_cleanup_test', - ) + lifetimes.test( + 'add-oauth-start refuses Claustrum mode before authorization', + async () => { + const authorize = mock(() => + Promise.resolve({ + url: 'https://example.test/authorize', + redirectUri: 'http://localhost/callback', + state: 'state', + verifier: 'verifier', + }), + ) + // A pool's custody mode (Claustrum vault or local) is fixed by the setup + // migration, so a vault-migrated pool is the Claustrum case and a + // locally migrated pool is the local case. + const vault = await migrate(emptyStorage(), 'claustrum') + const vaultPlugin = await vault.getPlugin({ authorize }) + const refused = await applyMenuAction( + vault, + vaultPlugin, + { actionId: 'add-oauth-start' }, + 'ses_claustrum_add', + ) + expect(refused.ok).toBe(false) + expect(authorize).not.toHaveBeenCalled() + + const local = await migrate() + const localPlugin = await local.getPlugin({ authorize }) + const started = await applyMenuAction( + local, + localPlugin, + { actionId: 'add-oauth-start' }, + 'ses_local_add', + ) + expect(started.ok).toBe(true) + expect(authorize).toHaveBeenCalledTimes(1) + }, + ) - // No accounts should have been added - const { loadAccounts } = await import('@cortexkit/anthropic-auth-core') - const loaded = await loadAccounts(accountPath) - expect(loaded!.accounts).toHaveLength(0) - }) + pluginTest( + 'pending entry is always cleared after finish (even on failure)', + async (fixture) => { + // add-oauth-start stores a pending entry for the session; a finish with a + // code the exchange rejects must still consume it, so a second finish for + // the same session reports an expired sign-in. + const plugin = await fixture.getPlugin() + + await applyMenuAction( + fixture, + plugin, + { actionId: 'add-oauth-start' }, + 'ses_cleanup_test', + ) + + // Finish with garbage code — exchange will fail, but pending MUST be cleared + await applyMenuAction( + fixture, + plugin, + { actionId: 'add-oauth-finish', values: { code: 'garbage-code' } }, + 'ses_cleanup_test', + ) + + // Second finish with same session — must get 'expired' (pending cleared) + capturedRecords = [] + const second = await applyMenuAction( + fixture, + plugin, + { actionId: 'add-oauth-finish', values: { code: 'another-code' } }, + 'ses_cleanup_test', + ) + expect(second.text).toContain('expired') + + // No accounts should have been added + expect(await readFallbacks(fixture)).toHaveLength(0) + }, + ) }) // --------------------------------------------------------------------------- // add-oauth — label threading (the fix: prompt for a label, default UUID name) // --------------------------------------------------------------------------- describe('add-oauth label threading', () => { - async function getPluginWithClient() { - const client = createMockClient() - const { AnthropicAuthPlugin } = await import('../index') - const plugin = (await AnthropicAuthPlugin( - { - // @ts-expect-error: minimal mock for testing - client, - }, - disabledPluginTimerOverrides(), - )) as any - return { plugin, client } - } - - function extractStateFromPromptCalls( - client: ReturnType, - ): string { - const calls = (client.session.promptAsync as { mock: { calls: any[] } }) - .mock.calls - for (const [req] of calls) { - const text: string | undefined = req?.body?.parts?.[0]?.text - if (!text) continue - const match = text.match(/state=([^\s&]+)/) - if (match?.[1]) return decodeURIComponent(match[1]) - } - throw new Error('no OAuth URL with state captured from prompt calls') - } - - async function runOAuthAddWithLabel(labelArg: string | null) { - const { plugin, client } = await getPluginWithClient() - const sessionId = 'ses_oauth_label' - - // Real add-oauth-start: builds a URL with a real state, stores pending, - // and delivers the URL text via session.promptAsync (TUI not connected). - await executeCommand(plugin, 'claude-account', 'add-oauth-start', sessionId) - const state = extractStateFromPromptCalls(client) - - // Mock the token exchange endpoint to succeed. - const originalFetch = globalThis.fetch - globalThis.fetch = mock((input: any) => { + let oauthLogins = 0 + + /** + * A synthetic Anthropic OAuth server for one sign-in: its token endpoint + * issues tokens unique to that sign-in, and its bootstrap endpoint names a + * distinct account for them, so two sign-ins never share a credential. + * Access tokens carry the `sk-ant-oat` prefix because the runtime looks up + * the owning account only for tokens of that shape. + */ + function syntheticOAuthServer() { + const login = ++oauthLogins + const identity = `00000000-0000-4000-8000-${String(login).padStart(12, '0')}` + const respond = async (input: Parameters[0]) => { const url = typeof input === 'string' ? input @@ -582,113 +834,219 @@ describe('add-oauth label threading', () => { ? input.toString() : input.url if (url.includes('oauth/token') || url.includes('/token')) { - return Promise.resolve( - new Response( - JSON.stringify({ - access_token: 'oauth-access', - refresh_token: 'oauth-refresh', - expires_in: 3600, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), + return new Response( + JSON.stringify({ + access_token: `sk-ant-oat01-oauth-access-${login}`, + refresh_token: `oauth-refresh-${login}`, + expires_in: 3600, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, ) } - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch + if (url.includes('/claude_cli/bootstrap')) + return Response.json({ oauth_account: { account_uuid: identity } }) + return new Response(null, { status: 200 }) + } + return Object.assign(respond, { preconnect: originalFetch.preconnect }) + } + async function withProvider( + provider: typeof fetch, + body: () => Promise, + ): Promise { + const previous = globalThis.fetch + globalThis.fetch = provider try { - const finishArgs = - labelArg == null - ? `add-oauth-finish dummy-code#${state}` - : `add-oauth-finish dummy-code#${state} --label ${labelArg}` - await executeCommand(plugin, 'claude-account', finishArgs, sessionId) + return await body() } finally { - globalThis.fetch = originalFetch + globalThis.fetch = previous } - - const { loadAccounts } = await import('@cortexkit/anthropic-auth-core') - const loaded = await loadAccounts(accountPath) - return loaded } - test('add-oauth-finish --label sets the account label', async () => { - const loaded = await runOAuthAddWithLabel('work') - expect(loaded).not.toBeNull() - expect(loaded!.accounts).toHaveLength(1) - const account = loaded!.accounts[0]! - expect(account.type).toBe('oauth') - expect(account.label).toBe('work') - }) + async function runOAuthAddWithLabel( + fixture: Migrated, + labelArg: string | null, + provider = syntheticOAuthServer(), + ) { + const plugin = await fixture.getPlugin() + const sessionId = 'ses_oauth_label' + const before = new Set((await readFallbacks(fixture)).map((a) => a.id)) - test('add-oauth-finish without --label leaves label undefined (UUID-name fallback)', async () => { - const loaded = await runOAuthAddWithLabel(null) - expect(loaded).not.toBeNull() - expect(loaded!.accounts).toHaveLength(1) - const account = loaded!.accounts[0]! - expect(account.label).toBeUndefined() - // id is a UUID (no natural key for OAuth) - expect(account.id).toMatch(/^[0-9a-f-]{36}$/) - }) + // Real add-oauth-start: builds a URL with a real state, stores pending, + // and returns the URL in the menu result for the TUI to show. + const started = await applyMenuAction( + fixture, + plugin, + { actionId: 'add-oauth-start' }, + sessionId, + ) + const match = started.text.match(/state=([^\s&]+)/) + if (!match?.[1]) throw new Error('no OAuth URL with state in menu result') + const state = decodeURIComponent(match[1]) + + // The token exchange answers with tokens unique to this sign-in, so + // add-oauth-finish can save the account. + await withProvider(provider, () => + applyMenuAction( + fixture, + plugin, + { + actionId: 'add-oauth-finish', + values: { + code: `dummy-code#${state}`, + ...(labelArg == null ? {} : { label: labelArg }), + }, + }, + sessionId, + ), + ) - test('a fresh login at the same label gets a new auth lineage', async () => { - const first = await runOAuthAddWithLabel('work') - const firstAccount = first!.accounts[0]! - expect(firstAccount.type).toBe('oauth') - if (firstAccount.type !== 'oauth') throw new Error('expected OAuth account') - - const second = await runOAuthAddWithLabel('work') - const secondAccount = second!.accounts[0]! - expect(secondAccount.type).toBe('oauth') - if (secondAccount.type !== 'oauth') - throw new Error('expected OAuth account') - expect(secondAccount.authLineageId).not.toBe(firstAccount.authLineageId) - const { tokenFingerprint } = await import('@cortexkit/anthropic-auth-core') - expect(tokenFingerprint(secondAccount.authLineageId!)).not.toBe( - tokenFingerprint(firstAccount.authLineageId!), + return (await readFallbacks(fixture)).filter( + (account) => !before.has(account.id), ) - }) + } + + /** + * The route's auth lineage: a random ID the runtime keeps for each stored + * OAuth credential and renews when the credential is replaced. The runtime + * returns it only for a credential it has validated, so the route is first + * authorized locally against `provider`. + */ + async function readLineage( + fixture: Migrated, + routeId: string, + provider: typeof fetch, + ) { + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + return await withProvider(provider, async () => { + const admitted = await runtime.authorizeLocal(routeId) + if (admitted.status !== 'usable') + throw new Error( + `Route ${routeId} was not admitted: ${admitted.status}`, + ) + return runtime.getOrCreateAuthLineage(routeId, admitted.subject) + }) + } finally { + runtime.close() + } + } + + pluginTest( + 'add-oauth-finish --label sets the account label', + async (fixture) => { + const added = await runOAuthAddWithLabel(fixture, 'work') + expect(added).toHaveLength(1) + const account = added[0]! + expect(account.type).toBe('oauth') + expect(account.label).toBe('work') + }, + ) + + pluginTest( + 'add-oauth-finish without --label leaves label undefined (UUID-name fallback)', + async (fixture) => { + const added = await runOAuthAddWithLabel(fixture, null) + expect(added).toHaveLength(1) + const account = added[0]! + expect(account.label).toBeUndefined() + // OAuth sign-in supplies no account name, so the route ID is a random UUID + expect(account.id).toMatch(/^[0-9a-f-]{36}$/) + }, + ) + + pluginTest( + 'a fresh login at the same label gets a new auth lineage', + async (fixture) => { + const firstProvider = syntheticOAuthServer() + const [firstAccount] = await runOAuthAddWithLabel( + fixture, + 'work', + firstProvider, + ) + expect(firstAccount?.type).toBe('oauth') + if (firstAccount?.type !== 'oauth') + throw new Error('expected OAuth account') + const firstLineage = await readLineage( + fixture, + firstAccount.id, + firstProvider, + ) + + const secondProvider = syntheticOAuthServer() + const [secondAccount] = await runOAuthAddWithLabel( + fixture, + 'work', + secondProvider, + ) + expect(secondAccount?.type).toBe('oauth') + if (secondAccount?.type !== 'oauth') + throw new Error('expected OAuth account') + const secondLineage = await readLineage( + fixture, + secondAccount.id, + secondProvider, + ) + expect(firstLineage).toBeString() + expect(secondLineage).toBeString() + expect(secondLineage).not.toBe(firstLineage) + const { tokenFingerprint } = await import( + '@cortexkit/anthropic-auth-core' + ) + expect(tokenFingerprint(secondLineage!)).not.toBe( + tokenFingerprint(firstLineage!), + ) + }, + ) }) // --------------------------------------------------------------------------- // security — no secrets in logs // --------------------------------------------------------------------------- describe('security — no secrets in logs', () => { - test('API key is never in any log record', async () => { - const plugin = await getPlugin() - await executeCommand( - plugin, - 'claude-account', - 'add-apikey sk-ant-super-secret-key-abc123 MyLabel', - ) + pluginTest('API key is never in any log record', async (fixture) => { + const plugin = await fixture.getPlugin() + await applyMenuAction(fixture, plugin, { + actionId: 'add-apikey', + values: { apiKey: 'sk-ant-super-secret-key-abc123', label: 'MyLabel' }, + }) const hits = scanLogsForSecrets(capturedRecords) expect(hits).toEqual([]) }) - test('apply path does not log raw API key arguments', async () => { - const plugin = await getPlugin() - await executeCommand( - plugin, - 'claude-account', - 'add-apikey sk-ant-rpc-key123', - ) + pluginTest( + 'apply path does not log raw API key arguments', + async (fixture) => { + const plugin = await fixture.getPlugin() + await applyMenuAction(fixture, plugin, { + actionId: 'add-apikey', + values: { apiKey: 'sk-ant-rpc-key123' }, + }) - for (const rec of capturedRecords) { - const payloadStr = JSON.stringify(rec.payload ?? {}) - expect(payloadStr).not.toContain('sk-ant-rpc-key123') - expect(payloadStr).not.toContain('add-apikey sk-ant') - } - }) + for (const rec of capturedRecords) { + const payloadStr = JSON.stringify(rec.payload ?? {}) + expect(payloadStr).not.toContain('sk-ant-rpc-key123') + expect(payloadStr).not.toContain('add-apikey sk-ant') + } + }, + ) - test('add-oauth-finish code is not logged', async () => { - const plugin = await getPlugin() + pluginTest('add-oauth-finish code is not logged', async (fixture) => { + const plugin = await fixture.getPlugin() - // The code travels through the arguments string to the parser, - // but should never appear in the log payload - await executeCommand( + // The code travels in the menu request values, but must never appear in + // a log payload. + await applyMenuAction( + fixture, plugin, - 'claude-account', - 'add-oauth-finish sk-ant-secret-auth-code', + { + actionId: 'add-oauth-finish', + values: { code: 'sk-ant-secret-auth-code' }, + }, 'ses_sec_code', ) diff --git a/packages/opencode/src/tests/cli.test.ts b/packages/opencode/src/tests/cli.test.ts index 3568d3c0b..61fb204b0 100644 --- a/packages/opencode/src/tests/cli.test.ts +++ b/packages/opencode/src/tests/cli.test.ts @@ -1,14 +1,22 @@ import { describe, expect } from 'bun:test' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { - addAccountPersistent, createNativeAccountRuntime, createNativePoolStore, getAccountStatePath, - loadAccounts, - saveAccounts, + getHostClaustrumEnrollmentPaths, + readNativeEnrollmentMode, + resetClaudeCodeIdentityCachesForTest, } from '@cortexkit/anthropic-auth-core' import { nativeQuotaCodec } from '../../../core/src/native-quota-codec' @@ -52,14 +60,110 @@ async function readNativeRows( return view.rows } -async function nativeFixture(root: string) { +const cliAccountUuid = '00000000-0000-4000-8000-000000000111' + +function interceptBootstrap() { + const original = globalThis.fetch + const tokens: string[] = [] + resetClaudeCodeIdentityCachesForTest() + globalThis.fetch = Object.assign( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL( + input instanceof Request ? input.url : input.toString(), + ) + if ( + url.origin !== 'https://api.anthropic.com' || + url.pathname !== '/api/claude_cli/bootstrap' + ) + throw new Error(`Unexpected CLI test request: ${url.pathname}`) + const authorization = new Headers(init?.headers).get('authorization') + if (!authorization?.startsWith('Bearer sk-ant-oat01-')) + throw new Error('CLI bootstrap did not use OAuth credentials') + tokens.push(authorization) + return Response.json({ oauth_account: { account_uuid: cliAccountUuid } }) + }, + { preconnect: original.preconnect }, + ) + lifetime.deferCleanup(() => { + globalThis.fetch = original + resetClaudeCodeIdentityCachesForTest() + }) + return tokens +} + +async function nativeFixture( + root: string, + legacyConfig: Parameters< + typeof migrateNativeOpencodeFixture + >[0]['legacyConfig'] = { version: 1, accounts: [] }, + legacyState?: Parameters< + typeof migrateNativeOpencodeFixture + >[0]['legacyState'], +) { return migrateNativeOpencodeFixture({ root, lifetime, - legacyConfig: { version: 1, accounts: [] }, + legacyConfig, + legacyState, }) } +/** + * Put this host's Claustrum enrollment files in a state setup leaves after + * enrollment: an owner-only token file, or an approved enrollment record whose + * token file is missing. Core's readNativeEnrollmentMode reports vault + * ('claustrum') mode for both, and the CLI must then use the vault for + * credentials; the local account pool holds no mode flag. + */ +async function enrollVault( + env: Record, + kind: 'token' | 'approved-without-token' = 'token', +) { + const paths = getHostClaustrumEnrollmentPaths('opencode', env) + if (kind === 'token') + await writeFile( + paths.tokenPath, + JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), + { mode: 0o600, flag: 'wx' }, + ) + else + await writeFile( + paths.statePath, + JSON.stringify({ + version: 1, + phase: 'approved', + proposedName: 'anthropic-auth-opencode', + tokenGeneration: 1, + updatedAt: 1, + }), + { mode: 0o600, flag: 'wx' }, + ) + const written = kind === 'token' ? paths.tokenPath : paths.statePath + expect((await stat(written)).mode & 0o777).toBe(0o600) + expect(await readNativeEnrollmentMode('opencode', env)).toBe('claustrum') +} + +const enrollmentKinds = ['token', 'approved-without-token'] as const +/** + * Every file under the fixture root with its bytes and permission bits, so a + * refused command can be shown to have created, changed or removed nothing. + */ +async function rootFiles(root: string) { + const files: Record = {} + for (const entry of await readdir(root, { + recursive: true, + withFileTypes: true, + })) { + const path = join(entry.parentPath, entry.name) + const info = await stat(path) + files[path] = { + mode: info.mode & 0o777, + bytes: entry.isFile() ? (await readFile(path)).toString('base64') : '', + } + } + return files +} + // Inject prompts and OAuth responses so command tests never open a browser // or use interactive stdin. Storage assertions still read the actual files. @@ -166,24 +270,143 @@ describe('CLI api add', () => { expect(await readNativeRows(fixture)).toEqual([]) await expect(readFile(accountPath, 'utf8')).rejects.toThrow() }) + + for (const kind of enrollmentKinds) { + test(`refuses with a vault enrollment (${kind}) before asking for a key and writes nothing`, async () => { + const tempDir = await createRoot() + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + await enrollVault(fixture.env, kind) + const before = await rootFiles(fixture.root) + const asked: string[] = [] + + await expect( + withAccountEnv( + accountPath, + { + ...fixture.env, + OPENCODE_ANTHROPIC_AUTH_API_BASE_URL: undefined, + OPENCODE_ANTHROPIC_AUTH_API_KEY: undefined, + OPENCODE_ANTHROPIC_AUTH_API_AUTH_HEADER: undefined, + }, + () => + addApiRoute(undefined, { + prompt: async (message) => { + asked.push(message) + return 'vault-mode-key' + }, + }), + ), + ).rejects.toThrow('Disconnect from the vault first.') + expect(asked).toEqual([]) + expect(await rootFiles(fixture.root)).toEqual(before) + }) + } + + test('writes nothing when setup enrolls the host while the key is entered', async () => { + const tempDir = await createRoot() + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + let enrolled: Awaited> | undefined + + await expect( + withAccountEnv( + accountPath, + { + ...fixture.env, + OPENCODE_ANTHROPIC_AUTH_API_BASE_URL: 'https://api.kie.ai/claude', + OPENCODE_ANTHROPIC_AUTH_API_KEY: undefined, + OPENCODE_ANTHROPIC_AUTH_API_AUTH_HEADER: 'x-api-key', + }, + () => + addApiRoute('late-api', { + // Offline setup enrolls this host with the Claustrum vault while + // the user is still typing the key. + prompt: async (message) => { + expect(message).toBe('API key: ') + await enrollVault(fixture.env) + enrolled = await rootFiles(fixture.root) + return 'late-api-key' + }, + }), + ), + ).rejects.toThrow('Disconnect from the vault first.') + expect(enrolled).toBeDefined() + expect(await rootFiles(fixture.root)).toEqual(enrolled!) + expect(await readNativeRows(fixture)).toEqual([]) + for (const file of Object.values(await rootFiles(fixture.root))) + expect(Buffer.from(file.bytes, 'base64').toString()).not.toContain( + 'late-api-key', + ) + }) }) -describe('CLI login', () => { - test('refuses before authorization while global Claustrum custody is committed', async () => { +describe('CLI vault refusal', () => { + test('refuses from enrollment alone, before the local account pool is read', async () => { const tempDir = await createRoot() - const accountPath = join(tempDir, 'anthropic-auth.json') - await saveAccounts( - { - version: 1, - accounts: [], - claustrum: { mode: 'claustrum' }, - }, - accountPath, - ) + // Setup never migrated a local account pool into this directory, so a + // command that reads the pool fails with "Anthropic account migration is + // required". A vault refusal here shows the command refused before it + // read the pool. + const env = { + OPENCODE_ANTHROPIC_AUTH_FILE: join(tempDir, 'anthropic-auth.json'), + OPENCODE_ANTHROPIC_AUTH_STATE_FILE: join( + tempDir, + 'anthropic-auth-state.json', + ), + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + tempDir, + 'opencode-enrollment.json', + ), + OPENCODE_ANTHROPIC_AUTH_API_KEY: 'unused-api-key', + } + const commands = { + login: () => + login('blocked', { + authorize: async () => { + throw new Error('authorization should not start') + }, + prompt: async () => { + throw new Error('prompt should not run') + }, + }), + api: () => + addApiRoute('blocked', { + prompt: async () => { + throw new Error('prompt should not run') + }, + }), + } + + for (const run of Object.values(commands)) + await expect( + withAccountEnv(env.OPENCODE_ANTHROPIC_AUTH_FILE, env, run), + ).rejects.toThrow('Anthropic account migration is required; run setup') + + await enrollVault(env) + const before = await rootFiles(tempDir) + await expect( + withAccountEnv(env.OPENCODE_ANTHROPIC_AUTH_FILE, env, commands.login), + ).rejects.toThrow('Disconnect from the vault first.') + await expect( + withAccountEnv(env.OPENCODE_ANTHROPIC_AUTH_FILE, env, commands.api), + ).rejects.toThrow('Disconnect from the vault first.') + expect(await rootFiles(tempDir)).toEqual(before) + }) + + test('treats unreadable enrollment as a refusal, never as local mode', async () => { + const tempDir = await createRoot() + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + await writeFile(fixture.enrollmentPath, 'not-an-enrollment-token', { + mode: 0o600, + flag: 'wx', + }) + const before = await rootFiles(fixture.root) let authorizeCalls = 0 await expect( - withAccountEnv(accountPath, {}, () => + withAccountEnv(accountPath, fixture.env, () => login('blocked', { authorize: async () => { authorizeCalls += 1 @@ -194,16 +417,85 @@ describe('CLI login', () => { }, }), ), - ).rejects.toThrow('Exit Claustrum mode first') + ).rejects.toThrow('Native custody refused: invalid-token') expect(authorizeCalls).toBe(0) - await expect(loadAccounts(accountPath)).resolves.toMatchObject({ - accounts: [], + expect(await rootFiles(fixture.root)).toEqual(before) + }) +}) + +describe('CLI login', () => { + for (const kind of enrollmentKinds) { + test(`refuses before authorization with a vault enrollment (${kind})`, async () => { + const tempDir = await createRoot() + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + const bootstraps = interceptBootstrap() + await enrollVault(fixture.env, kind) + const before = await rootFiles(fixture.root) + let authorizeCalls = 0 + let exchangeCalls = 0 + + await expect( + withAccountEnv(accountPath, fixture.env, () => + login('blocked', { + authorize: async () => { + authorizeCalls += 1 + throw new Error('authorization should not start') + }, + exchange: async () => { + exchangeCalls += 1 + throw new Error('exchange should not run') + }, + prompt: async () => { + throw new Error('prompt should not run') + }, + }), + ), + ).rejects.toThrow('Disconnect from the vault first.') + expect(authorizeCalls).toBe(0) + expect(exchangeCalls).toBe(0) + expect(bootstraps).toEqual([]) + expect(await rootFiles(fixture.root)).toEqual(before) }) + } + + test('writes nothing when setup enrolls the host during sign-in', async () => { + const tempDir = await createRoot() + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + const bootstraps = interceptBootstrap() + let enrolled: Awaited> | undefined + + await expect( + withAccountEnv(accountPath, fixture.env, () => + login('late', { + prompt: async () => 'cli-code', + // The browser sign-in is open while offline setup enrolls this host + // in vault custody; the exchanged tokens then arrive late. + exchange: async () => { + await enrollVault(fixture.env) + enrolled = await rootFiles(fixture.root) + return { + type: 'success' as const, + access: 'sk-ant-oat01-late-access', + refresh: 'late-refresh', + expires: Date.now() + 3_600_000, + } + }, + }), + ), + ).rejects.toThrow('Disconnect from the vault first.') + expect(enrolled).toBeDefined() + expect(await rootFiles(fixture.root)).toEqual(enrolled!) + expect(bootstraps).toEqual([]) + expect(await readNativeRows(fixture)).toEqual([]) }) test('continues from label prompt to OAuth callback prompt and saves account', async () => { const tempDir = await createRoot() - const accountPath = join(tempDir, 'anthropic-auth.json') + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + const bootstraps = interceptBootstrap() // Real authorize() (exercises PKCE + state + the printed URL); exchange() // is the network boundary, stubbed to return canned tokens. The prompt @@ -232,7 +524,7 @@ describe('CLI login', () => { exchangeArgs = { input, verifier } return { type: 'success', - access: 'cli-access', + access: 'sk-ant-oat01-cli-access', refresh: 'cli-refresh', expires: Date.now() + 3600 * 1000, } @@ -244,7 +536,7 @@ describe('CLI login', () => { logs.push(args.map(String).join(' ')) } try { - await withAccountEnv(accountPath, {}, () => + await withAccountEnv(accountPath, fixture.env, () => login(undefined, { prompt, exchange }), ) } finally { @@ -264,293 +556,248 @@ describe('CLI login', () => { 'https://platform.claude.com/oauth/code/callback?code=cli-code&state=stub', ) expect(exchangeArgs?.verifier).toBeString() - expect(stdout).toContain('Saved fallback account "cli-label"') + expect(stdout).toContain('Saved native fallback account "cli-label"') - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.accounts).toHaveLength(1) - expect(storage.accounts[0]).toMatchObject({ + const rows = await readNativeRows(fixture) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ id: 'cli-label', label: 'cli-label', enabled: true, + stamp: 'bound', + identity: cliAccountUuid, + credential: { + type: 'oauth', + access: 'sk-ant-oat01-cli-access', + refresh: 'cli-refresh', + }, }) - expect(storage.accounts[0].access).toBeUndefined() - expect(storage.accounts[0].refresh).toBeUndefined() - - const runtimeState = JSON.parse( - await readFile(getAccountStatePath(accountPath), 'utf8'), - ) - expect(runtimeState.accounts['cli-label']).toMatchObject({ - access: 'cli-access', - refresh: 'cli-refresh', - }) - expect(runtimeState.accounts['cli-label'].authLineageId).toMatch( - /^[0-9a-f-]{36}$/, - ) + const config = await readFile(fixture.paths.config, 'utf8') + expect(config).not.toContain('sk-ant-oat01-cli-access') + expect(config).not.toContain('cli-refresh') + const state = JSON.parse(await readFile(fixture.paths.runtime, 'utf8')) + expect(state.accounts['cli-label'].authLineageId).toMatch(/^[0-9a-f-]{36}$/) + expect(bootstraps).toEqual(['Bearer sk-ant-oat01-cli-access']) + await expect(readFile(accountPath, 'utf8')).rejects.toThrow() }) test('allows human-readable local OAuth labels without the obsolete manifest label restriction', async () => { const tempDir = await createRoot() - const accountPath = join(tempDir, 'anthropic-auth.json') + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + const bootstraps = interceptBootstrap() - await withAccountEnv(accountPath, {}, () => + await withAccountEnv(accountPath, fixture.env, () => login('invalid label', { prompt: async () => 'cli-code', exchange: async () => ({ type: 'success' as const, - access: 'cli-access', + access: 'sk-ant-oat01-cli-access', refresh: 'cli-refresh', expires: Date.now() + 3600 * 1000, }), }), ) - expect((await loadAccounts(accountPath))?.accounts[0]).toMatchObject({ + expect((await readNativeRows(fixture))[0]).toMatchObject({ id: 'invalid label', label: 'invalid label', - access: 'cli-access', + stamp: 'bound', + credential: { type: 'oauth', access: 'sk-ant-oat01-cli-access' }, }) + expect(bootstraps).toEqual(['Bearer sk-ant-oat01-cli-access']) }) test('preserves an account committed while the interactive OAuth flow is open', async () => { const tempDir = await createRoot() - const accountPath = join(tempDir, 'anthropic-auth.json') + const fixture = await nativeFixture(tempDir) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + const bootstraps = interceptBootstrap() const now = Date.now() - const exchange = async (): Promise<{ - type: 'success' - access: string - refresh: string - expires: number - }> => { - await addAccountPersistent( - { - id: 'umut', + const exchange = async () => { + const concurrent = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + await concurrent.putAccount({ + routeId: 'umut', label: 'umut', - type: 'oauth', - access: 'umut-access', - refresh: 'umut-refresh', - expires: now + 3600 * 1000, - enabled: true, - addedAt: now, - }, - accountPath, - ) + accountIdentity: '00000000-0000-4000-8000-000000000112', + credential: { + type: 'oauth', + access: 'sk-ant-oat01-umut-access', + refresh: 'umut-refresh', + expires: now + 3_600_000, + }, + }) + } finally { + concurrent.close() + } return { - type: 'success', - access: 'yiyi-access', + type: 'success' as const, + access: 'sk-ant-oat01-yiyi-access', refresh: 'yiyi-refresh', - expires: now + 3600 * 1000, + expires: now + 3_600_000, } } - - await withAccountEnv(accountPath, {}, () => - login('yiyi', { - prompt: async () => - 'https://platform.claude.com/oauth/code/callback?code=cli-code&state=stub', - exchange, - }), - ) - - const config = JSON.parse(await readFile(accountPath, 'utf8')) - expect( - config.accounts.map((account: { id: string }) => account.id), - ).toEqual(['umut', 'yiyi']) - const runtimeState = JSON.parse( - await readFile(getAccountStatePath(accountPath), 'utf8'), - ) - expect(Object.keys(runtimeState.accounts)).toEqual(['umut', 'yiyi']) - expect(runtimeState.accounts.umut.refresh).toBe('umut-refresh') - expect(runtimeState.accounts.yiyi.refresh).toBe('yiyi-refresh') - }) - - test('re-login with same label clears stale errors and quota', async () => { - const tempDir = await createRoot() - const accountPath = join(tempDir, 'anthropic-auth.json') - - await writeFile( - accountPath, - JSON.stringify({ - version: 1, - main: { type: 'opencode', provider: 'anthropic' }, - accounts: [ - { - id: 'cli-label', - label: 'cli-label', - type: 'oauth', - access: 'old-access', - refresh: 'old-refresh', - expires: 1, - enabled: true, - addedAt: 123, - quota: { - five_hour: { - usedPercent: 99, - remainingPercent: 1, - checkedAt: 123, - }, - }, - lastRefreshedAt: 123, - lastRefreshError: { message: 'old refresh failed', checkedAt: 123 }, - lastQuotaRefreshError: { - message: 'old quota failed', - checkedAt: 123, - }, - }, - ], - }), - 'utf8', - ) - - // Label is passed as the arg, so the only prompt is the callback code. - const asked: string[] = [] - const prompt = async (message: string) => { - asked.push(message) - return 'https://platform.claude.com/oauth/code/callback?code=cli-code&state=stub' - } - const exchange = async (): Promise<{ - type: 'success' - access: string - refresh: string - expires: number - }> => ({ - type: 'success', - access: 'new-access', - refresh: 'new-refresh', - expires: Date.now() + 3600 * 1000, - }) - - await withAccountEnv(accountPath, {}, () => - login('cli-label', { prompt, exchange }), + await withAccountEnv(accountPath, fixture.env, () => + login('yiyi', { prompt: async () => 'cli-code', exchange }), ) - - expect(asked).toEqual([ - 'Paste the full callback URL or authorization code here: ', - ]) - - const storage = JSON.parse(await readFile(accountPath, 'utf8')) - expect(storage.accounts).toHaveLength(1) - expect(storage.accounts[0]).toMatchObject({ - id: 'cli-label', - label: 'cli-label', - enabled: true, - addedAt: 123, + const rows = await readNativeRows(fixture) + expect(rows.map((row) => row.id)).toEqual(['umut', 'yiyi']) + expect(rows[0]?.credential).toMatchObject({ + type: 'oauth', + refresh: 'umut-refresh', }) - expect(storage.accounts[0].access).toBeUndefined() - expect(storage.accounts[0].refresh).toBeUndefined() - expect(storage.accounts[0].quota).toBeUndefined() - expect(storage.accounts[0].lastRefreshedAt).toBeUndefined() - expect(storage.accounts[0].lastRefreshError).toBeUndefined() - expect(storage.accounts[0].lastQuotaRefreshError).toBeUndefined() - - const runtimeState = JSON.parse( - await readFile(getAccountStatePath(accountPath), 'utf8'), - ) - expect(runtimeState.accounts['cli-label']).toMatchObject({ - access: 'new-access', - refresh: 'new-refresh', + expect(rows[1]?.credential).toMatchObject({ + type: 'oauth', + refresh: 'yiyi-refresh', }) - expect(runtimeState.accounts['cli-label'].quota).toBeUndefined() - expect(runtimeState.accounts['cli-label'].lastRefreshedAt).toBeNumber() - expect(runtimeState.accounts['cli-label'].lastRefreshError).toBeUndefined() - expect( - runtimeState.accounts['cli-label'].lastQuotaRefreshError, - ).toBeUndefined() + const metadata = JSON.parse(await readFile(fixture.paths.runtime, 'utf8')) + expect(Object.keys(metadata.accounts)).toEqual(['umut', 'yiyi']) + expect(bootstraps).toEqual(['Bearer sk-ant-oat01-yiyi-access']) + await expect(readFile(accountPath, 'utf8')).rejects.toThrow() }) - test('re-login with same label replaces split runtime state and clears stale reauth', async () => { - const tempDir = await createRoot() - const accountPath = join(tempDir, 'anthropic-auth.json') - const statePath = getAccountStatePath(accountPath) - const oldRefreshedAt = Date.now() - 60_000 - - await writeFile( - accountPath, - JSON.stringify( - { - version: 1, - main: { type: 'opencode', provider: 'anthropic' }, - accounts: [ - { - id: 'cli-label', - label: 'cli-label', - type: 'oauth', - enabled: true, - addedAt: 123, + for (const splitState of [false, true]) { + test( + splitState + ? 're-login with same label replaces split runtime state and clears stale reauth' + : 're-login with same label clears stale errors and quota', + async () => { + const tempDir = await createRoot() + const oldRefreshedAt = Date.now() - 60_000 + const oldIdentity = '00000000-0000-4000-8000-000000000110' + const stale = { + access: 'sk-ant-oat01-old-access', + refresh: 'old-refresh', + expires: 1, + lastRefreshedAt: oldRefreshedAt, + quota: { + accountIdentity: oldIdentity, + checkedAt: oldRefreshedAt, + five_hour: { + usedPercent: 99, + remainingPercent: 1, + checkedAt: oldRefreshedAt, }, - ], - }, - null, - 2, - ), - 'utf8', - ) - await writeFile( - statePath, - JSON.stringify( - { - version: 1, - accounts: { - 'cli-label': { - access: 'old-access', - refresh: 'old-refresh', - expires: 1, - lastRefreshedAt: oldRefreshedAt, - quota: { - five_hour: { - usedPercent: 99, - remainingPercent: 1, - checkedAt: Date.now(), - }, - }, - lastRefreshError: { - message: 'old invalid_grant', - checkedAt: oldRefreshedAt, - nextRetryAt: Date.now() + 3_600_000, - permanent: true, - }, - lastQuotaRefreshError: { - message: 'old quota error', - checkedAt: oldRefreshedAt, + }, + lastRefreshError: { + message: 'old invalid_grant', + checkedAt: oldRefreshedAt, + nextRetryAt: Date.now() + 3_600_000, + permanent: true, + tokenHash: createHash('sha256').update('old-refresh').digest('hex'), + }, + lastQuotaRefreshError: { + message: 'old quota error', + accountIdentity: oldIdentity, + checkedAt: oldRefreshedAt, + }, + } + const fixture = await nativeFixture( + tempDir, + { + version: 1, + accounts: [ + { + id: 'cli-label', + label: 'cli-label', + type: 'oauth', + anthropicAccountUuid: oldIdentity, + enabled: true, + addedAt: 123, + ...(splitState ? {} : stale), }, + ], + }, + splitState + ? { version: 1, accounts: { 'cli-label': stale } } + : undefined, + ) + const accountPath = fixture.env.OPENCODE_ANTHROPIC_AUTH_FILE + const before = (await readNativeRows(fixture))[0] + expect(before?.identity).toBe(oldIdentity) + const reader = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + const prior = (await reader.read()).accounts.find( + (row) => row.id === 'cli-label', + ) + expect(prior?.quota?.five_hour?.usedPercent).toBe(99) + } finally { + reader.close() + } + const oldMetadata = JSON.parse( + await readFile(fixture.paths.runtime, 'utf8'), + ).accounts['cli-label'] + expect(oldMetadata.lastRefreshError?.permanent).toBe(true) + expect(oldMetadata.lastQuotaRefreshError?.message).toBe( + 'Imported quota failure', + ) + const bootstraps = interceptBootstrap() + const asked: string[] = [] + await withAccountEnv(accountPath, fixture.env, () => + login('cli-label', { + prompt: async (message) => { + asked.push(message) + return 'cli-code' }, + exchange: async () => ({ + type: 'success' as const, + access: 'sk-ant-oat01-new-access', + refresh: 'new-refresh', + expires: Date.now() + 3_600_000, + }), + }), + ) + expect(asked).toEqual([ + 'Paste the full callback URL or authorization code here: ', + ]) + const rows = await readNativeRows(fixture) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + id: 'cli-label', + label: 'cli-label', + enabled: true, + addedAt: before?.addedAt, + identity: cliAccountUuid, + credential: { + type: 'oauth', + access: 'sk-ant-oat01-new-access', + refresh: 'new-refresh', }, - }, - null, - 2, - ), - 'utf8', - ) - - const prompt = async () => - 'https://platform.claude.com/oauth/code/callback?code=cli-code&state=stub' - const exchange = async (): Promise<{ - type: 'success' - access: string - refresh: string - expires: number - }> => ({ - type: 'success', - access: 'new-access', - refresh: 'new-refresh', - expires: Date.now() + 3600 * 1000, - }) - - await withAccountEnv(accountPath, {}, () => - login('cli-label', { prompt, exchange }), - ) - - const runtimeState = JSON.parse(await readFile(statePath, 'utf8')) - expect(runtimeState.accounts['cli-label']).toMatchObject({ - access: 'new-access', - refresh: 'new-refresh', - }) - expect(runtimeState.accounts['cli-label'].lastRefreshedAt).toBeGreaterThan( - oldRefreshedAt, + }) + expect(rows[0]?.credentialEpoch).toBeGreaterThan( + before?.credentialEpoch ?? 0, + ) + expect(rows[0]?.quota).toBeUndefined() + if (rows[0]?.credential?.type !== 'oauth') + throw new Error('OAuth replacement is missing') + expect(rows[0].credential.lastRefreshedAt).toBeGreaterThan( + oldRefreshedAt, + ) + const config = await readFile(fixture.paths.config, 'utf8') + expect(config).not.toContain('sk-ant-oat01-new-access') + expect(config).not.toContain('new-refresh') + const metadata = JSON.parse( + await readFile(fixture.paths.runtime, 'utf8'), + ).accounts['cli-label'] + expect(metadata.lastRefreshError).toBeUndefined() + expect(metadata.lastQuotaRefreshError).toBeUndefined() + expect(metadata.quota).toBeUndefined() + expect(metadata.authLineageId).toMatch(/^[0-9a-f-]{36}$/) + expect(bootstraps).toEqual(['Bearer sk-ant-oat01-new-access']) + await expect(readFile(accountPath, 'utf8')).rejects.toThrow() + if (splitState) + await expect( + readFile(getAccountStatePath(accountPath), 'utf8'), + ).rejects.toThrow() + }, ) - expect(runtimeState.accounts['cli-label'].quota).toBeUndefined() - expect(runtimeState.accounts['cli-label'].lastRefreshError).toBeUndefined() - expect( - runtimeState.accounts['cli-label'].lastQuotaRefreshError, - ).toBeUndefined() - }) + } }) describe('CLI relay setup', () => { From ae6a02857b1e26b1d4dd14e946aa62d800cfa05c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:38:51 +0200 Subject: [PATCH 143/169] test(pi): use explicit supported native OAuth fixture tokens --- packages/pi/src/tests/commands.test.ts | 8 +- packages/pi/src/tests/native-fixture.test.ts | 86 ++++++++++++++++++++ packages/pi/src/tests/native-fixture.ts | 5 +- packages/pi/src/tests/native-serving.test.ts | 18 ++-- packages/pi/src/tests/stream.test.ts | 55 +++++++------ 5 files changed, 134 insertions(+), 38 deletions(-) create mode 100644 packages/pi/src/tests/native-fixture.test.ts diff --git a/packages/pi/src/tests/commands.test.ts b/packages/pi/src/tests/commands.test.ts index c7ae04f8e..f73ef5347 100644 --- a/packages/pi/src/tests/commands.test.ts +++ b/packages/pi/src/tests/commands.test.ts @@ -41,7 +41,7 @@ beforeEach(async () => { id: 'fallback', type: 'oauth', refresh: 'fixture-fallback-refresh', - access: 'fixture-fallback-access', + access: 'sk-ant-oat01-fixture-fallback-access', expires: Date.now() + 3_600_000, }, ], @@ -193,7 +193,7 @@ describe('native account command persistence', () => { const before = await bytes() const text = await controller().backend.readStatus('account', {}) expect(text).toContain('fallback') - expect(text).not.toContain('fixture-fallback-access') + expect(text).not.toContain('sk-ant-oat01-fixture-fallback-access') expect(text).not.toContain('fixture-fallback-refresh') expect(await bytes()).toEqual(before) }) @@ -325,7 +325,9 @@ describe('single /claude menu and Pi Prime status', () => { const { commands, ctx, notified } = registration() await commands.get('claude')?.handler('', ctx) expect(notified.join('\n')).toContain('Prime') - expect(notified.join('\n')).not.toContain('fixture-fallback-access') + expect(notified.join('\n')).not.toContain( + 'sk-ant-oat01-fixture-fallback-access', + ) expect(await bytes()).toEqual(before) }) }) diff --git a/packages/pi/src/tests/native-fixture.test.ts b/packages/pi/src/tests/native-fixture.test.ts new file mode 100644 index 000000000..320b64652 --- /dev/null +++ b/packages/pi/src/tests/native-fixture.test.ts @@ -0,0 +1,86 @@ +import { expect } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createNativePoolStore, + resolveNativePoolPaths, +} from '@cortexkit/anthropic-auth-core' +import { nativeQuotaCodec } from '../../../core/src/native-quota-codec.ts' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { saveNativePiFixture } from './native-fixture.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'pi-native-fixture-credential-')) + const names = [ + 'PI_CODING_AGENT_DIR', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + ] as const + const previous = names.map((name) => [name, process.env[name]] as const) + deferCleanup(() => { + for (const [name, value] of previous) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + }) + deferCleanup(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'anthropic-auth.json') + const paths = await resolveNativePoolPaths( + path, + join(root, 'anthropic-auth-state.json'), + ) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + const rows = async () => { + const read = await store.read() + if (read.status !== 'ready') throw new Error('Fixture pool is unreadable') + return read.rows + } + return { path, rows } +} + +test('positive Pi fixture stores its explicit supported default token unchanged', async () => { + const f = await fixture() + await saveNativePiFixture({ version: 1, accounts: [] }, f.path) + const rows = await f.rows() + expect(rows).toHaveLength(1) + expect(rows[0]?.credential).toMatchObject({ + type: 'oauth', + access: 'sk-ant-oat01-main-access', + }) +}) + +test('Pi fixture refuses an explicitly unsupported main token instead of normalizing it', async () => { + const f = await fixture() + await expect( + saveNativePiFixture({ version: 1, accounts: [] }, f.path, { + mainAccess: 'unsupported-fixture-main', + }), + ).rejects.toThrow('Claude account identity could not be verified') + expect(await f.rows()).toEqual([]) +}) + +test('Pi fixture refuses an explicitly unsupported fallback token instead of normalizing it', async () => { + const f = await fixture() + await expect( + saveNativePiFixture( + { + version: 1, + accounts: [ + { + id: 'unsupported-fallback', + type: 'oauth', + enabled: true, + addedAt: 1, + access: 'unsupported-fixture-fallback', + refresh: 'synthetic-refresh', + expires: Date.now() + 3_600_000, + }, + ], + }, + f.path, + { primary: false }, + ), + ).rejects.toThrow('Claude account identity could not be verified') + expect(await f.rows()).toEqual([]) +}) diff --git a/packages/pi/src/tests/native-fixture.ts b/packages/pi/src/tests/native-fixture.ts index 1acc3f7ce..a53dc0609 100644 --- a/packages/pi/src/tests/native-fixture.ts +++ b/packages/pi/src/tests/native-fixture.ts @@ -140,7 +140,7 @@ export async function saveNativePiFixture( })) if (relay) await runtime.updateRelay(relay) if (options.primary !== false) { - const access = options.mainAccess ?? 'main-access' + const access = options.mainAccess ?? 'sk-ant-oat01-main-access' identityByToken.set(access, fixtureAccountIdentity('main')) await runtime.loginOAuth({ routeId: 'main', @@ -199,7 +199,8 @@ export async function saveNativePiFixture( ), }) } else { - const access = account.access ?? `synthetic-${account.id}-access` + const access = + account.access ?? `sk-ant-oat01-synthetic-${account.id}-access` identityByToken.set(access, fixtureAccountIdentity(account.id)) await runtime.loginOAuth({ routeId: account.id, diff --git a/packages/pi/src/tests/native-serving.test.ts b/packages/pi/src/tests/native-serving.test.ts index fb975fad1..2e36d2b4c 100644 --- a/packages/pi/src/tests/native-serving.test.ts +++ b/packages/pi/src/tests/native-serving.test.ts @@ -58,7 +58,7 @@ beforeEach(async () => { ], }, storagePath, - { mainAccess: 'synthetic-native-main-access' }, + { mainAccess: 'sk-ant-oat01-synthetic-native-main-access' }, ) }) afterEach(async () => { @@ -107,7 +107,7 @@ test('native Pi serving ignores host and legacy OAuth material after committed m }).result() ).stopReason, ).toBe('stop') - expect(sent).toEqual(['Bearer synthetic-native-main-access']) + expect(sent).toEqual(['Bearer sk-ant-oat01-synthetic-native-main-access']) expect(await readFile(storagePath, 'utf8')).toContain( 'synthetic-legacy-access', ) @@ -249,10 +249,10 @@ test.each([ expect(modelRequests).toEqual( scenario.paid ? [ - 'Bearer synthetic-native-main-access', + 'Bearer sk-ant-oat01-synthetic-native-main-access', 'Bearer synthetic-native-paid-key', ] - : ['Bearer synthetic-native-main-access'], + : ['Bearer sk-ant-oat01-synthetic-native-main-access'], ) })(), ), @@ -277,7 +277,7 @@ test('offline controller imports synthetic Pi host OAuth with consent before nat JSON.stringify({ anthropic: { type: 'oauth', - access: 'synthetic-imported-host-access', + access: 'sk-ant-oat01-synthetic-imported-host-access', refresh: 'synthetic-imported-host-refresh', expires: Date.now() + 3_600_000, }, @@ -309,7 +309,7 @@ test('offline controller imports synthetic Pi host OAuth with consent before nat host: 'pi', local: { refreshToken: async () => ({ - access: 'synthetic-imported-native-access', + access: 'sk-ant-oat01-synthetic-imported-native-access', refresh: 'synthetic-imported-native-refresh', expires: Date.now() + 3_600_000, expiresIn: 3600, @@ -340,10 +340,12 @@ test('offline controller imports synthetic Pi host OAuth with consent before nat expect( ( await streamCortexKitAnthropic(model, context, { - apiKey: 'synthetic-imported-host-access', + apiKey: 'sk-ant-oat01-synthetic-imported-host-access', }).result() ).stopReason, ).toBe('stop') - expect(sent).toEqual(['Bearer synthetic-imported-native-access']) + expect(sent).toEqual([ + 'Bearer sk-ant-oat01-synthetic-imported-native-access', + ]) })(), )) diff --git a/packages/pi/src/tests/stream.test.ts b/packages/pi/src/tests/stream.test.ts index 523e549b5..cea36ffab 100644 --- a/packages/pi/src/tests/stream.test.ts +++ b/packages/pi/src/tests/stream.test.ts @@ -314,14 +314,14 @@ describe('Pi API fallback routing helpers', () => { ) as unknown as typeof fetch const stream = streamCortexKitAnthropic(anthropicModel, anthropicContext, { - apiKey: 'main-access', + apiKey: 'sk-ant-oat01-main-access', sessionId: 'ses_pi_empty_quota', }) for await (const _event of stream) { // Drain the provider stream. } - expect(authorizations).toEqual(['Bearer main-access']) + expect(authorizations).toEqual(['Bearer sk-ant-oat01-main-access']) }) test('fallback-first admits an OAuth fallback whose quota is unknown', async () => { @@ -346,7 +346,7 @@ describe('Pi API fallback routing helpers', () => { { id: 'unknown-fallback', type: 'oauth', - access: 'unknown-fallback-access', + access: 'sk-ant-oat01-unknown-fallback-access', refresh: 'unknown-fallback-refresh', expires, }, @@ -377,14 +377,16 @@ describe('Pi API fallback routing helpers', () => { ) as unknown as typeof fetch const stream = streamCortexKitAnthropic(anthropicModel, anthropicContext, { - apiKey: 'main-access', + apiKey: 'sk-ant-oat01-main-access', sessionId: 'ses_pi_fallback_unknown_quota', }) for await (const _event of stream) { // Drain the provider stream. } - expect(authorizations).toEqual(['Bearer unknown-fallback-access']) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-unknown-fallback-access', + ]) }) test('main-first blocks an unknown main quota when the killswitch is fail-closed', async () => { @@ -435,7 +437,7 @@ describe('Pi API fallback routing helpers', () => { const events = [] const stream = streamCortexKitAnthropic(anthropicModel, anthropicContext, { - apiKey: 'main-access', + apiKey: 'sk-ant-oat01-main-access', sessionId: 'ses_pi_killswitch_unknown_quota', }) for await (const event of stream) events.push(event) @@ -561,7 +563,7 @@ describe('Pi API fallback routing helpers', () => { { id: 'yiyi', type: 'oauth', - access: 'scarce-access', + access: 'sk-ant-oat01-scarce-access', refresh: 'scarce-refresh', expires: checkedAt + 5 * 60 * 60_000, quota: quota(13), @@ -569,7 +571,7 @@ describe('Pi API fallback routing helpers', () => { { id: 'ufuk2', type: 'oauth', - access: 'abundant-access', + access: 'sk-ant-oat01-abundant-access', refresh: 'abundant-refresh', expires: checkedAt + 5 * 60 * 60_000, quota: quota(98), @@ -612,7 +614,7 @@ describe('Pi API fallback routing helpers', () => { const authorization = new Headers(init?.headers).get('authorization') ?? '' authorizations.push(authorization) - if (authorization === 'Bearer main-access') { + if (authorization === 'Bearer sk-ant-oat01-main-access') { return Promise.resolve(new Response('unauthorized', { status: 401 })) } return Promise.resolve( @@ -633,7 +635,7 @@ describe('Pi API fallback routing helpers', () => { anthropicModel, anthropicContext, { - apiKey: 'main-access', + apiKey: 'sk-ant-oat01-main-access', sessionId: 'ses_pi_sticky', }, ) @@ -646,7 +648,7 @@ describe('Pi API fallback routing helpers', () => { { ...anthropicModel, id: 'claude-opus-4-8', name: 'Claude Opus 4.8' }, anthropicContext, { - apiKey: 'main-access', + apiKey: 'sk-ant-oat01-main-access', sessionId: 'ses_pi_sticky', }, ) @@ -658,7 +660,7 @@ describe('Pi API fallback routing helpers', () => { { ...anthropicModel, id: 'claude-opus-4-8', name: 'Claude Opus 4.8' }, anthropicContext, { - apiKey: 'main-access', + apiKey: 'sk-ant-oat01-main-access', sessionId: 'ses_pi_direct_opus', }, ) @@ -671,11 +673,11 @@ describe('Pi API fallback routing helpers', () => { // session must select the usable fallback instead of sending the rejected // main credential again. expect(authorizations).toEqual([ - 'Bearer abundant-access', - 'Bearer abundant-access', - 'Bearer main-access', - 'Bearer abundant-access', - 'Bearer abundant-access', + 'Bearer sk-ant-oat01-abundant-access', + 'Bearer sk-ant-oat01-abundant-access', + 'Bearer sk-ant-oat01-main-access', + 'Bearer sk-ant-oat01-abundant-access', + 'Bearer sk-ant-oat01-abundant-access', ]) }) @@ -743,7 +745,7 @@ describe('Pi API fallback routing helpers', () => { { id: 'fallback-a', type: 'oauth', - access: 'fallback-a-access', + access: 'sk-ant-oat01-fallback-a-access', refresh: 'fallback-a-refresh', expires: checkedAt + 5 * 60 * 60_000, quota: quota(0), @@ -751,7 +753,7 @@ describe('Pi API fallback routing helpers', () => { { id: 'fallback-b', type: 'oauth', - access: 'fallback-b-access', + access: 'sk-ant-oat01-fallback-b-access', refresh: 'fallback-b-refresh', expires: checkedAt + 5 * 60 * 60_000, quota: quota(0), @@ -775,7 +777,7 @@ describe('Pi API fallback routing helpers', () => { const events = [] const stream = streamCortexKitAnthropic(anthropicModel, anthropicContext, { - apiKey: 'main-access', + apiKey: 'sk-ant-oat01-main-access', sessionId: 'ses_pi_sticky_no_fable_route', }) for await (const event of stream) events.push(event) @@ -839,7 +841,7 @@ describe('Pi API fallback routing helpers', () => { { id: 'oauth-fallback', type: 'oauth', - access: 'fallback-access', + access: 'sk-ant-oat01-fallback-access', refresh: 'fallback-refresh', expires: checkedAt + 5 * 60 * 60_000, quota: quota(100), @@ -890,7 +892,7 @@ describe('Pi API fallback routing helpers', () => { const authorization = new Headers(init?.headers).get('authorization') ?? '' authorizations.push(authorization) - if (authorization === 'Bearer fallback-access') { + if (authorization === 'Bearer sk-ant-oat01-fallback-access') { return Promise.resolve(new Response('exhausted', { status: 429 })) } return Promise.resolve( @@ -907,14 +909,17 @@ describe('Pi API fallback routing helpers', () => { ) as unknown as typeof fetch const stream = streamCortexKitAnthropic(anthropicModel, anthropicContext, { - apiKey: 'main-access', + apiKey: 'sk-ant-oat01-main-access', sessionId: 'ses_pi_sticky_api', }) for await (const _event of stream) { // Drain the provider stream. } - expect(authorizations).toEqual(['Bearer fallback-access', 'Bearer api-key']) + expect(authorizations).toEqual([ + 'Bearer sk-ant-oat01-fallback-access', + 'Bearer api-key', + ]) }) test('releases early-abandoned SSE readers without cancelling the stream', async () => { @@ -978,7 +983,7 @@ describe('Pi Anthropic stream content blocks', () => { anthropicModel, anthropicContext, { - apiKey: 'main-access', + apiKey: 'sk-ant-oat01-main-access', sessionId: 'ses_pi_redacted_thinking', }, ) From 4c53bcd079d788e1938cb4381ef14327aa32ebf2 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:42:48 +0200 Subject: [PATCH 144/169] fix(pi): forward physical quota dispatch through native reads --- mutations.toml | 16 +++ packages/pi/src/stream.ts | 9 +- packages/pi/src/tests/quota-dispatch.test.ts | 123 +++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 packages/pi/src/tests/quota-dispatch.test.ts diff --git a/mutations.toml b/mutations.toml index c1784ccb2..8732b0069 100644 --- a/mutations.toml +++ b/mutations.toml @@ -454,3 +454,19 @@ expect_message = "error: expect(received).toBe(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] broad_report = "tmp/mutations/core.xml" broad_id = "{name}" + +[[control]] +id = "pi-forwards-physical-quota-dispatch" +guards = "Pi forwards each native usage GET through the shared quota dispatcher rather than bypassing physical-request spacing." +file = "packages/pi/src/stream.ts" +old = "fetchQuota(\n id,\n fetchPiOAuth,\n undefined,\n dispatch,\n )" +new = "fetchQuota(id, fetchPiOAuth)" +test_file = "packages/pi/src/tests/quota-dispatch.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/pi/src/tests/quota-dispatch.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["Pi paces physical usage GETs for two due fallback accounts before a single model request"] +expect_message = "error: expect(received).toBeGreaterThanOrEqual(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/pi", "tmp/mutations/pi.xml"] +broad_report = "tmp/mutations/pi.xml" +broad_id = "{name}" diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index 9ee0357d3..ba9576ce8 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -130,10 +130,15 @@ function getPiRoutingServices( if (!quotaManager) { quotaManager = new QuotaManager({ storage, - fetchQuotaSnapshot: async (request) => { + fetchQuotaSnapshot: async (request, dispatch) => { const id = request.kind === 'main' ? 'main' : request.accountId if (!id) throw new Error('Native quota request has no account identity') - return (await native.service()).fetchQuota(id, fetchPiOAuth) + return (await native.service()).fetchQuota( + id, + fetchPiOAuth, + undefined, + dispatch, + ) }, }) setBoundedService(quotaManagers, storagePath, { diff --git a/packages/pi/src/tests/quota-dispatch.test.ts b/packages/pi/src/tests/quota-dispatch.test.ts new file mode 100644 index 000000000..5c8265010 --- /dev/null +++ b/packages/pi/src/tests/quota-dispatch.test.ts @@ -0,0 +1,123 @@ +import { expect } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Api, Context, Model } from '@earendil-works/pi-ai' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' +import { closePiNativeRuntime } from '../native.ts' +import { streamCortexKitAnthropic } from '../stream.ts' +import { saveNativePiFixture } from './native-fixture.ts' + +const { test, deferCleanup } = createTestLifetimeSuite() + +test('Pi paces physical usage GETs for two due fallback accounts before a single model request', async () => { + const root = await mkdtemp(join(tmpdir(), 'pi-physical-quota-dispatch-')) + const storagePath = join(root, 'anthropic-auth.json') + const originalFetch = globalThis.fetch + const previous = [ + 'PI_ANTHROPIC_AUTH_FILE', + 'PI_CODING_AGENT_DIR', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + ].map((name) => [name, process.env[name]] as const) + deferCleanup(() => { + closePiNativeRuntime(storagePath) + globalThis.fetch = originalFetch + for (const [name, value] of previous) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + }) + deferCleanup(() => rm(root, { recursive: true, force: true })) + process.env.PI_ANTHROPIC_AUTH_FILE = storagePath + const accesses = ['sk-ant-oat01-paced-first', 'sk-ant-oat01-paced-second'] + await saveNativePiFixture( + { + version: 1, + routing: { mode: 'fallback-first' }, + quota: { + enabled: true, + minimumRemaining: { five_hour: 1, seven_day: 1 }, + }, + accounts: accesses.map((access, index) => ({ + id: `paced-${index}`, + type: 'oauth', + enabled: true, + access, + refresh: `synthetic-paced-refresh-${index}`, + expires: Date.now() + 5 * 3_600_000, + })), + }, + storagePath, + ) + const upstream: Array<{ method: string; authorization: string | null }> = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch(request) { + upstream.push({ + method: request.method, + authorization: request.headers.get('authorization'), + }) + return Response.json({ + five_hour: { utilization: 4 }, + seven_day: { utilization: 13 }, + }) + }, + }) + deferCleanup(() => server.stop(true)) + const starts: number[] = [] + const modelAuthorizations: Array = [] + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.endsWith('/api/oauth/usage')) { + starts.push(performance.now()) + return originalFetch(server.url, init) + } + if (url.includes('/v1/messages')) { + modelAuthorizations.push( + new Headers(init?.headers).get('authorization'), + ) + return new Response( + 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":1,"output_tokens":0}}}\n\nevent: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n', + ) + } + throw new Error('Unexpected provider fixture request') + }, + { preconnect: originalFetch.preconnect }, + ) + const model: Model = { + id: 'claude-fable-5', + name: 'Fable', + provider: 'anthropic', + api: 'cortexkit-anthropic-messages', + baseUrl: 'https://api.anthropic.com', + reasoning: true, + input: ['text'], + cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + } + const context: Context = { + messages: [{ role: 'user', content: 'hello', timestamp: 0 }], + tools: [], + } + const stream = streamCortexKitAnthropic(model, context, { + apiKey: 'sk-ant-oat01-main-access', + sessionId: 'pi-paced-fallback-quota', + }) + const result = await stream.result() + expect(result.stopReason).toBe('stop') + expect(upstream).toEqual( + accesses.map((access) => ({ + method: 'GET', + authorization: `Bearer ${access}`, + })), + ) + expect(starts).toHaveLength(2) + expect(starts[1]! - starts[0]!).toBeGreaterThanOrEqual(1_000) + expect(modelAuthorizations).toEqual([`Bearer ${accesses[0]}`]) +}) From 19a0769983ef77b281b5be881b66290a43dd4d2e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:59:42 +0200 Subject: [PATCH 145/169] feat(auth): integrate custody-aware menus and truthful vault disconnect --- .../scripts/check-native-type-closure.test.ts | 6 +- packages/core/src/native-menu-executor.ts | 152 ++++- packages/core/src/native-menu-model.ts | 173 +++++- packages/core/src/native-ui.ts | 168 +++++- .../src/tests/native-menu-executor.test.ts | 278 ++++++++- .../core/src/tests/native-menu-model.test.ts | 116 +++- .../core/src/tests/native-public-api.test.ts | 4 +- packages/core/src/tests/native-ui.test.ts | 537 +++++++++++++++++- packages/opencode/src/native-command.ts | 32 +- packages/opencode/src/tests/native-ui.test.ts | 116 +++- .../opencode/src/tests/rpc-server.test.ts | 57 ++ packages/pi/src/commands.ts | 55 +- packages/pi/src/tests/commands.test.ts | 480 +++++++++++++++- 13 files changed, 2057 insertions(+), 117 deletions(-) diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index d2c376d60..fbfd8a133 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -294,12 +294,12 @@ test('packed Core types and Node runtime work in a fresh consumer without common import type { NativeLocalCredentialService, NativeLocalCredentialServiceOptions, NativeRefreshRequest, NativeRefreshResult, NativeCustody, NativeCustodyOptions, NativeCustodyIdentity, NativeCustodyReceipt, - NativeMenuExecutor, NativeMenuExecutorOptions, NativeMenuHost, NativeMenuModel, + NativeMenuExecutor, NativeMenuExecutorOptions, NativeMenuCustodyMode, NativeMenuHost, NativeMenuModel, } from '@cortexkit/anthropic-auth-core'; const localFactory: (options: NativeLocalCredentialServiceOptions) => NativeLocalCredentialService = core.createNativeLocalCredentialService; const custodyFactory: (options: NativeCustodyOptions) => NativeCustody = core.createNativeCustody; const menuFactory: (options: NativeMenuExecutorOptions) => NativeMenuExecutor = core.createNativeMenuExecutor; - const menuModel: (host: NativeMenuHost) => NativeMenuModel = core.getNativeMenuModel; + const menuModel: (host: NativeMenuHost, custody: NativeMenuCustodyMode) => NativeMenuModel = core.getNativeMenuModel; type LocalAuthorize = (request: NativeRefreshRequest) => Promise; type CustodyAuthorize = (identity: NativeCustodyIdentity, signal?: AbortSignal) => Promise; type NativeAuthorize = NativeLocalCredentialService['authorize'] extends LocalAuthorize ? true : false; @@ -396,7 +396,7 @@ test('packed Core types and Node runtime work in a fresh consumer without common assert.equal(loaded.rows[0].credential.access, 'synthetic-access'); for (const factory of ['createNativeLocalCredentialService', 'createNativeCustody', 'getNativeMenuModel', 'createNativeMenuExecutor', 'captureNativeLocalPoolBinding']) assert.equal(typeof core[factory], 'function', factory); for (const host of ['opencode', 'pi']) { - const model = core.getNativeMenuModel(host); + const model = core.getNativeMenuModel(host, 'local'); assert.equal(model.host, host); assert.ok(model.groups.length > 0); } diff --git a/packages/core/src/native-menu-executor.ts b/packages/core/src/native-menu-executor.ts index c47b4e129..c67c488c5 100644 --- a/packages/core/src/native-menu-executor.ts +++ b/packages/core/src/native-menu-executor.ts @@ -1,6 +1,8 @@ import { types as utilTypes } from 'node:util' import { - getNativeMenuModel, + isNativeMenuLocalCustodyAction, + isNativeMenuVaultCustodyAction, + listNativeMenuActions, type NativeMenuAction, type NativeMenuActionId, type NativeMenuCacheMode, @@ -72,6 +74,7 @@ export type NativeMenuActionValues = { readonly label?: string | null } readonly 'custody-mode': { readonly mode: NativeMenuCustodyMode } + readonly 'vault-disconnect': NativeMenuNoValues readonly 'quota-refresh': NativeMenuNoValues readonly 'routing-mode': { readonly mode: NativeMenuRoutingMode } readonly 'routing-reset': NativeMenuNoValues @@ -118,10 +121,13 @@ export type NativeMenuSessionActionId = | 'add-oauth-start' | 'add-oauth-finish' -/** Custody changes are offline setup steps, so they are never dispatched. */ +/** + * Custody changes are offline setup steps, and vault disconnect goes through + * the host's own disconnect capability, so neither is dispatched. + */ export type NativeMenuDispatchActionId = Exclude< NativeMenuActionId, - 'custody-mode' + 'custody-mode' | 'vault-disconnect' > /** Checked values: empty optional inputs are left out instead of `null`. */ @@ -166,12 +172,44 @@ export type NativeMenuDispatch = ( export type NativeMenuExecutorOptions = { readonly host: NativeMenuHost readonly dispatch: NativeMenuDispatch + /** + * Ends this host's vault enrollment. Without it the executor does not offer + * vault-disconnect and refuses it as unsupported. + */ + readonly disconnectVault?: NativeVaultDisconnect } +/** + * What a host's vault disconnect reports. `disconnected`: the host is back in + * local custody. `idle`: no active enrollment remains; Pi's saved sign-in may + * still have been restored. `busy`: another process holds the enrollment lock + * and disconnect could not finish. `conflict`: + * the host's stored sign-in could not be restored without overwriting one it + * already has, so both were kept and the enrollment remains. + */ +export type NativeVaultDisconnectResult = + | 'disconnected' + | 'idle' + | 'busy' + | 'conflict' + +/** The host capability that disconnects this host from the vault. */ +export type NativeVaultDisconnect = ( + options: NativeMenuDispatchOptions, +) => Promise + /** Facts about the invocation that the safety rules depend on. */ export type NativeMenuExecutionContext = { /** False when no person can answer prompts (a headless or scripted run). */ readonly interactive: boolean + /** + * The host's credential custody, read just before this request. Under + * claustrum custody the vault owns every account, so actions listed in + * NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS are refused before dispatch. Under + * local custody there is no enrollment to end, so vault disconnect is + * refused. There is no default: a context without a valid mode is refused. + */ + readonly custodyMode: NativeMenuCustodyMode /** True once the person confirmed an action that requires confirmation. */ readonly confirmed?: boolean /** The conversation the request came from, when the host has one. */ @@ -190,6 +228,10 @@ export type NativeMenuExecutorRefusalCode = | 'confirmation-required' | 'session-required' | 'aborted' + /** The host is connected to the vault, which owns its accounts. */ + | 'vault-managed' + /** The host is not connected to the vault, so there is nothing to end. */ + | 'vault-not-connected' export type NativeMenuExecutionResult = | { @@ -205,9 +247,15 @@ export type NativeMenuExecutionResult = * unreadable. Whether its effect happened is unknown. */ readonly status: 'failed' - readonly action: NativeMenuDispatchActionId + readonly action: NativeMenuDispatchActionId | 'vault-disconnect' readonly code: 'execution-failed' | 'invalid-outcome' } + | { + /** The host's disconnect capability ran and reported this result. */ + readonly status: 'disconnect' + readonly action: 'vault-disconnect' + readonly result: NativeVaultDisconnectResult + } | { /** Offline custody setup steps; nothing was changed. */ readonly status: 'guidance' @@ -474,6 +522,7 @@ function isAborted(signal: AbortSignal | undefined): boolean { type CheckedContext = { readonly interactive: boolean + readonly custodyMode: NativeMenuCustodyMode readonly confirmed: boolean readonly sessionId?: string readonly signal?: AbortSignal @@ -481,6 +530,7 @@ type CheckedContext = { const CONTEXT_KEYS = [ 'interactive', + 'custodyMode', 'confirmed', 'sessionId', 'signal', @@ -490,10 +540,12 @@ function readContext(value: unknown): CheckedContext | undefined { const fields = readPlain(value, CONTEXT_KEYS) if (!(fields instanceof Map)) return undefined const interactive = fields.get('interactive') + const custodyMode = fields.get('custodyMode') const confirmed = fields.get('confirmed') const sessionId = fields.get('sessionId') const rawSignal = fields.get('signal') if (typeof interactive !== 'boolean') return undefined + if (custodyMode !== 'local' && custodyMode !== 'claustrum') return undefined if (confirmed !== undefined && typeof confirmed !== 'boolean') return undefined if (sessionId !== undefined && typeof sessionId !== 'string') return undefined @@ -505,6 +557,7 @@ function readContext(value: unknown): CheckedContext | undefined { } return { interactive, + custodyMode, confirmed: confirmed === true, ...(sessionId !== undefined && hasContent(sessionId) ? { sessionId } : {}), ...(signal !== undefined ? { signal } : {}), @@ -519,6 +572,10 @@ type Plan = readonly request: NativeMenuDispatchRequest readonly options: NativeMenuDispatchOptions } + | { + readonly kind: 'disconnect' + readonly options: NativeMenuDispatchOptions + } function done(result: NativeMenuExecutionResult): Plan { return { kind: 'done', result } @@ -539,6 +596,21 @@ function plan( const context = readContext(rawContext) if (!context) return done(refused('invalid-context', action.id)) + // A menu opened before the host connected to the vault can still submit an + // action offered only under local custody. Refuse it before any input is + // read or dispatched. + if ( + context.custodyMode === 'claustrum' && + isNativeMenuLocalCustodyAction(action.id) + ) + return done(refused('vault-managed', action.id)) + // Likewise a menu opened while enrolled can submit a disconnect after the + // host has already left the vault. + if ( + context.custodyMode === 'local' && + isNativeMenuVaultCustodyAction(action.id) + ) + return done(refused('vault-not-connected', action.id)) if (action.interactive && !context.interactive) return done(refused(action.headlessRefusal, action.id)) @@ -585,6 +657,11 @@ function plan( ) } + const options = Object.freeze( + context.signal !== undefined ? { signal: context.signal } : {}, + ) + if (action.kind === 'vault-disconnect') return { kind: 'disconnect', options } + const dispatchRequest = Object.freeze({ action: action.id, values: Object.freeze(checked), @@ -594,12 +671,18 @@ function plan( kind: 'dispatch', action: action.id, request: dispatchRequest, - options: Object.freeze( - context.signal !== undefined ? { signal: context.signal } : {}, - ), + options, } } +const DISCONNECT_RESULTS: ReadonlySet = + new Set([ + 'disconnected', + 'idle', + 'busy', + 'conflict', + ]) + function readOutcome( action: NativeMenuDispatchActionId, outcome: unknown, @@ -618,24 +701,68 @@ function invalidOption(key: string): TypeError { /** * Creates an executor for one host. The host's available actions and their - * safety rules come from getNativeMenuModel; `dispatch` is captured once and - * called only with fully checked requests. + * safety rules come from the native menu model; `dispatch` and + * `disconnectVault` are captured once and called only with fully checked + * requests. */ export function createNativeMenuExecutor( options: NativeMenuExecutorOptions, ): NativeMenuExecutor { - const fields = readPlain(options, ['host', 'dispatch']) + const fields = readPlain(options, ['host', 'dispatch', 'disconnectVault']) if (!(fields instanceof Map)) throw new TypeError('Native menu executor options are invalid') const host = fields.get('host') const dispatch = fields.get('dispatch') + const disconnectVault = fields.get('disconnectVault') if (!HOSTS.has(host)) throw invalidOption('host') if (typeof dispatch !== 'function' || utilTypes.isProxy(dispatch)) throw invalidOption('dispatch') + if ( + disconnectVault !== undefined && + (typeof disconnectVault !== 'function' || + utilTypes.isProxy(disconnectVault)) + ) + throw invalidOption('disconnectVault') const actions = new Map() - for (const group of getNativeMenuModel(host as NativeMenuHost).groups) - for (const action of group.actions) actions.set(action.id, action) + // Index every action the host supports in either custody mode; plan() + // refuses the ones the request's custody mode does not offer before any + // capability runs. Without a disconnect capability, vault-disconnect is not + // offered at all. + for (const action of listNativeMenuActions(host as NativeMenuHost)) + if (action.kind !== 'vault-disconnect' || disconnectVault !== undefined) + actions.set(action.id, action) + + const disconnect = async ( + options: NativeMenuDispatchOptions, + ): Promise => { + let result: unknown + try { + result = await Reflect.apply( + disconnectVault as NativeVaultDisconnect, + undefined, + [options], + ) + } catch { + // The thrown value is never read: it may carry secrets or run code. + return Object.freeze({ + status: 'failed', + action: 'vault-disconnect', + code: 'execution-failed', + }) + } + if (!DISCONNECT_RESULTS.has(result)) + return Object.freeze({ + status: 'failed', + action: 'vault-disconnect', + code: 'invalid-outcome', + }) + return Object.freeze({ + status: 'disconnect', + action: 'vault-disconnect', + result: result as NativeVaultDisconnectResult, + }) + } const execute = async ( request: NativeMenuRequest, @@ -648,6 +775,7 @@ export function createNativeMenuExecutor( return refused('invalid-request') } if (next.kind === 'done') return next.result + if (next.kind === 'disconnect') return disconnect(next.options) let outcome: unknown try { outcome = await Reflect.apply(dispatch, undefined, [ diff --git a/packages/core/src/native-menu-model.ts b/packages/core/src/native-menu-model.ts index 4f2a0709c..0778a7bf2 100644 --- a/packages/core/src/native-menu-model.ts +++ b/packages/core/src/native-menu-model.ts @@ -102,6 +102,7 @@ export type NativeMenuActionId = | 'add-oauth-start' | 'add-oauth-finish' | 'custody-mode' + | 'vault-disconnect' | 'quota-refresh' | 'routing-mode' | 'routing-reset' @@ -124,12 +125,13 @@ export type NativeMenuActionId = | 'prime-off' | 'start-fire' -type DestructiveId = 'remove' | 'enrollment-reset' +type DestructiveId = 'remove' | 'enrollment-reset' | 'vault-disconnect' type InteractiveId = | 'add-apikey' | 'add-oauth-start' | 'add-oauth-finish' | 'custody-mode' + | 'vault-disconnect' type ActionSafety = (Id extends DestructiveId ? { readonly destructive: true; readonly confirm: string } @@ -157,12 +159,23 @@ export type NativeMenuAction = { readonly offlineRequired: true readonly setupInstructions: readonly string[] } - : { - readonly kind: 'action' - readonly command: NativeMenuCommandId - readonly group: NativeMenuGroupId - readonly parameters: readonly NativeMenuParameter[] - }) + : Id extends 'vault-disconnect' + ? { + /** + * Ends this host's vault enrollment through the host's own + * disconnect capability, never through the account dispatch. + */ + readonly kind: 'vault-disconnect' + readonly command: 'account' + readonly group: 'Accounts' + readonly parameters: readonly [] + } + : { + readonly kind: 'action' + readonly command: NativeMenuCommandId + readonly group: NativeMenuGroupId + readonly parameters: readonly NativeMenuParameter[] + }) }[NativeMenuActionId] export type NativeMenuStatus = { @@ -342,6 +355,19 @@ const actions: readonly NativeMenuAction[] = Object.freeze( 'This menu only provides guidance; it does not change settings, propose enrollment, or report setup success.', ], }, + { + id: 'vault-disconnect', + kind: 'vault-disconnect', + command: 'account', + group: 'Accounts', + label: 'Disconnect from the vault', + destructive: true, + confirm: + "Disconnect this host from the vault and return to local accounts? The vault keeps this host's enrollment approved until an operator revokes it there.", + interactive: true, + headlessRefusal: 'interactive-required', + parameters: [], + }, { ...ordinary, id: 'quota-refresh', @@ -629,7 +655,85 @@ const statuses: readonly NativeMenuStatus[] = Object.freeze( ).map((status) => Object.freeze(status)), ) -function buildModel(host: NativeMenuHost): NativeMenuModel { +/** + * Actions offered only under local custody. Most write accounts or credentials + * held by this host: sign-in, API-key additions, removal and reordering. + * Enrollment reset repairs a terminal enrollment ceremony, and custody setup + * guidance describes connecting through offline setup; neither has a use + * while the host is enrolled. Under claustrum custody the vault is the only + * account authority, so these actions are left out of the menu and refused if + * a request for one still arrives. They return once the host is disconnected + * from the vault and back in local custody. + */ +export const NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS = Object.freeze([ + 'remove', + 'move-up', + 'move-down', + 'enrollment-reset', + 'add-apikey', + 'add-oauth-start', + 'add-oauth-finish', + 'custody-mode', +] as const satisfies readonly NativeMenuActionId[]) + +export type NativeMenuLocalCustodyActionId = + (typeof NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS)[number] + +const localCustodyActionIds: ReadonlySet = new Set( + NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS, +) + +/** True for an action offered only while the host is in local custody. */ +export function isNativeMenuLocalCustodyAction( + id: NativeMenuActionId, +): id is NativeMenuLocalCustodyActionId { + return localCustodyActionIds.has(id) +} + +/** + * When the host uses its local credential pool, hide vault-disconnect because + * no vault enrollment needs to end. Offer disconnect when enrollment grants + * this host access to the vault, or when that enrollment cannot be read so a + * person can recover from damaged enrollment files. + */ +export const NATIVE_MENU_VAULT_CUSTODY_ACTION_IDS = Object.freeze([ + 'vault-disconnect', +] as const satisfies readonly NativeMenuActionId[]) + +export type NativeMenuVaultCustodyActionId = + (typeof NATIVE_MENU_VAULT_CUSTODY_ACTION_IDS)[number] + +const vaultCustodyActionIds: ReadonlySet = new Set( + NATIVE_MENU_VAULT_CUSTODY_ACTION_IDS, +) + +/** True for actions shown when this host obtains credentials from the vault instead of its local pool. */ +export function isNativeMenuVaultCustodyAction( + id: NativeMenuActionId, +): id is NativeMenuVaultCustodyActionId { + return vaultCustodyActionIds.has(id) +} + +function offeredUnder( + custody: NativeMenuCustodyMode, + id: NativeMenuActionId, +): boolean { + return custody === 'local' + ? !isNativeMenuVaultCustodyAction(id) + : !isNativeMenuLocalCustodyAction(id) +} + +function hostOffers(host: NativeMenuHost, action: NativeMenuAction): boolean { + return ( + host === 'opencode' || + !['quota', 'killswitch', 'prime', 'start'].includes(action.command) + ) +} + +function buildModel( + host: NativeMenuHost, + custody: NativeMenuCustodyMode, +): NativeMenuModel { return Object.freeze({ host, groups: Object.freeze( @@ -640,10 +744,8 @@ function buildModel(host: NativeMenuHost): NativeMenuModel { actions.filter( (action) => action.group === id && - (host === 'opencode' || - !['quota', 'killswitch', 'prime', 'start'].includes( - action.command, - )), + hostOffers(host, action) && + offeredUnder(custody, action.id), ), ), statuses: Object.freeze( @@ -660,11 +762,48 @@ function buildModel(host: NativeMenuHost): NativeMenuModel { } const models = Object.freeze({ - opencode: buildModel('opencode'), - pi: buildModel('pi'), + opencode: Object.freeze({ + local: buildModel('opencode', 'local'), + claustrum: buildModel('opencode', 'claustrum'), + }), + pi: Object.freeze({ + local: buildModel('pi', 'local'), + claustrum: buildModel('pi', 'claustrum'), + }), }) -/** Stable frozen metadata, with no current settings or credential material. */ -export function getNativeMenuModel(host: NativeMenuHost): NativeMenuModel { - return models[host] +const actionLists = Object.freeze({ + opencode: Object.freeze( + actions.filter((action) => hostOffers('opencode', action)), + ), + pi: Object.freeze(actions.filter((action) => hostOffers('pi', action))), +}) + +/** + * Every action the host supports under either custody mode, in menu order. + * Request checking uses this list so it can recognise an action the current + * custody mode no longer offers and refuse it by name. + */ +export function listNativeMenuActions( + host: NativeMenuHost, +): readonly NativeMenuAction[] { + return actionLists[host] +} + +/** + * Stable frozen metadata, with no current settings or credential material. + * Returns the actions the host offers under the given custody mode: local + * custody leaves out NATIVE_MENU_VAULT_CUSTODY_ACTION_IDS, and claustrum + * custody leaves out NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS. Local custody means + * this host keeps its own accounts; claustrum custody means the host is + * enrolled in the vault, which owns its accounts. The mode is required so no + * caller can fall back to the full action set by omission and offer local + * credential changes (sign-in, API keys, removal, reordering) to a host + * enrolled in the vault. + */ +export function getNativeMenuModel( + host: NativeMenuHost, + custody: NativeMenuCustodyMode, +): NativeMenuModel { + return models[host][custody] } diff --git a/packages/core/src/native-ui.ts b/packages/core/src/native-ui.ts index 0d7a1ea83..d4d226541 100644 --- a/packages/core/src/native-ui.ts +++ b/packages/core/src/native-ui.ts @@ -44,11 +44,16 @@ import { type NativeMenuDispatch, type NativeMenuExecutionResult, type NativeMenuRequest, + type NativeVaultDisconnect, + type NativeVaultDisconnectResult, } from './native-menu-executor.ts' import { getNativeMenuModel, + isNativeMenuLocalCustodyAction, + listNativeMenuActions, type NativeMenuAction, type NativeMenuCommandId, + type NativeMenuCustodyMode, type NativeMenuGroupId, type NativeMenuHost, type NativeMenuParameter, @@ -63,10 +68,37 @@ export interface NativeUiOptions { command: Exclude, invocation: CommandInvocation, ) => Promise + /** + * Reads the host's current credential custody from its enrollment record, + * never from status text. It is called each time the menu opens or applies + * an action, so connecting or disconnecting the vault takes effect without + * rebuilding the menu. Under 'claustrum' the menu leaves out and refuses + * actions offered only under local custody: host-local account and + * credential writes, and enrollment reset. A rejection or any other value + * is treated as unknown custody, which also withholds them. + */ + readonly readMode: () => Promise + /** + * Disconnects this host from the vault; while enrolled, this is the host's + * only enrollment action. Before ending the enrollment, the callback + * restores any host sign-in set aside when the host joined the vault. If + * that restore would overwrite a sign-in the host already has, the callback + * reports `conflict` and leaves the enrollment in place. Every host must + * supply it: a host enrolled in the vault, or with an unreadable + * enrollment, always needs a way back to local accounts. + */ + readonly disconnectVault: NativeVaultDisconnect /** The host sets this flag; RPC callers cannot declare themselves interactive. */ readonly interactive: boolean } +/** Shown in Accounts while the vault owns this host's accounts. */ +const VAULT_ACCOUNTS_GUIDANCE = + 'Accounts are managed in the vault. Disconnect from the vault to use local accounts.' +/** Shown in Accounts when the host's custody mode cannot be read. */ +const UNKNOWN_CUSTODY_GUIDANCE = + 'Account custody could not be read, so local account changes are unavailable.' + const slots: Record = { Accounts: 'accounts', Quota: 'quota', @@ -231,6 +263,30 @@ function showSignInLink(text: string, scrub: (text: string) => string) { return text.split(link).map(scrub).join(link) } +/** What a person sees for each result of a vault disconnect. */ +const DISCONNECT_OUTCOMES: Readonly< + Record> +> = { + disconnected: { + ok: true, + text: 'Disconnected from the vault. This host uses local accounts again.', + }, + idle: { + ok: true, + text: 'This host has no active vault enrollment.', + }, + busy: { + ok: false, + code: 'busy', + text: "Vault disconnect could not finish because another process is changing this host's enrollment. Try again.", + }, + conflict: { + ok: false, + code: 'conflict', + text: 'Disconnect stopped: this host already has a different stored sign-in, so it and the sign-in saved when the host joined the vault were both kept, and the vault connection remains. Resolve the two sign-ins with offline setup.', + }, +} + function outcome(result: NativeMenuExecutionResult): { ok: boolean text: string @@ -249,9 +305,23 @@ function outcome(result: NativeMenuExecutionResult): { ok: true, text: `Offline ${result.mode} setup guidance (nothing changed):\n${result.instructions.join('\n')}`, } + case 'disconnect': + return DISCONNECT_OUTCOMES[result.result] case 'failed': return { ok: false, code: result.code, text: 'Action failed.' } case 'refused': + if (result.code === 'vault-managed') + return { + ok: false, + code: result.code, + text: `Action refused: ${VAULT_ACCOUNTS_GUIDANCE}`, + } + if (result.code === 'vault-not-connected') + return { + ok: false, + code: result.code, + text: 'Action refused: this host is not connected to the vault.', + } return { ok: false, code: result.code, @@ -272,17 +342,28 @@ function outcome(result: NativeMenuExecutionResult): { * hidden entry until those controls support it. */ export function createNativeUi(options: NativeUiOptions): CommandMenu { - const { host, dispatch, readStatus, interactive } = options - const native = getNativeMenuModel(host) - const executor = createNativeMenuExecutor({ host, dispatch }) + const { host, dispatch, readStatus, readMode, disconnectVault, interactive } = + options + if (typeof readMode !== 'function') + throw new TypeError('Native UI requires a custody mode reader') + if (typeof disconnectVault !== 'function') + throw new TypeError('Native UI requires a vault disconnect callback') + // Look up submitted actions in every action the host supports under either + // custody mode, not just the ones currently shown. A hidden or stale action + // is then still recognised, and its secret input keys (an API key or OAuth + // code) are known, so the refusal and any status notification redact them. + const catalog = listNativeMenuActions(host) + const executor = createNativeMenuExecutor({ + host, + dispatch, + disconnectVault, + }) const protectedIds = new Set( - native.groups.flatMap((group) => - group.actions.flatMap((action) => - action.parameters.flatMap((parameter) => - parameter.kind === 'text' && parameter.protectedEntry - ? [parameter.id] - : [], - ), + catalog.flatMap((action) => + action.parameters.flatMap((parameter) => + parameter.kind === 'text' && parameter.protectedEntry + ? [parameter.id] + : [], ), ), ) @@ -301,15 +382,43 @@ export function createNativeUi(options: NativeUiOptions): CommandMenu { } } + /** + * Reads custody for one open or apply. Only the two known modes count; a + * rejection or any other value is unknown, never local. The rejection value + * is not inspected because it could quote credentials. + */ + async function readCustody(): Promise { + try { + const mode: unknown = await readMode() + return mode === 'local' || mode === 'claustrum' ? mode : undefined + } catch { + return undefined + } + } + async function model( invocation: CommandInvocation, scrub: (text: string) => string = redact, readable = true, ): Promise { const safeInvocation = capture(invocation, scrub) + const custody = await readCustody() + // When the custody mode cannot be read, the menu shows the vault + // (claustrum) action set. Sign-in, API-key, removal and reordering + // actions stay hidden because they would write this host's own account + // files. Account enable, disable, reset-backoff, plugin settings and + // vault disconnect remain, so a host whose enrollment is corrupt can + // still disconnect and return to local accounts. + const offered = getNativeMenuModel(host, custody ?? 'claustrum') const sections = [] - for (const group of native.groups) { + for (const group of offered.groups) { const lines: string[] = [] + if (group.id === 'Accounts' && custody !== 'local') + lines.push( + custody === 'claustrum' + ? VAULT_ACCOUNTS_GUIDANCE + : UNKNOWN_CUSTODY_GUIDANCE, + ) for (const status of group.statuses) { if (!readable) { lines.push(`${status.label}: status unavailable.`) @@ -333,7 +442,15 @@ export function createNativeUi(options: NativeUiOptions): CommandMenu { label: action.label, knobs: action.parameters.map(knob), ...(action.destructive - ? { confirm: { message: action.confirm, irreversible: true } } + ? { + confirm: { + message: action.confirm, + // Disconnecting is reversed by connecting the host to the + // vault again with offline setup. Removal and enrollment + // reset cannot be reversed that way. + irreversible: action.kind !== 'vault-disconnect', + }, + } : {}), ...(action.kind === 'setup-guidance' ? { description: 'Offline guidance only; no settings are changed.' } @@ -363,11 +480,9 @@ export function createNativeUi(options: NativeUiOptions): CommandMenu { // Refuse without inspecting the thrown value. request = undefined } - const group = native.groups.find( - (entry) => entry.id === request?.sectionId, - ) - const action: NativeMenuAction | undefined = group?.actions.find( - (entry) => entry.id === request?.actionId, + const action: NativeMenuAction | undefined = catalog.find( + (entry) => + entry.id === request?.actionId && entry.group === request?.sectionId, ) const values: Record = { ...request?.values } // Status callbacks can reflect submitted input even after a request was @@ -436,6 +551,24 @@ export function createNativeUi(options: NativeUiOptions): CommandMenu { } } } + // The custody mode is read again for every apply. The menu may have + // been opened under the other mode, and an action the current mode + // does not offer must be refused before it reaches the host. + const custody = await readCustody() + // Unknown custody refuses local-custody actions here. Vault disconnect + // stays available because a corrupted enrollment must keep a recovery + // action. The host's disconnect callback does the safety checks: it + // deletes only this host's enrollment token and ceremony record, and + // refuses if either file has the wrong owner or is readable by others. + // For Pi it first restores the Anthropic sign-in that enrollment moved + // out of Pi's auth file, and stops without disconnecting if that would + // overwrite a sign-in Pi already has. + if (custody === undefined && isNativeMenuLocalCustodyAction(action.id)) + return finish({ + ok: false, + code: 'custody-unavailable', + text: `Action refused: ${UNKNOWN_CUSTODY_GUIDANCE}`, + }) // The scalar transport cannot express the correlated native request type. // This is the untrusted boundary: the executor checks every field before // the injected capability receives a typed NativeMenuDispatchRequest. @@ -443,6 +576,7 @@ export function createNativeUi(options: NativeUiOptions): CommandMenu { { action: action.id, values } as NativeMenuRequest, { interactive, + custodyMode: custody ?? 'claustrum', confirmed: request.confirmed, sessionId: invocation.sessionId, }, diff --git a/packages/core/src/tests/native-menu-executor.test.ts b/packages/core/src/tests/native-menu-executor.test.ts index da8975d85..838ad48c5 100644 --- a/packages/core/src/tests/native-menu-executor.test.ts +++ b/packages/core/src/tests/native-menu-executor.test.ts @@ -17,11 +17,15 @@ import { type NativeMenuDispatchValues, type NativeMenuExecutionContext, type NativeMenuExecutionResult, + type NativeMenuExecutor, type NativeMenuExecutorOptions, type NativeMenuRequest, } from '../native-menu-executor.ts' import { getNativeMenuModel, + listNativeMenuActions, + NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS, + NATIVE_MENU_VAULT_CUSTODY_ACTION_IDS, type NativeMenuActionId, type NativeMenuHost, } from '../native-menu-model.ts' @@ -49,7 +53,7 @@ const typeContracts: [ Assert< Equal< NativeMenuDispatchRequest['action'], - Exclude + Exclude > >, Assert>, @@ -279,6 +283,7 @@ const samples: { [Id in NativeMenuActionId]: RequestFor } = { values: { code: 'abc#state', label: 'Second' }, }, 'custody-mode': { action: 'custody-mode', values: { mode: 'claustrum' } }, + 'vault-disconnect': { action: 'vault-disconnect' }, 'quota-refresh': { action: 'quota-refresh' }, 'routing-mode': { action: 'routing-mode', @@ -373,6 +378,7 @@ const SESSION_ACTIONS: readonly NativeMenuActionId[] = [ ] const full: NativeMenuExecutionContext = { interactive: true, + custodyMode: 'local', confirmed: true, sessionId: 'session-1', } @@ -389,10 +395,9 @@ function harness( return { calls, executor: createNativeMenuExecutor({ host, dispatch }) } } +/** IDs of every action this host offers under local or claustrum custody. */ function hostActionIds(host: NativeMenuHost): NativeMenuActionId[] { - return getNativeMenuModel(host).groups.flatMap((group) => - group.actions.map((action) => action.id), - ) + return listNativeMenuActions(host).map((action) => action.id) } // Untyped entry point for malformed runtime input a menu could still send. @@ -436,7 +441,7 @@ test('every available action reaches the capability with exactly the checked val expect(Object.keys(samples).sort()).toEqual(hostActionIds('opencode').sort()) for (const host of ['opencode', 'pi'] as const) { for (const id of hostActionIds(host)) { - if (id === 'custody-mode') continue + if (id === 'custody-mode' || id === 'vault-disconnect') continue const { calls, executor } = harness(host) const result = await executor.execute(samples[id], full) expect(result).toEqual({ @@ -530,7 +535,7 @@ test('actions the host does not offer and unknown actions are refused', async () test('custody setup is offline guidance only and never dispatches', async () => { const { calls, executor } = harness() - const model = getNativeMenuModel('opencode') + const model = getNativeMenuModel('opencode', 'local') .groups.flatMap((group) => group.actions) .find((action) => action.id === 'custody-mode') if (model?.kind !== 'setup-guidance') @@ -538,7 +543,7 @@ test('custody setup is offline guidance only and never dispatches', async () => for (const mode of ['local', 'claustrum'] as const) { const result = await executor.execute( { action: 'custody-mode', values: { mode } }, - { interactive: true }, + { interactive: true, custodyMode: 'local' }, ) expect(result).toEqual({ status: 'guidance', @@ -550,7 +555,10 @@ test('custody setup is offline guidance only and never dispatches', async () => expect(result.instructions).toBe(model.setupInstructions) } expect( - await executor.execute(samples['custody-mode'], { interactive: false }), + await executor.execute(samples['custody-mode'], { + interactive: false, + custodyMode: 'local', + }), ).toEqual({ status: 'refused', code: 'offline-required', @@ -583,7 +591,12 @@ test('interactive-only actions are refused in headless contexts', async () => { } expect(calls).toHaveLength(0) expect( - (await executor.execute(samples.enable, { interactive: false })).status, + ( + await executor.execute(samples.enable, { + interactive: false, + custodyMode: 'local', + }) + ).status, ).toBe('executed') }) @@ -591,9 +604,9 @@ test('destructive actions require explicit confirmation', async () => { const { calls, executor } = harness() for (const id of ['remove', 'enrollment-reset'] as const) { for (const context of [ - { interactive: true }, - { interactive: true, confirmed: false }, - ]) { + { interactive: true, custodyMode: 'local' }, + { interactive: true, custodyMode: 'local', confirmed: false }, + ] as const) { expect(await executor.execute(samples[id], context)).toEqual({ status: 'refused', code: 'confirmation-required', @@ -604,7 +617,12 @@ test('destructive actions require explicit confirmation', async () => { expect(calls).toHaveLength(0) expect((await executor.execute(samples.remove, full)).status).toBe('executed') expect( - (await executor.execute(samples.enable, { interactive: true })).status, + ( + await executor.execute(samples.enable, { + interactive: true, + custodyMode: 'local', + }) + ).status, ).toBe('executed') }) @@ -614,8 +632,8 @@ test('session-dependent actions require a session identifier', async () => { for (const sessionId of [undefined, '', ' ']) { const context = sessionId === undefined - ? { interactive: true } - : { interactive: true, sessionId } + ? { interactive: true, custodyMode: 'local' as const } + : { interactive: true, custodyMode: 'local' as const, sessionId } expect(await executor.execute(samples[id], context)).toEqual({ status: 'refused', code: 'session-required', @@ -625,8 +643,12 @@ test('session-dependent actions require a session identifier', async () => { } expect(calls).toHaveLength(0) expect( - (await executor.execute(samples['quota-refresh'], { interactive: true })) - .status, + ( + await executor.execute(samples['quota-refresh'], { + interactive: true, + custodyMode: 'local', + }) + ).status, ).toBe('executed') }) @@ -643,6 +665,7 @@ test('a signal aborted before dispatch does no work', async () => { expect( await executor.execute(samples['custody-mode'], { interactive: true, + custodyMode: 'local', signal: controller.signal, }), ).toEqual({ status: 'refused', code: 'aborted', action: 'custody-mode' }) @@ -1143,12 +1166,18 @@ test('malformed contexts are refused before any dispatch', async () => { null, undefined, {}, - { interactive: 'yes' }, - { interactive: true, confirmed: 1 }, - { interactive: true, sessionId: 5 }, - { interactive: true, extra: true }, - { interactive: true, signal: fakeSignal }, - { interactive: true, signal: {} }, + { interactive: 'yes', custodyMode: 'local' }, + { interactive: true, custodyMode: 'local', confirmed: 1 }, + { interactive: true, custodyMode: 'local', sessionId: 5 }, + { interactive: true, custodyMode: 'local', extra: true }, + { interactive: true, custodyMode: 'local', signal: fakeSignal }, + { interactive: true, custodyMode: 'local', signal: {} }, + // Custody has no default: a missing or unknown mode is not local. + { interactive: true }, + { interactive: true, custodyMode: undefined }, + { interactive: true, custodyMode: 'vault' }, + { interactive: true, custodyMode: 'LOCAL' }, + { interactive: true, custodyMode: null }, ]) { // Called directly so an undefined context is not replaced by a default. const result = await Reflect.apply(executor.execute, undefined, [ @@ -1170,11 +1199,15 @@ test('proxies are refused without running any trap', async () => { const cases: [unknown, unknown][] = [ [trappedProxy({ action: 'enable', values: { id: 'a' } }, log), full], [{ action: 'enable', values: trappedProxy({ id: 'a' }, log) }, full], - [samples.enable, trappedProxy({ interactive: true }, log)], + [ + samples.enable, + trappedProxy({ interactive: true, custodyMode: 'local' }, log), + ], [ samples['prime-on'], { interactive: true, + custodyMode: 'local', signal: trappedProxy(new AbortController().signal, log), }, ], @@ -1223,7 +1256,7 @@ test('accessors, iterators and foreign prototypes are refused without being call ) const accessorValues = Object.defineProperty({}, 'id', getter('id', 'a')) const accessorContext = Object.defineProperty( - {}, + { custodyMode: 'local' }, 'interactive', getter('interactive', true), ) @@ -1467,3 +1500,198 @@ test('the emitted declaration carries no common-auth reference', async () => { ) expect(specifiers.sort()).toEqual(['./native-menu-model.ts']) }) + +test('claustrum custody refuses local-custody actions before checking or dispatching them', async () => { + for (const host of ['opencode', 'pi'] as const) { + const { calls, executor } = harness(host) + const vault = { ...full, custodyMode: 'claustrum' } as const + for (const id of NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS) { + expect(await executor.execute(samples[id], vault)).toEqual({ + status: 'refused', + code: 'vault-managed', + action: id, + }) + // The custody refusal comes first: a headless request, an unconfirmed + // removal or a request with missing input is still reported as + // vault-managed rather than inviting the caller to fix and resend it. + expect( + await executeRaw( + executor, + { action: id, values: {} }, + { + interactive: false, + custodyMode: 'claustrum', + }, + ), + ).toEqual({ status: 'refused', code: 'vault-managed', action: id }) + } + expect(calls).toHaveLength(0) + // Every other action this host offers, except vault disconnect which + // this harness has no capability for, still reaches the capability. + const retained = hostActionIds(host).filter( + (id) => + !(NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS as readonly string[]).includes( + id, + ) && + !(NATIVE_MENU_VAULT_CUSTODY_ACTION_IDS as readonly string[]).includes( + id, + ), + ) + for (const id of retained) + expect((await executor.execute(samples[id], vault)).status).toBe( + 'executed', + ) + expect(calls.map(([request]) => request.action)).toEqual(retained) + // The same executor serves local actions again once custody is local. + for (const id of NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS) + expect((await executor.execute(samples[id], full)).status).toBe( + id === 'custody-mode' ? 'guidance' : 'executed', + ) + } +}) + +function disconnectHarness( + host: NativeMenuHost, + outcome: () => Promise, +) { + const dispatched: NativeMenuDispatchRequest[] = [] + const disconnects: NativeMenuDispatchOptions[] = [] + const options: unknown = { + host, + dispatch: async (request: NativeMenuDispatchRequest) => { + dispatched.push(request) + return { ok: true, text: 'done' } + }, + disconnectVault: async (options: NativeMenuDispatchOptions) => { + disconnects.push(options) + return outcome() + }, + } + // Reflect.apply skips type checking, so the disconnect callback may + // resolve to a value outside NativeVaultDisconnectResult, as a JavaScript + // host could. + const executor: NativeMenuExecutor = Reflect.apply( + createNativeMenuExecutor, + undefined, + [options], + ) + return { executor, dispatched, disconnects } +} + +test('vault disconnect runs only the disconnect capability, only under claustrum custody', async () => { + const vault = { ...full, custodyMode: 'claustrum' } as const + for (const host of ['opencode', 'pi'] as const) { + for (const result of [ + 'disconnected', + 'idle', + 'busy', + 'conflict', + ] as const) { + const { executor, dispatched, disconnects } = disconnectHarness( + host, + async () => result, + ) + expect( + await executor.execute(samples['vault-disconnect'], vault), + ).toEqual({ status: 'disconnect', action: 'vault-disconnect', result }) + expect(disconnects).toEqual([{}]) + expect(dispatched).toEqual([]) + } + const { executor, dispatched, disconnects } = disconnectHarness( + host, + async () => 'disconnected', + ) + // A menu opened while enrolled may submit after the host already left. + expect(await executor.execute(samples['vault-disconnect'], full)).toEqual({ + status: 'refused', + code: 'vault-not-connected', + action: 'vault-disconnect', + }) + expect( + await executor.execute(samples['vault-disconnect'], { + ...vault, + interactive: false, + }), + ).toEqual({ + status: 'refused', + code: 'interactive-required', + action: 'vault-disconnect', + }) + expect( + await executor.execute(samples['vault-disconnect'], { + ...vault, + confirmed: false, + }), + ).toEqual({ + status: 'refused', + code: 'confirmation-required', + action: 'vault-disconnect', + }) + expect( + await executeRaw( + executor, + { action: 'vault-disconnect', values: { id: 'x' } }, + vault, + ), + ).toEqual({ + status: 'refused', + code: 'unknown-parameter', + action: 'vault-disconnect', + }) + const controller = new AbortController() + controller.abort() + expect( + await executor.execute(samples['vault-disconnect'], { + ...vault, + signal: controller.signal, + }), + ).toEqual({ + status: 'refused', + code: 'aborted', + action: 'vault-disconnect', + }) + expect(disconnects).toEqual([]) + expect(dispatched).toEqual([]) + } +}) + +test('vault disconnect is unsupported without a capability and reports capability failures generically', async () => { + const vault = { ...full, custodyMode: 'claustrum' } as const + const { calls, executor } = harness() + expect(await executor.execute(samples['vault-disconnect'], vault)).toEqual({ + status: 'refused', + code: 'unsupported-action', + }) + expect(calls).toEqual([]) + for (const [outcome, code] of [ + [ + async () => { + throw new Error('sk-ant-synthetic-secret') + }, + 'execution-failed', + ], + [async () => 'removed', 'invalid-outcome'], + [async () => ({ result: 'disconnected' }), 'invalid-outcome'], + [async () => undefined, 'invalid-outcome'], + ] as const) { + const { executor } = disconnectHarness('pi', outcome) + const result = await executor.execute(samples['vault-disconnect'], vault) + expect(result).toEqual({ + status: 'failed', + action: 'vault-disconnect', + code, + }) + expect(JSON.stringify(result)).not.toContain('synthetic-secret') + } + for (const disconnectVault of ['yes', {}, null]) { + expect(() => + Reflect.apply(createNativeMenuExecutor, undefined, [ + { + host: 'opencode', + dispatch: async () => ({ ok: true, text: 'done' }), + disconnectVault, + }, + ]), + ).toThrow('Native menu executor option disconnectVault is invalid') + } +}) diff --git a/packages/core/src/tests/native-menu-model.test.ts b/packages/core/src/tests/native-menu-model.test.ts index 5de148987..fbb17a27b 100644 --- a/packages/core/src/tests/native-menu-model.test.ts +++ b/packages/core/src/tests/native-menu-model.test.ts @@ -8,7 +8,12 @@ import type { import { moduleReferences } from '../../scripts/check-native-type-closure.ts' import { getNativeMenuModel, + isNativeMenuLocalCustodyAction, + isNativeMenuVaultCustodyAction, + listNativeMenuActions, NATIVE_MENU_GROUP_IDS, + NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS, + NATIVE_MENU_VAULT_CUSTODY_ACTION_IDS, type NativeMenuAction, type NativeMenuActionId, type NativeMenuHost, @@ -65,12 +70,15 @@ const producerParity: [ Assert< Equal< Extract['id'], - Exclude + Exclude > >, Assert< Assignable< - Extract, + Extract< + NativeMenuAction, + { id: 'remove' | 'enrollment-reset' | 'vault-disconnect' } + >, { destructive: true; confirm: string } > >, @@ -84,6 +92,7 @@ const producerParity: [ | 'add-oauth-start' | 'add-oauth-finish' | 'custody-mode' + | 'vault-disconnect' } >, { interactive: true; headlessRefusal: NativeMenuRefusalCode } @@ -860,7 +869,9 @@ const fixture: Record< } function allActions(host: NativeMenuHost) { - return getNativeMenuModel(host).groups.flatMap((group) => group.actions) + return getNativeMenuModel(host, 'local').groups.flatMap( + (group) => group.actions, + ) } function parameterSignature(parameter: NativeMenuParameter): string { @@ -890,16 +901,16 @@ test('native group order is Accounts, Quota, Routing, Limits, Cache, Diagnostics ] as const expect(NATIVE_MENU_GROUP_IDS).toEqual(order) for (const host of ['opencode', 'pi'] as const) { - expect(getNativeMenuModel(host).groups.map((group) => group.id)).toEqual([ - ...order, - ]) + expect( + getNativeMenuModel(host, 'local').groups.map((group) => group.id), + ).toEqual([...order]) } expect(producerParity).toEqual([true, true, true, true, true, true]) }) for (const host of ['opencode', 'pi'] as const) { test(`${host} preserves the literal pinned action and status inventory`, () => { - const model = getNativeMenuModel(host) + const model = getNativeMenuModel(host, 'local') const actual: (string | boolean | readonly string[])[][] = model.groups .flatMap((group) => group.actions) .map((action) => [ @@ -945,7 +956,7 @@ for (const host of ['opencode', 'pi'] as const) { } test('Pi Prime and quota remain status-only with no Limits or start actions', () => { - const pi = getNativeMenuModel('pi') + const pi = getNativeMenuModel('pi', 'local') expect(pi.groups.find((group) => group.id === 'Limits')).toEqual({ id: 'Limits', actions: [], @@ -1056,7 +1067,7 @@ test('custody-mode is typed offline setup guidance, never an executable authorit test('every descriptor has only closed public metadata and parameter identifiers', () => { for (const host of ['opencode', 'pi'] as const) { - const model = getNativeMenuModel(host) + const model = getNativeMenuModel(host, 'local') expectKeys(model, ['host', 'groups']) for (const group of model.groups) { expectKeys(group, ['id', 'actions', 'statuses']) @@ -1125,10 +1136,10 @@ function expectDeepFrozen(value: unknown) { test('output is deterministic and deeply frozen including nested parameter domains', () => { expectDeepFrozen(NATIVE_MENU_GROUP_IDS) for (const host of ['opencode', 'pi'] as const) { - const model = getNativeMenuModel(host) + const model = getNativeMenuModel(host, 'local') const before = JSON.stringify(model) expectDeepFrozen(model) - expect(getNativeMenuModel(host)).toBe(model) + expect(getNativeMenuModel(host, 'local')).toBe(model) expect(Reflect.set(model, 'host', 'other')).toBe(false) expect( Reflect.set(model.groups, model.groups.length, { id: 'Accounts' }), @@ -1136,7 +1147,7 @@ test('output is deterministic and deeply frozen including nested parameter domai const action = model.groups[0]!.actions[0]! expect(Reflect.set(action, 'label', 'changed')).toBe(false) expect(Reflect.set(action.parameters[0]!, 'id', 'changed')).toBe(false) - expect(JSON.stringify(getNativeMenuModel(host))).toBe(before) + expect(JSON.stringify(getNativeMenuModel(host, 'local'))).toBe(before) expect(JSON.parse(before)).toEqual(model) } }) @@ -1157,3 +1168,84 @@ test('emitted native menu declaration has no producer or internal-types referenc ) expect(forbidden.map(({ specifier }) => specifier)).toEqual([]) }) + +test("each custody mode leaves out exactly the other mode's actions on every host", () => { + expect([...NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS]).toEqual([ + 'remove', + 'move-up', + 'move-down', + 'enrollment-reset', + 'add-apikey', + 'add-oauth-start', + 'add-oauth-finish', + 'custody-mode', + ]) + expect([...NATIVE_MENU_VAULT_CUSTODY_ACTION_IDS]).toEqual([ + 'vault-disconnect', + ]) + expectDeepFrozen(NATIVE_MENU_LOCAL_CUSTODY_ACTION_IDS) + expectDeepFrozen(NATIVE_MENU_VAULT_CUSTODY_ACTION_IDS) + for (const host of ['opencode', 'pi'] as const) { + const all = listNativeMenuActions(host) + expectDeepFrozen(all) + expect(listNativeMenuActions(host)).toBe(all) + // The local and claustrum models are each cached, and are different objects. + const catalog = getNativeMenuModel(host, 'local') + expect(getNativeMenuModel(host, 'local')).toBe(catalog) + const vault = getNativeMenuModel(host, 'claustrum') + expect(getNativeMenuModel(host, 'claustrum')).toBe(vault) + expect(catalog).not.toBe(vault) + expectDeepFrozen(vault) + expect(vault.host).toBe(host) + expect(vault.groups.map((group) => group.id)).toEqual( + catalog.groups.map((group) => group.id), + ) + const inGroup = (id: string) => all.filter((action) => action.group === id) + for (const [index, group] of vault.groups.entries()) { + const local = catalog.groups[index] + if (!local) throw new Error('group missing') + expect(group.statuses).toEqual(local.statuses) + expect(local.actions).toEqual( + inGroup(local.id).filter( + (action) => !isNativeMenuVaultCustodyAction(action.id), + ), + ) + expect(group.actions).toEqual( + inGroup(group.id).filter( + (action) => !isNativeMenuLocalCustodyAction(action.id), + ), + ) + } + const accounts = (model: typeof vault) => + model.groups + .find((group) => group.id === 'Accounts') + ?.actions.map((action) => action.id) + expect(accounts(vault)).toEqual([ + 'enable', + 'disable', + 'reset-backoff', + 'vault-disconnect', + ]) + expect(accounts(catalog)).not.toContain('vault-disconnect') + expect(accounts(catalog)).toContain('custody-mode') + // Settings, quota and vault-account controls stay in both modes. + const kept = (model: typeof vault) => + model.groups.flatMap((group) => group.actions).map((action) => action.id) + expect(kept(vault)).toContain('cache-on') + expect(kept(vault)).toContain('logging-level') + if (host === 'opencode') expect(kept(vault)).toContain('quota-refresh') + expect(all.find((action) => action.id === 'vault-disconnect')).toEqual({ + id: 'vault-disconnect', + kind: 'vault-disconnect', + command: 'account', + group: 'Accounts', + label: 'Disconnect from the vault', + destructive: true, + confirm: + "Disconnect this host from the vault and return to local accounts? The vault keeps this host's enrollment approved until an operator revokes it there.", + interactive: true, + headlessRefusal: 'interactive-required', + parameters: [], + }) + } +}) diff --git a/packages/core/src/tests/native-public-api.test.ts b/packages/core/src/tests/native-public-api.test.ts index c031a6e30..61adfa92c 100644 --- a/packages/core/src/tests/native-public-api.test.ts +++ b/packages/core/src/tests/native-public-api.test.ts @@ -33,8 +33,8 @@ test('native public factories do not expose internal runtime or authority publis test('the public menu getter preserves both host profiles', () => { for (const host of ['opencode', 'pi'] as const) { - const exposed = publicApi.getNativeMenuModel(host) - expect(exposed).toEqual(getNativeMenuModel(host)) + const exposed = publicApi.getNativeMenuModel(host, 'local') + expect(exposed).toEqual(getNativeMenuModel(host, 'local')) expect(exposed.host).toBe(host) expect(exposed.groups.length).toBeGreaterThan(0) expect(new Set(exposed.groups.map((group) => group.id)).size).toBe( diff --git a/packages/core/src/tests/native-ui.test.ts b/packages/core/src/tests/native-ui.test.ts index d3d26c634..0c3e5a280 100644 --- a/packages/core/src/tests/native-ui.test.ts +++ b/packages/core/src/tests/native-ui.test.ts @@ -1,16 +1,32 @@ import { describe, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { disconnectClaustrumEnrollment } from '@cortexkit/common-auth/claustrum' import type { CommandApplyRequest, KnobValues, } from '@cortexkit/common-auth/commands' import { authorize, exchange } from '../auth.ts' +import { getHostClaustrumEnrollmentPaths } from '../claustrum-enrollment.ts' import { TOKEN_URL } from '../constants.ts' -import type { NativeMenuDispatchRequest } from '../native-menu-executor.ts' +import { readNativeEnrollmentMode } from '../native-enrollment-authority.ts' +import type { + NativeMenuDispatchRequest, + NativeVaultDisconnect, + NativeVaultDisconnectResult, +} from '../native-menu-executor.ts' import type { NativeMenuActionId, NativeMenuGroupId, } from '../native-menu-model.ts' -import { createNativeUi, runPiCommandMenu } from '../native-ui.ts' +import { + type CommandMenu, + createNativeUi, + type NativeUiOptions, + runPiCommandMenu, +} from '../native-ui.ts' +import { resolveNativePoolPaths } from '../pool-paths.ts' const opaqueId = 'vault:oauth:team:person' const entries = [{ account: opaqueId, fh: 5, sd: 10, scoped: 23 }] @@ -37,6 +53,7 @@ const fixtures = { { code: 'synthetic-code', label: 'two words' }, ], 'custody-mode': ['Accounts', { mode: 'claustrum' }], + 'vault-disconnect': ['Accounts', {}], 'quota-refresh': ['Quota', {}], 'routing-mode': ['Routing', { mode: 'sticky-balanced' }], 'routing-reset': ['Routing', {}], @@ -61,6 +78,9 @@ const fixtures = { } satisfies Record const invocation = { sessionId: 'conversation-1', notify() {} } +const localCustody = async () => 'local' as const +/** These tests never select disconnect; return 'idle' if the callback is invoked. */ +const unusedDisconnect = async () => 'idle' as const const piUnavailable = [ 'quota-refresh', 'killswitch-on', @@ -71,12 +91,69 @@ const piUnavailable = [ 'start-fire', ] -function setup(host: 'opencode' | 'pi' = 'opencode', interactive = true) { +function applyFixture( + menu: CommandMenu, + actionId: NativeMenuActionId, + patch: Partial = {}, +) { + return menu.apply( + { + command: 'claude', + sectionId: fixtures[actionId][0], + actionId, + values: fixtures[actionId][1], + sessionId: invocation.sessionId, + confirmed: true, + ...patch, + }, + invocation, + ) +} + +/** + * Exercise JavaScript callers that return an invalid value from readMode. + * Reflect.apply allows those inputs without weakening the production type. + */ +function rawSetup( + readMode: () => Promise, + disconnectVault: NativeVaultDisconnect, +) { + const calls: NativeMenuDispatchRequest[] = [] + const options: unknown = { + host: 'opencode', + interactive: true, + readMode, + disconnectVault, + dispatch: async (request: NativeMenuDispatchRequest) => { + calls.push(request) + return { ok: true, text: 'Applied' } + }, + readStatus: async (command: string) => `${command}: public status`, + } + const menu: CommandMenu = Reflect.apply(createNativeUi, undefined, [options]) + return { + menu, + calls, + apply: ( + actionId: NativeMenuActionId, + patch: Partial = {}, + ) => applyFixture(menu, actionId, patch), + } +} + +function setup( + host: 'opencode' | 'pi' = 'opencode', + interactive = true, + readMode: NativeUiOptions['readMode'] = localCustody, + disconnectVault: NativeVaultDisconnect = unusedDisconnect, +) { const calls: NativeMenuDispatchRequest[] = [] const reads: string[] = [] const menu = createNativeUi({ host, interactive, + readMode, + disconnectVault, dispatch: async (request) => { calls.push(request) return { ok: true, text: 'Applied' } @@ -89,19 +166,7 @@ function setup(host: 'opencode' | 'pi' = 'opencode', interactive = true) { const apply = ( actionId: NativeMenuActionId, patch: Partial = {}, - ) => - menu.apply( - { - command: 'claude', - sectionId: fixtures[actionId][0], - actionId, - values: fixtures[actionId][1], - sessionId: invocation.sessionId, - confirmed: true, - ...patch, - }, - invocation, - ) + ) => applyFixture(menu, actionId, patch) return { menu, calls, reads, apply } } @@ -178,7 +243,9 @@ describe('native UI contract', () => { ), ).toEqual( Object.keys(fixtures).filter( - (id) => host === 'opencode' || !piUnavailable.includes(id), + (id) => + (host === 'opencode' || !piUnavailable.includes(id)) && + id !== 'vault-disconnect', ), ) const actions = payload.menu.sections.flatMap( @@ -219,6 +286,9 @@ describe('native UI contract', () => { }) for (const actionId of Object.keys(fixtures) as NativeMenuActionId[]) { if (host === 'pi' && piUnavailable.includes(actionId)) continue + // This loop covers local accounts. Separate disconnect tests check hosts + // with vault enrollment and every disconnect outcome. + if (actionId === 'vault-disconnect') continue test(`${host} executes ${actionId} with all parameters intact`, async () => { const { calls, apply } = setup(host) const result = await apply(actionId) @@ -338,6 +408,8 @@ describe('native UI contract', () => { const menu = createNativeUi({ host: 'opencode', interactive: true, + readMode: localCustody, + disconnectVault: unusedDisconnect, dispatch: async () => { after = true return { ok: true, text: 'Accepted synthetic-private-key' } @@ -378,6 +450,8 @@ describe('native UI contract', () => { const menu = createNativeUi({ host: 'opencode', interactive: mode !== 'headless', + readMode: localCustody, + disconnectVault: unusedDisconnect, dispatch: async () => { dispatches++ return { ok: true, text: `Accepted ${secret}` } @@ -489,6 +563,8 @@ describe('native UI contract', () => { const menu = createNativeUi({ host: 'opencode', interactive: true, + readMode: localCustody, + disconnectVault: unusedDisconnect, dispatch: async () => { dispatches++ return { ok: true, text: 'not expected' } @@ -527,6 +603,8 @@ describe('native UI contract', () => { const menu = createNativeUi({ host: 'opencode', interactive: true, + readMode: localCustody, + disconnectVault: unusedDisconnect, dispatch: async () => { throw new Error('secret-operation-material') }, @@ -590,6 +668,8 @@ describe('OAuth sign-in link', () => { const menu = createNativeUi({ host: 'opencode', interactive: true, + readMode: localCustody, + disconnectVault: unusedDisconnect, dispatch: async (request) => { if (request.action === 'add-oauth-start') { pending = await authorize('max') @@ -695,6 +775,8 @@ describe('OAuth sign-in link', () => { const menu = createNativeUi({ host: 'opencode', interactive: true, + readMode: localCustody, + disconnectVault: unusedDisconnect, dispatch: async () => { dispatches++ return outcome @@ -820,6 +902,8 @@ describe('OAuth sign-in link', () => { const headless = createNativeUi({ host: 'opencode', interactive: false, + readMode: localCustody, + disconnectVault: unusedDisconnect, dispatch: async () => ({ ok: true, text }), readStatus: async () => 'public', }) @@ -828,3 +912,422 @@ describe('OAuth sign-in link', () => { ) }) }) + +const localCustodyIds: readonly NativeMenuActionId[] = [ + 'remove', + 'move-up', + 'move-down', + 'enrollment-reset', + 'add-apikey', + 'add-oauth-start', + 'add-oauth-finish', + 'custody-mode', +] +const vaultGuidance = + 'Accounts are managed in the vault. Disconnect from the vault to use local accounts.' +const unknownGuidance = + 'Account custody could not be read, so local account changes are unavailable.' + +function offeredIds( + sections: readonly { actions: readonly { id: string }[] }[], +) { + return sections.flatMap((section) => + section.actions.map((action) => action.id), + ) +} + +describe('vault custody', () => { + for (const host of ['opencode', 'pi'] as const) { + test(`${host} vault menu removes local-custody actions and shows one guidance line`, async () => { + const { menu, calls, reads } = setup( + host, + true, + async () => 'claustrum', + async () => 'disconnected', + ) + const local = await setup(host).menu.open(invocation) + const payload = await menu.open(invocation) + const accounts = payload.menu.sections.find( + (section) => section.id === 'Accounts', + ) + expect(accounts?.lines[0]).toBe(vaultGuidance) + expect( + payload.menu.sections.flatMap((section) => section.lines), + ).toContain(vaultGuidance) + expect( + payload.menu.sections + .flatMap((section) => section.lines) + .filter((line) => line === vaultGuidance), + ).toHaveLength(1) + // The guidance is a read-only line, never a selectable item or action. + expect(accounts?.items).toEqual([]) + expect(accounts?.actions.map((action) => action.id)).toEqual([ + 'enable', + 'disable', + 'reset-backoff', + 'vault-disconnect', + ]) + // Disconnect is confirmed, but marked reversible: connecting again + // through offline setup undoes it. + expect(accounts?.actions.at(-1)?.confirm).toEqual({ + message: + "Disconnect this host from the vault and return to local accounts? The vault keeps this host's enrollment approved until an operator revokes it there.", + irreversible: false, + }) + expect( + offeredIds(payload.menu.sections).filter( + (id) => id !== 'vault-disconnect', + ), + ).toEqual( + offeredIds(local.menu.sections).filter( + (id) => !(localCustodyIds as readonly string[]).includes(id), + ), + ) + expect(offeredIds(local.menu.sections)).not.toContain('vault-disconnect') + // Every other section, its status lines and its actions are unchanged. + expect(payload.menu.sections.slice(1)).toEqual( + local.menu.sections.slice(1), + ) + expect(accounts?.lines.slice(1)).toEqual( + local.menu.sections[0]?.lines ?? [], + ) + expect(reads).toContain('account:conversation-1') + expect(calls).toEqual([]) + }) + } + + test('a stale local-custody apply is refused before dispatch and the refreshed menu is the vault menu', async () => { + let custody: 'local' | 'claustrum' = 'local' + const { menu, calls, apply } = setup('opencode', true, async () => custody) + const opened = await menu.open(invocation) + for (const id of localCustodyIds) + expect(offeredIds(opened.menu.sections)).toContain(id) + // The vault is connected after the menu was opened. + custody = 'claustrum' + for (const id of localCustodyIds) { + const result = await apply(id) + expect(result).toMatchObject({ ok: false, code: 'vault-managed' }) + expect(result.text).toBe(`Action refused: ${vaultGuidance}`) + expect(result.text).not.toContain('synthetic-key') + expect(result.text).not.toContain('synthetic-code') + expect(result.menu?.sections[0]?.lines[0]).toBe(vaultGuidance) + expect(offeredIds(result.menu?.sections ?? [])).not.toContain(id) + } + expect(calls).toEqual([]) + // Retained settings and vault-account controls still dispatch. + expect((await apply('cache-on')).ok).toBe(true) + expect((await apply('enable')).ok).toBe(true) + expect(calls.map((call) => call.action)).toEqual(['cache-on', 'enable']) + }) + + test('local-custody actions return after the vault is disconnected', async () => { + let custody: 'local' | 'claustrum' = 'claustrum' + const { menu, calls, apply } = setup('opencode', true, async () => custody) + expect( + offeredIds((await menu.open(invocation)).menu.sections), + ).not.toContain('add-oauth-start') + expect((await apply('add-oauth-start')).code).toBe('vault-managed') + custody = 'local' + const restored = await menu.open(invocation) + for (const id of localCustodyIds) + expect(offeredIds(restored.menu.sections)).toContain(id) + expect( + restored.menu.sections.flatMap((section) => section.lines), + ).not.toContain(vaultGuidance) + expect((await apply('add-oauth-start')).ok).toBe(true) + expect((await apply('remove')).ok).toBe(true) + expect(calls.map((call) => call.action)).toEqual([ + 'add-oauth-start', + 'remove', + ]) + }) + + test('an unreadable or unknown custody mode never counts as local but still offers disconnect', async () => { + let disconnects = 0 + const disconnect = async () => { + disconnects++ + return 'disconnected' as const + } + // readMode returns a Promise, so a rejecting reader satisfies its type. + const rejecting = setup( + 'opencode', + true, + async () => { + throw new Error('sk-ant-synthetic-secret unreadable') + }, + disconnect, + ) + // Results outside the declared mode type can only come from an untyped + // host, so these menus are built through the untyped runtime boundary. + const malformed = ['vault', 'LOCAL', undefined, null, 1].map((mode) => + rawSetup(async () => mode, disconnect), + ) + for (const [index, { menu, calls, apply }] of [ + rejecting, + ...malformed, + ].entries()) { + const payload = await menu.open(invocation) + expect(payload.menu.sections[0]?.lines[0]).toBe(unknownGuidance) + expect(JSON.stringify(payload)).not.toContain('synthetic-secret') + for (const id of localCustodyIds) { + expect(offeredIds(payload.menu.sections)).not.toContain(id) + const result = await apply(id) + expect(result).toMatchObject({ ok: false, code: 'custody-unavailable' }) + expect(result.text).toBe(`Action refused: ${unknownGuidance}`) + } + expect(calls).toEqual([]) + expect((await apply('cache-off')).ok).toBe(true) + expect(calls.map((call) => call.action)).toEqual(['cache-off']) + // When the enrollment record cannot be read, disconnecting is the way + // back to local accounts, so it stays offered and still requires + // confirmation. + expect(offeredIds(payload.menu.sections)).toContain('vault-disconnect') + expect( + await apply('vault-disconnect', { confirmed: false }), + ).toMatchObject({ ok: false, code: 'confirmation-required' }) + expect(disconnects).toBe(index) + expect((await apply('vault-disconnect')).ok).toBe(true) + expect(disconnects).toBe(index + 1) + } + }) + + test('a malformed enrollment token offers a confirmed disconnect that returns to local without touching local account files', async () => { + const root = await mkdtemp(join(tmpdir(), 'native-ui-malformed-token-')) + try { + const env = { + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + root, + 'opencode-enrollment.json', + ), + } + const enrollment = getHostClaustrumEnrollmentPaths('opencode', env) + const local = await resolveNativePoolPaths( + join(root, 'anthropic-auth.json'), + join(root, 'anthropic-auth-state.json'), + ) + const files = [ + local.legacyConfig, + local.legacyState, + local.config, + local.state, + local.runtime, + local.journal, + local.roster, + ] + for (const file of files) + await writeFile(file, JSON.stringify({ file }), { mode: 0o600 }) + const bytes = () => Promise.all(files.map((file) => readFile(file))) + const before = await bytes() + await writeFile(enrollment.tokenPath, 'synthetic-secret-not-json', { + mode: 0o600, + }) + const calls: NativeMenuDispatchRequest[] = [] + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + readMode: () => readNativeEnrollmentMode('opencode', env), + // The common-auth helper that deletes this host's enrollment token and + // ceremony state under the enrollment lock. OpenCode disconnect is + // only this step: OpenCode keeps no host sign-in to restore. + disconnectVault: () => + disconnectClaustrumEnrollment(enrollment, 'anthropic-auth-opencode'), + dispatch: async (request) => { + calls.push(request) + return { ok: true, text: 'Applied' } + }, + readStatus: async (command) => `${command}: public status`, + }) + const opened = await menu.open(invocation) + expect(opened.menu.sections[0]?.lines[0]).toBe(unknownGuidance) + expect(offeredIds(opened.menu.sections)).toContain('vault-disconnect') + expect(offeredIds(opened.menu.sections)).not.toContain('add-apikey') + expect(JSON.stringify(opened)).not.toContain('synthetic-secret') + const apply = (patch: Partial = {}) => + applyFixture(menu, 'vault-disconnect', patch) + expect(await apply({ confirmed: false })).toMatchObject({ + ok: false, + code: 'confirmation-required', + }) + expect(await readFile(enrollment.tokenPath, 'utf8')).toBe( + 'synthetic-secret-not-json', + ) + const result = await apply() + expect(result).toMatchObject({ + ok: true, + text: 'Disconnected from the vault. This host uses local accounts again.', + }) + expect(await readNativeEnrollmentMode('opencode', env)).toBe('local') + const ids = offeredIds(result.menu?.sections ?? []) + for (const id of localCustodyIds) expect(ids).toContain(id) + expect(ids).not.toContain('vault-disconnect') + expect(calls).toEqual([]) + expect(await bytes()).toEqual(before) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('the custody reader is required', () => { + const base = { + host: 'opencode', + interactive: true, + dispatch: async () => ({ ok: true, text: 'Applied' }), + readStatus: async () => 'public', + } + for (const options of [base, { ...base, readMode: 'local' }]) { + // Invoked through the untyped boundary: these objects do not satisfy + // NativeUiOptions, which is what a JavaScript host could still pass. + expect(() => Reflect.apply(createNativeUi, undefined, [options])).toThrow( + 'Native UI requires a custody mode reader', + ) + } + }) + + const disconnectTexts: Record< + NativeVaultDisconnectResult, + { ok: boolean; code?: string; text: string } + > = { + disconnected: { + ok: true, + text: 'Disconnected from the vault. This host uses local accounts again.', + }, + idle: { + ok: true, + text: 'This host has no active vault enrollment.', + }, + busy: { + ok: false, + code: 'busy', + text: "Vault disconnect could not finish because another process is changing this host's enrollment. Try again.", + }, + conflict: { + ok: false, + code: 'conflict', + text: 'Disconnect stopped: this host already has a different stored sign-in, so it and the sign-in saved when the host joined the vault were both kept, and the vault connection remains. Resolve the two sign-ins with offline setup.', + }, + } + + for (const host of ['opencode', 'pi'] as const) { + test(`${host} confirmed vault disconnect runs only the disconnect capability and reports each result`, async () => { + for (const [result, expected] of Object.entries(disconnectTexts)) { + let custody: 'local' | 'claustrum' = 'claustrum' + let disconnects = 0 + const { menu, calls, apply } = setup( + host, + true, + async () => custody, + async () => { + disconnects++ + // A real disconnect makes the custody reader report local, so + // local accounts are in use again. + if (result === 'disconnected') custody = 'local' + return result as NativeVaultDisconnectResult + }, + ) + await menu.open(invocation) + const unconfirmed = await apply('vault-disconnect', { + confirmed: false, + }) + expect(unconfirmed).toMatchObject({ + ok: false, + code: 'confirmation-required', + needsConfirmation: true, + }) + expect(disconnects).toBe(0) + const applied = await apply('vault-disconnect') + expect({ + ok: applied.ok, + text: applied.text, + ...(applied.code !== undefined ? { code: applied.code } : {}), + }).toEqual(expected) + expect(disconnects).toBe(1) + expect(calls).toEqual([]) + // The menu returned with the result reads the custody mode again + // after the disconnect ran. + const ids = offeredIds(applied.menu?.sections ?? []) + if (result === 'disconnected') { + expect(ids).not.toContain('vault-disconnect') + for (const id of localCustodyIds) expect(ids).toContain(id) + } else { + expect(ids).toContain('vault-disconnect') + expect(ids).not.toContain('add-apikey') + } + } + }) + } + + test('a stale or headless disconnect is refused before the capability runs', async () => { + let custody: 'local' | 'claustrum' = 'claustrum' + let disconnects = 0 + const disconnect = async () => { + disconnects++ + return 'disconnected' as const + } + const { menu, apply } = setup( + 'opencode', + true, + async () => custody, + disconnect, + ) + expect(offeredIds((await menu.open(invocation)).menu.sections)).toContain( + 'vault-disconnect', + ) + // The host's custody changed to local after this menu was opened. + custody = 'local' + const stale = await apply('vault-disconnect') + expect(stale).toMatchObject({ + ok: false, + code: 'vault-not-connected', + text: 'Action refused: this host is not connected to the vault.', + }) + const headless = setup( + 'opencode', + false, + async () => 'claustrum', + disconnect, + ) + expect((await headless.apply('vault-disconnect')).code).toBe( + 'interactive-required', + ) + expect(disconnects).toBe(0) + }) + + test('the vault disconnect callback is required', () => { + const base = { + host: 'pi', + interactive: true, + readMode: async () => 'claustrum', + dispatch: async () => ({ ok: true, text: 'Applied' }), + readStatus: async () => 'public', + } + for (const options of [ + base, + { ...base, disconnectVault: undefined }, + { ...base, disconnectVault: 'disconnect' }, + ]) { + // Invoked through the untyped boundary: these objects do not satisfy + // NativeUiOptions, which is what a JavaScript host could still pass. + expect(() => Reflect.apply(createNativeUi, undefined, [options])).toThrow( + 'Native UI requires a vault disconnect callback', + ) + } + }) + + test('a failing disconnect capability is reported generically', async () => { + const { apply } = setup( + 'pi', + true, + async () => 'claustrum', + async () => { + throw new Error('sk-ant-synthetic-secret refused') + }, + ) + const result = await apply('vault-disconnect') + expect(result).toMatchObject({ + ok: false, + code: 'execution-failed', + text: 'Action failed.', + }) + expect(JSON.stringify(result)).not.toContain('synthetic-secret') + }) +}) diff --git a/packages/opencode/src/native-command.ts b/packages/opencode/src/native-command.ts index cc9efa40e..55d1a997d 100644 --- a/packages/opencode/src/native-command.ts +++ b/packages/opencode/src/native-command.ts @@ -4,7 +4,9 @@ import { type CommandDialogPayload, type CommandMenu, createNativeUi, + disconnectNativeVaultForHost, type NativeUiOptions, + readNativeEnrollmentMode, } from '@cortexkit/anthropic-auth-core' export interface NativeCommand { @@ -13,16 +15,42 @@ export interface NativeCommand { readonly apply: (request: CommandApplyRequest) => Promise } +/** + * Menu callbacks from the OpenCode host. The custody reader and the vault + * disconnect may be injected; when left out, OpenCode's own enrollment record + * is read for every menu open and apply (there is no local fallback), and + * disconnect ends OpenCode's enrollment through the shared Core helper, which + * never changes OpenCode's own stored sign-in. + */ +export type NativeCommandOptions = Omit< + NativeUiOptions, + 'readMode' | 'disconnectVault' +> & { + readonly readMode?: NativeUiOptions['readMode'] + readonly disconnectVault?: NativeUiOptions['disconnectVault'] +} + function session(sessionId: string | undefined): string { if (!sessionId?.trim()) throw new TypeError('sessionId is required') return sessionId } /** Build and execute the Claude menu using account, status and session callbacks supplied by the host. */ -export function createNativeCommand(options: NativeUiOptions): NativeCommand { +export function createNativeCommand( + options: NativeCommandOptions, +): NativeCommand { if (options.host !== 'opencode') throw new TypeError('OpenCode host is required') - const menu = createNativeUi(options) + const menu = createNativeUi({ + ...options, + readMode: + options.readMode ?? + (() => readNativeEnrollmentMode('opencode', process.env)), + disconnectVault: + options.disconnectVault ?? + (() => + disconnectNativeVaultForHost({ host: 'opencode', env: process.env })), + }) return { menu, open(sessionId) { diff --git a/packages/opencode/src/tests/native-ui.test.ts b/packages/opencode/src/tests/native-ui.test.ts index d7cf85a1a..deb3d0b23 100644 --- a/packages/opencode/src/tests/native-ui.test.ts +++ b/packages/opencode/src/tests/native-ui.test.ts @@ -1,6 +1,13 @@ import { expect, test } from 'bun:test' import { spawnSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { + getHostClaustrumEnrollmentPaths, + readNativeEnrollmentMode, +} from '@cortexkit/anthropic-auth-core' import type { CommandApplyRequest, CommandApplyResult, @@ -13,7 +20,11 @@ import type { } from '@opencode-ai/plugin/tui' import { testRender } from '@opentui/solid' import type { JSX } from 'solid-js' -import { createNativeUi } from '../../../core/src/native-ui.ts' +import { + createNativeUi, + type NativeUiOptions, +} from '../../../core/src/native-ui.ts' +import { createNativeCommand } from '../native-command.ts' import { openNativeMenuDialog } from '../tui/command-dialogs.tsx' test('dual TUI entry exposes both default host methods without eagerly loading OC1 TSX', () => { @@ -44,10 +55,14 @@ test('dual TUI entry exposes both default host methods without eagerly loading O }) }) -async function harness() { +async function harness( + readMode: NativeUiOptions['readMode'] = async () => 'local', +) { const menu = createNativeUi({ host: 'opencode', interactive: true, + readMode, + disconnectVault: async () => 'idle', dispatch: async () => ({ ok: true, text: 'Applied' }), readStatus: async (command) => `${command}: honest status`, }) @@ -211,6 +226,36 @@ test('single Claude dialog displays all sections, honest status and every action expect(h.cleared).toBe(1) }) +test('vault-connected Accounts dialog shows read-only guidance and no local account actions', async () => { + const h = await harness(async () => 'claustrum') + await h.draw() + h.choose('Accounts') + const text = await h.draw() + expect(text).toContain('Accounts are managed in the vault.') + expect(text).toContain('account: honest status') + expect(h.select?.options.map((option) => option.value)).toEqual([ + 'action:enable', + 'action:disable', + 'action:reset-backoff', + 'action:vault-disconnect', + '__back', + ]) + // The guidance line is shown as text, never as a selectable option. + expect( + h.select?.options.some((option) => + option.title.includes('Accounts are managed in the vault'), + ), + ).toBe(false) + h.choose('__back') + await h.draw() + h.choose('Cache') + await h.draw() + expect(h.select?.options.map((option) => option.value)).toContain( + 'action:cache-on', + ) + expect(h.requests).toEqual([]) +}) + test('renderer collects API key, label, baseURL and authHeader without seeding secrets', async () => { const h = await harness() await h.draw() @@ -368,3 +413,70 @@ test('failed IPC is generic and duplicate clicks do not repeat an effect', async await h.draw() expect(h.toasts).toEqual(['Menu apply unavailable; outcome unknown.']) }) + +test('the OpenCode command wrapper disconnects through Core by default and returns the host to local accounts', async () => { + const root = await mkdtemp(join(tmpdir(), 'opencode-native-disconnect-')) + const previous = process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + root, + 'opencode-enrollment.json', + ) + try { + const enrollment = getHostClaustrumEnrollmentPaths('opencode') + await writeFile( + enrollment.tokenPath, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + await writeFile( + enrollment.statePath, + JSON.stringify({ + version: 1, + phase: 'approved', + proposedName: 'anthropic-auth-opencode', + tokenGeneration: 1, + updatedAt: 1, + }), + { mode: 0o600 }, + ) + const calls: string[] = [] + // Neither the custody reader nor the disconnect is injected, so both use + // the wrapper's defaults for OpenCode's own enrollment. + const command = createNativeCommand({ + host: 'opencode', + interactive: true, + dispatch: async (request) => { + calls.push(request.action) + return { ok: true, text: 'Applied' } + }, + readStatus: async (command) => `${command}: public status`, + }) + const opened = await command.open('session-a') + expect( + opened.menu.sections[0]?.actions.map((action) => action.id), + ).toContain('vault-disconnect') + const request: CommandApplyRequest = { + command: 'claude', + sectionId: 'Accounts', + actionId: 'vault-disconnect', + sessionId: 'session-a', + } + expect(await command.apply(request)).toMatchObject({ + ok: false, + code: 'confirmation-required', + }) + expect(await readNativeEnrollmentMode('opencode')).toBe('claustrum') + expect(await command.apply({ ...request, confirmed: true })).toMatchObject({ + ok: true, + text: 'Disconnected from the vault. This host uses local accounts again.', + }) + expect(await readNativeEnrollmentMode('opencode')).toBe('local') + expect(calls).toEqual([]) + } finally { + if (previous === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE + else + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = previous + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/opencode/src/tests/rpc-server.test.ts b/packages/opencode/src/tests/rpc-server.test.ts index 770277747..52be5c07b 100644 --- a/packages/opencode/src/tests/rpc-server.test.ts +++ b/packages/opencode/src/tests/rpc-server.test.ts @@ -31,6 +31,8 @@ describe('rpc-server', () => { const menu = createNativeUi({ host: 'opencode', interactive: true, + readMode: async () => 'local', + disconnectVault: async () => 'idle', dispatch: async (request) => { calls.push(request) return { ok: true, text: 'Applied limits' } @@ -117,6 +119,8 @@ describe('rpc-server', () => { const menu = createNativeUi({ host: 'opencode', interactive: true, + readMode: async () => 'local', + disconnectVault: async () => 'idle', dispatch: async () => { calls++ return { ok: true, text: 'removed' } @@ -147,6 +151,59 @@ describe('rpc-server', () => { expect(calls).toBe(1) }) + test('native IPC refuses a stale local account apply after the vault connects and restores it after disconnect', async () => { + dir = await mkdtemp(join(tmpdir(), 'aa-native-rpc-')) + const calls: NativeMenuDispatchRequest[] = [] + let custody: 'local' | 'claustrum' = 'local' + const menu = createNativeUi({ + host: 'opencode', + interactive: true, + readMode: async () => custody, + disconnectVault: async () => 'idle', + dispatch: async (request) => { + calls.push(request) + return { ok: true, text: 'Applied' } + }, + readStatus: async () => 'public', + }) + const server = await startRpcServer({ + dir, + drain: drainNotifications, + apply: async () => ({ text: 'legacy', knobs: {} }), + applyMenu: (request) => + menu.apply(request, { sessionId: request.sessionId, notify() {} }), + }) + stop = server.stop + const client = createRpcClient(dir, process.pid) + const opened = await menu.open({ sessionId: 'a', notify() {} }) + expect( + opened.menu.sections[0]?.actions.map((action) => action.id), + ).toContain('add-apikey') + const request: CommandApplyRequest = { + command: 'claude', + sectionId: 'Accounts', + actionId: 'add-apikey', + values: { apiKey: 'synthetic-rpc-key' }, + sessionId: 'a', + } + custody = 'claustrum' + const refused = await client.applyMenu(request) + expect(refused).toMatchObject({ ok: false, code: 'vault-managed' }) + expect(JSON.stringify(refused)).not.toContain('synthetic-rpc-key') + expect(refused.menu?.sections[0]?.lines[0]).toBe( + 'Accounts are managed in the vault. Disconnect from the vault to use local accounts.', + ) + expect( + refused.menu?.sections[0]?.actions.map((action) => action.id), + ).not.toContain('add-apikey') + expect(calls).toEqual([]) + custody = 'local' + expect((await client.applyMenu(request)).ok).toBe(true) + expect(calls).toEqual([ + { action: 'add-apikey', values: { apiKey: 'synthetic-rpc-key' } }, + ]) + }) + test('native IPC redacts arbitrary callback errors and refuses an absent capability', async () => { dir = await mkdtemp(join(tmpdir(), 'aa-native-rpc-')) const first = await startRpcServer({ diff --git a/packages/pi/src/commands.ts b/packages/pi/src/commands.ts index 932669a0a..ed4415d7e 100644 --- a/packages/pi/src/commands.ts +++ b/packages/pi/src/commands.ts @@ -1,10 +1,15 @@ +import { join } from 'node:path' import { createNativeUi, + disconnectNativeVaultForHost, type NativeMenuCommandId, type NativeMenuDispatch, + type NativeUiOptions, + openNativePiAuthSlot, + readNativeEnrollmentMode, runPiCommandMenu, } from '@cortexkit/anthropic-auth-core' -import type { ExtensionAPI } from '@earendil-works/pi-coding-agent' +import { type ExtensionAPI, getAgentDir } from '@earendil-works/pi-coding-agent' /** Backend callbacks use native authority; the renderer never receives credentials. */ export interface PiNativeCommands { @@ -13,8 +18,35 @@ export interface PiNativeCommands { command: Exclude, invocation: { sessionId?: string }, ) => Promise + /** + * Reads Pi's credential custody for each menu open and apply. When left + * out, the enrollment record the environment points to is read; there is + * no local fallback. + */ + readMode?: NativeUiOptions['readMode'] + /** + * Disconnects Pi from the vault. When left out, Core first restores the + * Anthropic sign-in that enrollment set aside in Pi's own auth file, then + * ends Pi's enrollment. + */ + disconnectVault?: NativeUiOptions['disconnectVault'] } +/** + * Pi's default disconnect. The auth file is the one Pi's SDK uses + * (getAgentDir() honours PI_CODING_AGENT_DIR); Core chooses the private + * stash and placeholder, so setup and disconnect always agree on them. + */ +const disconnectPi: NativeUiOptions['disconnectVault'] = () => + disconnectNativeVaultForHost({ + host: 'pi', + env: process.env, + slot: openNativePiAuthSlot({ + env: process.env, + authPath: join(getAgentDir(), 'auth.json'), + }), + }) + export function registerCommands(pi: ExtensionAPI, backend: PiNativeCommands) { pi.registerCommand('claude', { description: @@ -27,10 +59,31 @@ export function registerCommands(pi: ExtensionAPI, backend: PiNativeCommands) { ) return } + const disconnect = backend.disconnectVault ?? disconnectPi const menu = createNativeUi({ host: 'pi', dispatch: backend.dispatch, readStatus: backend.readStatus, + readMode: + backend.readMode ?? + (() => readNativeEnrollmentMode('pi', process.env)), + disconnectVault: async (options) => { + const result = await disconnect(options) + // Reload after successful or already-inactive enrollment so Pi sees + // any restored sign-in. Reload failure cannot undo the completed + // disconnect, so report it separately without exposing the exception. + if (result === 'disconnected' || result === 'idle') { + try { + await ctx.modelRegistry.refresh() + } catch { + ctx.ui.notify( + 'Vault enrollment is inactive, but Pi could not reload its saved sign-in. Restart Pi before sending another request.', + 'error', + ) + } + } + return result + }, interactive: ctx.hasUI, }) const sessionId = ctx.sessionManager.getSessionId() diff --git a/packages/pi/src/tests/commands.test.ts b/packages/pi/src/tests/commands.test.ts index f73ef5347..20bfbf0d1 100644 --- a/packages/pi/src/tests/commands.test.ts +++ b/packages/pi/src/tests/commands.test.ts @@ -1,19 +1,28 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { CacheKeepSessionRegistry, createNativeMenuExecutor, + createNativePoolStore, + getHostClaustrumEnrollmentPaths, + type NativeCustodyClient, type NativeMenuRequest, + openNativePiAuthSlot, + readNativeEnrollmentMode, resolveNativePoolPaths, StickySessionRouter, } from '@cortexkit/anthropic-auth-core' +import { readVaultRoster } from '@cortexkit/common-auth/claustrum' import type { ExtensionAPI, ExtensionCommandContext, } from '@earendil-works/pi-coding-agent' -import { registerCommands } from '../commands.ts' +import { nativeQuotaCodec } from '../../../core/src/native-quota-codec.ts' +import { readNativeRuntime } from '../../../core/src/native-runtime.ts' +import { nativeVaultPaths } from '../../../core/src/native-vault-paths.ts' +import { type PiNativeCommands, registerCommands } from '../commands.ts' import { closePiNativeRuntime, getPiNativeRuntime } from '../native.ts' import { createPiNativeCommands } from '../native-commands.ts' import { readNativePiFixture, saveNativePiFixture } from './native-fixture.ts' @@ -77,12 +86,18 @@ function controller() { execute: (request: NativeMenuRequest) => executor.execute(request, { interactive: true, + custodyMode: 'local', confirmed: true, sessionId: 'command-session', }), } } -function registration() { +function registration( + readMode: () => Promise<'local' | 'claustrum'> = async () => 'local', + ui: Record = {}, + disconnectVault?: PiNativeCommands['disconnectVault'], + refreshRegistry?: () => Promise, +) { const commands = new Map< string, Parameters[1] @@ -94,13 +109,31 @@ function registration() { ) => commands.set(name, definition), } as unknown as ExtensionAPI const notified: string[] = [] + const noticeLevels: Array = [] + const refreshes: number[] = [] const ctx = { - hasUI: false, - ui: { notify: (message: string) => notified.push(message) }, + hasUI: Object.keys(ui).length > 0, + ui: { + notify: (message: string, level?: string) => { + notified.push(message) + noticeLevels.push(level) + }, + ...ui, + }, sessionManager: { getSessionId: () => 'command-session' }, + modelRegistry: { + refresh: async () => { + refreshes.push(Date.now()) + await refreshRegistry?.() + }, + }, } as unknown as ExtensionCommandContext - registerCommands(pi, createPiNativeCommands(storagePath)) - return { commands, ctx, notified } + registerCommands(pi, { + ...createPiNativeCommands(storagePath), + readMode, + ...(disconnectVault !== undefined ? { disconnectVault } : {}), + }) + return { commands, ctx, notified, noticeLevels, refreshes } } describe('native account command persistence', () => { @@ -331,3 +364,436 @@ describe('single /claude menu and Pi Prime status', () => { expect(await bytes()).toEqual(before) }) }) + +const vaultGuidance = + 'Accounts are managed in the vault. Disconnect from the vault to use local accounts.' + +/** + * Drives the Pi /claude menu: opens Accounts, picks `label`, answers each + * input from `inputs` and confirms. `onPick` runs when the action is chosen, + * before its inputs are collected and the request is applied. + */ +function piMenuDriver( + label: string, + inputs: string[], + onPick: () => void = () => {}, +) { + const seen: { title: string; choices: string[] }[] = [] + let roots = 0 + let picks = 0 + const ui = { + async select(title: string, choices: string[]) { + seen.push({ title, choices }) + if (title === 'Claude') + return roots++ === 0 + ? choices.find((choice) => choice.startsWith('Accounts')) + : undefined + if (title.startsWith('Accounts') && picks++ === 0) { + onPick() + return choices.find((choice) => choice === label) + } + return undefined + }, + async input() { + return inputs.shift() + }, + async confirm() { + return true + }, + } + return { ui, seen } +} + +describe('vault custody in the Pi /claude menu', () => { + test('headless vault status shows the guidance line and leaves native storage unchanged', async () => { + const before = await bytes() + const { commands, ctx, notified } = registration(async () => 'claustrum') + await commands.get('claude')?.handler('', ctx) + expect(notified.join('\n')).toContain(`Accounts\n${vaultGuidance}`) + expect(await bytes()).toEqual(before) + }) + + test('the interactive vault menu offers no local account actions', async () => { + const before = await bytes() + const { ui, seen } = piMenuDriver('Remove account', ['fallback']) + const { commands, ctx, notified } = registration( + async () => 'claustrum', + ui, + ) + await commands.get('claude')?.handler('', ctx) + const root = seen.find((entry) => entry.title === 'Claude') + expect(root?.choices).toContain(`Accounts: ${vaultGuidance}`) + const accounts = seen.find((entry) => entry.title.startsWith('Accounts')) + expect(accounts?.title).toContain(vaultGuidance) + for (const hidden of [ + 'Remove account', + 'Move account up', + 'Move account down', + 'Reset host enrollment', + 'Add API-key fallback', + 'Start OAuth sign-in', + 'Finish OAuth sign-in', + ]) + expect(accounts?.choices).not.toContain(hidden) + expect(accounts?.choices).toContain('Disable account') + expect(notified).toEqual([]) + expect(await bytes()).toEqual(before) + }) + + test('a stale remove is refused after the vault connects, and allowed again after disconnect', async () => { + let custody: 'local' | 'claustrum' = 'local' + const before = await bytes() + // The menu is opened in local custody; the vault connects while the + // person is choosing the action, before the request is applied. + const stale = piMenuDriver('Remove account', ['fallback'], () => { + custody = 'claustrum' + }) + const first = registration(async () => custody, stale.ui) + await first.commands.get('claude')?.handler('', first.ctx) + expect( + stale.seen.find((entry) => entry.title.startsWith('Accounts'))?.choices, + ).toContain('Remove account') + expect(first.notified).toContain(`Action refused: ${vaultGuidance}`) + expect(await bytes()).toEqual(before) + expect( + (await readNativePiFixture(storagePath)).accounts.map( + (account) => account.id, + ), + ).toContain('fallback') + + custody = 'local' + const restored = piMenuDriver('Remove account', ['fallback']) + const second = registration(async () => custody, restored.ui) + await second.commands.get('claude')?.handler('', second.ctx) + expect(second.notified.join('\n')).not.toContain('Action refused') + expect( + (await readNativePiFixture(storagePath)).accounts.find( + (account) => account.id === 'fallback', + ), + ).toBeUndefined() + }) +}) + +const listedVaultRow = { + id: 'oauth:anthropic', + accountId: '11111111-2222-4333-8444-555555555555', + credentialType: 'oauth', + categories: ['anthropic-native'], + refreshAdapter: 'anthropic', + serves: ['anthropic'], + providerIds: [], + state: 'active', + operations: ['read'], + recordVersion: 7, + createdAtMs: null, +} as const + +/** Reads a file's bytes, or undefined when it does not exist. */ +async function bytesOrAbsent(path: string): Promise { + return readFile(path).catch((error: unknown) => { + if ((error as { code?: unknown })?.code === 'ENOENT') return undefined + throw error + }) +} + +describe('vault custody controls through the Pi /claude menu', () => { + test('enable, disable and reset-backoff change only vault files and leave every local account file byte-identical', async () => { + // Enrol Pi in the vault as completed setup leaves it: an enrollment state + // file in the 'approved' phase for Pi's enrollment name, plus the token + // the vault issued, at the path the Pi runtime reads from + // PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE. + process.env.PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + directory, + 'pi-enrollment.json', + ) + const enrollment = getHostClaustrumEnrollmentPaths('pi') + await writeFile( + enrollment.tokenPath, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + await writeFile( + enrollment.statePath, + JSON.stringify({ + version: 1, + phase: 'approved', + proposedName: 'anthropic-auth-pi', + tokenGeneration: 1, + updatedAt: 1, + }), + { mode: 0o600 }, + ) + try { + expect(await readNativeEnrollmentMode('pi', process.env)).toBe( + 'claustrum', + ) + const paths = await resolveNativePoolPaths(storagePath) + const vaultPaths = nativeVaultPaths(paths) + await createNativePoolStore({ + paths: vaultPaths, + quota: nativeQuotaCodec, + }).initialize() + const client: NativeCustodyClient = { + listScoped: async () => ({ view: 'synthetic', rows: [listedVaultRow] }), + getScoped: async (input) => ({ + credentialId: input.credentialId, + accountId: listedVaultRow.accountId, + material: 'synthetic-vault-bearer', + recordVersion: 7, + expiresAtMs: Date.now() + 3_600_000, + }), + reportAuthFailureScoped: async () => {}, + close: () => {}, + } + // The real Pi native runtime that the /claude command dispatches to; + // only the vault client it connects to is a test double. + const runtime = await getPiNativeRuntime(storagePath, { + connect: async () => client, + }).service() + await runtime.vault.refresh() + expect((await runtime.read()).mode).toBe('claustrum') + // Record a quota failure on the vault account so the test can check + // that reset-backoff clears it. + const receipt = await runtime.authorizeVault('main') + expect( + await runtime.vault.publish(receipt, { + lastQuotaRefreshError: { + message: 'synthetic quota failure', + checkedAt: Date.now(), + accountIdentity: listedVaultRow.accountId, + }, + }), + ).toBe(true) + + const localFiles = [ + paths.legacyConfig, + paths.legacyState, + paths.config, + paths.state, + paths.runtime, + paths.journal, + paths.roster, + ] + const localBefore = await Promise.all(localFiles.map(bytesOrAbsent)) + expect(localBefore.some((file) => file !== undefined)).toBe(true) + const vaultRoster = () => readVaultRoster(vaultPaths.roster) + const vaultMetadata = async () => { + const state = await readNativeRuntime( + vaultPaths.runtime, + vaultPaths.storageId, + ) + return state.status === 'ready' ? state.state : undefined + } + const quotaError = async () => + Object.values((await vaultMetadata())?.accounts ?? {}).find( + (entry) => entry.lastQuotaRefreshError !== undefined, + )?.lastQuotaRefreshError + expect(await quotaError()).toBeDefined() + + const run = async (label: string, inputs: string[]) => { + const { ui, seen } = piMenuDriver(label, inputs) + const { commands, ctx, notified } = registration( + () => readNativeEnrollmentMode('pi', process.env), + ui, + ) + await commands.get('claude')?.handler('', ctx) + const accounts = seen.find((entry) => + entry.title.startsWith('Accounts'), + ) + expect(accounts?.title).toContain(vaultGuidance) + expect(accounts?.choices).toContain(label) + return notified + } + + expect(await run('Disable account', ['main'])).toEqual([ + 'Native account disabled.', + ]) + expect((await vaultRoster())?.rows[0]?.enabled).toBe(false) + expect((await vaultRoster())?.declined).toEqual([ + { + credentialId: listedVaultRow.id, + accountIdentity: listedVaultRow.accountId, + }, + ]) + expect(await Promise.all(localFiles.map(bytesOrAbsent))).toEqual( + localBefore, + ) + + expect(await run('Enable account', ['main'])).toEqual([ + 'Native account enabled.', + ]) + expect((await vaultRoster())?.rows[0]?.enabled).toBe(true) + expect((await vaultRoster())?.declined).toEqual([]) + expect(await Promise.all(localFiles.map(bytesOrAbsent))).toEqual( + localBefore, + ) + + expect(await run('Reset account backoff', [])).toEqual([ + 'Native main OAuth refresh and quota backoff cleared.', + ]) + expect(await quotaError()).toBeUndefined() + expect(await Promise.all(localFiles.map(bytesOrAbsent))).toEqual( + localBefore, + ) + expect(await readFile(vaultPaths.runtime, 'utf8')).not.toContain( + 'synthetic-vault-bearer', + ) + } finally { + delete process.env.PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE + } + }) +}) + +/** Writes Pi's approved enrollment and its token where the runtime reads them. */ +async function enrollPi() { + process.env.PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + directory, + 'pi-enrollment.json', + ) + const enrollment = getHostClaustrumEnrollmentPaths('pi') + await writeFile( + enrollment.tokenPath, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + await writeFile( + enrollment.statePath, + JSON.stringify({ + version: 1, + phase: 'approved', + proposedName: 'anthropic-auth-pi', + tokenGeneration: 1, + updatedAt: 1, + }), + { mode: 0o600 }, + ) + return enrollment +} + +describe('vault disconnect through the Pi /claude menu', () => { + for (const [result, refreshes, notice] of [ + [ + 'disconnected', + 1, + 'Disconnected from the vault. This host uses local accounts again.', + ], + ['idle', 1, 'This host has no active vault enrollment.'], + [ + 'busy', + 0, + "Vault disconnect could not finish because another process is changing this host's enrollment. Try again.", + ], + [ + 'conflict', + 0, + 'Disconnect stopped: this host already has a different stored sign-in, so it and the sign-in saved when the host joined the vault were both kept, and the vault connection remains. Resolve the two sign-ins with offline setup.', + ], + ] as const) { + test(`a confirmed '${result}' disconnect ${refreshes ? 'reloads' : 'does not reload'} Pi's model registry`, async () => { + let disconnects = 0 + const { ui, seen } = piMenuDriver('Disconnect from the vault', []) + const { + commands, + ctx, + notified, + refreshes: reloads, + } = registration( + async () => 'claustrum', + ui, + async () => { + disconnects++ + return result + }, + ) + await commands.get('claude')?.handler('', ctx) + expect( + seen.find((entry) => entry.title.startsWith('Accounts'))?.choices, + ).toContain('Disconnect from the vault') + expect(disconnects).toBe(1) + expect(reloads).toHaveLength(refreshes) + expect(notified).toEqual([notice]) + }) + } + + for (const reloadFails of [false, true]) { + test( + reloadFails + ? 'Pi reports reload failure without undoing a completed vault disconnect or exposing the exception' + : 'the default disconnect restores the stored Pi sign-in, selects local custody and leaves local account files byte-identical', + async () => { + const enrollment = await enrollPi() + try { + // Pi's SDK keeps its sign-ins in /auth.json; beforeEach points + // PI_CODING_AGENT_DIR at this test's directory. + const authPath = join(directory, 'auth.json') + const original = `{\n "other": {"type":"api_key","key":"synthetic-unrelated"},\n "anthropic": {"type":"oauth","access":"sk-ant-oat01-synthetic-host","refresh":"synthetic-refresh","expires":1}\n}\n` + await writeFile(authPath, original, { mode: 0o600 }) + // Joining the vault set the sign-in aside through Core's shared slot + // writer, leaving an inert placeholder in Pi's auth file. + const slot = openNativePiAuthSlot({ env: process.env, authPath }) + await slot.enterVault() + expect(await slot.inspect()).toEqual({ + slot: 'placeholder', + stash: 'valid', + }) + expect(await readNativeEnrollmentMode('pi', process.env)).toBe( + 'claustrum', + ) + const paths = await resolveNativePoolPaths(storagePath) + const localFiles = [ + paths.legacyConfig, + paths.legacyState, + paths.config, + paths.state, + paths.runtime, + paths.journal, + paths.roster, + ] + const localBefore = await Promise.all(localFiles.map(bytesOrAbsent)) + expect(localBefore.some((file) => file !== undefined)).toBe(true) + + // No disconnect callback is injected: the menu uses Pi's real wiring. + const { ui } = piMenuDriver('Disconnect from the vault', []) + const { commands, ctx, notified, noticeLevels, refreshes } = + registration( + () => readNativeEnrollmentMode('pi', process.env), + ui, + undefined, + async () => { + if (reloadFails) + throw new Error('synthetic-private-reload-exception') + }, + ) + await commands.get('claude')?.handler('', ctx) + expect(notified).toEqual([ + ...(reloadFails + ? [ + 'Vault enrollment is inactive, but Pi could not reload its saved sign-in. Restart Pi before sending another request.', + ] + : []), + 'Disconnected from the vault. This host uses local accounts again.', + ]) + if (reloadFails) expect(noticeLevels[0]).toBe('error') + expect(notified.join('\n')).not.toContain( + 'synthetic-private-reload-exception', + ) + expect(await readFile(authPath, 'utf8')).toBe(original) + expect(await slot.inspect()).toEqual({ + slot: 'original', + stash: 'missing', + }) + expect(await readNativeEnrollmentMode('pi', process.env)).toBe( + 'local', + ) + expect(await bytesOrAbsent(enrollment.tokenPath)).toBeUndefined() + expect(refreshes).toHaveLength(1) + expect(await Promise.all(localFiles.map(bytesOrAbsent))).toEqual( + localBefore, + ) + } finally { + delete process.env.PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE + } + }, + ) + } +}) From 4f31a2c0ce65e9170bca064c0e3978496ccbb9b9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:46:56 +0200 Subject: [PATCH 146/169] feat: integrate native OpenCode hosts and preserve quota ordering --- mutations.toml | 16 + packages/core/src/commands/account.ts | 5 - packages/core/src/native-account-view.ts | 25 +- .../src/tests/native-account-view.test.ts | 69 + packages/opencode/package.json | 14 +- .../scripts/check-bundle-globals.test.ts | 74 ++ .../opencode/scripts/check-bundle-globals.ts | 90 +- packages/opencode/src/index.ts | 416 ++++-- .../src/tests/account-command.test.ts | 30 +- packages/opencode/src/tests/index.test.ts | 497 ++++--- packages/opencode/src/tests/native-fixture.ts | 78 +- .../opencode/src/tests/native-serving.test.ts | 699 ++++++++-- .../src/tests/native-status-profile.test.ts | 94 +- .../src/tests/opencode2-adapter.test.ts | 14 +- .../src/tests/opencode2-contract.test.ts | 890 +++++++++++++ .../opencode/src/tests/opencode2-e2e.test.ts | 770 +++++++++++ .../src/tests/opencode2-native-source.test.ts | 1155 +++++++++++++++++ packages/opencode/src/v2/activation.ts | 8 + packages/opencode/src/v2/adapter.ts | 262 +++- packages/opencode/src/v2/install.ts | 106 ++ packages/opencode/src/v2/lifecycle.ts | 76 ++ packages/opencode/src/v2/menu-contract.ts | 14 + packages/opencode/src/v2/menu.ts | 309 +++++ packages/opencode/src/v2/models.ts | 46 + packages/opencode/src/v2/ownership.ts | 17 + packages/opencode/src/v2/prime.ts | 237 ++++ packages/opencode/src/v2/rpc.ts | 173 +++ packages/opencode/src/v2/server.ts | 17 + packages/opencode/src/v2/setup.ts | 317 +++++ packages/opencode/src/v2/source.ts | 811 ++++++++++++ packages/opencode/src/v2/tui.ts | 265 ++++ packages/opencode/src/v2/wire.ts | 201 +++ 32 files changed, 7291 insertions(+), 504 deletions(-) create mode 100644 packages/opencode/scripts/check-bundle-globals.test.ts create mode 100644 packages/opencode/src/tests/opencode2-contract.test.ts create mode 100644 packages/opencode/src/tests/opencode2-e2e.test.ts create mode 100644 packages/opencode/src/tests/opencode2-native-source.test.ts create mode 100644 packages/opencode/src/v2/activation.ts create mode 100644 packages/opencode/src/v2/install.ts create mode 100644 packages/opencode/src/v2/lifecycle.ts create mode 100644 packages/opencode/src/v2/menu-contract.ts create mode 100644 packages/opencode/src/v2/menu.ts create mode 100644 packages/opencode/src/v2/models.ts create mode 100644 packages/opencode/src/v2/ownership.ts create mode 100644 packages/opencode/src/v2/prime.ts create mode 100644 packages/opencode/src/v2/rpc.ts create mode 100644 packages/opencode/src/v2/server.ts create mode 100644 packages/opencode/src/v2/setup.ts create mode 100644 packages/opencode/src/v2/source.ts create mode 100644 packages/opencode/src/v2/tui.ts create mode 100644 packages/opencode/src/v2/wire.ts diff --git a/mutations.toml b/mutations.toml index 8732b0069..6ae038d0e 100644 --- a/mutations.toml +++ b/mutations.toml @@ -470,3 +470,19 @@ expect_message = "error: expect(received).toBeGreaterThanOrEqual(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/pi", "tmp/mutations/pi.xml"] broad_report = "tmp/mutations/pi.xml" broad_id = "{name}" + +[[control]] +id = "quota-metadata-excludes-cleared-failures" +guards = "Late quota failures cannot restore restrictions cleared by a newer successful observation." +file = "packages/core/src/native-account-view.ts" +old = "next.lastQuotaRefreshError.checkedAt <= (next.quotaErrorClearedAt ?? -1)" +new = "false" +test_file = "packages/core/src/tests/native-account-view.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/native-account-view.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["native metadata ignores quota failures at or before the successful observation floor", "native quota clear floors and generations cannot move backward or clear a newer failure"] +expect_message = "error: expect(received).toBeUndefined()" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" diff --git a/packages/core/src/commands/account.ts b/packages/core/src/commands/account.ts index c0d221af2..8a3a16418 100644 --- a/packages/core/src/commands/account.ts +++ b/packages/core/src/commands/account.ts @@ -364,11 +364,6 @@ export async function executeAccountCommand(input: { return input.transition(action.mode) } if (action.type === 'enrollment-reset') { - if (getClaustrumMode(input.storage) !== 'claustrum') { - return { - text: 'Claustrum enrollment is available only in Claustrum mode.', - } - } if (!input.resetEnrollment) { return { text: 'Claustrum enrollment reset is unavailable.' } } diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts index 79a98e936..90b2c9b59 100644 --- a/packages/core/src/native-account-view.ts +++ b/packages/core/src/native-account-view.ts @@ -184,7 +184,30 @@ export function applyNativeMetadataPatch( ) as NativeRuntimeEntry['quota'] next.quotaCheckedAt = quota.checkedAt } - if (patch.quotaErrorClearedAt !== undefined) delete next.lastQuotaRefreshError + // Quota results can finish out of order. Preserve newer failures and clear + // markers instead of letting a delayed writer restore an old restriction. + if (patch.quotaErrorClearedAt !== undefined) + next.quotaErrorClearedAt = Math.max( + entry.quotaErrorClearedAt ?? 0, + patch.quotaErrorClearedAt, + ) + if (patch.quotaErrorGeneration !== undefined) + next.quotaErrorGeneration = Math.max( + entry.quotaErrorGeneration ?? 0, + patch.quotaErrorGeneration, + ) + if ( + entry.lastQuotaRefreshError && + patch.lastQuotaRefreshError && + patch.lastQuotaRefreshError.checkedAt < + entry.lastQuotaRefreshError.checkedAt + ) + next.lastQuotaRefreshError = entry.lastQuotaRefreshError + if ( + next.lastQuotaRefreshError && + next.lastQuotaRefreshError.checkedAt <= (next.quotaErrorClearedAt ?? -1) + ) + delete next.lastQuotaRefreshError return next } diff --git a/packages/core/src/tests/native-account-view.test.ts b/packages/core/src/tests/native-account-view.test.ts index 4ef011799..0a7171673 100644 --- a/packages/core/src/tests/native-account-view.test.ts +++ b/packages/core/src/tests/native-account-view.test.ts @@ -370,3 +370,72 @@ for (const roster of [ expect(JSON.stringify(rows)).toBe(before) }) } + +test('native metadata ignores quota failures at or before the successful observation floor', () => { + const entry = { + ...runtime.accounts['primary-row']!, + quotaErrorClearedAt: 200, + } + for (const checkedAt of [199, 200]) { + const next = applyNativeMetadataPatch(entry, { + lastQuotaRefreshError: { + message: 'late failure', + checkedAt, + accountIdentity: uuid, + }, + }) + expect(next.lastQuotaRefreshError).toBeUndefined() + expect(next.quotaErrorClearedAt).toBe(200) + } + expect( + applyNativeMetadataPatch(entry, { + lastQuotaRefreshError: { + message: 'new failure', + checkedAt: 201, + accountIdentity: uuid, + }, + }).lastQuotaRefreshError?.checkedAt, + ).toBe(201) +}) + +test('native metadata retains newer quota failures against delayed writers', () => { + const failure = { + message: 'new failure', + checkedAt: 300, + accountIdentity: uuid, + } + const next = applyNativeMetadataPatch( + { ...runtime.accounts['primary-row']!, lastQuotaRefreshError: failure }, + { + lastQuotaRefreshError: { + message: 'old failure', + checkedAt: 250, + accountIdentity: uuid, + }, + }, + ) + expect(next.lastQuotaRefreshError).toEqual(failure) +}) + +test('native quota clear floors and generations cannot move backward or clear a newer failure', () => { + const entry = { + ...runtime.accounts['primary-row']!, + quotaErrorClearedAt: 250, + quotaErrorGeneration: 3, + lastQuotaRefreshError: { + message: 'new failure', + checkedAt: 300, + accountIdentity: uuid, + }, + } + const stale = applyNativeMetadataPatch(entry, { + quotaErrorClearedAt: 200, + quotaErrorGeneration: 1, + }) + expect(stale.quotaErrorClearedAt).toBe(250) + expect(stale.quotaErrorGeneration).toBe(3) + expect(stale.lastQuotaRefreshError).toEqual(entry.lastQuotaRefreshError) + const cleared = applyNativeMetadataPatch(entry, { quotaErrorClearedAt: 350 }) + expect(cleared.quotaErrorClearedAt).toBe(350) + expect(cleared.lastQuotaRefreshError).toBeUndefined() +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 263c56009..026c9af70 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -18,6 +18,10 @@ "types": "./dist/tui.d.ts", "import": "./src/tui/entry.mjs" }, + "./server": { + "types": "./dist/v2/server.d.ts", + "import": "./dist/v2/server.js" + }, "./tui-prefs": { "types": "./dist/tui-preferences.d.ts", "import": "./dist/tui-preferences.js" @@ -45,7 +49,7 @@ "LICENSE" ], "scripts": { - "build": "rm -rf dist && bun build src/index.ts src/cli.ts src/sidebar-state.ts src/tui-preferences.ts src/rpc/rpc-client.ts src/rpc/port-file.ts src/rpc/protocol.ts src/rpc/rpc-dir.ts --outdir dist --target node --format esm --splitting --external @opencode-ai/plugin --external jsonc-parser --minify && tsc -p tsconfig.build.json --emitDeclarationOnly && bun run build:tui && bun run check:bundle", + "build": "rm -rf dist && bun build src/index.ts src/v2/server.ts src/v2/tui.ts src/cli.ts src/sidebar-state.ts src/tui-preferences.ts src/rpc/rpc-client.ts src/rpc/port-file.ts src/rpc/protocol.ts src/rpc/rpc-dir.ts --outdir dist --root src --target node --format esm --splitting --external @opencode-ai/plugin --external @opencode/plugin --external jsonc-parser --minify && tsc -p tsconfig.build.json --emitDeclarationOnly && bun run build:tui && bun run check:bundle", "build:tui": "bun scripts/build-tui.ts", "smoke:tui": "bun scripts/smoke-tui-pack-install.ts", "check:bundle": "bun scripts/check-bundle-globals.ts", @@ -59,7 +63,13 @@ "prepublishOnly": "bun run build" }, "peerDependencies": { - "@opencode-ai/plugin": "*" + "@opencode-ai/plugin": "*", + "@opencode/plugin": ">=2.0.21" + }, + "peerDependenciesMeta": { + "@opencode/plugin": { + "optional": true + } }, "dependencies": { "@clack/prompts": "^1.6.0", diff --git a/packages/opencode/scripts/check-bundle-globals.test.ts b/packages/opencode/scripts/check-bundle-globals.test.ts new file mode 100644 index 000000000..8336b682a --- /dev/null +++ b/packages/opencode/scripts/check-bundle-globals.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { checkBundleGlobals } from './check-bundle-globals' + +async function fixture(run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'anthropic-bundle-check-')) + try { + await run(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} +const body = (name = '__anthropicAuthRpcServers') => + `export const registry = ${JSON.stringify(name)};\n/*${'padding'.repeat(200)}*/` + +test('a small entry is valid when its imported chunk contains the implementation', async () => { + await fixture(async (root) => { + await writeFile( + join(root, 'index.js'), + 'export { registry } from "./shared.js"', + ) + await writeFile(join(root, 'shared.js'), body()) + await expect( + checkBundleGlobals(join(root, 'index.js')), + ).resolves.toBeUndefined() + }) +}) + +test('an unrelated chunk cannot supply the registry positive control', async () => { + await fixture(async (root) => { + await writeFile(join(root, 'index.js'), body('not-the-registry')) + await writeFile(join(root, 'unrelated.js'), body()) + await expect(checkBundleGlobals(join(root, 'index.js'))).rejects.toThrow( + 'positive-control', + ) + }) +}) + +test('a stale singleton in an imported chunk is refused', async () => { + await fixture(async (root) => { + await writeFile(join(root, 'index.js'), 'import "./shared.js"') + await writeFile( + join(root, 'shared.js'), + `${body()}\nexport const stale = "__anthropicAuthRpcServer"`, + ) + await expect(checkBundleGlobals(join(root, 'index.js'))).rejects.toThrow( + 'stale-global', + ) + }) +}) + +test('a missing imported chunk refuses the artifact', async () => { + await fixture(async (root) => { + await writeFile(join(root, 'index.js'), 'import "./missing.js"') + await expect(checkBundleGlobals(join(root, 'index.js'))).rejects.toThrow( + 'missing', + ) + }) +}) + +test('a chunk cycle is checked once without hiding a stale singleton', async () => { + await fixture(async (root) => { + await writeFile(join(root, 'index.js'), 'import "./shared.js"') + await writeFile( + join(root, 'shared.js'), + `import "./index.js";\n${body()}\nexport const stale = "__anthropicAuthRpcServer"`, + ) + await expect(checkBundleGlobals(join(root, 'index.js'))).rejects.toThrow( + 'stale-global', + ) + }) +}) diff --git a/packages/opencode/scripts/check-bundle-globals.ts b/packages/opencode/scripts/check-bundle-globals.ts index b1c966a65..f6826f56c 100644 --- a/packages/opencode/scripts/check-bundle-globals.ts +++ b/packages/opencode/scripts/check-bundle-globals.ts @@ -1,35 +1,61 @@ -import { readFile, stat } from 'node:fs/promises' -import { join } from 'node:path' +import { readFile, realpath } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' -const bundlePath = join(import.meta.dir, '..', 'dist', 'index.js') -const minBundleBytes = 1024 - -let size: number -try { - size = (await stat(bundlePath)).size -} catch { - throw new Error(`Bundle artifact check failed: ${bundlePath} is missing`) -} - -if (size <= minBundleBytes) { - throw new Error( - `Bundle artifact check failed: ${bundlePath} is not substantial (${size} bytes)`, - ) +export async function checkBundleGlobals(bundlePath: string): Promise { + const root = await realpath(dirname(bundlePath)) + const pending = [bundlePath] + const seen = new Set() + const sources: string[] = [] + const scanner = new Bun.Transpiler({ loader: 'js' }) + let size = 0 + while (pending.length) { + const next = pending.pop() + if (!next) continue + let path: string + let source: string + try { + path = await realpath(next) + const within = relative(root, path) + if ( + isAbsolute(within) || + within === '..' || + within.startsWith(`..${sep}`) + ) + throw new Error('Bundle import leaves dist') + if (seen.has(path)) continue + source = await readFile(path, 'utf8') + } catch { + throw new Error( + `Bundle artifact check failed: ${next} is missing or outside dist`, + ) + } + seen.add(path) + sources.push(source) + size += Buffer.byteLength(source) + for (const dependency of scanner.scanImports(source)) { + if (dependency.path.startsWith('.')) + pending.push(resolve(dirname(path), dependency.path)) + } + } + if (size <= 1024) + throw new Error( + `Bundle artifact check failed: ${bundlePath} is not substantial (${size} bytes)`, + ) + // Shared entry points can move implementation into chunks. Inspect only + // files imported by this entry, not unrelated files that happen to be in dist. + const bundle = sources.join('\n') + if (!bundle.includes('__anthropicAuthRpcServers')) + throw new Error( + 'Bundle positive-control check failed: __anthropicAuthRpcServers is absent', + ) + // This is a check for one obsolete identifier, not a complete global audit. + const singularMatches = + bundle.match(/__anthropicAuthRpcServer(?!s)/g)?.length ?? 0 + if (singularMatches) + throw new Error( + `Bundle stale-global check failed: __anthropicAuthRpcServer appears ${singularMatches} time(s)`, + ) } -const bundle = await readFile(bundlePath, 'utf8') -const registryMatches = bundle.match(/__anthropicAuthRpcServers/g)?.length ?? 0 -if (registryMatches === 0) { - throw new Error( - 'Bundle positive-control check failed: __anthropicAuthRpcServers is absent', - ) -} - -// This catches one identifier; the positive control makes its zero assertion meaningful, not proof that no other stale global exists. -const singularMatches = - bundle.match(/__anthropicAuthRpcServer(?!s)/g)?.length ?? 0 -if (singularMatches !== 0) { - throw new Error( - `Bundle stale-global check failed: __anthropicAuthRpcServer appears ${singularMatches} time(s)`, - ) -} +if (import.meta.main) + await checkBundleGlobals(join(import.meta.dir, '..', 'dist', 'index.js')) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 69b8472b6..78163592b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' import type { NativeRefreshFailure } from '@cortexkit/anthropic-auth-core' import { type AccountCommandStatusProjection, @@ -86,6 +87,7 @@ import { getStickyRoutingStatePath, getThinkingPrefixMismatchBehavior, type IdentityState, + inspectNativeHostAuthEntry, isApiKeyAccount, isCache1hEnabled, isCache1hPersistentlyEnabled, @@ -161,6 +163,8 @@ import { quotaSnapshotPassesModelScope, quotaSnapshotPassesPolicy, readClaustrumEnrollmentStatus, + readNativeEnrollmentMode, + readNativeHostAuthKind, readNativeMigrationJournal, refreshBackoffActive, remapRequestBodyModel, @@ -258,6 +262,7 @@ import { resolveContentFilterFallbackMode, type ServerSideFallbackOutcome, } from './server-fallback.ts' +import { getOpenCodeAuthPath } from './setup/paths.ts' import { getInitialSidebarRoutingTestHooks, getSidebarState, @@ -1323,18 +1328,16 @@ const anthropicAuthPlugin = async ( credentialEpoch: binding.credentialEpoch, } } - if ( - snapshot.mode !== 'claustrum' || - !snapshot.accounts.some((account) => account.source === 'vault') - ) - return snapshot.policyStorage + if (snapshot.mode !== 'claustrum') return snapshot.policyStorage const primary = snapshot.accounts.find( (account) => account.id === 'main' && account.source === 'vault', ) const primaryUuid = asProviderAccountUuid(primary?.accountIdentity) - // Routing uses scopedRoster to identify a discovered vault account pool. - // Derive it from native account rows, not legacy config. This is routing - // metadata only; each physical send obtains a fresh vault credential. + // Enrollment, not account settings, selects vault custody, so an enrolled + // host routes over the vault account pool even before its first roster + // discovery has listed any account. Derive the pool marker from the + // native mode, not legacy config. This is routing metadata only; each + // physical send obtains a fresh vault credential. return { ...snapshot.policyStorage, claustrum: { @@ -1728,11 +1731,43 @@ const anthropicAuthPlugin = async ( const desktopNoticeIdleUserMessages = new Map() const desktopNoticeMessageIds = new Map>() const desktopNoticeProbes = new Map() - const stickySessionRouter = new StickySessionRouter({ - path: - process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE || - getStickyRoutingStatePath(accountStoragePath), - }) + // Session-to-account assignments live in separate files for local and vault + // accounts. A local host keeps its configured assignment file; an enrolled + // host uses only the vault routing file the native runtime names, so vault + // routing never opens or rewrites the local file. The runtime's mode is read + // per use because enrollment can change while the plugin runs. + const localStickyRoutingPath = + process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE || + getStickyRoutingStatePath(accountStoragePath) + const stickySessionRouters = new Map() + async function currentStickyRouting(): Promise<{ + router: StickySessionRouter + accountIdentities?: ReadonlyMap + }> { + const snapshot = await nativeAccounts.read() + let path = localStickyRoutingPath + // Vault pins are bound to the verified account UUID behind each route, so + // a route whose vault account was replaced (such as a new default account + // under the same credential) loses its pins and quota preference, while + // other accounts keep theirs. Local routing passes no map and keeps its + // assignments unchanged. + let accountIdentities: Map | undefined + if (snapshot.mode === 'claustrum') { + if (!snapshot.routingPath) + throw new Error('Vault routing storage is unavailable') + path = snapshot.routingPath + accountIdentities = new Map() + for (const account of snapshot.accounts) + if (account.source === 'vault' && account.accountIdentity) + accountIdentities.set(account.id, account.accountIdentity) + } + let router = stickySessionRouters.get(path) + if (!router) { + router = new StickySessionRouter({ path }) + stickySessionRouters.set(path, router) + } + return { router, ...(accountIdentities && { accountIdentities }) } + } // The runtime stores quota readings for the account and tokens used to fetch // them. QuotaManager caches those readings and when they were checked. // A failed poll supplies no exhaustion evidence and cannot prove that a @@ -1990,6 +2025,8 @@ const anthropicAuthPlugin = async ( * later display does not wait on the same attempt again. */ deadline?: AbortSignal + /** True once the profile response arrived or the attempt failed. */ + settled?: boolean profile?: OAuthAccountProfile persistence?: 'pending' | 'saved' | 'refused' | 'failed' isCurrent?: () => Promise @@ -2052,6 +2089,7 @@ const anthropicAuthPlugin = async ( ) .then( ({ profile, persisted, isCurrent }) => { + hydration.settled = true // Keep a profile only for the account it was read for. A route // whose identity was not yet known accepts the identity this read // reports; the display checks it against the route again later. @@ -2101,6 +2139,7 @@ const anthropicAuthPlugin = async ( void saving.then(() => profilePublications.delete(saving)) }, (error: unknown) => { + hydration.settled = true logger.debug('quota', 'failed to hydrate account profile', { account: account.id, error: error instanceof Error ? error.message : String(error), @@ -2172,6 +2211,20 @@ const anthropicAuthPlugin = async ( continue const key = profileHydrationKey(account.id, account.accountIdentity) let hydration = profileHydrations.get(key) + if (!hydration && account.accountIdentity !== undefined) { + // A profile attempt for a route whose account was not yet known + // first authorizes the route, and that records the account identity. + // A display that starts while such an attempt is still in flight + // therefore sees the route as identified. Join the in-flight attempt + // instead of sending a second profile request for the same route; the + // check after the wait still requires the fetched profile to name + // this account before it is filed or shown. + const earlier = profileHydrations.get(profileHydrationKey(account.id)) + if (earlier && !earlier.settled) { + hydration = earlier + unidentified.set(account.id, earlier) + } + } if ( !hydration || (hydration.profile && !oauthProfileIsFresh(hydration.profile, now)) @@ -3002,6 +3055,88 @@ const anthropicAuthPlugin = async ( let latestGetActivation: | (() => Promise<{ type: string; active: boolean }>) | null = null + // OpenCode's own reader for this host's Anthropic login, captured from the + // first auth.loader call. The vault login method prefers it to reading + // OpenCode's auth file itself. + let latestHostGetAuth: (() => Promise) | null = null + + /** The native placeholder, field for field; a look-alike is a different login. */ + function isExactNativePlaceholder(auth: unknown) { + const placeholder = custodyTombstoneOAuth('anthropic') + if (!auth || typeof auth !== 'object') return false + const value = auth as Record + return ( + value.type === placeholder.type && + value.access === placeholder.access && + value.refresh === placeholder.refresh && + value.expires === placeholder.expires + ) + } + + /** + * What OpenCode's login store holds for Anthropic: 'absent' or the kind of + * login. Uses OpenCode's reader when a loader call supplied one; otherwise + * reads OpenCode's auth file without changing it. Anything that cannot be + * read or recognized with certainty throws, so callers refuse rather than + * treat the slot as empty. + */ + async function hostAnthropicLoginKind(): Promise< + ReturnType['kind'] + > { + if (latestHostGetAuth) { + // OpenCode's reader returns the Anthropic entry itself; undefined means + // there is none, while null or a malformed entry is refused. + const entry = await latestHostGetAuth() + return inspectNativeHostAuthEntry( + 'opencode', + entry === undefined ? {} : { anthropic: entry }, + ).kind + } + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) + throw new Error( + 'OpenCode logins cannot be checked while OPENCODE_AUTH_CONTENT is set', + ) + return readNativeHostAuthKind('opencode', getOpenCodeAuthPath()) + } + + /** + * The vault login only writes the native placeholder into an empty + * Anthropic slot, and only while this host is enrolled with the vault. + */ + async function requireVaultLoginAllowed() { + if ((await readNativeEnrollmentMode('opencode')) !== 'claustrum') + throw new Error( + 'This host is not connected to the credential vault; run setup to connect it first', + ) + if ((await hostAnthropicLoginKind()) !== 'absent') + throw new Error( + 'OpenCode already holds an Anthropic login; remove it before choosing the vault login', + ) + } + const vaultLoginMethod = { + label: 'Claude via credential vault', + type: 'oauth' as const, + authorize: async () => { + await requireVaultLoginAllowed() + return { + url: '', + instructions: + 'Claude requests use the credential vault this host is connected to. OpenCode stores only a placeholder, never a credential.', + method: 'auto' as const, + callback: async () => { + // The slot or enrollment may have changed while OpenCode waited. + await requireVaultLoginAllowed() + const placeholder = custodyTombstoneOAuth('anthropic') + return { + type: 'success' as const, + access: placeholder.access, + refresh: placeholder.refresh, + expires: placeholder.expires, + } + }, + } + }, + } const cacheKeepRegistry = new CacheKeepSessionRegistry({ directory: process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR || @@ -4479,7 +4614,7 @@ const anthropicAuthPlugin = async ( } if (action.type === 'reset' && sessionId) { - await stickySessionRouter.clear(sessionId) + await (await currentStickyRouting()).router.clear(sessionId) logger.info('commands', 'sticky routing assignment reset') } @@ -4620,9 +4755,9 @@ const anthropicAuthPlugin = async ( // -- add-oauth-start --------------------------------------------------- if (action.type === 'add-oauth-start') { if ( - getClaustrumMode(await loadAccounts(accountStoragePath)) === 'claustrum' + (await readNativeEnrollmentMode('opencode', process.env)) !== 'local' ) { - throw new Error('Exit Claustrum mode first: /claude-account local') + throw new Error('Disconnect from the vault first.') } const authResult = await authorizeImpl('max') const entry: OAuthPendingEntry = { @@ -5680,6 +5815,7 @@ const anthropicAuthPlugin = async ( active: isCustodyTombstoneOAuth(current, 'anthropic'), } } + latestHostGetAuth = hostGetAuth const activation = await hostGetAuth() // Ordinary OpenCode API-key authentication remains unchanged. After // migration, the OAuth marker only enables our provider. Each send must @@ -5695,6 +5831,32 @@ const anthropicAuthPlugin = async ( }, } } + // While this host is enrolled with the vault, only the exact native + // placeholder enables Claude. Any other OAuth login in OpenCode's slot + // is left byte-for-byte as it is and never used, refreshed or checked + // with the provider; requests refuse with the way out instead. + let enrolled: boolean + try { + enrolled = + (await readNativeEnrollmentMode('opencode')) === 'claustrum' + } catch (error) { + return { + apiKey: '', + fetch: async () => { + throw error + }, + } + } + if (enrolled && !isExactNativePlaceholder(activation)) { + return { + apiKey: '', + fetch: async () => { + throw new Error( + 'This host uses the credential vault, but OpenCode holds a separate Anthropic login. Remove it and choose the vault login, or disconnect from the vault.', + ) + }, + } + } if (nativeStartupError) { return { apiKey: '', @@ -5981,7 +6143,13 @@ const anthropicAuthPlugin = async ( main, fallbacks: fallbackDimensions.fallbacks, evidence: mainEvidence, - authority: nativeMigrationAuthorityPhase(journal), + // Vault files have no migration journal: the host's enrollment + // alone authorizes them, and it already selected vault mode. + // Local accounts still require the committed migration record. + authority: + custodyMode === 'claustrum' + ? 'committed' + : nativeMigrationAuthorityPhase(journal), }) } catch (error) { if (!(error instanceof CustodyStateMismatchError)) throw error @@ -6075,24 +6243,6 @@ const anthropicAuthPlugin = async ( mainAccessToken: auth.access, routingAuthoritative: false, }) - if ( - process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION !== - '1' - ) { - void ensureProfilesForQuotaDisplay( - initialStorage ?? createEmptyStorage(), - auth.access, - ) - .then((displayStorage) => { - writeSidebarState(displayStorage, { - activeId: 'main', - route: 'main', - mainAccessToken: auth.access, - routingAuthoritative: false, - }) - }) - .catch(() => {}) - } function isReplayableRequest( input: string | URL | Request, @@ -8321,8 +8471,11 @@ const anthropicAuthPlugin = async ( requestedModelId: routingModelId, mainQuotaIdentity: requestMainQuotaIdentity, }) - const resolveRoute = (excludeAccountIds?: Set) => - stickySessionRouter.resolve({ + const resolveRoute = async ( + excludeAccountIds?: Set, + ) => { + const routing = await currentStickyRouting() + return routing.router.resolve({ sessionId, family, modelId: routingModelId, @@ -8336,7 +8489,11 @@ const anthropicAuthPlugin = async ( : 1, preferredAccountId, excludeAccountIds, + ...(routing.accountIdentities && { + accountIdentities: routing.accountIdentities, + }), }) + } const mainPermanentlyUnavailable = isPermanentRefreshError( stickyRoutes.storage?.refresh?.mainLastRefreshError, ) @@ -9355,96 +9512,109 @@ const anthropicAuthPlugin = async ( return {} }, - methods: [ - { - label: 'Claude Pro/Max', - type: 'oauth', - authorize: async () => { - if ( - getClaustrumMode(await loadAccounts(accountStoragePath)) === - 'claustrum' - ) { - throw new Error( - 'Exit Claustrum mode first: /claude-account local', - ) - } - if (process.env.OPENCODE_AUTH_CONTENT !== undefined) { - throw new Error( - 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set', - ) - } - const result = await authorizeImpl('max') - return { - url: result.url, - instructions: 'Paste the authorization code here:', - method: 'code', - callback: async (code: string) => { - const exchanged = await exchange( - code, - result.verifier, - result.redirectUri, - result.state, + // Offered only while this host holds a vault enrollment token; the + // method itself rechecks vault authority and the empty slot. + get methods() { + return [ + ...(existsSync(getHostClaustrumEnrollmentPaths('opencode').tokenPath) + ? [vaultLoginMethod] + : []), + { + label: 'Claude Pro/Max', + type: 'oauth', + authorize: async () => { + // Enrollment alone decides whether this host signs in locally; + // check it before the browser sign-in starts. Saving the new + // login is refused again by the native runtime if the host + // enrolls while the user is signing in. + if ( + (await readNativeEnrollmentMode('opencode', process.env)) !== + 'local' + ) { + throw new Error('Disconnect from the vault first.') + } + // Local sign-in saves into the migrated local account pool; + // reading it refuses before the browser flow when offline + // migration has not run. + await nativeAccounts.read() + if (process.env.OPENCODE_AUTH_CONTENT !== undefined) { + throw new Error( + 'Local login cannot be verified while OPENCODE_AUTH_CONTENT is set', ) - if (exchanged.type === 'failed') return exchanged - assertNativeEnvironment() - await nativeAccounts.loginOAuth({ - routeId: 'main', - replace: true, - credential: { - access: exchanged.access, - refresh: exchanged.refresh, - expires: exchanged.expires, - }, - }) - const activation = custodyTombstoneOAuth('anthropic') - return { - type: 'success' as const, - access: activation.access, - refresh: activation.refresh, - expires: activation.expires, - } - }, - } + } + const result = await authorizeImpl('max') + return { + url: result.url, + instructions: 'Paste the authorization code here:', + method: 'code', + callback: async (code: string) => { + const exchanged = await exchange( + code, + result.verifier, + result.redirectUri, + result.state, + ) + if (exchanged.type === 'failed') return exchanged + assertNativeEnvironment() + await nativeAccounts.loginOAuth({ + routeId: 'main', + replace: true, + credential: { + access: exchanged.access, + refresh: exchanged.refresh, + expires: exchanged.expires, + }, + }) + const activation = custodyTombstoneOAuth('anthropic') + return { + type: 'success' as const, + access: activation.access, + refresh: activation.refresh, + expires: activation.expires, + } + }, + } + }, }, - }, - { - label: 'Create an API Key', - type: 'oauth', - authorize: async () => { - const result = await authorize('console') - return { - url: result.url, - instructions: 'Paste the authorization code here:', - method: 'code', - callback: async (code: string) => { - const credentials = await exchange( - code, - result.verifier, - result.redirectUri, - result.state, - ) - if (credentials.type === 'failed') return credentials - const apiKey = await fetch( - `https://api.anthropic.com/api/oauth/claude_cli/create_api_key`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - authorization: `Bearer ${credentials.access}`, + { + label: 'Create an API Key', + type: 'oauth', + authorize: async () => { + const result = await authorize('console') + return { + url: result.url, + instructions: 'Paste the authorization code here:', + method: 'code', + callback: async (code: string) => { + const credentials = await exchange( + code, + result.verifier, + result.redirectUri, + result.state, + ) + if (credentials.type === 'failed') return credentials + const apiKey = await fetch( + `https://api.anthropic.com/api/oauth/claude_cli/create_api_key`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authorization: `Bearer ${credentials.access}`, + }, }, - }, - ).then((r) => r.json() as Promise<{ raw_key: string }>) - return { type: 'success' as const, key: apiKey.raw_key } - }, - } + ).then((r) => r.json() as Promise<{ raw_key: string }>) + return { type: 'success' as const, key: apiKey.raw_key } + }, + } + }, }, - }, - { - provider: 'anthropic', - label: 'Manually enter API Key', - type: 'api', - }, - ], + { + provider: 'anthropic', + label: 'Manually enter API Key', + type: 'api', + }, + ] + }, }, dispose, __primeManager: primeManager, diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index 93f31b139..7eec6248d 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -635,24 +635,24 @@ describe('executeAccountCommand status', () => { expect(result.text).not.toContain('request`touch') }) - test('routes enrollment-reset only in Claustrum mode', async () => { - const local = await executeAccountCommand({ - argumentsText: 'enrollment-reset', - storage: baseStorage(), - resetEnrollment: async () => ({ text: 'unexpected' }), - }) - expect(local.text).toContain('only in Claustrum mode') - - const storage = baseStorage() - storage.claustrum = { mode: 'claustrum' } + test('terminal enrollment reset delegates to enrollment state without a stored custody-mode prerequisite', async () => { const resetEnrollment = mock(async () => ({ text: 'reset' })) - const result = await executeAccountCommand({ + for (const mode of ['local', 'claustrum'] as const) { + const storage = baseStorage() + storage.claustrum = { mode } + const result = await executeAccountCommand({ + argumentsText: 'enrollment-reset', + storage, + resetEnrollment, + }) + expect(result.text).toBe('reset') + } + expect(resetEnrollment).toHaveBeenCalledTimes(2) + const unavailable = await executeAccountCommand({ argumentsText: 'enrollment-reset', - storage, - resetEnrollment, + storage: baseStorage(), }) - expect(result.text).toBe('reset') - expect(resetEnrollment).toHaveBeenCalledTimes(1) + expect(unavailable.text).toBe('Claustrum enrollment reset is unavailable.') }) test('projects a scoped fallback conservatively without a status projection', async () => { diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index a3025e30b..606b67199 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -38,7 +38,6 @@ import { createNativePoolStore, custodyTombstoneOAuth, extractBillingHeaderCCH, - FallbackAccountManager, getAccountStatePath, getClaudeCodeIdentityForVerifiedAccount, getDumpDirectory, @@ -67,6 +66,11 @@ import { } from '@cortexkit/anthropic-auth-core' import { withLock } from '@cortexkit/common-auth/fs' import { nativeQuotaCodec } from '../../../core/src/native-quota-codec.ts' +import type { NativeRefreshObservation } from '../../../core/src/native-refresh-coordinator.ts' +import { + publishNativeLocalRefreshFailure, + readNativeRuntime, +} from '../../../core/src/native-runtime.ts' import { TestLifetime } from '../../../core/src/tests/test-lifetime.ts' import { EFFORT_MARKER_PREFIX } from '../effort-history' import { AnthropicAuthPlugin } from '../index' @@ -1248,16 +1252,27 @@ async function updatePoolSettings( /** * Add an alternative OAuth account through a separate account runtime. * Its tokens belong only in shared storage, never in OpenCode's auth.json. + * Sign-in verifies which Claude account the access token belongs to; here + * that lookup answers `accountIdentity` (a new synthetic account by default) + * instead of calling the bootstrap endpoint. */ async function addPoolOAuthAccount( routeId: string, credential: { access: string; refresh: string; expires: number }, + accountIdentity: string = randomUUID(), ) { const pool = migratedPool if (!pool) throw new Error('This test has no migrated pool') const runtime = createNativeAccountRuntime({ paths: pool.paths, host: 'opencode', + local: { + resolveIdentity: async () => ({ + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: accountIdentity as ProviderAccountUuid, + }), + }, }) try { await runtime.loginOAuth({ routeId, credential, replace: false }) @@ -2005,27 +2020,15 @@ describe('scoped enrollment is explicit', () => { }) test('enrollment-reset only clears terminal state; setup owns the next proposal', async () => { - const vault = mainOnlyVault() - const fixture = await useVaultAccountFile( - createFallbackStorage({ - accounts: [], - mainAccountId: 'primary-route', - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: 'oauth:anthropic', - accountId: syntheticMainIdentity as ProviderAccountUuid, - state: 'active', - }, - }, - }), - vault, - ) - // Represent lost host enrollment by removing its consumer token. Keep a - // terminal refusal record so reset clears that record without proposing. - await rm(fixture.enrollmentPath) - const stateFile = fixture.enrollmentPath.replace(/\.json$/, '-state.json') + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + if (!tempConfigDir) throw new Error('Missing local fixture directory') + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join( + tempConfigDir, + 'opencode-enrollment.json', + ) + // A terminal refused ceremony has no approved token. Local custody can + // clear that record without proposing another enrollment. + const stateFile = join(tempConfigDir, 'opencode-enrollment-state.json') await writeFile( stateFile, JSON.stringify({ @@ -2037,9 +2040,7 @@ describe('scoped enrollment is explicit', () => { }), { mode: 0o600 }, ) - const plugin = await getPlugin(createMockClient(), fixture.root, { - claustrumScopedConnect: async () => vault, - }) + const plugin = await getPlugin(createMockClient(), tempConfigDir) const result = await applyMenuAction(plugin, 'ses_reset_only', { sectionId: 'Accounts', actionId: 'enrollment-reset', @@ -2195,39 +2196,104 @@ describe('fallback quota persistence ordering', () => { test('does not restore a late quota error older than the persisted success', async () => { const successAt = Date.now() await useTempAccountFile( - createFallbackStorage({ - accounts: [ - { - id: 'fallback-late-error', - type: 'oauth', - access: 'sk-ant-oat01-fallback-access', - refresh: 'fallback-refresh', - expires: successAt + 5 * 60 * 60 * 1000, - quota: { - five_hour: { - usedPercent: 10, - remainingPercent: 90, - checkedAt: successAt, - }, - seven_day: { - usedPercent: 20, - remainingPercent: 80, - checkedAt: successAt, + bindPoolAccounts( + createFallbackStorage({ + accounts: [ + { + id: 'fallback-late-error', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: successAt + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: successAt, + }, + seven_day: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: successAt, + }, }, }, - }, - ], - }), + ], + }), + ), ) - const plugin = await getPlugin() - await plugin.__persistFallbackQuotaErrorForTest('fallback-late-error', { - message: 'older quota failure', - checkedAt: successAt - 1, - nextRetryAt: successAt + 60_000, - retryCount: 1, - accountIdentity: 'fallback-late-error', + if (!migratedPool) throw new Error('Missing native quota fixture') + const identity = poolLogins.find( + (login) => login.access === 'sk-ant-oat01-fallback-access', + )?.identity + if (!identity) throw new Error('Missing fallback fixture identity') + const runtime = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + local: { + refreshToken: async () => ({ + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: successAt + 5 * 60 * 60 * 1_000, + expiresIn: 5 * 60 * 60, + }), + resolveIdentity: async () => + getClaudeCodeIdentityForVerifiedAccount( + 'fallback-late-error', + identity as ProviderAccountUuid, + ), + now: () => successAt, + }, }) + const plugin = await getPlugin() + try { + const authorized = await runtime.authorizeLocal('fallback-late-error') + expect(authorized.status).toBe('usable') + if (authorized.status !== 'usable') + throw new Error( + `Fallback quota fixture is not authorized: ${authorized.status}`, + ) + let quotaRequests = 0 + await runtime.fetchQuota( + 'fallback-late-error', + Object.assign( + async (input: Parameters[0]) => { + expect(extractUrl(input)).toBe( + 'https://api.anthropic.com/api/oauth/usage', + ) + quotaRequests++ + return Response.json({ + five_hour: { utilization: 10, resets_at: null }, + seven_day: { utilization: 20, resets_at: null }, + }) + }, + { + preconnect() { + throw new Error('Unexpected fixture preconnect') + }, + }, + ), + ) + expect(quotaRequests).toBe(1) + expect( + (await readAccountStorage())?.accounts.find( + (account): account is OAuthAccount => + account.id === 'fallback-late-error' && isOAuthAccount(account), + )?.quota?.five_hour?.usedPercent, + ).toBe(10) + await runtime.publishLocal(authorized.subject, { + lastQuotaRefreshError: { + message: 'older quota failure', + checkedAt: successAt - 1, + nextRetryAt: successAt + 60_000, + retryCount: 1, + accountIdentity: identity, + }, + }) + } finally { + runtime.close() + } await plugin.dispose?.() const saved = await readAccountStorage() @@ -4033,7 +4099,7 @@ describe('quota header feed integration', () => { ], }), }), - ).rejects.toThrow('Native OAuth authorization refused: refused') + ).rejects.toThrow('Claude OAuth refresh is backed off') expect(calls).toBe(0) expect( (await readAccountStorage())?.refresh?.mainLastRefreshError?.permanent, @@ -4422,54 +4488,97 @@ describe('main identity boot order', () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION }) - test('mints before background refresh can observe storage and survives token rotation', async () => { + test('publishes main identity before background timers observe storage and preserves it across native token rotation', async () => { await useTempAccountFile( - createFallbackStorage({ quota: { enabled: false }, accounts: [] }), + bindPoolAccounts( + createFallbackStorage({ quota: { enabled: false }, accounts: [] }), + ), + { ...syntheticMainHostAuth(), expires: Date.now() + 8 * 60 * 60_000 }, ) process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + const timerCallbacks: (() => void)[] = [] const observedIds: (string | undefined)[] = [] const timerObservedIds: (string | undefined)[] = [] let authCalls = 0 const auth = () => { authCalls += 1 if (authCalls > 1) { - void readAccountStorage().then((storage) => - observedIds.push(storage?.mainAccountId), + bodyLifetime().trackDetached( + readAccountStorage().then((storage) => + observedIds.push(storage?.mainAccountId), + ), ) } - return Promise.resolve({ - type: 'oauth' as const, - access: `access-${authCalls}`, - refresh: `refresh-${authCalls}`, - expires: Date.now() + 100_000, - }) + return Promise.resolve(custodyTombstoneOAuth('anthropic')) } const plugin = await getPlugin(undefined, undefined, { setInterval: ((callback: () => void) => { - const config = JSON.parse( - readFileSync(process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, 'utf8'), - ) as { mainAccountId?: unknown } + if (!migratedPool) throw new Error('Missing native main fixture') + const runtime = JSON.parse( + readFileSync(migratedPool.paths.runtime, 'utf8'), + ) as { + accounts: Record< + string, + { binding: { kind: string; rowId: string; identity?: string } } + > + } timerObservedIds.push( - typeof config.mainAccountId === 'string' - ? config.mainAccountId - : undefined, + Object.values(runtime.accounts).find( + (entry) => entry.binding.kind === 'local', + )?.binding.identity, ) - callback() + timerCallbacks.push(callback) return { unref() {} } as unknown as ReturnType }) as typeof setInterval, clearInterval: (() => {}) as typeof clearInterval, }) - timerObservedIds.length = 0 - await plugin.auth.loader(auth, { models: {} }) - await Bun.sleep(25) const first = (await readAccountStorage())?.mainAccountId + if (!first || !migratedPool) throw new Error('Missing native main identity') + const runtime = createNativeAccountRuntime({ + paths: migratedPool.paths, + host: 'opencode', + local: { + refreshToken: async () => ({ + access: 'sk-ant-oat01-main-boot-rotated', + refresh: 'main-boot-rotated-refresh', + expires: Date.now() + 8 * 60 * 60_000, + expiresIn: 8 * 60 * 60, + }), + resolveIdentity: async () => + getClaudeCodeIdentityForVerifiedAccount( + 'main', + poolMainIdentity() as ProviderAccountUuid, + ), + }, + }) + try { + const rotated = await runtime.authorizeLocal('main', { + intent: 'refresh', + }) + if (rotated.status !== 'usable') + throw new Error( + `Native main rotation failed: ${rotated.status} ${'failure' in rotated ? JSON.stringify(rotated.failure) : 'reason' in rotated ? rotated.reason : ''}`, + ) + expect(rotated.status).toBe('usable') + expect(rotated.access).toBe('sk-ant-oat01-main-boot-rotated') + } finally { + runtime.close() + } + const mainLogin = poolLogins[0] + if (!mainLogin) throw new Error('Missing main mock account') + mainLogin.access = 'sk-ant-oat01-main-boot-rotated' + mainLogin.refresh = 'main-boot-rotated-refresh' await plugin.auth.loader(auth, { models: {} }) + await Promise.all( + timerCallbacks.map((callback) => Promise.resolve(callback())), + ) const second = (await readAccountStorage())?.mainAccountId expect(first).toMatch(/^[0-9a-f-]{36}$/) expect(second).toBe(first) - expect(timerObservedIds).toEqual([first, first]) + expect(timerObservedIds.length).toBeGreaterThan(0) + expect(timerObservedIds.every((identity) => identity === first)).toBe(true) expect(observedIds).toContain(first) }) }) @@ -4550,61 +4659,84 @@ describe('AnthropicAuthPlugin', () => { test('refresh error merge prefers the newer observation by checkedAt', async () => { const accountId = 'fallback-fence-recency' const now = Date.now() - const errorAt = (checkedAt: number, message: string) => - buildRefreshOperationError({ - error: new ClaudeOAuthRefreshError( - 400, - `{"error":"invalid_grant","m":"${message}"}`, - ), - now: checkedAt, - accountIdentity: accountId, - }) await useTempAccountFile( - createFallbackStorage({ - quota: { enabled: false }, - accounts: [ - { - id: accountId, - type: 'oauth', - access: 'sk-ant-oat01-dead', - refresh: 'dead-refresh', - expires: now + 3 * 60 * 60_000, - lastRefreshedAt: now - 60_000, - lastRefreshError: errorAt(now - 200_000, 'older-latch'), - }, - ], - claustrum: { mode: 'local' }, - }), - ) - const newerSnapshot = await readAccountStorage() - if (!newerSnapshot) throw new Error('missing test storage') - const newerAccount = newerSnapshot.accounts.find( - (account): account is OAuthAccount => - account.id === accountId && isOAuthAccount(account), - ) - if (!newerAccount) throw new Error('missing account') - newerAccount.lastRefreshError = errorAt(now, 'newer-incoming') - await saveAccounts(newerSnapshot) - const afterNewer = (await readAccountStorage())?.accounts.find( - (account): account is OAuthAccount => - account.id === accountId && isOAuthAccount(account), - ) - expect(afterNewer?.lastRefreshError?.message).toContain('newer-incoming') - - const olderSnapshot = await readAccountStorage() - if (!olderSnapshot) throw new Error('missing test storage') - const olderAccount = olderSnapshot.accounts.find( - (account): account is OAuthAccount => - account.id === accountId && isOAuthAccount(account), - ) - if (!olderAccount) throw new Error('missing account') - olderAccount.lastRefreshError = errorAt(now - 300_000, 'older-incoming') - await saveAccounts(olderSnapshot) - const afterOlder = (await readAccountStorage())?.accounts.find( - (account): account is OAuthAccount => - account.id === accountId && isOAuthAccount(account), + bindPoolAccounts( + createFallbackStorage({ + quota: { enabled: false }, + accounts: [ + { + id: accountId, + type: 'oauth', + access: 'sk-ant-oat01-dead', + refresh: 'dead-refresh', + expires: now + 8 * 60 * 60_000, + }, + ], + }), + ), ) - expect(afterOlder?.lastRefreshError?.message).toContain('newer-incoming') + if (!migratedPool) throw new Error('Missing native refresh fixture') + const paths = migratedPool.paths + const runtime = createNativeAccountRuntime({ paths, host: 'opencode' }) + try { + const subject = await runtime.captureLocalSubject(accountId) + const read = await readNativeRuntime(paths.runtime, paths.storageId) + const entry = Object.values( + read.status === 'ready' ? read.state.accounts : {}, + ).find( + (value) => + value.binding.kind === 'local' && + value.binding.rowId === subject.binding.rowId, + ) + const event: NativeRefreshObservation = { + status: 'failed', + failure: { + kind: 'provider', + classification: 'invalid-grant', + status: 400, + }, + persisted: false, + binding: subject.binding, + credentialFingerprint: subject.credentialFingerprint, + credentialVersion: subject.version, + attempt: 1, + bootstrap: 'unavailable', + handoff: 'not-needed', + context: { + subject, + runtimeBinding: + entry?.binding.kind === 'local' ? entry.binding : null, + refreshErrorClearedAt: entry?.refreshErrorClearedAt ?? null, + quotaErrorClearedAt: entry?.quotaErrorClearedAt ?? null, + quotaErrorGeneration: entry?.quotaErrorGeneration ?? null, + }, + } + const publish = (checkedAt: number) => + publishNativeLocalRefreshFailure(paths, event, { + checkedAt, + nextRetryAt: checkedAt + 86_400_000, + retryCount: 1, + }) + expect((await publish(now - 200_000)).status).toBe('published') + expect((await publish(now)).status).toBe('published') + const beforeStale = await readFile(paths.runtime, 'utf8') + expect(await publish(now - 300_000)).toEqual({ + status: 'superseded', + reason: 'older-than-stored', + }) + expect(await readFile(paths.runtime, 'utf8')).toBe(beforeStale) + const afterOlder = (await readAccountStorage())?.accounts.find( + (account): account is OAuthAccount => + account.id === accountId && isOAuthAccount(account), + ) + expect(afterOlder?.lastRefreshError).toMatchObject({ + checkedAt: now, + status: 400, + permanent: true, + }) + } finally { + runtime.close() + } }) }) @@ -6021,11 +6153,7 @@ describe('Fable 5.1 request-scoped effort history', () => { describe('meaningful trailing assistant history', () => { const originalFetch = globalThis.fetch - let restoreFallbackSelection: (() => void) | undefined - afterEach(async () => { - restoreFallbackSelection?.() - restoreFallbackSelection = undefined globalThis.fetch = originalFetch __setLogTestSink(null) await drainSidebarWrites() @@ -6127,11 +6255,9 @@ describe('meaningful trailing assistant history', () => { { models: {} }, ) - const fallbackSelection = spyOn( - FallbackAccountManager.prototype, - 'getUsableFallbackAccounts', - ) - restoreFallbackSelection = () => fallbackSelection.mockRestore() + const fallbackUseBefore = (await readAccountStorage())?.accounts.find( + (account) => account.id === 'fallback-1', + )?.lastUsed await expectLocalRefusal( await auth.fetch(MESSAGES_URL, { method: 'POST', @@ -6140,7 +6266,11 @@ describe('meaningful trailing assistant history', () => { }), ) expect(modelDispatches).toEqual([]) - expect(fallbackSelection).not.toHaveBeenCalled() + expect( + (await readAccountStorage())?.accounts.find( + (account) => account.id === 'fallback-1', + )?.lastUsed, + ).toBe(fallbackUseBefore) await drainSidebarWrites() expect((await getSidebarState()).activeId).not.toBe('fallback-1') expect(logs).toContainEqual({ @@ -6170,7 +6300,13 @@ describe('meaningful trailing assistant history', () => { 'Bearer sk-ant-oat01-main-access', 'Bearer sk-ant-oat01-fallback-access', ]) - expect(fallbackSelection).toHaveBeenCalled() + await drainSidebarWrites() + expect((await getSidebarState()).activeId).toBe('fallback-1') + expect( + (await readAccountStorage())?.accounts.find( + (account) => account.id === 'fallback-1', + )?.lastUsed, + ).toBeGreaterThan(0) }) test('API-key route refuses locally without model dispatch', async () => { @@ -7052,9 +7188,28 @@ describe('auth.loader', () => { ) const pool = migratedPool if (!pool) throw new Error('Expected migrated pool') + // Sign-in verifies the account behind each new access token; answer + // that lookup from this map instead of the bootstrap endpoint. + const signInIdentities = new Map([ + [ + 'sk-ant-oat01-work-current-access', + '99999999-2222-4333-8444-555555555555', + ], + ]) const peer = createNativeAccountRuntime({ paths: pool.paths, host: 'opencode', + local: { + resolveIdentity: async (access) => { + const identity = signInIdentities.get(access) + if (!identity) throw new Error('Unknown synthetic sign-in token') + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: identity as ProviderAccountUuid, + } + }, + }, }) try { const witness = (await peer.read()).accounts.find( @@ -7062,6 +7217,10 @@ describe('auth.loader', () => { ) if (!witness?.accountIdentity) throw new Error('Expected known witness UUID') + signInIdentities.set( + 'sk-ant-oat01-work-witness-current-access', + witness.accountIdentity, + ) const staleSubject = requireKnownPoolSubject( await peer.captureLocalSubject('work-deleted'), ) @@ -7077,7 +7236,6 @@ describe('auth.loader', () => { await peer.loginOAuth({ routeId: 'work-current', replace: false, - accountIdentity: '99999999-2222-4333-8444-555555555555', credential: { access: 'sk-ant-oat01-work-current-access', refresh: 'work-current-refresh', @@ -7087,7 +7245,6 @@ describe('auth.loader', () => { await peer.loginOAuth({ routeId: 'work-witness', replace: true, - accountIdentity: witness.accountIdentity, credential: { access: 'sk-ant-oat01-work-witness-current-access', refresh: 'work-witness-current-refresh', @@ -10853,6 +11010,7 @@ describe('auth.loader', () => { await useTempAccountFile(createFallbackStorage()) const mainProfile = heldResponse() const mainProfileStarted = lifetimeSignal() + const fallbackProfileStarted = lifetimeSignal() const firstFetchCalls: string[] = [] globalThis.fetch = mock( withNativeAdmission( @@ -10873,6 +11031,7 @@ describe('auth.loader', () => { mainProfileStarted.raise() return mainProfile.response } + fallbackProfileStarted.raise() return Promise.resolve( Response.json({ organization: { @@ -10895,7 +11054,13 @@ describe('auth.loader', () => { }), { models: {} }, ) + // Each route's account lookup (bootstrap) runs before its profile request + // and goes through the global fetch of the moment, not the fetch the + // plugin captured for profiles. Replace the global fetch only once both + // profile requests have been sent, so the replacement can only be + // reached by work that does not keep the plugin's own fetch. await mainProfileStarted.raised + await fallbackProfileStarted.raised const nextTestFetch = mock(() => Promise.resolve(new Response('ok'))) globalThis.fetch = nextTestFetch as unknown as typeof fetch @@ -22113,6 +22278,30 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { }) }) +async function expirePoolCredentialForPrime( + runtime: ReturnType, + routeId: string, +) { + if (!migratedPool) throw new Error('Missing native Prime fixture') + const before = await runtime.captureLocalSubject(routeId) + const store = createNativePoolStore({ + paths: migratedPool.paths, + quota: nativeQuotaCodec, + }) + const rotated = await store.refresh( + before.binding.rowId, + async (credential) => ({ + access: `sk-ant-oat01-prime-expired-${routeId}`, + refresh: credential.refresh, + expires: Date.now() - 1_000, + }), + ) + expect(rotated.status).toBe('rotated') + const after = await runtime.captureLocalSubject(routeId) + expect(after.binding).toEqual(before.binding) + expect(after.credentialFingerprint).toBe(before.credentialFingerprint) +} + async function primeWarningsAfterQuotaCheck( fallback: boolean, change: ( @@ -22242,16 +22431,7 @@ describe('claude-prime — warn dedup (R3)', () => { const { records, refreshRequests } = await primeWarningsAfterQuotaCheck( false, async (runtime) => { - await runtime.loginOAuth({ - routeId: 'main', - replace: true, - accountIdentity: poolMainIdentity(), - credential: { - access: '', - refresh: 'main-refresh', - expires: Date.now() - 1_000, - }, - }) + await expirePoolCredentialForPrime(runtime, 'main') }, ) expect(refreshRequests).toBe(3) @@ -22316,20 +22496,7 @@ describe('claude-prime — warn dedup (R3)', () => { const { records, refreshRequests } = await primeWarningsAfterQuotaCheck( true, async (runtime) => { - const identity = (await runtime.read()).accounts.find( - (account) => account.id === 'work-alt', - )?.accountIdentity - expect(identity).toBeDefined() - await runtime.loginOAuth({ - routeId: 'work-alt', - replace: true, - accountIdentity: identity, - credential: { - access: '', - refresh: 'fb-refresh', - expires: Date.now() - 1_000, - }, - }) + await expirePoolCredentialForPrime(runtime, 'work-alt') }, ) expect(refreshRequests).toBe(3) diff --git a/packages/opencode/src/tests/native-fixture.ts b/packages/opencode/src/tests/native-fixture.ts index 84f77a1b3..963cfaa22 100644 --- a/packages/opencode/src/tests/native-fixture.ts +++ b/packages/opencode/src/tests/native-fixture.ts @@ -1,6 +1,7 @@ import { lstat, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { dirname, isAbsolute, join } from 'node:path' import { + createNativePoolStore, discoverNativeVaultInventory, getStickyRoutingStatePath, type NativeCustodyClient, @@ -11,6 +12,8 @@ import { resolveNativePoolPaths, runNativeMigration, } from '@cortexkit/anthropic-auth-core' +import { nativeQuotaCodec } from '../../../core/src/native-quota-codec.ts' +import { nativeVaultPaths } from '../../../core/src/native-vault-paths.ts' /** * deferCleanup registers fixture removal; trackDetached registers work that @@ -119,6 +122,57 @@ async function writeSource( ) } +/** Native account metadata in the shape the vault runtime seed publisher accepts. */ +type NativeRuntimeState = Parameters[1] + +/** The migrated vault account metadata, rebound to the vault namespace's storage. */ +function vaultRuntimeSeed( + state: NativeRuntimeState, + storageId: string, +): NativeRuntimeState { + const accounts: NativeRuntimeState['accounts'] = {} + for (const [id, entry] of Object.entries(state.accounts)) + if (entry.binding.kind === 'custody') + accounts[id] = { ...entry, binding: { ...entry.binding, storageId } } + return { ...state, storageId, accounts } +} + +/** + * Give the vault namespace the migrated settings. The default vault account + * stays pinned by its credential and account UUID; enrollment, not a mode + * flag, selects vault custody, so the flag is not carried over. + */ +async function seedVaultSettings(paths: NativePoolPaths) { + const local = await createNativePoolStore({ + paths, + quota: nativeQuotaCodec, + }).readSettings() + if (local.status !== 'ready') + throw new Error('Fixture migration left no readable settings') + const vault = createNativePoolStore({ + paths: nativeVaultPaths(paths), + quota: nativeQuotaCodec, + }) + await vault.initialize() + await vault.updateSettings(() => { + const settings = structuredClone(local.settings) + const claustrum = settings.claustrum + if ( + claustrum && + typeof claustrum === 'object' && + !Array.isArray(claustrum) + ) { + const { + mode: _mode, + scopedRoster: _scopedRoster, + ...rest + } = claustrum as Record + settings.claustrum = rest + } + return settings + }) +} + async function migrate( input: NativeOpencodeFixtureInput, ): Promise { @@ -176,6 +230,8 @@ async function migrate( if (input.hostAuth !== undefined) await writeSource(hostAuthPath, input.hostAuth) const custody = input.custody + const vaultPaths = nativeVaultPaths(paths) + // The owner-only enrollment token is what selects vault custody. if (custody) await writeSource(enrollmentPath, custody.enrollment) // This fixture does not start or inspect host processes. Its processFence @@ -207,8 +263,25 @@ async function migrate( connect: custody.connect, env: { ...env }, }), - publishSeed: publishNativeVaultRosterSeed, - publishRuntimeSeed: publishNativeVaultRuntimeSeed, + // Vault accounts are served from the vault namespace, which the + // enrollment selects; the copies the migration keeps beside the local + // pool remain only as its own bookkeeping. Publish each seed to both. + publishSeed: async (localPaths, seedInput, options) => { + await publishNativeVaultRosterSeed( + { roster: vaultPaths.roster }, + seedInput, + options, + ) + return publishNativeVaultRosterSeed(localPaths, seedInput, options) + }, + publishRuntimeSeed: async (localPaths, state, options) => { + await publishNativeVaultRuntimeSeed(localPaths, state, options) + await publishNativeVaultRuntimeSeed( + vaultPaths, + vaultRuntimeSeed(state, vaultPaths.storageId), + options, + ) + }, }, }), }) @@ -216,6 +289,7 @@ async function migrate( throw new Error('Fixture migration did not check its process fence') if (journal.phase !== 'retired') throw new Error(`Fixture migration ended in phase ${journal.phase}`) + if (custody) await seedVaultSettings(paths) return { root, paths, diff --git a/packages/opencode/src/tests/native-serving.test.ts b/packages/opencode/src/tests/native-serving.test.ts index e99821a62..6df7894a2 100644 --- a/packages/opencode/src/tests/native-serving.test.ts +++ b/packages/opencode/src/tests/native-serving.test.ts @@ -1,27 +1,38 @@ import { afterEach, expect, mock } from 'bun:test' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { __setLogTestSink, type authorize, CacheKeepManager, createNativeAccountRuntime, + createNativePoolStore, custodyTombstoneOAuth, - discoverNativeVaultInventory, getLogLevel, type LogTestRecord, type NativeCustodyClient, PrimeManager, - publishNativeVaultRosterSeed, - publishNativeVaultRuntimeSeed, resetClaudeCodeIdentityCachesForTest, resolveNativePoolPaths, runNativeMigration, + StickySessionRouter, setLogLevel, + stickyRouteFamilyForModel, } from '@cortexkit/anthropic-auth-core' import { createOpencodeClient, type Provider } from '@opencode-ai/sdk' import { $ } from 'bun' +import { nativeQuotaCodec } from '../../../core/src/native-quota-codec.ts' +import { nativeVaultPaths } from '../../../core/src/native-vault-paths.ts' import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { MockRelayServer } from '../../../e2e-tests/src/mock-relay.ts' import { AnthropicAuthPlugin } from '../index.ts' @@ -46,6 +57,7 @@ const envKeys = [ 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', 'OPENCODE_AUTH_CONTENT', 'CLAUDE_CONFIG_DIR', + 'XDG_DATA_HOME', ] as const const originalFetch = globalThis.fetch const provider: Provider = { @@ -212,6 +224,8 @@ async function setupBody() { }) await writeFile(configPath, legacyBytes, { mode: 0o600 }) process.env.CLAUDE_CONFIG_DIR = join(directory, 'synthetic-claude') + // OpenCode's own login store for this test, never the user's. + process.env.XDG_DATA_HOME = join(directory, 'synthetic-data') resetClaudeCodeIdentityCachesForTest() network.mockClear() globalThis.fetch = Object.assign( @@ -423,6 +437,9 @@ async function migrateServingFixture( }> = [] const authorizedGets: string[] = [] let vaultVersion = 1 + // The account the vault's default credential belongs to; the user can + // replace it with a different account under the same credential ID. + let defaultIdentity = mainIdentity const scopedClient: NativeCustodyClient = { async listScoped() { return { @@ -430,7 +447,7 @@ async function migrateServingFixture( rows: [ { id: 'oauth:anthropic', - accountId: mainIdentity, + accountId: defaultIdentity, credentialType: 'oauth', state: 'active', categories: ['anthropic-native'], @@ -467,7 +484,9 @@ async function migrateServingFixture( credentialId, material: `sk-ant-oat01-vault-${credentialId === 'oauth:anthropic' ? 'main' : 'fallback'}-v${vaultVersion}`, accountId: - credentialId === 'oauth:anthropic' ? mainIdentity : fallbackIdentity, + credentialId === 'oauth:anthropic' + ? defaultIdentity + : fallbackIdentity, recordVersion: vaultVersion, expiresAtMs: Date.now() + 8 * 60 * 60_000, } @@ -477,98 +496,102 @@ async function migrateServingFixture( }, close() {}, } - const config = { - version: 1, - mainAccountId: 'primary-route', - refresh: { enabled: false }, - quota: { enabled: false }, - routing: { mode: 'main-first' }, - ...(mode === 'claustrum' - ? { - claustrum: { - mode, - scopedRoster: true, - primaryAccount: { - credentialId: 'oauth:anthropic', - accountId: mainIdentity, - state: 'active', - }, - }, - } - : {}), - accounts: fallback - ? [ - { - id: 'fallback-route', - type: 'oauth', - enabled: true, - ...(mode === 'claustrum' - ? { - refresh: '', - claustrumScopedCredentialId: 'oauth:secondary', - anthropicAccountUuid: fallbackIdentity, - } - : { - access: 'sk-ant-oat01-local-fallback', - refresh: 'synthetic-fallback-refresh', - expires: Date.now() + 8 * 60 * 60_000, - }), - }, - ] - : [], - } - await writeFile(paths.legacyConfig, JSON.stringify(config), { mode: 0o600 }) - await writeFile( - hostAuthPath, - JSON.stringify({ - anthropic: - mode === 'claustrum' - ? custodyTombstoneOAuth('anthropic') - : { + if (mode === 'claustrum') { + // The plugin serves Claude from the credential vault when this host's + // enrollment token file exists (readable by its owner only, mode 0600); + // the account settings no longer decide that. Vault account settings, + // account list and metadata are separate files beside the local ones. + // Write the token, create empty vault settings, then ask the vault for its + // account list once: that records the accounts and marks the default + // account as main. Local credentials are never migrated or touched. + await writeFile( + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE!, + JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const vaultPaths = nativeVaultPaths(paths) + await createNativePoolStore({ + paths: vaultPaths, + quota: nativeQuotaCodec, + }).initialize() + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + vault: { connect: async () => scopedClient }, + }) + try { + expect((await runtime.read()).mode).toBe('claustrum') + await runtime.vault.refresh() + await runtime.updateSettings((settings) => ({ + ...settings, + refresh: { enabled: false }, + quota: { enabled: false }, + routing: { mode: 'main-first' }, + })) + const snapshot = await runtime.read() + expect(snapshot.routingPath).toBe(vaultPaths.routing) + expect( + snapshot.accounts.find((account) => account.id === 'main') + ?.accountIdentity, + ).toBe(mainIdentity) + } finally { + runtime.close() + } + // The local account files stay exactly as the user left them. + expect(await readFile(configPath, 'utf8')).toBe(legacyBytes) + } else { + const config = { + version: 1, + mainAccountId: 'primary-route', + refresh: { enabled: false }, + quota: { enabled: false }, + routing: { mode: 'main-first' }, + accounts: fallback + ? [ + { + id: 'fallback-route', type: 'oauth', - access: 'sk-ant-oat01-local-main', - refresh: 'synthetic-primary-refresh', + enabled: true, + access: 'sk-ant-oat01-local-fallback', + refresh: 'synthetic-fallback-refresh', expires: Date.now() + 8 * 60 * 60_000, }, - other: { type: 'api', key: 'synthetic-unrelated-provider' }, - }), - { mode: 0o600 }, - ) - await writeFile( - process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE!, - JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), - { mode: 0o600 }, - ) - const journal = await runNativeMigration({ - paths, - host: 'opencode', - hostAuthPath, - routingSourcePath: join(directory, 'legacy-routing.json'), - routingDestinationPath: join(directory, 'native-routing.json'), - env: {}, - processFence: async () => {}, - removePiAnthropicAuth: false, - ...(mode === 'claustrum' - ? { - custody: { - discover: () => - discoverNativeVaultInventory({ - paths, - host: 'opencode', - connect: async () => scopedClient, - }), - publishSeed: publishNativeVaultRosterSeed, - publishRuntimeSeed: publishNativeVaultRuntimeSeed, - }, - } - : {}), - }) - expect(journal.phase).toBe('retired') - expect(journal.version).toBe(4) - expect(JSON.parse(await readFile(hostAuthPath, 'utf8')).other).toEqual({ - type: 'api', - key: 'synthetic-unrelated-provider', - }) + ] + : [], + } + await writeFile(paths.legacyConfig, JSON.stringify(config), { + mode: 0o600, + }) + await writeFile( + hostAuthPath, + JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'sk-ant-oat01-local-main', + refresh: 'synthetic-primary-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + other: { type: 'api', key: 'synthetic-unrelated-provider' }, + }), + { mode: 0o600 }, + ) + const journal = await runNativeMigration({ + paths, + host: 'opencode', + hostAuthPath, + routingSourcePath: join(directory, 'legacy-routing.json'), + routingDestinationPath: join(directory, 'native-routing.json'), + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: false, + }) + expect(journal.phase).toBe('retired') + expect(journal.version).toBe(4) + expect(JSON.parse(await readFile(hostAuthPath, 'utf8')).other).toEqual({ + type: 'api', + key: 'synthetic-unrelated-provider', + }) + } const records: Array<{ url: string; authorization: string; body: string }> = [] const fetchProvider = async ( @@ -646,10 +669,17 @@ async function migrateServingFixture( rotateVault: () => { vaultVersion++ }, + replaceDefaultAccount: (identity: string) => { + defaultIdentity = identity + }, } } -async function sendNative(plugin: PluginHooks, model = 'claude-sonnet-4-6') { +async function sendNative( + plugin: PluginHooks, + model = 'claude-sonnet-4-6', + sessionId = `synthetic-session-${directory}`, +) { const options = await load(plugin, async () => custodyTombstoneOAuth('anthropic'), ) @@ -657,7 +687,7 @@ async function sendNative(plugin: PluginHooks, model = 'claude-sonnet-4-6') { method: 'POST', headers: { 'content-type': 'application/json', - 'x-session-affinity': `synthetic-session-${directory}`, + 'x-session-affinity': sessionId, }, body: JSON.stringify({ model, @@ -1077,6 +1107,479 @@ for (const mode of ['main-first', 'fallback-first'] as const) { } } +/** A session's sticky assignment in a routing file, keyed as the router keys it. */ +async function stickyPin(path: string, sessionId: string) { + const state = JSON.parse(await readFile(path, 'utf8')) as { + assignments: Record< + string, + { accountId: string; accountIdentity?: string; assignedAt: number } + > + } + return state.assignments[createHash('sha256').update(sessionId).digest('hex')] +} + +test('a replaced default vault account loses its session pins and quota while another account keeps its pin', async () => { + const fixture = await migrateServingFixture('claustrum', true) + const replacementIdentity = '33333333-3333-4333-8333-333333333333' + const model = 'claude-sonnet-4-6' + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + vault: { connect: async () => fixture.scopedClient }, + }) + let routingPath: string | undefined + let fallbackRoute: string | undefined + try { + await runtime.updateSettings((settings) => ({ + ...settings, + quota: { enabled: true }, + routing: { mode: 'sticky-balanced' }, + })) + for (const route of (await runtime.read()).accounts) { + const receipt = await runtime.authorizeVault(route.id) + expect( + await runtime.vault.publish(receipt, { + quota: { + accountIdentity: receipt.accountIdentity, + checkedAt: Date.now(), + five_hour: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), + }, + seven_day: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), + }, + }, + }), + ).toBe(true) + } + const before = await runtime.read() + routingPath = before.routingPath + fallbackRoute = before.accounts.find( + (account) => account.accountIdentity === fallbackIdentity, + )?.id + expect( + before.accounts.find((account) => account.id === 'main')?.quota, + ).toMatchObject({ accountIdentity: mainIdentity }) + } finally { + runtime.close() + } + if (!routingPath || !fallbackRoute) + throw new Error('Vault fixture has no routing file or fallback route') + // Earlier sessions pinned through the vault routing file: one to the + // default account, one to the other vault account. + const earlier = new StickySessionRouter({ path: routingPath }) + const identities = new Map([ + ['main', mainIdentity], + [fallbackRoute, fallbackIdentity], + ]) + for (const [sessionId, accountId] of [ + ['session-on-default', 'main'], + ['session-on-fallback', fallbackRoute], + ] as const) { + const pinned = await earlier.resolve({ + sessionId, + family: stickyRouteFamilyForModel(model), + modelId: model, + affinityModelId: model, + candidates: [ + { + accountId, + quota: { + accountIdentity: identities.get(accountId), + checkedAt: Date.now(), + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: Date.now(), + }, + }, + order: 0, + }, + ], + retainAccountIds: new Set(identities.keys()), + accountIdentities: identities, + storage: null, + inputBytes: 1, + }) + expect(pinned?.accountId).toBe(accountId) + } + const defaultPin = await stickyPin(routingPath, 'session-on-default') + const fallbackPin = await stickyPin(routingPath, 'session-on-fallback') + expect(defaultPin?.accountIdentity).toBe(mainIdentity) + + // The user replaces the account behind the vault's default credential. + fixture.replaceDefaultAccount(replacementIdentity) + const discovery = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + vault: { connect: async () => fixture.scopedClient }, + }) + try { + await discovery.vault.refresh() + const main = (await discovery.read()).accounts.find( + (account) => account.id === 'main', + ) + expect(main?.accountIdentity).toBe(replacementIdentity) + // The previous account's usage reading is not carried over. + expect(main?.quota).toBeUndefined() + } finally { + discovery.close() + } + + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + expect((await sendNative(plugin, model, 'session-on-default')).status).toBe( + 200, + ) + expect((await sendNative(plugin, model, 'session-on-fallback')).status).toBe( + 200, + ) + // The session pinned to the replaced account is placed again and its new + // pin names the account that now serves it. + const replaced = await stickyPin(routingPath, 'session-on-default') + expect(replaced?.accountIdentity).toBeDefined() + expect(replaced?.accountIdentity).not.toBe(mainIdentity) + expect(replaced?.assignedAt).toBeGreaterThan(defaultPin?.assignedAt ?? 0) + expect(replaced?.accountIdentity).toBe( + replaced?.accountId === 'main' ? replacementIdentity : fallbackIdentity, + ) + // The other vault account's pin is untouched. + const kept = await stickyPin(routingPath, 'session-on-fallback') + expect(kept).toMatchObject({ + accountId: fallbackRoute, + accountIdentity: fallbackIdentity, + assignedAt: fallbackPin?.assignedAt, + }) + const sent = fixture.records + .filter((record) => record.url.includes('/v1/messages')) + .map((record) => record.authorization) + expect(sent).toHaveLength(2) + expect(sent[1]).toBe('Bearer sk-ant-oat01-vault-fallback-v1') +}) + +test('vault sticky routing keeps session assignments only in the vault routing file', async () => { + const fixture = await migrateServingFixture('claustrum', true) + // The local session-to-account assignment file. While the host uses the + // vault, sticky routing must neither read nor rewrite it. Its content is + // not JSON, so a request that tried to read it would fail to route. + const localRouting = process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE! + const localBytes = 'synthetic local routing state, not JSON' + await writeFile(localRouting, localBytes, { mode: 0o600 }) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + vault: { connect: async () => fixture.scopedClient }, + }) + let vaultRouting: string | undefined + try { + await runtime.updateSettings((settings) => ({ + ...settings, + quota: { enabled: true }, + routing: { mode: 'sticky-balanced' }, + })) + vaultRouting = (await runtime.read()).routingPath + const quota = () => ({ + checkedAt: Date.now(), + five_hour: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), + }, + seven_day: { + remainingPercent: 90, + usedPercent: 10, + checkedAt: Date.now(), + }, + }) + for (const route of (await runtime.read()).accounts) { + const receipt = await runtime.authorizeVault(route.id) + expect( + await runtime.vault.publish(receipt, { + quota: { ...quota(), accountIdentity: receipt.accountIdentity }, + }), + ).toBe(true) + } + } finally { + runtime.close() + } + if (!vaultRouting) throw new Error('Vault mode names no routing file') + expect(vaultRouting).not.toBe(localRouting) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + expect((await sendNative(plugin)).status).toBe(200) + const assignments = Object.values( + ( + JSON.parse(await readFile(vaultRouting, 'utf8')) as { + assignments: Record + } + ).assignments, + ) + expect(assignments).toHaveLength(1) + expect(await readFile(localRouting, 'utf8')).toBe(localBytes) +}) + +/** OpenCode's login store for this test, under the test's XDG_DATA_HOME. */ +function hostLoginStore() { + return join(process.env.XDG_DATA_HOME!, 'opencode', 'auth.json') +} + +async function writeHostLoginStore(bytes: string) { + await mkdir(dirname(hostLoginStore()), { recursive: true }) + await writeFile(hostLoginStore(), bytes, { mode: 0o600 }) +} + +function vaultLoginMethod(plugin: PluginHooks) { + return plugin.auth?.methods.find( + (method) => method.label === 'Claude via credential vault', + ) +} + +async function startVaultLogin(plugin: PluginHooks) { + const method = vaultLoginMethod(plugin) + if (method?.type !== 'oauth') + throw new Error('Plugin offers no vault login method') + const started = await method.authorize() + if (started.method !== 'auto') + throw new Error('Vault login unexpectedly asks for a code') + return started.callback() +} + +const localHostOAuth = { + type: 'oauth' as const, + access: 'sk-ant-oat01-synthetic-host-login', + refresh: 'synthetic-host-login-refresh', + expires: Date.now() + 60 * 60_000, +} + +test('vault login is offered only while this host holds an enrollment token', async () => { + const plugin = await createPlugin() + expect(vaultLoginMethod(plugin)).toBeUndefined() + await writeFile( + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE!, + JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + expect(vaultLoginMethod(plugin)?.type).toBe('oauth') + expect(network).not.toHaveBeenCalled() +}) + +test('enrolled vault login returns the exact placeholder for an empty slot without any network', async () => { + const fixture = await migrateServingFixture('claustrum') + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + network.mockClear() + const placeholder = custodyTombstoneOAuth('anthropic') + expect(await startVaultLogin(plugin)).toEqual({ + type: 'success', + access: placeholder.access, + refresh: placeholder.refresh, + expires: placeholder.expires, + }) + // The plugin returns the placeholder; it never writes OpenCode's store. + await expect(readFile(hostLoginStore(), 'utf8')).rejects.toThrow() + expect(network).not.toHaveBeenCalled() +}) + +for (const [state, bytes] of [ + ['a local OAuth login', JSON.stringify({ anthropic: localHostOAuth })], + [ + 'a stored API key', + JSON.stringify({ anthropic: { type: 'api', key: 'synthetic-host-key' } }), + ], + [ + 'the placeholder itself', + JSON.stringify({ anthropic: custodyTombstoneOAuth('anthropic') }), + ], + ['unparseable logins', '{"anthropic": '], + ['logins of the wrong shape', JSON.stringify(['anthropic'])], + ['a null Anthropic entry', JSON.stringify({ anthropic: null })], + [ + 'an incomplete OAuth entry', + JSON.stringify({ anthropic: { type: 'oauth' } }), + ], +] as const) { + test(`enrolled vault login refuses ${state} and leaves its bytes unchanged`, async () => { + const fixture = await migrateServingFixture('claustrum') + await writeHostLoginStore(bytes) + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + network.mockClear() + await expect(startVaultLogin(plugin)).rejects.toThrow() + expect(await readFile(hostLoginStore(), 'utf8')).toBe(bytes) + expect(network).not.toHaveBeenCalled() + }) +} + +for (const unsafe of ['symlink', 'group-readable'] as const) { + test(`vault login refuses an ${unsafe} host login store before network or any write`, async () => { + const fixture = await migrateServingFixture('claustrum') + const bytes = JSON.stringify({ + other: { type: 'api', key: 'synthetic-unrelated-provider' }, + }) + await writeHostLoginStore(bytes) + if (unsafe === 'symlink') { + const target = join(dirname(hostLoginStore()), 'original-auth.json') + await writeFile(target, bytes, { mode: 0o600 }) + await rm(hostLoginStore()) + await symlink(target, hostLoginStore()) + } else { + await chmod(hostLoginStore(), 0o640) + } + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + network.mockClear() + await expect(startVaultLogin(plugin)).rejects.toThrow( + 'Invalid native host auth source.', + ) + expect(await readFile(hostLoginStore(), 'utf8')).toBe(bytes) + expect(network).not.toHaveBeenCalled() + }) +} + +test('enrolled host refuses a local OAuth slot without using it and the vault login sees it as occupied', async () => { + const fixture = await migrateServingFixture('claustrum') + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + network.mockClear() + const slot = structuredClone(localHostOAuth) + const options = await load(plugin, async () => slot) + await expect( + requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] }), + }), + ).rejects.toThrow('Remove it and choose the vault login, or disconnect') + // OpenCode's Anthropic login is read through the reader OpenCode gave the + // auth loader (it returns the local OAuth login above); no auth.json file + // exists in this test. + await expect(startVaultLogin(plugin)).rejects.toThrow( + 'already holds an Anthropic login', + ) + expect(slot).toEqual(localHostOAuth) + expect(network).not.toHaveBeenCalled() + expect(fixture.authorizedGets).toEqual([]) +}) + +test('enrolled host refuses a placeholder look-alike that carries an access token', async () => { + const fixture = await migrateServingFixture('claustrum') + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + network.mockClear() + const options = await load(plugin, async () => ({ + ...custodyTombstoneOAuth('anthropic'), + access: 'sk-ant-oat01-synthetic-look-alike', + })) + await expect( + requestFetch(options)('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] }), + }), + ).rejects.toThrow('Remove it and choose the vault login, or disconnect') + expect(network).not.toHaveBeenCalled() + expect(fixture.authorizedGets).toEqual([]) +}) + +test('enrolled host passes a stored API key through unchanged', async () => { + const fixture = await migrateServingFixture('claustrum') + const plugin = await createPlugin({ + claustrumScopedConnect: async () => fixture.scopedClient, + }) + network.mockClear() + const slot = { type: 'api' as const, key: 'synthetic-host-key' } + expect(await load(plugin, async () => slot)).toEqual({}) + expect(slot).toEqual({ type: 'api', key: 'synthetic-host-key' }) + expect(network).not.toHaveBeenCalled() + expect(fixture.authorizedGets).toEqual([]) +}) + +function proMaxMethod(plugin: PluginHooks) { + const method = plugin.auth?.methods.find( + (candidate) => candidate.label === 'Claude Pro/Max', + ) + if (method?.type !== 'oauth') + throw new Error('Plugin offers no Claude Pro/Max sign-in') + return method +} + +function syntheticAuthorize(): typeof authorize { + return mock(async () => ({ + url: 'https://example.test/oauth', + redirectUri: 'https://example.test/callback', + verifier: 'synthetic-verifier', + state: 'synthetic-state', + })) +} + +test('an enrolled host with local account settings refuses Pro/Max sign-in before the browser flow starts', async () => { + await migrateServingFixture('local') + // The local pool and its settings stay as migrated; only the enrollment + // token says this host now uses the vault. + await writeFile( + process.env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE!, + JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const authorizeImpl = syntheticAuthorize() + const plugin = await createPlugin({ authorize: authorizeImpl }) + network.mockClear() + await expect(proMaxMethod(plugin).authorize()).rejects.toThrow( + 'Disconnect from the vault first.', + ) + expect(authorizeImpl).not.toHaveBeenCalled() + expect(network).not.toHaveBeenCalled() +}) + +test('an unenrolled host with a leftover vault mode setting still starts Pro/Max sign-in', async () => { + const fixture = await migrateServingFixture('local') + // Simulate an old custody-mode flag by writing directly to the store. + // Runtime settings updates reject that flag; enrollment alone selects + // the credential source. + const store = createNativePoolStore({ + paths: fixture.paths, + quota: nativeQuotaCodec, + }) + await store.updateSettings((settings) => ({ + ...settings, + claustrum: { mode: 'claustrum' }, + })) + const stored = await store.readSettings() + expect(stored.status === 'ready' && stored.settings.claustrum).toEqual({ + mode: 'claustrum', + }) + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + expect((await runtime.read()).mode).toBe('local') + } finally { + runtime.close() + } + const authorizeImpl = syntheticAuthorize() + const plugin = await createPlugin({ authorize: authorizeImpl }) + network.mockClear() + const started = await proMaxMethod(plugin).authorize() + expect(started.url).toBe('https://example.test/oauth') + expect(authorizeImpl).toHaveBeenCalledTimes(1) + expect(network).not.toHaveBeenCalled() +}) + test('OAuth SDK callback commits native credentials and returns only inert activation', async () => { const fixture = await migrateServingFixture('local') const authorization: typeof authorize = mock(async () => ({ diff --git a/packages/opencode/src/tests/native-status-profile.test.ts b/packages/opencode/src/tests/native-status-profile.test.ts index 9456a071c..6f9d8ed60 100644 --- a/packages/opencode/src/tests/native-status-profile.test.ts +++ b/packages/opencode/src/tests/native-status-profile.test.ts @@ -28,8 +28,9 @@ import { // The unified /claude menu fetches missing or stale OAuth profiles before it // shows the account and quota sections. These tests run the real plugin on a -// migrated native pool and never call auth.loader: the menu must work before -// OpenCode first asks the plugin for credentials. Profile HTTP goes through a +// migrated native pool. Except where a test starts the startup profile display +// itself, they never call auth.loader: the menu must work before OpenCode +// first asks the plugin for credentials. Profile HTTP goes through a // replaced global fetch, installed before the plugin captures it. const { test, deferCleanup, trackDetached, gate } = createTestLifetimeSuite() @@ -727,6 +728,91 @@ test('vault accounts show fetched profiles through vault-issued credentials', as ) }) +test('a menu opened while startup fetches an unidentified route joins that request', async () => { + const s = await start( + { legacyConfig: localLegacyConfig(), hostAuth: localHostAuth() }, + { + beforePlugin: async (paths) => { + // Main already has a fresh saved profile, so only the fallback route + // needs a profile request. + await seedProfile( + paths, + 'main', + accountTier[mainIdentity] ?? '', + Date.now(), + ) + }, + }, + ) + expect(await storedTiers(s.paths)).toEqual({ + main: accountTier[mainIdentity], + 'fallback-route': undefined, + }) + const release = gate() + let fallbackArrived!: () => void + const arrived = new Promise((resolve) => { + fallbackArrived = resolve + }) + s.network.respond = async (account) => { + // The menu sends the request for the account added before it opened + // only after it has decided, for every route, whether to join an + // existing profile request or send a new one. Releasing the held + // fallback answer at that point cannot hide a second fallback request, + // and the menu waits for every request it sent before it renders. + if (account === otherIdentity) release.open() + if ( + account === fallbackIdentity && + s.network.profileAccounts.filter((entry) => entry === fallbackIdentity) + .length === 1 + ) { + fallbackArrived() + await release.wait + } + return undefined + } + // OpenCode's first credential request starts the startup profile display, + // which has no time bound of its own. + const loader = s.plugin.auth?.loader + if (!loader) throw new Error('The plugin has no auth loader') + await loader( + async () => + custodyTombstoneOAuth('anthropic') as Awaited< + ReturnType[0]> + >, + { models: {} } as unknown as Parameters[1], + ) + // The startup request has authorized the fallback route, which recorded + // the route's account identity, and its answer is still held. + await arrived + const runtime = createNativeAccountRuntime({ + paths: s.paths, + host: 'opencode', + local: nativeLocal(), + }) + try { + await runtime.loginOAuth({ + routeId: 'other-route', + credential: { + access: 'sk-ant-oat01-synthetic-other-access', + refresh: 'synthetic-other-refresh', + expires: Date.now() + 8 * 60 * 60_000, + }, + replace: false, + }) + } finally { + runtime.close() + } + const text = await s.readMenu() + expect(accountLine(text, 'fallback-route')).toContain('Max 5x') + expect(accountLine(text, 'other-route')).toContain('Max 10x') + // One request per account: the menu joined the startup fallback request + // that was still in flight instead of repeating it. + expect(s.network.profileAccounts.sort()).toEqual( + [fallbackIdentity, otherIdentity].sort(), + ) + expect(s.network.unexpected).toEqual([]) +}) + test('missing native authority refuses status without any profile request', async () => { const s = await start({ legacyConfig: localLegacyConfig(), @@ -745,7 +831,7 @@ test('a profile whose save was refused is not shown once the refusal is known', saveRefused = resolve }) // The deferred refusal gate: `refused` resolves when the plugin logs that - // the fallback profile save failed. Teardown opens `teardown` before it + // it refused to publish the fallback profile. Teardown opens `teardown` before it // joins the test body, so a body still waiting for a refusal that never // came is released instead of hanging teardown. const teardown = gate() @@ -755,7 +841,7 @@ test('a profile whose save was refused is not shown once the refusal is known', }) __setLogTestSink((record) => { if ( - record.message === 'failed to save account profile' && + record.message === 'refused account profile publication' && record.payload?.account === 'fallback-route' ) saveRefused() diff --git a/packages/opencode/src/tests/opencode2-adapter.test.ts b/packages/opencode/src/tests/opencode2-adapter.test.ts index cb256e55a..a0e5c6407 100644 --- a/packages/opencode/src/tests/opencode2-adapter.test.ts +++ b/packages/opencode/src/tests/opencode2-adapter.test.ts @@ -777,12 +777,12 @@ describe('OpenCode 2 native Anthropic adapter: refusals', () => { const cases: { name: string answers: Parameters[0] - error: unknown + error: typeof NativeAnthropicNoAccountError }[] = [ { name: 'no account chosen', answers: { choose: () => undefined, chooseAccount: () => undefined }, - error: 'installer: no-account', + error: NativeAnthropicNoAccountError, }, { name: 'authorization refused', @@ -814,12 +814,10 @@ describe('OpenCode 2 native Anthropic adapter: refusals', () => { const failure = await host .run(scopeOf(), hostRequest(sonnetPayload())) .catch((caught: unknown) => caught) - if (typeof error === 'string') { - expect((failure as Error).message, name).toBe(error) - } else { - expect(failure, name).toBeInstanceOf(error as typeof Error) - expect((failure as Error).message, name).toBe(NO_ACCOUNT_REFUSAL) - } + expect(failure, name).toBeInstanceOf(error) + if (!(failure instanceof Error)) + throw new Error('Expected a local refusal') + expect(failure.message, name).toBe(NO_ACCOUNT_REFUSAL) expect(host.sent, name).toHaveLength(0) } }) diff --git a/packages/opencode/src/tests/opencode2-contract.test.ts b/packages/opencode/src/tests/opencode2-contract.test.ts new file mode 100644 index 000000000..8eaf60773 --- /dev/null +++ b/packages/opencode/src/tests/opencode2-contract.test.ts @@ -0,0 +1,890 @@ +import { describe, expect, test } from 'bun:test' +import { + custodyTombstoneOAuth, + THINKING_BINDING_CONTROLS_BETA, +} from '@cortexkit/anthropic-auth-core' +import type { OpenCode2HookContext } from '@cortexkit/common-auth/opencode2' +import { + isPlaceholderCredential, + placeholderSecret, + registerOpenCode2AuthMethods, +} from '@cortexkit/common-auth/opencode2' +import type { IntegrationOAuthMethodRegistration } from '@opencode/plugin/promise/integration' +import type { Registration } from '@opencode/plugin/promise/registration' +import type { SessionHooks } from '@opencode/plugin/promise/session' +import { SERVER_SIDE_FALLBACK_BETA } from '../server-fallback' +import { + CLAUDE_POOL_LOGIN_METHOD_ID, + CLAUDE_VAULT_ACTIVATION_METHOD_ID, +} from '../v2/activation' +import { + NativeAnthropicRevokedError, + type NativeAnthropicSource, + NativeAnthropicTransportRefusal, +} from '../v2/adapter' +import { installNativeAnthropic } from '../v2/install' +import { preparedAnthropicCredential } from '../v2/ownership' +import { + type ClaudeLoginMethodsInput, + claudeLoginMethods, + ENROLLMENT_UNREADABLE, + LOCAL_LOGIN_REFUSED_IN_VAULT, + VAULT_ACTIVATION_REFUSED_LOCALLY, +} from '../v2/setup' + +type Source = NativeAnthropicSource +interface RelayEnvelope { + protocol: number + mode: string + affinity: string + upstream: { url: string; method: string; headers: Record } + body: string + next_hash: string +} +const sessionID = + 'ses_oc2_contract' as SessionHooks['model.request']['sessionID'] +const scope = { + sessionID, + agent: 'build' as SessionHooks['model.request']['agent'], + model: { + providerID: + 'anthropic' as SessionHooks['model.request']['model']['providerID'], + id: 'claude-sonnet-5' as SessionHooks['model.request']['model']['id'], + }, + kind: 'primary' as const, +} + +/** Register and execute common-auth's public hooks; assert disposal removes them and pending credential callbacks cannot settle into headers. */ +class Hooks { + private readonly callbacks = new Map< + keyof SessionHooks, + (input: unknown) => Promise | void + >() + readonly disposed: string[] = [] + failAt?: keyof SessionHooks + readonly hook: OpenCode2HookContext['session']['hook'] = async ( + name, + callback, + ): Promise => { + if (name === this.failAt) throw new Error('synthetic registration failure') + this.callbacks.set(name, (input) => + callback(input as SessionHooks[typeof name]), + ) + return { + dispose: async () => { + this.disposed.push(name) + this.callbacks.delete(name) + }, + } + } + async run( + name: Name, + input: SessionHooks[Name], + ) { + const callback = this.callbacks.get(name) + if (!callback) throw new Error(`hook ${name} is not installed`) + await callback(input) + } + get size() { + return this.callbacks.size + } +} + +function source(overrides: Partial = {}): Source { + return { + chooseAccount: () => 'main', + authorizeAccount: () => ({ + route: 'oauth', + accessToken: 'sk-ant-oat01-synthetic', + provenance: { version: 7 }, + }), + ...overrides, + } +} + +function request() { + return new Request('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + authorization: `Bearer ${placeholderSecret('anthropic')}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'claude-sonnet-5', + max_tokens: 128, + messages: [{ role: 'user', content: 'Hello' }], + }), + }) +} + +describe('OpenCode 2 published installation contract', () => { + test('generic WebSocket transport leaves stock untouched but refuses an exact pool placeholder before dispatch', async () => { + const hooks = new Hooks() + const native = await installNativeAnthropic( + { session: hooks }, + { source: source() }, + { credential: preparedAnthropicCredential }, + ) + try { + const stock = { + ...scope, + url: 'wss://stock.invalid/model', + headers: { + authorization: 'Bearer stock-ws-key', + 'x-stock-field': 'unchanged', + }, + } + await hooks.run('experimental.ws.handshake', stock) + expect(stock.url).toBe('wss://stock.invalid/model') + expect(stock.headers).toEqual({ + authorization: 'Bearer stock-ws-key', + 'x-stock-field': 'unchanged', + }) + await expect( + hooks.run('experimental.ws.handshake', { + ...scope, + url: 'wss://stock.invalid/model', + headers: { + authorization: `Bearer ${placeholderSecret('anthropic')}`, + }, + }), + ).rejects.toBeInstanceOf(NativeAnthropicTransportRefusal) + } finally { + await native.dispose() + } + }) + test('owned Fable and Haiku body-dependent betas survive generic account header reapplication', async () => { + const hooks = new Hooks() + const native = await installNativeAnthropic( + { session: hooks }, + { + source: source({ + authorizeAccount: () => ({ + route: 'oauth', + accessToken: 'sk-ant-oat01-synthetic', + provenance: { version: 7 }, + headers: { 'anthropic-beta': 'user-extra-beta' }, + }), + requestOptions: () => ({ serverSideFallbackEnabled: true }), + }), + }, + { credential: preparedAnthropicCredential }, + ) + try { + for (const model of ['claude-fable-5-1', 'claude-haiku-5-5']) { + const draft = { + ...scope, + request: new Request('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'x-api-key': placeholderSecret('anthropic'), + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model, + max_tokens: 128, + messages: [{ role: 'user', content: 'owned wire' }], + }), + }), + } + await hooks.run('http.request', draft) + const body = JSON.parse(await draft.request.clone().text()) + expect(body.thinking.type).toBe('adaptive') + const betas = draft.request.headers.get('anthropic-beta') ?? '' + expect(betas).toContain('user-extra-beta') + expect(betas).toContain('oauth-2025-04-20') + if (body.fallbacks === 'default') + expect(betas).toContain(SERVER_SIDE_FALLBACK_BETA) + if (body.thinking.block_binding) + expect(betas).toContain(THINKING_BINDING_CONTROLS_BETA) + expect(draft.request.headers.get('x-api-key')).toBeNull() + } + } finally { + await native.dispose() + } + }) + test('exact stock credentials keep their wire key, headers, URL, body and feedback untouched', async () => { + const hooks = new Hooks() + let selections = 0 + let publications = 0 + const native = await installNativeAnthropic( + { session: hooks }, + { + source: source({ + chooseAccount: () => { + selections++ + return 'main' + }, + quotaFromHeaders: () => { + publications++ + return undefined + }, + onAttemptEnd: () => { + publications++ + }, + }), + }, + { credential: preparedAnthropicCredential }, + ) + try { + const stockCredentials: Record[] = [ + { 'x-api-key': 'stock-api-key-exact' }, + { 'x-api-key': 'stock-env-key-exact' }, + { authorization: 'Bearer stock-opencode-oauth-exact' }, + { authorization: `Bearer ${placeholderSecret('anthropic')}-suffix` }, + {}, + ] + for (const credential of stockCredentials) { + const body = JSON.stringify({ + model: 'claude-sonnet-5', + messages: [{ role: 'user', content: 'stock body' }], + fallbacks: 'keep-stock-field', + }) + const headers = new Headers({ + ...credential, + 'content-type': 'application/json', + 'anthropic-beta': 'stock-beta', + 'x-stock-field': 'unchanged', + }) + const draft = { + ...scope, + request: new Request( + 'https://stock-gateway.invalid/v1/messages?stock=1', + { method: 'POST', headers, body }, + ), + } + const before = draft.request + const model = { ...scope, headers: { 'x-stock-model': 'unchanged' } } + await hooks.run('model.request', model) + expect(model.headers).toEqual({ 'x-stock-model': 'unchanged' }) + await hooks.run('http.request', draft) + expect(draft.request).toBe(before) + expect(draft.request.url).toBe( + 'https://stock-gateway.invalid/v1/messages?stock=1', + ) + expect(Object.fromEntries(draft.request.headers)).toEqual( + Object.fromEntries(headers), + ) + expect(await draft.request.clone().text()).toBe(body) + const response = { + ...scope, + request: draft.request, + response: Response.json( + { content: [{ type: 'tool_use', name: 'mcp_stock' }] }, + { + headers: { + 'request-id': 'req_stock', + 'anthropic-ratelimit-unified-5h-utilization': '0.42', + }, + }, + ), + } + const original = response.response + await hooks.run('http.response', response) + expect(response.response).toBe(original) + } + expect(selections).toBe(0) + expect(publications).toBe(0) + } finally { + await native.dispose() + } + }) + test('only the exact explicit pool placeholder selects a pool bearer and never forwards marker headers', async () => { + const hooks = new Hooks() + let selections = 0 + const native = await installNativeAnthropic( + { session: hooks }, + { + source: source({ + chooseAccount: () => { + selections++ + return 'main' + }, + }), + }, + { credential: preparedAnthropicCredential }, + ) + try { + for (const header of ['authorization', 'x-api-key']) { + const draft = { ...scope, request: request() } + draft.request.headers.delete('authorization') + draft.request.headers.set( + header, + header === 'authorization' + ? `Bearer ${placeholderSecret('anthropic')}` + : placeholderSecret('anthropic'), + ) + await hooks.run('http.request', draft) + expect(draft.request.headers.get('authorization')).toBe( + 'Bearer sk-ant-oat01-synthetic', + ) + expect(draft.request.headers.get('x-api-key')).toBeNull() + expect( + [...draft.request.headers.values()].some((value) => + value.includes(placeholderSecret('anthropic')), + ), + ).toBe(false) + } + expect(selections).toBe(2) + } finally { + await native.dispose() + } + }) + test('prepared credential switches in both directions take effect on the next request in one session', async () => { + const hooks = new Hooks() + let selections = 0 + const native = await installNativeAnthropic( + { session: hooks }, + { + source: source({ + chooseAccount: () => { + selections++ + return 'main' + }, + }), + }, + { credential: preparedAnthropicCredential }, + ) + try { + for (const [index, token] of [ + 'stock-one', + placeholderSecret('anthropic'), + 'stock-two', + placeholderSecret('anthropic'), + ].entries()) { + const draft = { ...scope, request: request() } + draft.request.headers.set('authorization', `Bearer ${token}`) + await hooks.run('http.request', draft) + expect(draft.request.headers.get('authorization')).toBe( + index % 2 ? 'Bearer sk-ant-oat01-synthetic' : `Bearer ${token}`, + ) + } + expect(selections).toBe(2) + } finally { + await native.dispose() + } + }) + test('explicit placeholder with an empty pool refuses with repair guidance before any HTTP dispatch', async () => { + const hooks = new Hooks() + let sends = 0 + const native = await installNativeAnthropic( + { session: hooks }, + { source: source({ chooseAccount: () => undefined }) }, + { credential: preparedAnthropicCredential }, + ) + try { + const dispatch = async () => { + const draft = { ...scope, request: request() } + draft.request.headers.set( + 'authorization', + `Bearer ${placeholderSecret('anthropic')}`, + ) + await hooks.run('http.request', draft) + sends++ + } + await expect(dispatch()).rejects.toThrow( + 'Complete offline migration or repair', + ) + expect(sends).toBe(0) + } finally { + await native.dispose() + } + }) + test('only an explicit Claude pool login stores the provider-tagged inert OAuth placeholder', async () => { + let registration: IntegrationOAuthMethodRegistration | undefined + let logins = 0 + await registerOpenCode2AuthMethods( + { + integration: { + transform: async (callback) => { + callback({ + list: () => [], + get: () => undefined, + update: () => {}, + remove: () => {}, + method: { + list: () => [], + remove: () => {}, + update: (input) => { + if ('authorize' in input) registration = input + }, + }, + }) + return { dispose: async () => {} } + }, + }, + }, + { + integrationID: 'anthropic', + methods: [ + { + method: { + id: CLAUDE_POOL_LOGIN_METHOD_ID, + type: 'oauth', + label: 'Claude pool', + }, + authorize: async () => ({ + mode: 'code', + url: 'https://fixture.invalid/authorize', + instructions: 'Synthetic explicit login', + callback: async () => ({ done: true }), + }), + }, + ], + onLogin: async () => { + logins++ + }, + }, + ) + expect(logins).toBe(0) + if (!registration) throw new Error('Explicit pool login was not registered') + const pending = await registration.authorize({}) + if (pending.mode !== 'code') + throw new Error('An explicit user code is required') + const marker = await pending.callback('synthetic-code') + expect(logins).toBe(1) + expect(String(marker.methodID)).toBe(CLAUDE_POOL_LOGIN_METHOD_ID) + expect(isPlaceholderCredential(marker, 'anthropic')).toBe(true) + expect(marker.access).toBe(placeholderSecret('anthropic')) + }) + test('real recipe overrides host credentials and removes its attempt header on physical HTTP request', async () => { + const hooks = new Hooks() + const native = await installNativeAnthropic( + { session: hooks }, + { source: source() }, + { credential: preparedAnthropicCredential }, + ) + try { + const modelRequest = { ...scope, headers: {} as Record } + await hooks.run('model.request', modelRequest) + const draft = { ...scope, request: request() } + for (const [name, value] of Object.entries(modelRequest.headers)) + draft.request.headers.set(name, value) + await hooks.run('http.request', draft) + expect(draft.request.headers.get('authorization')).toBe( + 'Bearer sk-ant-oat01-synthetic', + ) + expect(draft.request.headers.get('x-common-auth-attempt')).toBeNull() + expect(draft.request.headers.get('x-api-key')).toBeNull() + expect(await draft.request.text()).toContain('x-anthropic-billing-header') + } finally { + await native.dispose() + } + expect(hooks.size).toBe(0) + }) + + test('selected WebSocket relay refuses before any physical HTTP send or handshake', async () => { + const hooks = new Hooks() + let sends = 0 + const native = await installNativeAnthropic( + { session: hooks }, + { + source: source({ + authorizeAccount: () => ({ + route: 'oauth', + accessToken: 'synthetic', + provenance: { version: 7 }, + dispatch: { kind: 'websocket-relay' }, + }), + }), + }, + { credential: preparedAnthropicCredential }, + ) + try { + const dispatch = async () => { + await hooks.run('model.request', { ...scope, headers: {} }) + await hooks.run('http.request', { ...scope, request: request() }) + sends++ + } + await expect(dispatch()).rejects.toBeInstanceOf( + NativeAnthropicTransportRefusal, + ) + await expect( + hooks.run('experimental.ws.handshake', { + ...scope, + url: 'wss://relay.invalid', + headers: { + authorization: `Bearer ${placeholderSecret('anthropic')}`, + }, + }), + ).rejects.toBeInstanceOf(NativeAnthropicTransportRefusal) + expect(sends).toBe(0) + } finally { + await native.dispose() + } + }) + + test('disposal revokes pending credential callbacks before they can settle into host headers', async () => { + const hooks = new Hooks() + const completion = + Promise.withResolvers>>() + const entered = Promise.withResolvers() + const native = await installNativeAnthropic( + { session: hooks }, + { + source: source({ + authorizeAccount: () => { + entered.resolve() + return completion.promise + }, + }), + }, + { credential: preparedAnthropicCredential }, + ) + const draft = { ...scope, request: request() } + const result = hooks + .run('http.request', draft) + .catch((error: unknown) => error) + await entered.promise + const cleanup = native.dispose() + completion.resolve({ + route: 'oauth', + accessToken: 'revoked-synthetic', + provenance: { version: 7 }, + }) + expect(await result).toBeInstanceOf(NativeAnthropicRevokedError) + await cleanup + expect(draft.request.headers.get('authorization')).toBe( + `Bearer ${placeholderSecret('anthropic')}`, + ) + expect(draft.request.headers.get('authorization')).not.toContain( + 'revoked-synthetic', + ) + expect(hooks.size).toBe(0) + await native.dispose() + expect(new Set(hooks.disposed).size).toBe(hooks.disposed.length) + }) + + test('partial installation failure disposes every already-registered hook', async () => { + const hooks = new Hooks() + hooks.failAt = 'http.response' + await expect( + installNativeAnthropic( + { session: hooks }, + { source: source() }, + { credential: preparedAnthropicCredential }, + ), + ).rejects.toThrow('synthetic registration failure') + expect(hooks.size).toBe(0) + expect(hooks.disposed).toEqual(['model.request', 'http.request']) + }) + + test('HTTP relay carries the signed full wire request with only relay auth on the outer request', async () => { + const hooks = new Hooks() + let version = 7 + const native = await installNativeAnthropic( + { session: hooks }, + { + source: source({ + authorizeAccount: () => ({ + route: 'oauth', + accessToken: `sk-ant-oat01-synthetic-${version++}`, + headers: { 'x-native-account-header': 'preserved' }, + provenance: { version }, + dispatch: { + kind: 'http-relay', + url: 'https://relay.invalid/send', + token: 'relay-secret', + }, + }), + }), + }, + { credential: preparedAnthropicCredential }, + ) + try { + for (const tokenVersion of [7, 8]) { + const modelRequest = { ...scope, headers: {} as Record } + await hooks.run('model.request', modelRequest) + const draft = { ...scope, request: request() } + for (const [name, value] of Object.entries(modelRequest.headers)) + draft.request.headers.set(name, value) + await hooks.run('http.request', draft) + expect(draft.request.url).toBe('https://relay.invalid/send') + expect(draft.request.headers.get('authorization')).toBeNull() + expect(draft.request.headers.get('x-api-key')).toBeNull() + expect(draft.request.headers.get('x-relay-token')).toBe('relay-secret') + const envelope = (await draft.request.json()) as RelayEnvelope + expect(envelope.protocol).toBe(1) + expect(envelope.mode).toBe('full_sync') + expect(envelope.affinity).toBe(sessionID) + expect(envelope.upstream.url).toBe( + 'https://api.anthropic.com/v1/messages?beta=true', + ) + expect(envelope.upstream.headers.authorization).toBe( + `Bearer sk-ant-oat01-synthetic-${tokenVersion}`, + ) + expect(envelope.upstream.headers['x-native-account-header']).toBe( + 'preserved', + ) + expect(envelope.upstream.headers['x-relay-token']).toBeUndefined() + expect( + envelope.upstream.headers['x-common-auth-attempt'], + ).toBeUndefined() + expect(envelope.upstream.headers['x-api-key']).toBeUndefined() + expect(envelope.body).toContain('x-anthropic-billing-header') + expect(envelope.next_hash).toMatch(/^sha256:[0-9a-f]{64}$/) + } + } finally { + await native.dispose() + } + }) + + test('relay-local 401 has no credential/quota attribution, but genuine upstream 401 keeps its receipt', async () => { + const hooks = new Hooks() + const quotaVersions: number[] = [] + const statuses: Array<{ version: number; status?: number }> = [] + const native = await installNativeAnthropic( + { session: hooks }, + { + source: source({ + authorizeAccount: () => ({ + route: 'oauth', + accessToken: 'synthetic', + provenance: { version: 7 }, + dispatch: { + kind: 'http-relay', + url: 'https://relay.invalid/send', + token: 'relay-secret', + }, + }), + quotaFromHeaders: (_headers, context) => { + quotaVersions.push(context.provenance.version) + return undefined + }, + onAttemptEnd: (context, outcome) => { + statuses.push({ + version: context.provenance.version, + status: outcome.status, + }) + }, + }), + }, + { credential: preparedAnthropicCredential }, + ) + try { + for (const upstream of [false, true]) { + const modelRequest = { ...scope, headers: {} as Record } + await hooks.run('model.request', modelRequest) + const draft = { ...scope, request: request() } + for (const [name, value] of Object.entries(modelRequest.headers)) + draft.request.headers.set(name, value) + await hooks.run('http.request', draft) + const response = { + ...scope, + request: draft.request, + response: Response.json( + { + type: 'error', + error: { type: 'authentication_error', message: 'rejected' }, + }, + { + status: 401, + headers: upstream ? { 'request-id': 'req_upstream7' } : {}, + }, + ), + } + await hooks.run('http.response', response) + expect(response.response.status).toBe(upstream ? 401 : 502) + await response.response.text() + } + expect(quotaVersions).toEqual([7]) + expect(statuses).toEqual([ + { version: 7, status: undefined }, + { version: 7, status: 401 }, + ]) + } finally { + await native.dispose() + } + }) +}) + +/** Registers the setup's login methods through common-auth, recording what the host would store and every local pool write. */ +async function enrollmentLogin(readMode: ClaudeLoginMethodsInput['readMode']) { + const registrations = new Map() + const writes: unknown[] = [] + let browserStarts = 0 + // Writes to OpenCode's existing connections, as opposed to the credential + // a completed login method hands back for OpenCode to store. + let connectionWrites = 0 + await registerOpenCode2AuthMethods( + { + integration: { + transform: async (callback) => { + callback({ + list: () => [], + get: () => undefined, + update: () => { + connectionWrites++ + }, + remove: () => { + connectionWrites++ + }, + method: { + list: () => [], + remove: () => {}, + update: (input) => { + if ('authorize' in input) + registrations.set(String(input.method.id), input) + }, + }, + }) + return { dispose: async () => {} } + }, + }, + }, + claudeLoginMethods({ + isActive: () => true, + readMode, + runtime: { + loginOAuth: async (input) => { + writes.push(input) + return undefined as never + }, + }, + authorize: async () => { + browserStarts++ + return { + url: 'https://claude.invalid/authorize', + redirectUri: 'https://claude.invalid/callback', + state: 'synthetic-state', + verifier: 'synthetic-verifier', + } + }, + exchange: async () => ({ + type: 'success', + access: 'sk-ant-oat01-synthetic-login', + refresh: 'synthetic-login-refresh', + expires: 4_102_444_800_000, + }), + }), + ) + const method = (id: string) => { + const registration = registrations.get(id) + if (!registration) throw new Error(`login method ${id} is not registered`) + return registration + } + /** Starts vault activation as OpenCode would and returns its callback, which resolves to the credential OpenCode stores. */ + const activateVault = async () => { + const pending = await method(CLAUDE_VAULT_ACTIVATION_METHOD_ID).authorize( + {}, + ) + if (pending.mode !== 'auto') + throw new Error('Vault activation must need no user step') + return pending.callback + } + return { + method, + activateVault, + writes, + browserStarts: () => browserStarts, + connectionWrites: () => connectionWrites, + } +} + +describe('Claude login methods follow vault enrollment', () => { + test('an enrolled host refuses local sign-in before the browser opens and activates vault accounts without local writes', async () => { + const login = await enrollmentLogin(async () => 'claustrum') + await expect( + login.method(CLAUDE_POOL_LOGIN_METHOD_ID).authorize({}), + ).rejects.toThrow(LOCAL_LOGIN_REFUSED_IN_VAULT) + expect(login.browserStarts()).toBe(0) + const marker = await login.activateVault() + expect(String(marker.methodID)).toBe(CLAUDE_VAULT_ACTIVATION_METHOD_ID) + expect(isPlaceholderCredential(marker, 'anthropic')).toBe(true) + expect(marker.access).toBe(placeholderSecret('anthropic')) + expect(login.writes).toHaveLength(0) + }) + test('a host without vault enrollment refuses vault activation and keeps local sign-in', async () => { + const login = await enrollmentLogin(async () => 'local') + await expect(login.activateVault()).rejects.toThrow( + VAULT_ACTIVATION_REFUSED_LOCALLY, + ) + const pending = await login + .method(CLAUDE_POOL_LOGIN_METHOD_ID) + .authorize({}) + if (pending.mode !== 'code') + throw new Error('An explicit user code is required') + const marker = await pending.callback('synthetic-code') + expect(String(marker.methodID)).toBe(CLAUDE_POOL_LOGIN_METHOD_ID) + expect(login.writes).toEqual([ + { + routeId: 'main', + credential: { + access: 'sk-ant-oat01-synthetic-login', + refresh: 'synthetic-login-refresh', + expires: 4_102_444_800_000, + }, + replace: true, + }, + ]) + }) + test('enrollment that starts while the user is in the browser stops the local write', async () => { + let mode: 'local' | 'claustrum' = 'local' + const login = await enrollmentLogin(async () => mode) + const pending = await login + .method(CLAUDE_POOL_LOGIN_METHOD_ID) + .authorize({}) + if (pending.mode !== 'code') + throw new Error('An explicit user code is required') + mode = 'claustrum' + await expect(pending.callback('synthetic-code')).rejects.toThrow( + LOCAL_LOGIN_REFUSED_IN_VAULT, + ) + expect(login.writes).toHaveLength(0) + }) + test('unreadable enrollment refuses both methods with fixed text and writes nothing', async () => { + const login = await enrollmentLogin(async () => { + throw new Error('token sk-ant-oat01-must-not-surface') + }) + await expect( + login.method(CLAUDE_POOL_LOGIN_METHOD_ID).authorize({}), + ).rejects.toThrow(ENROLLMENT_UNREADABLE) + const refusal = await login.activateVault().catch((error: Error) => error) + expect(refusal).toBeInstanceOf(Error) + expect((refusal as Error).message).toBe(ENROLLMENT_UNREADABLE) + expect(login.browserStarts()).toBe(0) + expect(login.writes).toHaveLength(0) + }) + test('vault activation hands OpenCode the exact request-owning placeholder, never a custody tombstone, and leaves existing connections alone', async () => { + const login = await enrollmentLogin(async () => 'claustrum') + const marker = await login.activateVault() + expect(login.connectionWrites()).toBe(0) + const tombstone = custodyTombstoneOAuth('anthropic') + expect(marker.access).not.toBe(tombstone.access) + expect(marker.refresh).not.toBe(tombstone.refresh) + // The stored marker's secret is what OpenCode later sends; only that + // exact value may select the native account pool. + const hooks = new Hooks() + let selections = 0 + const native = await installNativeAnthropic( + { session: hooks }, + { + source: source({ + chooseAccount: () => { + selections++ + return 'main' + }, + }), + }, + { credential: preparedAnthropicCredential }, + ) + try { + const owned = { ...scope, request: request() } + owned.request.headers.set('authorization', `Bearer ${marker.access}`) + await hooks.run('http.request', owned) + expect(owned.request.headers.get('authorization')).toBe( + 'Bearer sk-ant-oat01-synthetic', + ) + for (const value of [tombstone.access, tombstone.refresh]) { + const stock = { ...scope, request: request() } + stock.request.headers.set('authorization', `Bearer ${value}`) + const before = stock.request + await hooks.run('http.request', stock) + expect(stock.request).toBe(before) + } + expect(selections).toBe(1) + } finally { + await native.dispose() + } + }) +}) diff --git a/packages/opencode/src/tests/opencode2-e2e.test.ts b/packages/opencode/src/tests/opencode2-e2e.test.ts new file mode 100644 index 000000000..4d272654b --- /dev/null +++ b/packages/opencode/src/tests/opencode2-e2e.test.ts @@ -0,0 +1,770 @@ +// This is an unconditional CI gate on the real pinned hosts and packed plugin. +// CLI installation failure is a failure, not a skipped interoperability test. +import { afterAll, beforeAll, describe, expect } from 'bun:test' +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + CLAUDE_CODE_IDENTITY, + captureNativeLocalPoolBinding, + createNativePoolStore, + custodyTombstoneOAuth, + resolveNativePoolPaths, + runNativeMigration, + signRequestBody, + USER_AGENT, +} from '@cortexkit/anthropic-auth-core' +import { placeholderSecret } from '@cortexkit/common-auth/opencode2' +import { fingerprintOf } from '@cortexkit/common-auth/store' +import { Host } from '@opencode/plugin/host' +import { nativeQuotaCodec } from '../../../core/src/native-quota-codec' +import { + createTestLifetimeSuite, + type TestGate, +} from '../../../core/src/tests/test-lifetime' +import { tokenFingerprint } from '../../../core/src/token-fingerprint' + +const { test, gate, trackDetached } = createTestLifetimeSuite() + +const PACKAGE = '@cortexkit/opencode-anthropic-auth' +const TOKEN = 'sk-ant-oat01-oc2-disposable-only' +const IDENTITY = '11111111-2222-4333-8444-555555555555' +const PASSWORD = 'anthropic-oc2-disposable-only' +const workspace = resolve(import.meta.dir, '../../../..') +let scratch = '' +let cli = '' +let oc1 = '' +let packedProject = '' +const children = new Set() +/** + * Host output is kept inside the fixture that ran it: the temporary directory + * isolatedEnv gives each host as TMPDIR. Removing that fixture removes these + * logs, so they never outlive the test run. + */ +const hostLogDirectory = (env: Record) => + env.TMPDIR ? join(env.TMPDIR, 'host-logs') : undefined + +/** Also used for installation: the CLI's postinstall runs --version itself. */ +async function isolatedEnv(root: string): Promise> { + const directories = { + HOME: join(root, 'home'), + XDG_CONFIG_HOME: join(root, 'config'), + XDG_DATA_HOME: join(root, 'data'), + XDG_STATE_HOME: join(root, 'state'), + XDG_CACHE_HOME: join(root, 'cache'), + TMPDIR: join(root, 'tmp'), + } + await Promise.all( + Object.values(directories).map((directory) => + mkdir(directory, { recursive: true }), + ), + ) + return { + PATH: process.env.PATH ?? '', + ...directories, + OPENCODE_DB: join(root, 'disposable.sqlite'), + OPENCODE_SERVER_PASSWORD: PASSWORD, + OPENCODE_PASSWORD: PASSWORD, + OPENCODE_ANTHROPIC_AUTH_LOG_FILE: join(root, 'anthropic-auth.log'), + } +} +async function command( + args: string[], + cwd: string, + env: Record, + limit = 60_000, + cancellation?: TestGate, +) { + const child = Bun.spawn(args, { cwd, env, stdout: 'pipe', stderr: 'pipe' }) + children.add(child) + const stdoutWork = new Response(child.stdout).text() + const stderrWork = new Response(child.stderr).text() + const exitWork = child.exited + const completed = Promise.withResolvers() + let settled = false + const stopWork = cancellation + ? Promise.race([cancellation.wait, completed.promise]).then(() => { + if (!settled && child.exitCode === null) child.kill('SIGKILL') + }) + : Promise.resolve() + if (cancellation) trackDetached(stopWork) + const timer = setTimeout(() => child.kill('SIGKILL'), limit) + try { + const [stdout, stderr, exit] = await Promise.all([ + stdoutWork, + stderrWork, + exitWork, + ]) + const logs = hostLogDirectory(env) + if (logs && (args[0] === cli || args[0] === oc1)) { + await mkdir(logs, { recursive: true }) + const prefix = `${Date.now()}-${args[0] === cli ? 'oc2' : 'oc1'}` + await writeFile(join(logs, `${prefix}-stdout.log`), stdout) + await writeFile(join(logs, `${prefix}-stderr.log`), stderr) + } + return { stdout, stderr, exit } + } finally { + clearTimeout(timer) + if (child.exitCode === null) child.kill('SIGKILL') + settled = true + completed.resolve() + await Promise.allSettled([stdoutWork, stderrWork, exitWork, stopWork]) + children.delete(child) + } +} +async function requireCommand( + args: string[], + cwd: string, + env: Record, +) { + const result = await command(args, cwd, env) + if (result.exit !== 0) + throw new Error( + `Disposable command failed: ${args[0]} ${args[1]}\n${result.stdout}\n${result.stderr}`, + ) + return result +} + +beforeAll(async () => { + const cache = join(workspace, 'node_modules', '.cache', 'opencode2-e2e') + await mkdir(cache, { recursive: true }) + scratch = await mkdtemp(join(cache, 'fixture-')) + const install = join(scratch, 'install') + await mkdir(install, { recursive: true }) + const env = await isolatedEnv(install) + // Both real hosts are installed from the npm registry. If it is + // unreachable, fail here before an install stalls: an offline runner must + // report this test as failed, never skip it. + const registry = await command( + [ + 'npm', + 'ping', + '--registry=https://registry.npmjs.org', + '--fetch-retries=0', + '--fetch-timeout=2000', + ], + install, + env, + 10_000, + ) + if (registry.exit !== 0) + throw new Error( + `Packed OpenCode host verification requires npm registry access. Run this gate in network-enabled CI; no host checks ran.\n${registry.stdout}\n${registry.stderr}`, + ) + await writeFile( + join(install, 'package.json'), + JSON.stringify({ private: true }), + ) + await requireCommand( + ['npm', 'install', '--no-audit', '--no-fund', '@opencode/cli@2.0.22'], + install, + env, + ) + cli = join(install, 'node_modules', '.bin', 'opencode') + const version = await requireCommand([cli, '--version'], install, env) + expect(version.stdout).toContain('2.0.22') + const install1 = join(scratch, 'install1') + await mkdir(install1, { recursive: true }) + await writeFile( + join(install1, 'package.json'), + JSON.stringify({ private: true }), + ) + await requireCommand( + ['npm', 'install', '--no-audit', '--no-fund', 'opencode-ai@1.18.31'], + install1, + await isolatedEnv(install1), + ) + oc1 = join(install1, 'node_modules', '.bin', 'opencode') + packedProject = join(scratch, 'packed') + await mkdir(packedProject, { recursive: true }) + const pack = join(scratch, 'archives') + await mkdir(pack, { recursive: true }) + for (const pkg of ['core', 'opencode']) { + await requireCommand( + ['bun', 'pm', 'pack', '--destination', pack], + join(workspace, 'packages', pkg), + env, + ) + } + const archives = (await readdir(pack)) + .filter((file) => file.endsWith('.tgz')) + .map((file) => join(pack, file)) + expect(archives).toHaveLength(2) + await writeFile( + join(packedProject, 'package.json'), + JSON.stringify({ private: true, type: 'module' }), + ) + await requireCommand( + ['npm', 'install', '--no-audit', '--no-fund', ...archives], + packedProject, + env, + ) +}, 180_000) +afterAll(async () => { + for (const child of children) + if (child.exitCode === null) child.kill('SIGKILL') + await Promise.all([...children].map((child) => child.exited)) + children.clear() + if (scratch) await rm(scratch, { recursive: true, force: true }) +}) + +/** + * Seed synthetic accounts without network access. The plugin serves model + * requests only after the native credential migration has committed its + * journal (the record that host credentials moved into the account pool), so + * the seed runs that migration before adding accounts. + */ +async function seed( + env: Record, + relay?: { url: string; transport: 'http' | 'websocket' }, +) { + const config = join(env.XDG_CONFIG_HOME!, 'opencode', 'anthropic-auth.json') + await mkdir(dirname(config), { recursive: true, mode: 0o700 }) + const paths = await resolveNativePoolPaths( + config, + join(dirname(config), 'anthropic-auth-state.json'), + ) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + await writeFile( + paths.legacyConfig, + JSON.stringify({ version: 1, accounts: [] }), + { mode: 0o600 }, + ) + await writeFile( + paths.legacyState, + JSON.stringify({ version: 1, accounts: {} }), + { mode: 0o600 }, + ) + await runNativeMigration({ + paths, + host: 'opencode', + hostAuthPath: join(env.XDG_DATA_HOME!, 'opencode', 'auth.json'), + routingSourcePath: join(dirname(config), 'legacy-routing.json'), + routingDestinationPath: join(dirname(config), 'native-routing.json'), + env, + processFence: async () => {}, + removePiAnthropicAuth: false, + }) + await store.add({ + id: 'main', + identity: IDENTITY, + credential: { + type: 'oauth', + access: TOKEN, + refresh: 'synthetic-refresh', + expires: Date.now() + 3600_000, + }, + }) + await store.updateSettings(() => ({ + mainAccountId: 'main', + routing: { mode: 'main-first' }, + quota: { enabled: false }, + ...(relay + ? { relay: { enabled: true, ...relay, fallbackToDirect: true } } + : {}), + })) + const current = await store.read() + if ( + current.status !== 'ready' || + !current.rows[0]?.credential || + current.rows[0].credential.type !== 'oauth' + ) + throw new Error('Seeded row missing') + const row = current.rows[0] + const credential = row.credential + if ( + credential?.type !== 'oauth' || + !credential.access || + credential.expires === undefined + ) + throw new Error('Seeded access missing') + const binding = captureNativeLocalPoolBinding(paths, row) + await writeFile( + paths.runtime, + JSON.stringify({ + version: 1, + storageId: paths.storageId, + ...(relay ? { relay: { token: 'relay-disposable-only' } } : {}), + accounts: { + main: { + binding, + credentialValidation: { + binding, + credentialFingerprint: fingerprintOf(credential), + version: { + accessFingerprint: tokenFingerprint(credential.access), + expires: credential.expires, + ...(Object.hasOwn(credential, 'lastRefreshedAt') + ? { lastRefreshedAt: credential.lastRefreshedAt } + : {}), + }, + }, + }, + }, + }), + { mode: 0o600 }, + ) + // Corrupt the pre-migration account files. A request can then succeed only + // with the credentials in migrated storage, and the direct-request test + // checks the legacy credential file is still unchanged afterwards. + await writeFile(paths.legacyConfig, '{invalid legacy config') + await writeFile(paths.legacyState, '{invalid legacy credential state') + return paths +} + +const frame = (type: string, data: unknown) => + `event: ${type}\ndata: ${JSON.stringify(data)}\n\n` +function reply(model: string, text = 'OC2_SYNTHETIC_OK') { + return new Response( + [ + frame('message_start', { + type: 'message_start', + message: { + id: 'msg_oc2_synthetic', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 20, output_tokens: 0 }, + }, + }), + frame('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + frame('content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text }, + }), + frame('content_block_stop', { type: 'content_block_stop', index: 0 }), + frame('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 3 }, + }), + frame('message_stop', { type: 'message_stop' }), + ].join(''), + { + headers: { + 'content-type': 'text/event-stream', + 'request-id': 'req_oc2_synthetic', + 'anthropic-ratelimit-unified-5h-utilization': '0.42', + }, + }, + ) +} +function toolReply( + model: string, + name: string, + input: Record, +) { + return new Response( + [ + frame('message_start', { + type: 'message_start', + message: { + id: 'msg_oc2_tool', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 20, output_tokens: 0 }, + }, + }), + frame('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_oc2_read', + name, + input: {}, + }, + }), + frame('content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(input), + }, + }), + frame('content_block_stop', { type: 'content_block_stop', index: 0 }), + frame('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'tool_use', stop_sequence: null }, + usage: { output_tokens: 6 }, + }), + frame('message_stop', { type: 'message_stop' }), + ].join(''), + { + headers: { + 'content-type': 'text/event-stream', + 'request-id': 'req_oc2_tool', + }, + }, + ) +} +interface Wire { + readonly body: string + readonly headers: Headers + readonly url: string +} +interface RelayEnvelope extends Record { + readonly upstream: { + url: string + method: string + headers: Record + } + readonly body: string +} +async function scenario( + transport: 'direct' | 'http' | 'websocket', + host: 'oc1' | 'oc2' = 'oc2', + entry: 'package' | 'main' = 'package', + mode?: 'tool' | 'structured', +) { + const root = await mkdtemp(join(scratch, 'scenario-')) + const project = join(root, 'project') + await mkdir(project) + await requireCommand(['git', 'init', '-q'], project, { + PATH: process.env.PATH ?? '', + }) + await Bun.$`cp -R ${join(packedProject, 'node_modules')} ${join(project, 'node_modules')}`.quiet() + const env = await isolatedEnv(root) + Object.assign(env, { + HTTP_PROXY: 'http://127.0.0.1:9', + HTTPS_PROXY: 'http://127.0.0.1:9', + http_proxy: 'http://127.0.0.1:9', + https_proxy: 'http://127.0.0.1:9', + NO_PROXY: '127.0.0.1,localhost', + no_proxy: '127.0.0.1,localhost', + }) + const wire: Wire[] = [] + const envelopes: Record[] = [] + let usedTool = false + await writeFile(join(project, 'fixture.txt'), 'OC2_NATIVE_TOOL_FILE') + const upstream = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(request) { + const body = await request.text() + wire.push({ + body, + headers: new Headers(request.headers), + url: request.url, + }) + const payload = JSON.parse(body) + if ( + mode === 'tool' && + payload.tool_choice?.type === 'tool' && + !usedTool + ) { + usedTool = true + const tool = payload.tools.find( + (tool: { name: string }) => tool.name === payload.tool_choice.name, + ) + const key = tool?.input_schema?.properties?.path ? 'path' : 'filePath' + return toolReply(payload.model, payload.tool_choice.name, { + [key]: join(project, 'fixture.txt'), + }) + } + return reply( + payload.model, + mode === 'structured' + ? '{"answer":"OC2_SYNTHETIC_OK"}' + : 'OC2_SYNTHETIC_OK', + ) + }, + }) + const relay = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(request) { + const envelope = (await request.json()) as RelayEnvelope + envelopes.push(envelope) + expect(request.headers.get('authorization')).toBeNull() + expect(request.headers.get('x-relay-token')).toBe('relay-disposable-only') + return fetch(envelope.upstream.url, { + method: envelope.upstream.method, + headers: envelope.upstream.headers, + body: envelope.body, + }) + }, + }) + const paths = await seed( + env, + transport === 'direct' ? undefined : { url: relay.url.href, transport }, + ) + const configDirectory = join(env.XDG_CONFIG_HOME!, 'opencode') + const packed = join(project, 'node_modules', PACKAGE) + const manifest = JSON.parse( + await readFile(join(packed, 'package.json'), 'utf8'), + ) + let plugin = entry === 'main' ? join(packed, manifest.main) : PACKAGE + if (mode) { + // OpenCode 2 loads a local plugin from a directory package's server.js. + // This server.js registers a request hook that adds the test's forced + // read-tool choice or JSON output schema, then runs the packed ./server + // setup, so those requests pass through the packed production adapter. + const pluginDirectory = join(project, 'wire-loop') + await mkdir(pluginDirectory) + await writeFile( + join(pluginDirectory, 'package.json'), + JSON.stringify({ + name: '@cortexkit/oc2-wire-fixture', + version: '0.0.0', + type: 'module', + exports: { './server': './server.js' }, + }), + ) + plugin = join(pluginDirectory, 'server.js') + await writeFile( + plugin, + `import native from '${PACKAGE}/server'; import {appendFileSync} from 'node:fs'; +export default {id:'cortexkit-anthropic-auth',async setup(ctx){appendFileSync('${join(project, 'hook-audit.jsonl')}',JSON.stringify({setup:true})+'\\n');let forced=false;const hook=await ctx.session.hook('http.request',async draft=>{appendFileSync('${join(project, 'hook-audit.jsonl')}',JSON.stringify({kind:draft.kind,model:draft.model})+'\\n');if(draft.kind!=='primary')return;const body=await draft.request.json();${mode === 'structured' ? `body.output_config={...body.output_config,format:{type:'json_schema',schema:{type:'object',properties:{answer:{type:'string'}},required:['answer'],additionalProperties:false}}};` : `if(!forced){const tool=body.tools.find(tool=>/read/i.test(tool.name));if(!tool)throw new Error('Native read tool is missing');body.tool_choice={type:'tool',name:tool.name};forced=true;}`}draft.request=new Request(draft.request,{body:JSON.stringify(body)});},{providerID:'anthropic'});const cleanup=await native.setup(ctx);return async()=>{await cleanup();await hook.dispose()}}};`, + ) + plugin = pathToFileURL(pluginDirectory).href + } + if (host === 'oc1') { + await mkdir(join(env.XDG_DATA_HOME!, 'opencode'), { recursive: true }) + await writeFile( + join(env.XDG_DATA_HOME!, 'opencode', 'auth.json'), + JSON.stringify({ anthropic: custodyTombstoneOAuth('anthropic') }), + { mode: 0o600 }, + ) + } + await writeFile( + join(configDirectory, 'opencode.json'), + JSON.stringify( + host === 'oc2' + ? { + plugins: [plugin], + providers: { + anthropic: { + settings: { + apiKey: placeholderSecret('anthropic'), + baseURL: `${upstream.url.origin}/v1`, + }, + models: { 'claude-sonnet-5': { name: 'Sonnet 5 synthetic' } }, + }, + }, + } + : { + plugin: [plugin], + provider: { + anthropic: { options: { baseURL: `${upstream.url.origin}/v1` } }, + }, + }, + ), + ) + const executable = host === 'oc2' ? cli : oc1 + const result = await command( + [ + executable, + 'run', + ...(host === 'oc2' + ? ['--standalone', '--print-logs', '--log-level', 'debug'] + : []), + '--auto', + '--format', + 'json', + '--model', + 'anthropic/claude-sonnet-5', + 'Say OC2_SYNTHETIC_OK', + ], + project, + env, + 60_000, + gate(), + ) + if (result.exit !== 0) + console.error(JSON.stringify({ host, entry, mode, ...result })) + if (mode) + console.error( + JSON.stringify({ + mode, + audit: await readFile(join(project, 'hook-audit.jsonl'), 'utf8').catch( + () => 'not-created', + ), + stdout: result.stdout, + stderr: result.stderr, + models: wire.map((send) => ({ + model: JSON.parse(send.body).model, + toolChoice: JSON.parse(send.body).tool_choice, + outputConfig: JSON.parse(send.body).output_config, + })), + }), + ) + try { + return { result, wire, envelopes, paths, root, env } + } finally { + await upstream.stop(true) + await relay.stop(true) + } +} + +/** + * Runs one host scenario and hands it to `body`, then deletes the scenario's + * temporary directory, including its host logs. If `body` fails, the logs are + * printed first so the failure can still be diagnosed after the deletion. + */ +async function withScenario( + args: Parameters, + body: (run: Awaited>) => Promise | void, +) { + const run = await scenario(...args) + try { + await body(run) + } catch (error) { + const logs = hostLogDirectory(run.env) + for (const name of logs ? await readdir(logs).catch(() => []) : []) + console.error( + `${name}:\n${await readFile(join(logs ?? '', name), 'utf8')}`, + ) + throw error + } finally { + await rm(run.root, { recursive: true, force: true }) + } +} + +describe('packed Anthropic plugin on pinned real hosts', () => { + test('OC2 published loader discovers the actual packed server export and its setup function', async () => { + const entry = Host.resolve({ name: PACKAGE, directory: packedProject }) + expect(entry.server).toContain('/dist/v2/server.js') + if (!entry.server) throw new Error('Packed ./server export missing') + const mod = (await Host.load(entry.server)) as { + default: { setup: unknown; server: unknown } + } + expect(typeof mod.default.setup).toBe('function') + expect(typeof mod.default.server).toBe('function') + }) + test('real OC2 direct request uses native authority without legacy credential reads', async () => { + await withScenario(['direct'], async (run) => { + expect(run.result.exit).toBe(0) + expect(run.result.stdout).toContain('OC2_SYNTHETIC_OK') + expect(run.wire.length).toBeGreaterThan(0) + for (const send of run.wire) { + expect(send.headers.get('authorization')).toBe(`Bearer ${TOKEN}`) + expect(send.headers.get('x-api-key')).toBeNull() + expect(send.headers.get('user-agent')).toBe(USER_AGENT) + expect(send.headers.get('x-common-auth-attempt')).toBeNull() + const body = JSON.parse(send.body) + expect( + body.system.some( + (block: { text?: string }) => block.text === CLAUDE_CODE_IDENTITY, + ), + ).toBe(true) + if (body.model === 'claude-sonnet-5') + expect(body.thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + }) + expect(await signRequestBody(send.body)).toBe(send.body) + } + expect(await readFile(run.paths.legacyState, 'utf8')).toBe( + '{invalid legacy credential state', + ) + }) + }, 60_000) + test('real OC2 HTTP relay delivers the exact signed native body and upstream headers', async () => { + await withScenario(['http'], async (run) => { + expect(run.result.exit).toBe(0) + expect(run.result.stdout).toContain('OC2_SYNTHETIC_OK') + expect(run.envelopes.length).toBeGreaterThan(0) + expect(run.envelopes).toHaveLength(run.wire.length) + for (const [index, send] of run.wire.entries()) { + expect(run.envelopes[index]?.body).toBe(send.body) + expect(run.envelopes[index]?.mode).toBe('full_sync') + expect(send.headers.get('authorization')).toBe(`Bearer ${TOKEN}`) + expect(send.headers.get('x-relay-token')).toBeNull() + expect(await signRequestBody(send.body)).toBe(send.body) + } + }) + }, 60_000) + test('real OC2 selected WebSocket relay is a zero-provider zero-relay dispatch refusal', async () => { + await withScenario(['websocket'], async (run) => { + expect(run.result.stdout + run.result.stderr).toContain( + 'WebSocket relay is not supported', + ) + expect(run.wire).toHaveLength(0) + expect(run.envelopes).toHaveLength(0) + }) + }, 60_000) + test('real OC2 forced tool loop restores names and round-trips tool history over HTTP relay', async () => { + await withScenario(['http', 'oc2', 'package', 'tool'], async (run) => { + expect(run.result.exit).toBe(0) + expect(run.result.stdout).toContain('OC2_SYNTHETIC_OK') + const bodies = run.wire.map((send) => JSON.parse(send.body)) + expect( + bodies.some((body) => body.tool_choice?.name?.startsWith('mcp_')), + ).toBe(true) + expect( + bodies.some((body) => + body.messages.some( + (message: { + content?: Array<{ type: string; tool_use_id?: string }> + }) => + Array.isArray(message.content) && + message.content.some( + (block) => + block.type === 'tool_result' && + block.tool_use_id === 'toolu_oc2_read', + ), + ), + ), + ).toBe(true) + }) + }, 60_000) + test('real OC2 native structured output keeps its schema and structured-output beta', async () => { + await withScenario( + ['direct', 'oc2', 'package', 'structured'], + async (run) => { + expect(run.result.exit).toBe(0) + expect(run.result.stdout).toContain('OC2_SYNTHETIC_OK') + const structured = run.wire.find( + (send) => + JSON.parse(send.body).output_config?.format?.type === 'json_schema', + ) + expect(structured).toBeDefined() + if (!structured) + throw new Error('Native structured output request was not dispatched') + expect( + JSON.parse(structured.body).output_config.format.schema.required, + ).toEqual(['answer']) + expect(structured.headers.get('anthropic-beta')).toContain( + 'structured-outputs', + ) + }, + ) + }, 60_000) + test('supported OC1 loads the packed ./server entry without invoking OC2 setup', async () => { + await withScenario(['direct', 'oc1'], async (run) => { + expect(run.result.exit).toBe(0) + expect(run.result.stdout).toContain('OC2_SYNTHETIC_OK') + expect(run.wire.length).toBeGreaterThan(0) + expect(run.wire[0]?.headers.get('authorization')).toBe(`Bearer ${TOKEN}`) + }) + }, 60_000) + test('supported OC1 still loads the actual packed legacy main entry', async () => { + await withScenario(['direct', 'oc1', 'main'], async (run) => { + expect(run.result.exit).toBe(0) + expect(run.result.stdout).toContain('OC2_SYNTHETIC_OK') + expect(run.wire.length).toBeGreaterThan(0) + expect(run.wire[0]?.headers.get('authorization')).toBe(`Bearer ${TOKEN}`) + }) + }, 60_000) +}) diff --git a/packages/opencode/src/tests/opencode2-native-source.test.ts b/packages/opencode/src/tests/opencode2-native-source.test.ts new file mode 100644 index 000000000..263543c8e --- /dev/null +++ b/packages/opencode/src/tests/opencode2-native-source.test.ts @@ -0,0 +1,1155 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { createHash } from 'node:crypto' +import { + access, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { + captureNativeLocalPoolBinding, + createNativeAccountRuntime, + createNativePoolStore, + getStickyRoutingStatePath, + type NativeAccountRuntime, + type NativeCustodyClient, + type NativePoolPaths, + readNativeEnrollmentMode, + resolveNativePoolPaths, + runNativeMigration, +} from '@cortexkit/anthropic-auth-core' +import { registerOpenCode2AuthMethods } from '@cortexkit/common-auth/opencode2' +import { fingerprintOf } from '@cortexkit/common-auth/store' +import type { IntegrationOAuthMethodRegistration } from '@opencode/plugin/promise/integration' +import { nativeQuotaCodec } from '../../../core/src/native-quota-codec' +import { nativeVaultPaths } from '../../../core/src/native-vault-paths' +import { tokenFingerprint } from '../../../core/src/token-fingerprint' +import { + CLAUDE_POOL_LOGIN_METHOD_ID, + CLAUDE_VAULT_ACTIVATION_METHOD_ID, +} from '../v2/activation' +import { + createNativeAnthropicAdapter, + type NativeAnthropicAttemptContext, + NativeAnthropicTransportRefusal, +} from '../v2/adapter' +import { + claudeLoginMethods, + LOCAL_LOGIN_REFUSED_IN_VAULT, + nativeEnrollmentCallbacks, + VAULT_ACTIVATION_REFUSED_LOCALLY, +} from '../v2/setup' +import { + createNativeAnthropicSource, + type NativeSourceProvenance, +} from '../v2/source' + +const identity = '11111111-2222-4333-8444-555555555555' +const changedIdentity = '99999999-2222-4333-8444-555555555555' +const fallbackIdentity = '12345678-1234-4234-8234-123456789abc' +const scope = { + providerID: 'anthropic', + modelID: 'claude-sonnet-5', + sessionID: 'ses_native_oc2', + agent: 'build', + kind: 'primary' as const, +} +const exists = (path: string) => + access(path).then( + () => true, + () => false, + ) +const cleanups: Array<() => Promise | void> = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +async function storage() { + const parent = resolve( + import.meta.dir, + '../../../../node_modules/.cache/opencode2-native-source', + ) + await mkdir(parent, { recursive: true }) + const directory = await mkdtemp(join(parent, 'fixture-')) + cleanups.push(() => rm(directory, { recursive: true, force: true })) + const paths = await resolveNativePoolPaths( + join(directory, 'anthropic-auth.json'), + join(directory, 'anthropic-auth-state.json'), + ) + const store = createNativePoolStore({ paths, quota: nativeQuotaCodec }) + await writeFile( + paths.legacyConfig, + JSON.stringify({ version: 1, accounts: [] }), + { mode: 0o600 }, + ) + await writeFile( + paths.legacyState, + JSON.stringify({ version: 1, accounts: {} }), + { mode: 0o600 }, + ) + await runNativeMigration({ + paths, + host: 'opencode', + hostAuthPath: join(directory, 'auth.json'), + routingSourcePath: join(directory, 'legacy-routing.json'), + routingDestinationPath: join(directory, 'native-routing.json'), + env: {}, + processFence: async () => {}, + removePiAnthropicAuth: false, + }) + // The account runtime serves vault accounts only when this OpenCode's vault + // enrollment file exists, regardless of account settings. Give each fixture + // its own enrollment file path inside its directory, so the tests never read + // an enrollment file on the machine running them. + const env = { + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + directory, + 'opencode-enrollment.json', + ), + } + return { paths, store, env } +} +async function local( + localOptions?: Parameters[0]['local'], +) { + const { paths, store, env } = await storage() + await store.add({ + id: 'primary-row', + identity, + credential: { + type: 'oauth', + access: 'sk-ant-oat01-native-only', + refresh: 'native-only-refresh', + expires: Date.now() + 3600_000, + }, + }) + await store.updateSettings(() => ({ + mainAccountId: 'primary-row', + quota: { enabled: false }, + routing: { mode: 'main-first' }, + })) + const read = await store.read() + const row = read.status === 'ready' ? read.rows[0] : undefined + if ( + row?.credential?.type !== 'oauth' || + !row.credential.access || + row.credential.expires === undefined + ) + throw new Error('Missing native seed') + const binding = captureNativeLocalPoolBinding(paths, row) + await writeFile( + paths.runtime, + JSON.stringify({ + version: 1, + storageId: paths.storageId, + accounts: { + 'primary-row': { + binding, + credentialValidation: { + binding, + credentialFingerprint: fingerprintOf(row.credential), + version: { + accessFingerprint: tokenFingerprint(row.credential.access), + expires: row.credential.expires, + ...(Object.hasOwn(row.credential, 'lastRefreshedAt') + ? { lastRefreshedAt: row.credential.lastRefreshedAt } + : {}), + }, + }, + }, + }, + }), + { mode: 0o600 }, + ) + // Invalid legacy JSON cannot supply credentials, so a request must use the + // validated login in migrated storage. + await writeFile(paths.legacyConfig, '{invalid-legacy-config') + await writeFile(paths.legacyState, '{invalid-legacy-credential-state') + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: localOptions, + vault: { env }, + }) + cleanups.push(() => runtime.close()) + return { paths, runtime, store, env } +} +/** + * Registers the login methods built by claudeLoginMethods through common-auth, + * as plugin setup does, and returns the registration OpenCode would hold for + * one of them: the Claude Pro / Max sign-in unless another method ID is given. + */ +async function registeredLogin( + input: Parameters[0], + methodID: string = CLAUDE_POOL_LOGIN_METHOD_ID, +) { + let registration: IntegrationOAuthMethodRegistration | undefined + await registerOpenCode2AuthMethods( + { + integration: { + transform: async (callback) => { + callback({ + list: () => [], + get: () => undefined, + update: () => {}, + remove: () => {}, + method: { + list: () => [], + remove: () => {}, + update: (method) => { + if ('authorize' in method && method.method.id === methodID) + registration = method + }, + }, + }) + return { dispose: async () => {} } + }, + }, + }, + claudeLoginMethods(input), + ) + if (!registration) + throw new Error(`Login method ${methodID} was not registered`) + return registration +} +function source(runtime: NativeAccountRuntime, paths: NativePoolPaths) { + const native = createNativeAnthropicSource({ + runtime, + paths, + requestOptions: () => ({}), + }) + cleanups.push(() => native.close()) + return native +} +function context( + provenance: NativeSourceProvenance, + attemptId = 'attempt-one', +): NativeAnthropicAttemptContext { + return { + ...scope, + accountId: 'main', + attemptId, + transport: 'http', + route: 'oauth', + dispatch: 'direct', + provenance, + } +} +async function vault( + versions: number[] = [7, 8], + servedIdentity: (get: number) => string = () => identity, + routing: { mode: string } = { mode: 'main-first' }, +) { + const { paths, env, store: localStore } = await storage() + await writeFile( + env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const store = createNativePoolStore({ + paths: nativeVaultPaths(paths), + quota: nativeQuotaCodec, + }) + await store.initialize() + await store.updateSettings(() => ({ + quota: { enabled: false }, + routing, + })) + const gets: Parameters[0][] = [] + const reports: Parameters< + NativeCustodyClient['reportAuthFailureScoped'] + >[0][] = [] + const credentialId = 'oauth:anthropic' + const client: NativeCustodyClient = { + async listScoped() { + return { + view: 'oc2-native-view', + rows: [ + { + id: credentialId, + accountId: identity, + credentialType: 'oauth', + categories: ['anthropic-native'], + refreshAdapter: 'anthropic', + serves: ['anthropic'], + providerIds: [], + operations: ['read'], + state: 'active', + recordVersion: 7, + createdAtMs: null, + }, + ], + } + }, + async getScoped(input) { + gets.push(input) + const version = + versions[Math.min(gets.length - 1, versions.length - 1)] ?? 7 + return { + credentialId, + accountId: servedIdentity(gets.length), + material: `sk-ant-oat01-vault-${version}`, + recordVersion: version, + expiresAtMs: Date.now() + 3600_000, + } + }, + async reportAuthFailureScoped(input) { + reports.push(input) + }, + close() {}, + } + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + vault: { + env, + connect: async () => client, + readToken: async () => ({ token: '01'.repeat(32), token_generation: 1 }), + }, + }) + await runtime.vault.refresh() + const roster = await runtime.vault.read() + const primary = roster?.rows.find((row) => row.credentialId === credentialId) + if (!primary) throw new Error('Native vault seed has no asserted primary') + await store.updateSettings((settings) => ({ + ...settings, + mainAccountId: primary.routeId, + claustrum: { + primaryAccount: { credentialId, accountId: identity, state: 'active' }, + }, + })) + cleanups.push(() => runtime.close()) + return { runtime, paths, store, localStore, env, gets, reports } +} + +/** + * A vault that holds the default Claude account (credential oauth:anthropic) + * and one fallback account, with sticky-balanced routing. `setDefaultIdentity` + * makes the vault report a different account UUID for the default credential, + * as when its Claude account is replaced. + */ +async function vaultWithFallback() { + const { paths, env } = await storage() + await writeFile( + env.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE, + JSON.stringify({ token: '01'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + const store = createNativePoolStore({ + paths: nativeVaultPaths(paths), + quota: nativeQuotaCodec, + }) + await store.initialize() + await store.updateSettings(() => ({ + quota: { enabled: false }, + routing: { mode: 'sticky-balanced' }, + })) + const identities = new Map([ + ['oauth:anthropic', identity], + ['oauth:anthropic:work', fallbackIdentity], + ]) + const row = (credentialId: string) => ({ + id: credentialId, + accountId: identities.get(credentialId) ?? '', + credentialType: 'oauth' as const, + categories: ['anthropic-native'], + refreshAdapter: 'anthropic', + serves: ['anthropic'], + providerIds: [], + operations: ['read'], + state: 'active', + recordVersion: 7, + createdAtMs: null, + }) + const client: NativeCustodyClient = { + async listScoped() { + return { + view: `oc2-view-${[...identities.values()].join('-')}`, + rows: [...identities.keys()].map(row), + } + }, + async getScoped(input) { + return { + credentialId: input.credentialId, + accountId: identities.get(input.credentialId), + material: `sk-ant-oat01-${input.credentialId}`, + recordVersion: 7, + expiresAtMs: Date.now() + 3600_000, + } + }, + async reportAuthFailureScoped() {}, + close() {}, + } + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + vault: { + env, + connect: async () => client, + readToken: async () => ({ token: '01'.repeat(32), token_generation: 1 }), + }, + }) + cleanups.push(() => runtime.close()) + await runtime.vault.refresh() + const roster = await runtime.vault.read() + const primary = roster?.rows.find( + (entry) => entry.credentialId === 'oauth:anthropic', + ) + if (!primary) throw new Error('Vault seed has no default account') + await store.updateSettings((settings) => ({ + ...settings, + mainAccountId: primary.routeId, + claustrum: { + primaryAccount: { + credentialId: 'oauth:anthropic', + accountId: identity, + state: 'active', + }, + }, + })) + return { + paths, + runtime, + setDefaultIdentity: (next: string) => { + identities.set('oauth:anthropic', next) + }, + } +} +/** The exact stored JSON of one session's pin; sessions are stored under SHA-256 keys. */ +function rawPin(text: string, sessionID: string) { + const key = createHash('sha256').update(sessionID).digest('hex') + // Pins hold only scalar fields and brace-free strings, so the object's + // bytes end at its first closing brace. + const match = text.match(new RegExp(`"${key}":\\s*(\\{[^}]*\\})`)) + if (!match?.[1]) throw new Error('Expected pin is missing') + return match[1] +} + +describe('OC2 source over actual native services', () => { + test('queued newer vault receipts recheck exact-model admission after a relay-config wait', async () => { + const fixture = await vault([7, 8, 8]) + const native = source(fixture.runtime, fixture.paths) + const fable = { ...scope, modelID: 'claude-fable-5-1' } + const first = await native.authorizeAccount(fable, 'main') + if (!first) throw new Error('Initial native authorization was refused') + const limit = await native.limitFromResponse?.({ + ...context(first.provenance), + modelID: fable.modelID, + status: 401, + headers: new Headers({ 'request-id': 'req_native_rejected' }), + body: async () => '{}', + }) + if (!limit) throw new Error('Newer native receipt was not offered') + const receipt = await fixture.runtime.authorizeVault('main') + const quota = (remainingPercent: number) => { + const checkedAt = Date.now() + return { + accountIdentity: identity, + checkedAt, + five_hour: { usedPercent: 0, remainingPercent: 100, checkedAt }, + seven_day: { usedPercent: 0, remainingPercent: 100, checkedAt }, + scoped: [ + { + id: 'seven_day_fable', + title: 'Fable weekly', + modelId: fable.modelID, + modelName: 'Fable 5.1', + usedPercent: 100 - remainingPercent, + remainingPercent, + checkedAt, + }, + ], + } + } + await fixture.runtime.vault.publish(receipt, { quota: quota(100) }) + expect( + await native.chooseAccount({ + ...fable, + rerouteFrom: { accountId: 'main', limit }, + }), + ).toBe('main') + await fixture.runtime.updateRelay({ + enabled: true, + transport: 'http', + url: 'https://relay.invalid', + token: 'synthetic-relay-token', + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const original = fixture.runtime.getRelayConfig.bind(fixture.runtime) + const delayed = spyOn(fixture.runtime, 'getRelayConfig').mockImplementation( + async () => { + entered.resolve() + await release.promise + return original() + }, + ) + cleanups.push(() => { + release.resolve() + delayed.mockRestore() + }) + const pending = native.authorizeAccount(fable, 'main') + await entered.promise + await fixture.runtime.vault.publish(receipt, { quota: quota(0) }) + release.resolve() + expect(await pending).toBeUndefined() + delayed.mockRestore() + expect((await native.authorizeAccount(scope, 'main'))?.route).toBe('oauth') + expect(fixture.reports.map((report) => report.recordVersion)).toEqual([7]) + }) + test('final dispatch admission closes a paid route whose general exhaustion ended after authorization', async () => { + const fixture = await local() + await fixture.runtime.addApi({ + routeId: 'paid', + apiKey: 'synthetic-api-key', + baseURL: 'https://api-route.invalid', + authHeader: 'x-api-key', + }) + const localCredential = await fixture.runtime.authorizeLocal('main') + if (localCredential.status !== 'usable') + throw new Error('Native proof was not admitted') + const quota = (remainingPercent: number) => { + const checkedAt = Date.now() + return { + accountIdentity: identity, + checkedAt, + five_hour: { + usedPercent: 100 - remainingPercent, + remainingPercent, + checkedAt, + }, + seven_day: { usedPercent: 0, remainingPercent: 100, checkedAt }, + } + } + await fixture.runtime.publishLocal(localCredential.subject, { + quota: quota(0), + }) + const native = source(fixture.runtime, fixture.paths) + const auth = await native.authorizeAccount(scope, 'paid') + if (auth?.route !== 'api') + throw new Error('Confirmed general exhaustion did not admit paid route') + await fixture.runtime.publishLocal(localCredential.subject, { + quota: quota(100), + }) + await expect( + native.admitRequest?.({ + ...context(auth.provenance), + accountId: 'paid', + route: 'api', + }), + ).rejects.toThrow('policy changed before dispatch') + expect((await native.authorizeAccount(scope, 'main'))?.route).toBe('oauth') + }) + test('delayed vault authorization rechecks exact-model policy before releasing a bearer', async () => { + const fixture = await vault([7, 7, 7]) + const receipt = await fixture.runtime.authorizeVault('main') + const quota = (remainingPercent: number) => { + const checkedAt = Date.now() + return { + accountIdentity: identity, + checkedAt, + five_hour: { usedPercent: 0, remainingPercent: 100, checkedAt }, + seven_day: { usedPercent: 0, remainingPercent: 100, checkedAt }, + scoped: [ + { + id: 'seven_day_fable', + title: 'Fable weekly', + modelId: 'claude-fable-5-1', + modelName: 'Fable 5.1', + usedPercent: 100 - remainingPercent, + remainingPercent, + checkedAt, + }, + ], + } + } + await fixture.runtime.vault.publish(receipt, { quota: quota(100) }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const original = fixture.runtime.authorizeVault.bind(fixture.runtime) + const delayed = spyOn(fixture.runtime, 'authorizeVault').mockImplementation( + async (...args) => { + entered.resolve() + await release.promise + return original(...args) + }, + ) + cleanups.push(() => { + release.resolve() + delayed.mockRestore() + }) + const native = source(fixture.runtime, fixture.paths) + const pending = native.authorizeAccount( + { ...scope, modelID: 'claude-fable-5-1' }, + 'main', + ) + await entered.promise + await fixture.runtime.vault.publish(receipt, { quota: quota(0) }) + release.resolve() + expect(await pending).toBeUndefined() + delayed.mockRestore() + expect((await native.authorizeAccount(scope, 'main'))?.route).toBe('oauth') + expect(fixture.reports).toHaveLength(0) + }) + test('delayed paid API authorization rechecks general exhaustion before releasing a key', async () => { + const fixture = await local() + await fixture.runtime.addApi({ + routeId: 'paid', + apiKey: 'synthetic-api-key', + baseURL: 'https://api-route.invalid', + authHeader: 'x-api-key', + }) + const localCredential = await fixture.runtime.authorizeLocal('main') + if (localCredential.status !== 'usable') + throw new Error('Native proof was not admitted') + const quota = (remainingPercent: number) => { + const checkedAt = Date.now() + return { + accountIdentity: identity, + checkedAt, + five_hour: { + usedPercent: 100 - remainingPercent, + remainingPercent, + checkedAt, + }, + seven_day: { usedPercent: 0, remainingPercent: 100, checkedAt }, + } + } + await fixture.runtime.publishLocal(localCredential.subject, { + quota: quota(0), + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const original = fixture.runtime.authorizeApi.bind(fixture.runtime) + const delayed = spyOn(fixture.runtime, 'authorizeApi').mockImplementation( + async (...args) => { + entered.resolve() + await release.promise + return original(...args) + }, + ) + cleanups.push(() => { + release.resolve() + delayed.mockRestore() + }) + const native = source(fixture.runtime, fixture.paths) + const pending = native.authorizeAccount(scope, 'paid') + await entered.promise + await fixture.runtime.publishLocal(localCredential.subject, { + quota: quota(100), + }) + release.resolve() + expect(await pending).toBeUndefined() + }) + test('model aliases use request-local scoped quota without contaminating other model requests', async () => { + const fixture = await local() + const authorized = await fixture.runtime.authorizeLocal('main') + if (authorized.status !== 'usable') + throw new Error('Native proof was not admitted') + const checkedAt = Date.now() + await fixture.runtime.publishLocal(authorized.subject, { + quota: { + accountIdentity: identity, + checkedAt, + five_hour: { usedPercent: 0, remainingPercent: 100, checkedAt }, + seven_day: { usedPercent: 0, remainingPercent: 100, checkedAt }, + scoped: [ + { + id: 'seven_day_fable', + title: 'Fable weekly', + modelId: 'claude-fable-5-1', + modelName: 'Fable 5.1', + usedPercent: 100, + remainingPercent: 0, + checkedAt, + }, + ], + }, + }) + const native = createNativeAnthropicSource({ + runtime: fixture.runtime, + paths: fixture.paths, + requestOptions: () => ({}), + resolveModelId: async (input) => + input.modelID === 'custom-fable' ? 'claude-fable-5-1' : input.modelID, + }) + cleanups.push(() => native.close()) + expect( + await native.chooseAccount({ ...scope, modelID: 'custom-fable' }), + ).toBeUndefined() + expect(await native.chooseAccount(scope)).toBe('main') + expect( + await native.authorizeAccount( + { ...scope, modelID: 'custom-fable' }, + 'main', + ), + ).toBeUndefined() + }) + test('paid API credentials require fresh general OAuth exhaustion, not a scoped window alone', async () => { + const fixture = await local() + await fixture.runtime.addApi({ + routeId: 'paid', + apiKey: 'synthetic-api-key', + baseURL: 'https://api-route.invalid', + authHeader: 'x-api-key', + }) + const authorized = await fixture.runtime.authorizeLocal('main') + if (authorized.status !== 'usable') + throw new Error('Native proof was not admitted') + const native = source(fixture.runtime, fixture.paths) + const checkedAt = Date.now() + const quota = { + accountIdentity: identity, + checkedAt, + five_hour: { usedPercent: 0, remainingPercent: 100, checkedAt }, + seven_day: { usedPercent: 0, remainingPercent: 100, checkedAt }, + scoped: [ + { + id: 'seven_day_fable', + title: 'Fable weekly', + modelId: 'claude-fable-5-1', + modelName: 'Fable 5.1', + usedPercent: 100, + remainingPercent: 0, + checkedAt, + }, + ], + } + await fixture.runtime.publishLocal(authorized.subject, { quota }) + expect( + await native.authorizeAccount( + { ...scope, modelID: 'claude-fable-5-1' }, + 'paid', + ), + ).toBeUndefined() + await fixture.runtime.publishLocal(authorized.subject, { + quota: { + ...quota, + five_hour: { usedPercent: 100, remainingPercent: 0, checkedAt }, + }, + }) + const api = await native.authorizeAccount(scope, 'paid') + expect(api?.route).toBe('api') + if (api?.route !== 'api') + throw new Error('Confirmed exhaustion did not admit API route') + expect(api.headers['x-api-key']).toBe('synthetic-api-key') + }) + test('local serving uses positive native proof and never the poisoned legacy credentials', async () => { + const fixture = await local() + const native = source(fixture.runtime, fixture.paths) + expect(await native.chooseAccount(scope)).toBe('main') + const auth = await native.authorizeAccount(scope, 'main') + expect(auth?.route).toBe('oauth') + if (auth?.route !== 'oauth') + throw new Error('Local native authorization refused') + expect(auth.accessToken).toBe('sk-ant-oat01-native-only') + expect(auth.provenance.kind).toBe('local') + expect(JSON.stringify(auth.provenance)).not.toContain(auth.accessToken) + const snapshot = await fixture.runtime.read() + expect(JSON.stringify(snapshot)).not.toContain('native-only-refresh') + expect(JSON.stringify(snapshot)).not.toContain(auth.accessToken) + }) + test('genuine header quota is account/version bound and persists through native publication', async () => { + const fixture = await local() + const native = source(fixture.runtime, fixture.paths) + const auth = await native.authorizeAccount(scope, 'main') + if (!auth) throw new Error('Native authorization refused') + const request = context(auth.provenance) + const quota = native.quotaFromHeaders?.( + new Headers({ + 'request-id': 'req_native_oc2', + 'anthropic-ratelimit-unified-5h-utilization': '0.42', + }), + { ...request, status: 200 }, + ) + expect(quota?.five_hour?.remainingPercent).toBe(58) + await native.onAttemptEnd?.(request, { status: 200, outputStarted: true }) + const view = await fixture.runtime.read() + expect( + view.accounts.find((account) => account.id === 'main')?.quota?.five_hour + ?.remainingPercent, + ).toBe(58) + expect( + view.accounts.find((account) => account.id === 'main')?.quota + ?.accountIdentity, + ).toBe(identity) + }) + test('selected WebSocket is refused from secret-free policy before any vault get or provider send', async () => { + const fixture = await vault() + await fixture.runtime.updateRelay({ + enabled: true, + transport: 'websocket', + url: 'https://relay.invalid', + token: 'synthetic-relay-token', + }) + const native = source(fixture.runtime, fixture.paths) + const adapter = createNativeAnthropicAdapter({ source: native }) + await expect(adapter.adapter.chooseAccount(scope)).rejects.toBeInstanceOf( + NativeAnthropicTransportRefusal, + ) + expect(fixture.gets).toHaveLength(0) + expect(fixture.reports).toHaveLength(0) + }) + test('vault 401 reports each physical version and permits exactly one strict-newer same-account retry', async () => { + const fixture = await vault() + const native = source(fixture.runtime, fixture.paths) + expect(await native.chooseAccount(scope)).toBe('main') + const first = await native.authorizeAccount(scope, 'main') + if (!first) throw new Error('Vault authorization refused') + const firstAttempt = context(first.provenance) + const limit = await native.limitFromResponse?.({ + ...firstAttempt, + status: 401, + headers: new Headers({ 'request-id': 'req_vault_one' }), + body: async () => '{}', + }) + expect(limit?.status).toBe(401) + if (!limit) throw new Error('Strict-newer retry was not offered') + expect( + await native.chooseAccount({ + ...scope, + rerouteFrom: { accountId: 'main', limit }, + }), + ).toBe('main') + const second = await native.authorizeAccount(scope, 'main') + if (second?.route !== 'oauth') + throw new Error('Retry authorization refused') + expect(second.accessToken).toBe('sk-ant-oat01-vault-8') + expect(JSON.stringify(second.provenance)).not.toContain(second.accessToken) + await native.onAttemptEnd?.(firstAttempt, { + status: 401, + outputStarted: false, + }) + const secondAttempt = context(second.provenance, 'attempt-two') + expect( + await native.limitFromResponse?.({ + ...secondAttempt, + status: 401, + headers: new Headers({ 'request-id': 'req_vault_two' }), + body: async () => '{}', + }), + ).toBeUndefined() + await native.onAttemptEnd?.(secondAttempt, { + status: 401, + outputStarted: false, + }) + expect(fixture.gets).toHaveLength(2) + expect(fixture.reports.map((report) => report.recordVersion)).toEqual([ + 7, 8, + ]) + }) + test('unchanged vault version cannot authorize a 401 retry', async () => { + const fixture = await vault([7, 7]) + const native = source(fixture.runtime, fixture.paths) + await native.chooseAccount(scope) + const auth = await native.authorizeAccount(scope, 'main') + if (!auth) throw new Error('Vault authorization refused') + expect( + await native.limitFromResponse?.({ + ...context(auth.provenance), + status: 401, + headers: new Headers({ 'request-id': 'req_unchanged' }), + body: async () => '{}', + }), + ).toBeUndefined() + expect(fixture.reports.map((report) => report.recordVersion)).toEqual([7]) + }) + test('relay-to-direct fallback obtains a fresh receipt and does not report the relay-local 401', async () => { + const fixture = await vault([7, 8]) + await fixture.runtime.updateRelay({ + enabled: true, + transport: 'http', + url: 'https://relay.invalid', + fallbackToDirect: true, + token: 'synthetic-relay-token', + }) + const native = source(fixture.runtime, fixture.paths) + await native.chooseAccount(scope) + const first = await native.authorizeAccount(scope, 'main') + if (!first) throw new Error('Vault authorization refused') + expect(first.dispatch?.kind).toBe('http-relay') + const limit = await native.limitFromRelayFailure?.({ + ...context(first.provenance), + dispatch: 'http-relay', + status: 401, + }) + if (!limit) throw new Error('Direct fallback was not offered') + await native.onAttemptEnd?.( + { ...context(first.provenance), dispatch: 'http-relay' }, + { outputStarted: false }, + ) + await native.chooseAccount({ + ...scope, + rerouteFrom: { accountId: 'main', limit }, + }) + const direct = await native.authorizeAccount(scope, 'main') + expect(direct?.dispatch?.kind).toBe('direct') + if (direct?.route !== 'oauth') + throw new Error('Direct authorization refused') + expect(direct.accessToken).toBe('sk-ant-oat01-vault-8') + expect(fixture.gets).toHaveLength(2) + expect(fixture.reports).toHaveLength(0) + }) + test('vault session affinity is kept in the vault routing file and never in the local one', async () => { + const fixture = await vault([7, 8], () => identity, { + mode: 'sticky-balanced', + }) + const native = source(fixture.runtime, fixture.paths) + expect(await native.chooseAccount(scope)).toBe('main') + const snapshot = await fixture.runtime.read() + if (!snapshot.routingPath) + throw new Error('Vault mode named no routing file') + const localRouting = getStickyRoutingStatePath(fixture.paths.legacyConfig) + expect(snapshot.routingPath).not.toBe(localRouting) + // Sessions are stored under hashed keys; count the assignments instead. + const assignments = async (path: string) => + Object.values( + ( + JSON.parse(await readFile(path, 'utf8')) as { + assignments: Record + } + ).assignments, + ).map((assignment) => assignment.accountId) + expect(await assignments(snapshot.routingPath)).toEqual(['main']) + expect(await exists(localRouting)).toBe(false) + await native.resetRouting(scope.sessionID) + expect(await assignments(snapshot.routingPath)).toEqual([]) + expect(await exists(localRouting)).toBe(false) + }) + test('replacing the vault default account re-places only its session and an unrelated fallback session pin survives unchanged', async () => { + const fixture = await vaultWithFallback() + const native = source(fixture.runtime, fixture.paths) + const before = await fixture.runtime.read() + const fallbackRoute = before.accounts.find( + (account) => account.accountIdentity === fallbackIdentity, + )?.id + if (!fallbackRoute) throw new Error('Fallback vault account is missing') + const mainSession = { ...scope, sessionID: 'ses_vault_main' } + const fallbackSession = { ...scope, sessionID: 'ses_vault_fallback' } + expect(await native.chooseAccount(mainSession)).toBe('main') + // A session refused on main moves to the fallback and is pinned there. + expect( + await native.chooseAccount({ + ...fallbackSession, + rerouteFrom: { + accountId: 'main', + limit: { reason: 'provider-overloaded', status: 529 }, + }, + }), + ).toBe(fallbackRoute) + const routing = before.routingPath + if (!routing) throw new Error('Vault mode named no routing file') + const pinned = await readFile(routing, 'utf8') + expect(JSON.parse(rawPin(pinned, mainSession.sessionID))).toMatchObject({ + accountId: 'main', + accountIdentity: identity, + }) + const fallbackPin = rawPin(pinned, fallbackSession.sessionID) + expect(JSON.parse(fallbackPin)).toMatchObject({ + accountId: fallbackRoute, + accountIdentity: fallbackIdentity, + }) + + fixture.setDefaultIdentity(changedIdentity) + await fixture.runtime.vault.refresh() + const after = await fixture.runtime.read() + const replaced = after.accounts.find( + (account) => account.accountIdentity === changedIdentity, + ) + if (!replaced) throw new Error('Replaced default account is missing') + const choice = await native.chooseAccount(mainSession) + const repinned = JSON.parse( + rawPin(await readFile(routing, 'utf8'), mainSession.sessionID), + ) + expect(repinned.accountId).toBe(choice) + expect(repinned.accountIdentity).toBe( + after.accounts.find((account) => account.id === choice)?.accountIdentity, + ) + expect(repinned.accountIdentity).not.toBe(identity) + expect( + rawPin(await readFile(routing, 'utf8'), fallbackSession.sessionID), + ).toBe(fallbackPin) + expect(await native.chooseAccount(fallbackSession)).toBe(fallbackRoute) + }) + test('local sticky routing records no account UUID in its session pins', async () => { + const fixture = await local() + await fixture.store.updateSettings((settings) => ({ + ...settings, + routing: { mode: 'sticky-balanced' }, + })) + const native = source(fixture.runtime, fixture.paths) + expect(await native.chooseAccount(scope)).toBe('main') + const snapshot = await fixture.runtime.read() + expect(snapshot.routingPath).toBeUndefined() + const pin = JSON.parse( + rawPin( + await readFile( + getStickyRoutingStatePath(fixture.paths.legacyConfig), + 'utf8', + ), + scope.sessionID, + ), + ) + expect(pin.accountId).toBe('main') + expect(pin).not.toHaveProperty('accountIdentity') + }) + test('a vault credential that now asserts a different account UUID is refused instead of serving under the old identity', async () => { + const fixture = await vault([7, 8], () => changedIdentity) + const native = source(fixture.runtime, fixture.paths) + expect(await native.chooseAccount(scope)).toBe('main') + await expect(native.authorizeAccount(scope, 'main')).rejects.toThrow( + 'identity-changed', + ) + expect(fixture.gets).toHaveLength(1) + }) + test('a 401 retry is not offered when the newer vault version belongs to a different account UUID', async () => { + const fixture = await vault([7, 8], (get) => + get <= 1 ? identity : changedIdentity, + ) + const native = source(fixture.runtime, fixture.paths) + expect(await native.chooseAccount(scope)).toBe('main') + const first = await native.authorizeAccount(scope, 'main') + if (!first) throw new Error('Vault authorization refused') + expect( + await native.limitFromResponse?.({ + ...context(first.provenance), + status: 401, + headers: new Headers({ 'request-id': 'req_vault_changed' }), + body: async () => '{}', + }), + ).toBeUndefined() + expect(fixture.gets).toHaveLength(2) + expect(fixture.reports.map((report) => report.recordVersion)).toEqual([7]) + }) +}) + +describe('OC2 Claude sign-in over the actual native account runtime', () => { + test('a completed local sign-in stores an OAuth account holding only the token fields', async () => { + const fixture = await local({ + resolveIdentity: async () => ({ accountUuid: changedIdentity }) as never, + }) + const registration = await registeredLogin({ + isActive: () => true, + readMode: async () => 'local', + runtime: fixture.runtime, + authorize: async () => ({ + url: 'https://claude.invalid/authorize', + redirectUri: 'https://claude.invalid/callback', + state: 'synthetic-state', + verifier: 'synthetic-verifier', + }), + exchange: async () => ({ + type: 'success', + access: 'sk-ant-oat01-signed-in', + refresh: 'signed-in-refresh', + expires: Date.now() + 3600_000, + }), + }) + const pending = await registration.authorize({}) + if (pending.mode !== 'code') throw new Error('A pasted code is required') + await pending.callback('synthetic-code') + const read = await fixture.store.read() + if (read.status !== 'ready') throw new Error('Pool unreadable') + const credentials = read.rows.map((row) => row.credential) + const signedIn = credentials.find( + (credential) => + credential?.type === 'oauth' && + credential.access === 'sk-ant-oat01-signed-in', + ) + expect(signedIn).toBeDefined() + expect(credentials.some((credential) => credential?.type !== 'oauth')).toBe( + false, + ) + const view = await fixture.runtime.read() + const main = view.accounts.find((account) => account.id === 'main') + expect(main?.type).toBe('oauth') + expect(main?.accountIdentity).toBe(changedIdentity) + }) + test('enrollment files make OpenCode refuse local sign-in and accept vault activation without touching local accounts', async () => { + const fixture = await vault() + const before = await readFile(fixture.paths.config, 'utf8') + const input = { + isActive: () => true, + readMode: () => readNativeEnrollmentMode('opencode', fixture.env), + runtime: fixture.runtime, + } + const local = await registeredLogin(input) + await expect(local.authorize({})).rejects.toThrow( + LOCAL_LOGIN_REFUSED_IN_VAULT, + ) + const activation = await registeredLogin( + input, + CLAUDE_VAULT_ACTIVATION_METHOD_ID, + ) + const pending = await activation.authorize({}) + if (pending.mode !== 'auto') + throw new Error('Vault activation must need no user step') + const marker = await pending.callback + expect(String(marker.methodID)).toBe(CLAUDE_VAULT_ACTIVATION_METHOD_ID) + expect(await readFile(fixture.paths.config, 'utf8')).toBe(before) + const snapshot = await fixture.runtime.read() + expect(snapshot.mode).toBe('claustrum') + expect(snapshot.accounts.map((account) => account.source)).toEqual([ + 'vault', + ]) + }) + test('without enrollment files vault activation is refused and the local account stays served', async () => { + const fixture = await local() + const activation = await registeredLogin( + { + isActive: () => true, + readMode: () => readNativeEnrollmentMode('opencode', fixture.env), + runtime: fixture.runtime, + }, + CLAUDE_VAULT_ACTIVATION_METHOD_ID, + ) + const pending = await activation.authorize({}) + if (pending.mode !== 'auto') + throw new Error('Vault activation must need no user step') + await expect(pending.callback).rejects.toThrow( + VAULT_ACTIVATION_REFUSED_LOCALLY, + ) + const snapshot = await fixture.runtime.read() + expect(snapshot.mode).toBe('local') + expect( + snapshot.accounts.find((account) => account.id === 'main')?.source, + ).toBe('local') + }) +}) + +describe('OC2 vault enrollment callbacks for the claude menu', () => { + test('disconnect ends vault enrollment so the runtime serves local accounts, without touching OpenCode sign-ins', async () => { + const fixture = await vault() + const dataHome = join(dirname(fixture.paths.config), 'data') + const hostAuth = join(dataHome, 'opencode', 'auth.json') + await mkdir(dirname(hostAuth), { recursive: true }) + const stored = JSON.stringify({ + anthropic: { type: 'api', key: 'stock-opencode-key' }, + }) + await writeFile(hostAuth, stored, { mode: 0o600 }) + const callbacks = nativeEnrollmentCallbacks({ + ...fixture.env, + XDG_DATA_HOME: dataHome, + }) + expect(await callbacks.readMode()).toBe('claustrum') + expect((await fixture.runtime.read()).mode).toBe('claustrum') + expect(await callbacks.disconnectVault()).toBe('disconnected') + expect(await callbacks.readMode()).toBe('local') + expect((await fixture.runtime.read()).mode).toBe('local') + expect(await readFile(hostAuth, 'utf8')).toBe(stored) + }) + test('disconnect without vault enrollment changes nothing and local accounts stay served', async () => { + const fixture = await local() + const callbacks = nativeEnrollmentCallbacks(fixture.env) + expect(await callbacks.readMode()).toBe('local') + expect(await callbacks.disconnectVault()).toBe('idle') + const snapshot = await fixture.runtime.read() + expect(snapshot.mode).toBe('local') + expect( + snapshot.accounts.find((account) => account.id === 'main')?.source, + ).toBe('local') + }) +}) diff --git a/packages/opencode/src/v2/activation.ts b/packages/opencode/src/v2/activation.ts new file mode 100644 index 000000000..bc2fb8989 --- /dev/null +++ b/packages/opencode/src/v2/activation.ts @@ -0,0 +1,8 @@ +/** Explicit sign-in to the native Claude account pool creates this OAuth connection. Startup leaves existing OpenCode connections unchanged. */ +export const CLAUDE_POOL_LOGIN_METHOD_ID = 'native-claude-oauth' + +/** + * Connects OpenCode to the Claude accounts held in the vault. It signs in to + * nothing and stores no local account; only the host's placeholder is written. + */ +export const CLAUDE_VAULT_ACTIVATION_METHOD_ID = 'native-claude-vault' diff --git a/packages/opencode/src/v2/adapter.ts b/packages/opencode/src/v2/adapter.ts index 477c841f8..74bca30b1 100644 --- a/packages/opencode/src/v2/adapter.ts +++ b/packages/opencode/src/v2/adapter.ts @@ -1,40 +1,16 @@ -// The Anthropic half of the OpenCode 2 hooks recipe: what -// `@cortexkit/common-auth/opencode2` leaves to its caller, for requests the -// host sends itself through its own Anthropic HTTP driver. -// -// The installer in common-auth edits those requests at fixed points and asks -// this adapter, in order, for one physical send (an "attempt"): -// -// - `chooseAccount`: which account the request goes to; -// - `accountHeaders`: that account's credential headers, plus the attempt's -// `data`, which the installer hands back on every later call for the same -// send; -// - `rewriteRequest`: the Claude Code wire shape for an OAuth send (URL, -// persona headers, body rewrite and signature), or the plain API shape for -// an API-key send; -// - `quotaFromHeaders`, `limitFromResponse`, `inspectEvent`, `limitFromError`: -// what the response says about the account; -// - `rewriteResponse`: undoing the OAuth tool-name prefix in the response; -// - `onAttemptEnd`: how the send ended. -// -// Everything about accounts and policy belongs to an injected source owned -// by Core (`NativeAnthropicSource`): which account to use, the credential it -// authorizes for one send, the body rewrite options (billing, effort, cache, -// safety, diagnostics), and how a response or error is classified. The -// adapter never reads an account pool, never falls back to the host's own -// credential, and never guesses an account or a plan from the wire. What the -// source says about one send (its route and its opaque provenance, such as a -// vault receipt) stays attached to that send and is handed back unchanged. -// -// The adapter never sends anything itself: no retry, no second send from the -// response side, no relay. Retrying on another account is the installer's -// retry hook, which acts only on the limit signals the source returns here. +// OpenCode sends the requests; this adapter supplies account credentials, +// Claude Code request transforms and response classification to common-auth's +// transport hooks. The injected source uses Core's account and policy services. +// Each response keeps the credential version and account of its own request, +// including HTTP relay responses. This adapter never sends a retry itself. +import { createHash } from 'node:crypto' import { applyCustomHeaders, assertNotCustodyTombstone, type ClaudeCodeIdentity, mergeAnthropicBetas, + remapModelId, remapRequestBodyModel, stripEmptyTrailingAssistantMessages, TrailingAssistantHistoryError, @@ -105,7 +81,7 @@ const CREDENTIAL_HEADERS = new Set(['authorization', 'x-api-key']) * it. */ export const NO_ACCOUNT_REFUSAL = - 'request refused: no account with a usable credential' + 'Claude pool login is selected but no native account is usable. Complete offline migration or repair the pool and its quota policy before retrying.' /** * Most sends whose credential may wait between `accountHeaders` and @@ -128,6 +104,20 @@ const MAX_STREAM_ERROR_MESSAGE = 500 /** Which kind of credential one send goes out with. */ export type NativeAnthropicRoute = 'oauth' | 'api' +/** + * Where one send goes: directly to Anthropic, or through the selected HTTP + * relay. A selected WebSocket relay appears here only so the adapter can + * refuse it before anything is sent. No local proxy transport is used. + */ +export type NativeAnthropicDispatch = + | { readonly kind: 'direct' } + | { + readonly kind: 'http-relay' + readonly url: string + readonly token: string + } + | { readonly kind: 'websocket-relay' } + /** * What the source authorizes for one send. `provenance` is the source's own * record of the account and credential version that serve it (a local slot, @@ -144,6 +134,7 @@ export type NativeAnthropicAuthorization

= /** Additional OAuth request headers; authorization and x-api-key edits are ignored so they cannot replace the authorized bearer. */ readonly headers?: HeaderEdits readonly provenance: P + readonly dispatch?: NativeAnthropicDispatch } | { readonly route: 'api' @@ -154,15 +145,17 @@ export type NativeAnthropicAuthorization

= /** The URL the send goes to instead of the host's, for this account. */ readonly baseURL?: string readonly provenance: P + readonly dispatch?: NativeAnthropicDispatch } /** - * The per-send value the installer carries as `Attempt.data`. It holds no - * credential and no request body. + * Correlation metadata carried by common-auth's request hooks for one send. + * It contains neither credentials nor request body content. */ export interface NativeAnthropicAttemptData

{ readonly route: NativeAnthropicRoute readonly provenance: P + readonly dispatch: 'direct' | 'http-relay' } /** @@ -174,6 +167,7 @@ export interface NativeAnthropicAttemptContext

extends AccountRequest { readonly transport: Transport | undefined readonly route: NativeAnthropicRoute readonly provenance: P + readonly dispatch: 'direct' | 'http-relay' } /** Non-nullable options accepted by rewriteRequestBody. */ @@ -277,6 +271,10 @@ export interface NativeAnthropicSource { readonly requestOptions?: ( input: NativeAnthropicRequestContext

, ) => Promise | NativeAnthropicBodyOptions + /** Rechecks policy after asynchronous rewriting, immediately before dispatch. */ + readonly admitRequest?: ( + input: NativeAnthropicAttemptContext

, + ) => Promise | void /** Response observers for an OAuth response. */ readonly responseOptions?: ( input: NativeAnthropicResponseContext

, @@ -296,6 +294,10 @@ export interface NativeAnthropicSource { readonly body: () => Promise }, ) => Promise | LimitSignal | undefined + /** Classifies a relay-local failure, never an Anthropic credential failure. */ + readonly limitFromRelayFailure?: ( + input: NativeAnthropicResponseContext

, + ) => Promise | LimitSignal | undefined /** Classifies an `error` event inside a streamed response. */ readonly limitFromStreamError?: ( input: NativeAnthropicStreamErrorContext

, @@ -347,6 +349,8 @@ export interface NativeAnthropicAdapter { * none of them can return, and calls the source's `forgetSession`. */ forgetSession(sessionID: string): void + /** Revokes all pending work. Later calls refuse without invoking the source. */ + dispose(): void /** Number of sends authorized and not yet rewritten or ended. */ readonly pendingSendCount: number /** Number of OAuth access tokens held for sends not yet rewritten. */ @@ -390,6 +394,19 @@ export class NativeAnthropicRevokedError extends Error { } } +/** OpenCode 2's HTTP model driver cannot speak the relay's binary WebSocket protocol. */ +export class NativeAnthropicTransportRefusal extends Error { + readonly kind = 'unsupported-transport' + readonly providerID = ANTHROPIC_PROVIDER_ID + + constructor() { + super( + 'request refused: WebSocket relay is not supported on OpenCode 2; select HTTP relay or direct transport', + ) + this.name = 'NativeAnthropicTransportRefusal' + } +} + /** * The request was refused with a local 400 because it can never succeed as * written: its history ends on a meaningful assistant turn, or an effort @@ -423,6 +440,9 @@ interface PendingSend { readonly identity?: ClaudeCodeIdentity } readonly baseURL?: string + readonly dispatch?: NativeAnthropicDispatch + readonly apiHeaders?: HeaderEdits + readonly oauthHeaders?: HeaderEdits } const isRecord = (value: unknown): value is Record => @@ -485,7 +505,8 @@ const OAUTH_ONLY_BETAS = new Set([ SERVER_SIDE_FALLBACK_BETA, ]) -/** The host's betas for an API send, without the OAuth-only ones. */ +/** Remove plugin-added OAuth authentication, Claude Code compatibility and server + * safety-fallback betas from paid API/proxy sends; preserve unrelated host betas. */ function apiBetas(value: string) { const kept = value .split(',') @@ -495,12 +516,10 @@ function apiBetas(value: string) { } /** - * Applies the API route's body rules. There is no Claude Code persona, tool - * prefix or signature; only the local history check, the plugin's - * server-side fallback opt-in and stored fallback markers removed (the same - * rule the OAuth rewrite applies when fallback is off), and the configured - * model remap. Returns the body text unchanged when nothing changed or it is - * not a JSON object. + * Validate history, remove OAuth-only fallback fields and internal boundary + * markers, and apply the configured model alias for paid API/proxy requests. + * No Claude Code identity, tool-name prefix or body signature is added. + * Preserve the original text when no field changes or the body is not an object. */ function rewriteApiBody(body: string) { const parsed = parseJson(body) @@ -557,6 +576,19 @@ export function createNativeAnthropicAdapter( // keeps it on the attempt), because the retry hook may ask about the send // after it has ended. const localRefusals = new WeakMap() + // A relay's own failure is not a provider credential failure. Only a + // provider request-id proves that its response came from Anthropic. + const relayUpstream = new WeakMap() + let disposed = false + + const fromUpstream = (headers: Headers, data: Data) => { + if (data.dispatch !== 'http-relay') return true + const upstream = /^req_[A-Za-z0-9_-]+$/.test( + headers.get('request-id') ?? '', + ) + relayUpstream.set(data, upstream) + return upstream + } const refuse = (scope: RequestScope) => new NativeAnthropicNoAccountError(scope.sessionID, scope.kind) @@ -574,6 +606,8 @@ export function createNativeAnthropicAdapter( scope: RequestScope, work: (ensureLive: () => void) => Promise, ): Promise => { + if (disposed) + throw new NativeAnthropicRevokedError(scope.sessionID, scope.kind) const operation = { revoked: false } let operations = inProgress.get(scope.sessionID) if (!operations) { @@ -582,7 +616,7 @@ export function createNativeAnthropicAdapter( } operations.add(operation) const ensureLive = () => { - if (operation.revoked) { + if (disposed || operation.revoked) { throw new NativeAnthropicRevokedError(scope.sessionID, scope.kind) } } @@ -626,6 +660,7 @@ export function createNativeAnthropicAdapter( transport: attempt.transport, route: data.route, provenance: data.provenance, + dispatch: data.dispatch, }) const accountHeaders = ( @@ -643,6 +678,18 @@ export function createNativeAnthropicAdapter( auth: NativeAnthropicAuthorization

| undefined, ): AccountHeadersResult => { if (!auth) throw refuse(scope) + if (auth.dispatch?.kind === 'websocket-relay') + throw new NativeAnthropicTransportRefusal() + const dispatch = + auth.dispatch?.kind === 'http-relay' ? 'http-relay' : 'direct' + const relayHeaders: HeaderEdits | undefined = + auth.dispatch?.kind === 'http-relay' + ? { + authorization: null, + 'x-api-key': null, + 'x-relay-token': auth.dispatch.token, + } + : undefined if (auth.route === 'oauth') { if (!auth.accessToken) throw refuse(scope) // A slot whose credential the vault owns holds a non-secret activation @@ -653,6 +700,7 @@ export function createNativeAnthropicAdapter( const data: Data = Object.freeze({ route: 'oauth', provenance: auth.provenance, + dispatch, }) remember(data, { sessionID: scope.sessionID, @@ -660,11 +708,16 @@ export function createNativeAnthropicAdapter( accessToken: auth.accessToken, ...(auth.identity ? { identity: auth.identity } : {}), }, + dispatch: auth.dispatch, + oauthHeaders: auth.headers, }) const extra = lowerCaseEdits(auth.headers) for (const name of CREDENTIAL_HEADERS) delete extra[name] + // rewriteRequest finalizes betas for the outgoing body. Omit that header + // here so the installer's later generic edits cannot overwrite it. + delete extra['anthropic-beta'] return { - headers: { + headers: relayHeaders ?? { ...extra, 'x-api-key': null, authorization: `Bearer ${auth.accessToken}`, @@ -681,12 +734,15 @@ export function createNativeAnthropicAdapter( const data: Data = Object.freeze({ route: 'api', provenance: auth.provenance, + dispatch, }) remember(data, { sessionID: scope.sessionID, ...(auth.baseURL ? { baseURL: auth.baseURL } : {}), + dispatch: auth.dispatch, + apiHeaders: headers, }) - return { headers, attempt: data } + return { headers: relayHeaders ?? headers, attempt: data } } const rewriteRequest = ( @@ -715,6 +771,7 @@ export function createNativeAnthropicAdapter( } const headers = new Headers(request.headers) + applyCustomHeaders(headers) const correlation = takeInternalHeaders(headers) let body = request.body === null ? undefined : await request.text() // A session forgotten while the body was read never reaches the source's @@ -725,10 +782,25 @@ export function createNativeAnthropicAdapter( try { if (data.route === 'oauth' && send.oauth) { const { accessToken, identity } = send.oauth + for (const [name, value] of Object.entries( + lowerCaseEdits(send.oauthHeaders), + )) { + if (CREDENTIAL_HEADERS.has(name)) continue + if (value === null) headers.delete(name) + else headers.set(name, value) + } let finalBody: Record | null = null if (body !== undefined && messages) { const context: NativeAnthropicRequestContext

= { ...contextOf(attempt, data), + modelID: (() => { + const payload = parseJson(body) + return remapModelId( + isRecord(payload) && typeof payload.model === 'string' + ? payload.model + : input.modelID, + ) + })(), route: 'oauth', correlation, } @@ -752,6 +824,10 @@ export function createNativeAnthropicAdapter( if (body !== undefined && messages) body = rewriteApiBody(body) headers.delete('authorization') headers.delete('x-api-key') + for (const [name, value] of Object.entries(send.apiHeaders ?? {})) { + if (value === null) headers.delete(name) + else headers.set(name, value) + } if (body !== undefined && messages && !headers.has('content-type')) { headers.set('content-type', 'application/json') } @@ -761,7 +837,6 @@ export function createNativeAnthropicAdapter( if (kept) headers.set('anthropic-beta', kept) else headers.delete('anthropic-beta') } - applyCustomHeaders(headers) } } catch (error) { const refusal = localRefusalFrom(error) @@ -774,6 +849,16 @@ export function createNativeAnthropicAdapter( } // The body may have changed length; let the runtime compute it. + const finalPayload = body !== undefined ? parseJson(body) : undefined + await source.admitRequest?.({ + ...contextOf(attempt, data), + modelID: remapModelId( + isRecord(finalPayload) && typeof finalPayload.model === 'string' + ? finalPayload.model + : input.modelID, + ), + }) + takeInternalHeaders(headers) if (body !== undefined) headers.delete('content-length') // Recheck session revocation after rewriting and signing before the // authenticated request can be returned to the caller. @@ -783,6 +868,37 @@ export function createNativeAnthropicAdapter( request.url, send.baseURL !== undefined ? { baseURL: send.baseURL } : {}, ).url?.toString() ?? request.url + if (send.dispatch?.kind === 'http-relay') { + // Each HTTP relay attempt sends the complete upstream body, not a patch. + // A host retry authorizes credentials again and builds a new envelope; + // it must not reuse this attempt's authorization. + const upstreamBody = body ?? '' + headers.delete('x-relay-token') + const envelope = { + protocol: 1, + type: 'request', + affinity: input.sessionID, + upstream: { + url, + method: request.method, + headers: Object.fromEntries(headers), + }, + mode: 'full_sync', + revision: 1, + body: upstreamBody, + next_hash: `sha256:${createHash('sha256').update(upstreamBody).digest('hex')}`, + } + return new Request(send.dispatch.url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-relay-token': send.dispatch.token, + }, + redirect: 'error', + signal: request.signal, + body: JSON.stringify(envelope), + }) + } return new Request(url, { method: request.method, headers, @@ -795,11 +911,35 @@ export function createNativeAnthropicAdapter( const adapter: OpenCode2AuthAdapter = { providerID: ANTHROPIC_PROVIDER_ID, chooseAccount: (input) => - tracked(input, async () => source.chooseAccount(input)), + tracked(input, async () => { + const accountId = await source.chooseAccount(input) + if (!accountId) throw refuse(input) + return accountId + }), accountHeaders, rewriteRequest, + rewriteHandshakeURL() { + throw new NativeAnthropicTransportRefusal() + }, rewriteResponse({ response, attempt }) { const data = attempt.data + if (data && !fromUpstream(response.headers, data)) { + // Relay authentication errors must not appear as an OAuth 401 to the + // host or the native credential service. + if (response.status === 401) + return Response.json( + { + type: 'error', + error: { + type: 'api_error', + message: + 'Relay authentication failed before Anthropic received the request', + }, + }, + { status: 502 }, + ) + return undefined + } // Only the OAuth route prefixes tool names, so only its responses are // mapped back. An API response reaches the host byte for byte. if (data?.route !== 'oauth' || !response.body) return undefined @@ -816,7 +956,7 @@ export function createNativeAnthropicAdapter( }, quotaFromHeaders(headers, status, attempt) { const data = attempt.data - if (!data) return undefined + if (!data || !fromUpstream(headers, data)) return undefined return source.quotaFromHeaders?.(headers, { ...contextOf(attempt, data), status, @@ -824,7 +964,13 @@ export function createNativeAnthropicAdapter( }, async limitFromResponse({ status, headers, body, attempt }) { const data = attempt.data - if (!data || !source.limitFromResponse) return undefined + if (!data) return undefined + if (!fromUpstream(headers, data)) + return source.limitFromRelayFailure?.({ + ...contextOf(attempt, data), + status, + }) + if (!source.limitFromResponse) return undefined return source.limitFromResponse({ ...contextOf(attempt, data), status, @@ -848,6 +994,8 @@ export function createNativeAnthropicAdapter( const message = typeof error.message === 'string' ? error.message : undefined const data = attempt.data + if (data?.dispatch === 'http-relay' && relayUpstream.get(data) === false) + return undefined const limit = data ? source.limitFromStreamError?.({ ...contextOf(attempt, data), @@ -869,6 +1017,8 @@ export function createNativeAnthropicAdapter( // A local refusal is about the request, not the account: answering it // with a limit would move the same broken request to another account. if (!data || localRefusals.has(data)) return undefined + if (data.dispatch === 'http-relay' && relayUpstream.get(data) === false) + return undefined return source.limitFromHostError?.(error, contextOf(attempt, data)) }, async onAttemptEnd(attempt, outcome) { @@ -876,8 +1026,13 @@ export function createNativeAnthropicAdapter( if (!data) return pending.delete(data) const check = localRefusals.get(data) + const { status, ...rest } = outcome const ended = source.onAttemptEnd?.(contextOf(attempt, data), { - ...outcome, + ...rest, + ...(status !== undefined && + (data.dispatch !== 'http-relay' || relayUpstream.get(data) === true) + ? { status } + : {}), ...(check !== undefined ? { localRefusal: { check } } : {}), }) if ((await settleWithin(ended, endTimeoutMs)) === 'timed-out') { @@ -890,6 +1045,15 @@ export function createNativeAnthropicAdapter( return { adapter, + dispose() { + disposed = true + for (const [sessionID, operations] of inProgress) { + for (const operation of operations) operation.revoked = true + source.forgetSession?.(sessionID) + } + inProgress.clear() + pending.clear() + }, forgetSession(sessionID) { for (const operation of inProgress.get(sessionID) ?? []) { operation.revoked = true diff --git a/packages/opencode/src/v2/install.ts b/packages/opencode/src/v2/install.ts new file mode 100644 index 000000000..194dab186 --- /dev/null +++ b/packages/opencode/src/v2/install.ts @@ -0,0 +1,106 @@ +import { + type InstallOpenCode2AuthOptions, + installOpenCode2Auth, + type OpenCode2AuthInstallation, + type OpenCode2HookContext, +} from '@cortexkit/common-auth/opencode2' +import type { Plugin } from '@opencode/plugin' +import type { Registration } from '@opencode/plugin/promise/registration' +import { + createNativeAnthropicAdapter, + type NativeAnthropicAdapterDeps, + type NativeAnthropicAttemptData, +} from './adapter' + +export interface NativeAnthropicInstallation { + readonly installation: OpenCode2AuthInstallation< + Q, + NativeAnthropicAttemptData

+ > + readonly forgetSession: (sessionID: string) => void + readonly dispose: () => Promise +} + +/** + * The shared installer removes request records on session deletion. Also cancel + * the adapter's pending credential lookup and body preparation: removing a + * record alone cannot stop an asynchronous callback from returning a token. + */ +export async function installNativeAnthropic( + ctx: OpenCode2HookContext, + deps: NativeAnthropicAdapterDeps, + ownership: NonNullable, +): Promise> { + const native = createNativeAnthropicAdapter(deps) + const registrations: Registration[] = [] + const hook: Plugin.Context['session']['hook'] = async ( + name, + callback, + options, + ) => { + const registration = await ctx.session.hook(name, callback, options) + registrations.push(registration) + return registration + } + const abort = new AbortController() + let installation: OpenCode2AuthInstallation> + try { + installation = await installOpenCode2Auth( + { session: { hook }, event: ctx.event }, + native.adapter, + { + logger: deps.log, + gateOnPlaceholder: ownership, + }, + ) + } catch (error) { + native.dispose() + await Promise.allSettled( + registrations.map((registration) => registration.dispose()), + ) + throw error + } + + const forgetSession = (sessionID: string) => { + native.forgetSession(sessionID) + installation.forgetSession(sessionID) + } + // Cancel pending credential and policy callbacks as well as removing the + // shared installer's records for this session. + const events = ctx.event + const listener = events + ? (async () => { + try { + for await (const event of events.subscribe({ + signal: abort.signal, + })) { + if (event.type === 'session.deleted') + forgetSession(event.data.sessionID) + } + } catch (error) { + if (!abort.signal.aborted) + deps.log?.warn('stopped listening for session deletion', { + error: error instanceof Error ? error.name : 'unknown', + }) + } + })() + : Promise.resolve() + + let cleanup: Promise | undefined + return { + installation, + forgetSession, + dispose() { + if (cleanup) return cleanup + // Cancel credential callbacks before awaiting hook removal, so a + // callback that finishes during cleanup cannot return a usable token. + native.dispose() + abort.abort() + cleanup = (async () => { + await installation.dispose() + await listener + })() + return cleanup + }, + } +} diff --git a/packages/opencode/src/v2/lifecycle.ts b/packages/opencode/src/v2/lifecycle.ts new file mode 100644 index 000000000..afd029f15 --- /dev/null +++ b/packages/opencode/src/v2/lifecycle.ts @@ -0,0 +1,76 @@ +import { + getQuotaCheckIntervalMs, + logger, + type NativeAccountRuntime, + oauthProfileIsFresh, + quotaSnapshotCheckedAt, +} from '@cortexkit/anthropic-auth-core' + +/** + * Background quota and profile reads obtain their own account credentials. + * They must not inherit the model policy of the latest user request. + */ +export function startNativeAnthropicLifecycle( + runtime: NativeAccountRuntime, + fetchImpl?: typeof fetch, +) { + const abort = new AbortController() + let inFlight: Promise | undefined + let stopped = false + const run = async () => { + const snapshot = await runtime.read() + abort.signal.throwIfAborted() + if ( + snapshot.policyStorage.relay?.enabled === true && + snapshot.policyStorage.relay.transport !== 'http' + ) + return + if (snapshot.mode === 'claustrum') await runtime.vault.refresh(abort.signal) + const current = + snapshot.mode === 'claustrum' ? await runtime.read() : snapshot + const interval = getQuotaCheckIntervalMs(current.policyStorage) + for (const account of current.accounts) { + if (abort.signal.aborted) return + if (!account.enabled || account.type !== 'oauth') continue + if ( + current.policyStorage.quota?.enabled !== false && + Date.now() - quotaSnapshotCheckedAt(account.quota) >= interval + ) { + // Each quota reading or failure is bound to the credential version + // used for its request, so a late result cannot affect a newer login. + await runtime + .fetchQuota(account.id, fetchImpl, abort.signal) + .catch(() => {}) + } + if (!oauthProfileIsFresh(account.profile) && !abort.signal.aborted) + await runtime + .fetchProfile(account.id, fetchImpl, abort.signal) + .catch(() => {}) + } + } + const tick = () => { + if (stopped || inFlight) return + inFlight = run() + .catch(() => { + if (!abort.signal.aborted) + logger.debug( + 'opencode2', + 'native background readings did not complete', + ) + }) + .finally(() => { + inFlight = undefined + }) + } + const timer = setInterval(tick, 60_000) + timer.unref?.() + tick() + return { + async dispose() { + stopped = true + clearInterval(timer) + abort.abort() + await inFlight + }, + } +} diff --git a/packages/opencode/src/v2/menu-contract.ts b/packages/opencode/src/v2/menu-contract.ts new file mode 100644 index 000000000..6adeacd50 --- /dev/null +++ b/packages/opencode/src/v2/menu-contract.ts @@ -0,0 +1,14 @@ +import type { + NativeMenuCommandId, + NativeMenuDispatch, +} from '@cortexkit/anthropic-auth-core' + +/** Account, setting and session actions exposed through the server's menu RPC. */ +export interface NativeMenuCapabilities { + readonly dispatch: NativeMenuDispatch + readonly readStatus: ( + command: Exclude, + ) => Promise + readonly forgetSession: (sessionID: string) => void + readonly close: () => void +} diff --git a/packages/opencode/src/v2/menu.ts b/packages/opencode/src/v2/menu.ts new file mode 100644 index 000000000..a2765f6ad --- /dev/null +++ b/packages/opencode/src/v2/menu.ts @@ -0,0 +1,309 @@ +import { + type AuthorizationResult, + authorize, + buildClaudeQuotaSummary, + buildPrimeAccountStatuses, + buildPrimeStatusSummary, + CLAUSTRUM_OPENCODE_ENROLLMENT_NAME, + exchange, + formatOAuthAccountTier, + getHostClaustrumEnrollmentPaths, + type NativeAccountRuntime, + type NativeMenuDispatch, + resetClaustrumEnrollmentState, +} from '@cortexkit/anthropic-auth-core' +import { LANE_START_TEXT } from '../lane-start' +import type { NativeMenuCapabilities } from './menu-contract' +import type { NativeSource } from './source' + +export type { NativeMenuCapabilities } from './menu-contract' + +const record = (value: unknown): Record => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} + +/** Account and setting changes use the same Core runtime as model requests. */ +export function createNativeMenuCapabilities(options: { + readonly runtime: NativeAccountRuntime + readonly source: NativeSource + readonly start: (sessionID: string, text: string) => Promise +}): NativeMenuCapabilities { + const { runtime, source } = options + const pending = new Map< + string, + { auth?: AuthorizationResult; readonly active: AbortController } + >() + let closed = false + const write = async (field: string, patch: Record) => { + await runtime.updateSettings((settings) => ({ + ...settings, + [field]: { ...record(settings[field]), ...patch }, + })) + } + const done = () => ({ ok: true, text: 'Native Claude settings updated.' }) + const dispatch: NativeMenuDispatch = async (request, options) => { + if (closed) throw new Error('Native menu is closed') + options.signal?.throwIfAborted() + switch (request.action) { + case 'enable': + case 'disable': + await runtime.setEnabled(request.values.id, request.action === 'enable') + return done() + case 'remove': + await runtime.remove(request.values.id) + return done() + case 'move-up': + case 'move-down': { + const snapshot = await runtime.read() + const ids = snapshot.accounts.map((account) => account.id) + const index = ids.indexOf(request.values.id) + const next = index + (request.action === 'move-up' ? -1 : 1) + if (index < 0 || next < 0 || next >= ids.length) + return { ok: false, text: 'Account cannot move in that direction.' } + const moved = ids[index] + const neighbour = ids[next] + if (moved === undefined || neighbour === undefined) + return { ok: false, text: 'Account order changed.' } + ids[index] = neighbour + ids[next] = moved + await runtime.reorder(ids) + return done() + } + case 'add-apikey': + await runtime.addApi(request.values) + return { + ok: true, + text: 'API route stored in the native account pool.', + } + case 'add-oauth-start': { + const active = new AbortController() + pending.get(request.sessionId)?.active.abort() + const login = { + active, + auth: undefined as AuthorizationResult | undefined, + } + pending.set(request.sessionId, login) + const auth = await authorize('max') + if (closed || active.signal.aborted || options.signal?.aborted) + return { ok: false, text: 'Sign-in cancelled.' } + login.auth = auth + return { + ok: true, + text: `Open ${auth.url} and paste the returned code using Finish OAuth sign-in.`, + } + } + case 'add-oauth-finish': { + const login = pending.get(request.sessionId) + if (!login?.auth) + return { + ok: false, + text: 'Start OAuth sign-in in this session first.', + } + const result = await exchange( + request.values.code, + login.auth.verifier, + login.auth.redirectUri, + login.auth.state, + ) + login.active.signal.throwIfAborted() + options.signal?.throwIfAborted() + if (closed || result.type !== 'success') + return { ok: false, text: 'OAuth sign-in did not complete.' } + await runtime.loginOAuth({ + routeId: crypto.randomUUID(), + credential: result, + label: request.values.label, + }) + login.active.signal.throwIfAborted() + options.signal?.throwIfAborted() + if (closed) return { ok: false, text: 'Sign-in cancelled.' } + pending.delete(request.sessionId) + return { + ok: true, + text: 'OAuth account stored in the native account pool.', + } + } + case 'reset-backoff': { + const snapshot = await runtime.read() + for (const account of snapshot.accounts) { + options.signal?.throwIfAborted() + if (account.type !== 'oauth') continue + const fence = + account.source === 'vault' + ? await runtime.authorizeVault(account.id, options.signal) + : await runtime.captureLocalSubject(account.id) + await runtime.resetBackoff(account.id, fence) + } + return done() + } + case 'enrollment-reset': { + const result = await resetClaustrumEnrollmentState( + getHostClaustrumEnrollmentPaths('opencode'), + CLAUSTRUM_OPENCODE_ENROLLMENT_NAME, + ) + return { + ok: result === 'reset', + text: + result === 'reset' + ? 'Terminal enrollment state reset. Run offline setup to reconnect.' + : 'Only terminal enrollment state may be reset; active enrollment was left unchanged.', + } + } + case 'quota-refresh': { + const snapshot = await runtime.read() + const accounts = snapshot.accounts.filter( + (account) => account.type === 'oauth' && account.enabled, + ) + const results = await Promise.allSettled( + accounts.map((account) => + runtime.fetchQuota(account.id, undefined, options.signal), + ), + ) + const ok = results.every((result) => result.status === 'fulfilled') + return { + ok, + text: `${results.filter((result) => result.status === 'fulfilled').length}/${accounts.length} native quota readings refreshed.`, + } + } + case 'routing-reset': + await source.resetRouting(request.sessionId) + return done() + case 'routing-mode': + await write('routing', { mode: request.values.mode }) + return done() + case 'killswitch-on': + case 'killswitch-off': + await write('killswitch', { + enabled: request.action === 'killswitch-on', + }) + return done() + case 'killswitch-set': { + await runtime.updateSettings((settings) => { + const existing = record(settings.killswitch) + const accounts = { ...record(existing.accounts) } + let main = existing.main + for (const entry of request.values.entries) { + const threshold = { + five_hour: entry.fh, + seven_day: entry.sd, + ...(entry.scoped !== undefined ? { scoped: entry.scoped } : {}), + } + if (entry.account === 'main' || entry.account === 'all') + main = threshold + if (entry.account === 'all') + for (const id of Object.keys(accounts)) accounts[id] = threshold + else if (entry.account !== 'main') + accounts[entry.account] = threshold + } + return { ...settings, killswitch: { ...existing, main, accounts } } + }) + return done() + } + case 'cache-on': + case 'cache-off': + await write('claudeCache', { enabled: request.action === 'cache-on' }) + return done() + case 'cache-mode': + await write('claudeCache', { mode: request.values.mode }) + return done() + case 'cachekeep-always': + await write('cacheKeep', { enabled: true, always: true }) + return done() + case 'cachekeep-off': + await write('cacheKeep', { enabled: false }) + return done() + case 'cachekeep-window': + await write('cacheKeep', { + enabled: true, + always: false, + startHour: request.values.startHour, + endHour: request.values.endHour, + }) + return done() + case 'cachekeep-subagents': + await write('cacheKeep', { subagents: request.values.enabled === 'on' }) + return done() + case 'dump-on': + case 'dump-off': + await write('dump', { enabled: request.action === 'dump-on' }) + return done() + case 'logging-level': + await write('logging', { level: request.values.level }) + return done() + case 'fast-on': + case 'fast-off': + await write('claudeFast', { enabled: request.action === 'fast-on' }) + return done() + case 'prime-on': + case 'prime-off': + await write('prime', { enabled: request.action === 'prime-on' }) + return done() + case 'start-fire': + await optionsStart(request.sessionId) + return { ok: true, text: 'Claude lane start queued in this session.' } + } + } + const optionsStart = (sessionID: string) => + options.start(sessionID, LANE_START_TEXT) + return { + dispatch, + async readStatus(command) { + const snapshot = await runtime.read() + if (command === 'account') + return ( + snapshot.accounts + .map( + (account) => + `${account.id}${account.label ? ` (${account.label})` : ''}: ${account.type}, ${account.source}, ${account.enabled ? 'enabled' : 'disabled'}${account.accountIdentity ? `, ${account.accountIdentity}` : ''}`, + ) + .join('\n') || 'No native Claude accounts.' + ) + if (command === 'quota') + return buildClaudeQuotaSummary({ + accounts: snapshot.accounts + .filter((account) => account.type === 'oauth') + .map((account) => ({ + name: account.label ?? account.id, + role: + account.id === 'main' + ? ('main' as const) + : ('fallback' as const), + enabled: account.enabled, + quota: account.quota, + tierLabel: formatOAuthAccountTier(account.profile), + lastRefreshedAt: account.lastRefreshedAt, + error: account.lastQuotaRefreshError?.message, + })), + }).replace( + /Usage:[^\n]*/, + 'Use /claude to refresh native quota readings.', + ) + if (command === 'prime') + return buildPrimeStatusSummary({ + enabled: snapshot.policyStorage.prime?.enabled === true, + accounts: buildPrimeAccountStatuses(snapshot.policyStorage), + }).replace(/Usage:[^\n]*/, 'Use /claude to change Prime settings.') + const fields = { + routing: 'routing', + killswitch: 'killswitch', + cache: 'claudeCache', + cachekeep: 'cacheKeep', + dump: 'dump', + logging: 'logging', + fast: 'claudeFast', + prime: 'prime', + } as const + return JSON.stringify(snapshot.settings[fields[command]] ?? {}) + }, + forgetSession(sessionID) { + pending.get(sessionID)?.active.abort() + pending.delete(sessionID) + }, + close() { + closed = true + for (const login of pending.values()) login.active.abort() + pending.clear() + }, + } +} diff --git a/packages/opencode/src/v2/models.ts b/packages/opencode/src/v2/models.ts new file mode 100644 index 000000000..55efb83f2 --- /dev/null +++ b/packages/opencode/src/v2/models.ts @@ -0,0 +1,46 @@ +import { + isClaudeFable51Model, + isClaudeHaiku55Model, + isClaudeOpus5FamilyModel, + isClaudeSonnet55Model, +} from '@cortexkit/anthropic-auth-core' +import type { Model, Plugin } from '@opencode/plugin' + +/** Each model variant supplies request settings for adaptive thinking and its selected reasoning effort. */ +export function createNativeClaudeModelRules() { + const ids = new Map() + const install = (ctx: Pick) => + ctx.model.transform((editor) => { + ids.clear() + for (const candidate of editor.list('anthropic')) { + ids.set(String(candidate.id), String(candidate.modelID)) + if ( + !( + isClaudeFable51Model(candidate.modelID) || + isClaudeOpus5FamilyModel(candidate.modelID) || + isClaudeSonnet55Model(candidate.modelID) || + isClaudeHaiku55Model(candidate.modelID) + ) + ) + continue + editor.update('anthropic', String(candidate.id), (model) => { + const efforts = ['low', 'medium', 'high', 'xhigh', 'max'] as const + const retained = model.variants.filter( + (variant) => + !efforts.some((effort) => effort === String(variant.id)), + ) + model.variants = [ + ...retained, + ...efforts.map((effort) => ({ + id: effort as Model.VariantID, + settings: { + effort, + thinking: { type: 'adaptive', display: 'summarized' }, + }, + })), + ] + }) + } + }) + return { install, modelId: (id: string) => ids.get(id) ?? id } +} diff --git a/packages/opencode/src/v2/ownership.ts b/packages/opencode/src/v2/ownership.ts new file mode 100644 index 000000000..031bc17c3 --- /dev/null +++ b/packages/opencode/src/v2/ownership.ts @@ -0,0 +1,17 @@ +/** + * Read the credential OpenCode attached to this request: the bearer token, or + * else the API key. When a user signs in through this plugin (Claude pool + * login or vault activation), OpenCode stores a fixed, non-secret placeholder + * string instead of a real token: placeholderSecret('anthropic') from + * @cortexkit/common-auth. The request hooks that installOpenCode2Auth installs + * serve a request from the plugin's own accounts only when this value equals + * that placeholder exactly. Any real token or key, whether from OpenCode's own + * login, a configured API key or an environment variable, is left untouched. + */ +export function preparedAnthropicCredential( + headers: Headers, +): string | undefined { + const authorization = headers.get('authorization') + const bearer = authorization?.match(/^Bearer ([^\s]+)$/i) + return bearer?.[1] ?? headers.get('x-api-key') ?? undefined +} diff --git a/packages/opencode/src/v2/prime.ts b/packages/opencode/src/v2/prime.ts new file mode 100644 index 000000000..b0a728bc5 --- /dev/null +++ b/packages/opencode/src/v2/prime.ts @@ -0,0 +1,237 @@ +import { + buildPrimeRequestBody, + type NativeAccountRuntime, + type NativePoolPaths, + PrimeManager, + type PrimeSendResult, +} from '@cortexkit/anthropic-auth-core' +import type { Attempt, RequestScope } from '@cortexkit/common-auth/opencode2' +import { + createNativeAnthropicAdapter, + type NativeAnthropicAttemptData, +} from './adapter' +import type { NativeSource, NativeSourceProvenance } from './source' + +const record = (value: unknown): Record => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} + +/** After the five-hour quota resets, send one minimal authenticated request to start its next window and record that request's token usage. */ +export function createNativePrime(options: { + runtime: NativeAccountRuntime + paths: NativePoolPaths + source: NativeSource + fetch?: typeof fetch +}) { + const { runtime, source } = options + const abort = new AbortController() + const sends = new Set>() + const scopeFor = (accountId: string): RequestScope => ({ + providerID: 'anthropic', + modelID: buildPrimeRequestBody().model, + sessionID: `oc2-prime:${accountId}`, + agent: 'prime', + kind: 'generate', + }) + const send = async (accountId: string): Promise => { + const scope = scopeFor(accountId) + const native = createNativeAnthropicAdapter({ source }) + const started = performance.now() + try { + for (let index = 0; index < 3; index++) { + abort.signal.throwIfAborted() + const snapshot = await runtime.read() + if (snapshot.policyStorage.prime?.enabled !== true) + throw new Error('Native Prime is disabled') + const authorized = await native.adapter.accountHeaders({ + ...scope, + accountId, + }) + if ( + !('headers' in authorized) || + typeof authorized.headers !== 'object' || + authorized.headers === null || + typeof authorized.attempt !== 'object' || + authorized.attempt === null + ) + throw new Error('Native Prime authorization refused') + const attempt: Attempt< + NativeAnthropicAttemptData + > = { + ...scope, + accountId, + attemptId: crypto.randomUUID(), + transport: 'http', + data: authorized.attempt, + } + let status: number | undefined + let delivered = false + try { + const request = await native.adapter.rewriteRequest?.({ + ...scope, + accountId, + attempt, + request: new Request('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildPrimeRequestBody()), + signal: abort.signal, + }), + }) + if (!request) throw new Error('Native Prime rewrite refused') + const headers = new Headers(request.headers) + for (const [name, value] of Object.entries(authorized.headers)) { + if (value === null) headers.delete(name) + else headers.set(name, value) + } + const final = new Request(request, { headers }) + const response = await (options.fetch ?? fetch)(final) + status = response.status + native.adapter.quotaFromHeaders?.( + response.headers, + response.status, + attempt, + ) + const limit = response.ok + ? undefined + : await native.adapter.limitFromResponse?.({ + status: response.status, + headers: response.headers, + body: () => response.clone().text(), + attempt, + }) + if ( + limit && + /^(vault-newer|local-newer|relay-direct):/.test(limit.reason) && + index < 2 + ) { + await response.body?.cancel() + const next = await source.chooseAccount({ + ...scope, + rerouteFrom: { accountId, limit }, + }) + if (next !== accountId) + throw new Error('Native Prime retry changed account') + continue + } + if (!response.ok) + return { + ok: false, + status, + ms: performance.now() - started, + error: 'Native Prime upstream refused the request', + } + const data = record(await response.json()) + const usage = record(data.usage) + const delta = { + inputTokens: + typeof usage.input_tokens === 'number' + ? usage.input_tokens + : undefined, + outputTokens: + typeof usage.output_tokens === 'number' + ? usage.output_tokens + : undefined, + } + // Require an Anthropic request ID before treating a relay response as a successful quota-start request. + if ( + attempt.data?.dispatch === 'http-relay' && + !/^req_[A-Za-z0-9_-]+$/.test( + response.headers.get('request-id') ?? '', + ) + ) + throw new Error( + 'Native Prime relay response has no upstream provenance', + ) + delivered = await source.recordPrime( + authorized.attempt.provenance, + delta, + ) + if (!delivered) + throw new Error('Native Prime counter attribution was superseded') + return { + ok: true, + status, + ms: performance.now() - started, + usage: delta, + } + } finally { + await native.adapter.onAttemptEnd?.(attempt, { + status, + outputStarted: false, + ...(delivered ? {} : { error: { reason: 'failed' as const } }), + }) + } + } + return { ok: false, error: 'Native Prime retry budget exhausted' } + } catch { + return { + ok: false, + error: 'Native Prime was not completed', + ms: performance.now() - started, + } + } finally { + native.dispose() + } + } + const manager = new PrimeManager({ + storagePath: options.paths.legacyConfig, + loadStorage: async () => (await runtime.read()).policyStorage, + async getAccountFingerprint(accountId) { + const snapshot = await runtime.read() + const account = snapshot.accounts.find((row) => row.id === accountId) + if (!account?.enabled || account.type !== 'oauth') return undefined + if ( + snapshot.policyStorage.relay?.enabled === true && + snapshot.policyStorage.relay.transport !== 'http' + ) + return undefined + if (account.source === 'vault') { + const receipt = await runtime.authorizeVault(accountId, abort.signal) + return runtime.getOrCreateAuthLineage(accountId, receipt) + } + const credential = await runtime.authorizeLocal(accountId, { + signal: abort.signal, + }) + return credential.status === 'usable' + ? runtime.getOrCreateAuthLineage(accountId, credential.subject) + : undefined + }, + async refreshQuota(accountId) { + const snapshot = await runtime.read() + if ( + snapshot.policyStorage.relay?.enabled === true && + snapshot.policyStorage.relay.transport !== 'http' + ) + throw new Error('WebSocket relay is unsupported on OpenCode 2') + return { + quota: await runtime.fetchQuota(accountId, options.fetch, abort.signal), + fresh: true, + } + }, + sendPrime(accountId) { + const pending = send(accountId) + sends.add(pending) + void pending.finally(() => sends.delete(pending)) + return pending + }, + async recordSuccess(accountId) { + const account = (await runtime.read()).accounts.find( + (row) => row.id === accountId, + ) + if (!account?.prime) + throw new Error('Native Prime counters are unavailable') + return account.prime + }, + }) + manager.start() + return { + manager, + async dispose() { + manager.stop() + abort.abort() + await Promise.allSettled(sends) + }, + } +} diff --git a/packages/opencode/src/v2/rpc.ts b/packages/opencode/src/v2/rpc.ts new file mode 100644 index 000000000..a8ab7b5a0 --- /dev/null +++ b/packages/opencode/src/v2/rpc.ts @@ -0,0 +1,173 @@ +import type { + CommandApplyRequest, + CommandApplyResult, + CommandDialogPayload, + CommandInvocation, + CommandMenu, +} from '@cortexkit/anthropic-auth-core' +import type { Plugin } from '@opencode/plugin' +import type { Registration } from '@opencode/plugin/promise/registration' +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { NativeMenuCapabilities } from './menu-contract' + +const record = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value) +const session = ( + value: unknown, +): value is Record & { sessionId: string } => + record(value) && + typeof value.sessionId === 'string' && + value.sessionId.length > 0 +const apply = ( + value: unknown, +): value is CommandApplyRequest & { sessionId: string } => { + if ( + !session(value) || + !record(value) || + value.command !== 'claude' || + typeof value.sectionId !== 'string' || + typeof value.actionId !== 'string' + ) + return false + if (value.itemId !== undefined && typeof value.itemId !== 'string') + return false + if (value.confirmed !== undefined && typeof value.confirmed !== 'boolean') + return false + if ( + value.values !== undefined && + (!record(value.values) || + !Object.values(value.values).every( + (entry) => + entry === null || + typeof entry === 'string' || + typeof entry === 'boolean' || + (typeof entry === 'number' && Number.isFinite(entry)), + )) + ) + return false + return true +} +function schema( + valid: (value: unknown) => value is T, +): StandardSchemaV1 { + return { + '~standard': { + version: 1 as const, + vendor: 'cortexkit-native', + validate: (value: unknown) => + valid(value) + ? { value } + : { issues: [{ message: 'Invalid native menu payload' }] }, + }, + } +} +// Validate the `claude` menu payload the plugin server sends over RPC: it must +// name the `claude` command and carry a menu with a title and a sections +// array, which the OpenCode 2 TUI in ./tui.ts reads to draw the menu. +const dialog = (value: unknown): value is CommandDialogPayload => + record(value) && + value.command === 'claude' && + record(value.menu) && + value.menu.command === 'claude' && + typeof value.menu.title === 'string' && + Array.isArray(value.menu.sections) +const result = (value: unknown): value is CommandApplyResult => + record(value) && + value.command === 'claude' && + typeof value.ok === 'boolean' && + typeof value.text === 'string' && + record(value.menu) && + Array.isArray(value.menu.sections) + +export const NATIVE_MENU_RPC = { + id: 'cortexkit-anthropic-native', + methods: { + open: { input: schema(session), output: schema(dialog) }, + apply: { input: schema(apply), output: schema(result) }, + }, + events: { + opened: { + schema: schema( + ( + value: unknown, + ): value is { sessionId: string; payload: CommandDialogPayload } => + session(value) && dialog(value.payload), + ), + }, + notification: { + schema: schema( + ( + value: unknown, + ): value is { + sessionId: string + message: string + kind: 'info' | 'warning' | 'error' + } => + session(value) && + typeof value.message === 'string' && + ['info', 'warning', 'error'].includes(String(value.kind)), + ), + }, + }, +} as const + +/** OpenCode routes these RPC calls to the current project. No separate loopback server is started. */ +export async function registerNativeMenu( + ctx: Pick, + command: CommandMenu, + capabilities: NativeMenuCapabilities, +): Promise { + const abort = new AbortController() + const invocation = (sessionId: string): CommandInvocation => ({ + sessionId, + notify(message, kind = 'info') { + void rpc.events + .emit('notification', { sessionId, message, kind }) + .catch(() => {}) + }, + }) + const rpc = await ctx.rpc.register(NATIVE_MENU_RPC, { + open: (input) => command.open(invocation(input.sessionId)), + apply: (input) => command.apply(input, invocation(input.sessionId)), + }) + let registered: Registration + try { + registered = await ctx.command.transform((editor) => + editor.add({ + name: 'claude', + description: 'Native Claude accounts and settings', + async execute(input) { + const payload = await command.open(invocation(input.sessionID)) + await rpc.events.emit('opened', { + sessionId: input.sessionID, + payload, + }) + }, + }), + ) + } catch (error) { + await rpc.dispose() + throw error + } + const listener = (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: abort.signal })) { + if (event.type === 'session.deleted') + capabilities.forgetSession(event.data.sessionID) + } + } catch { + /* The OpenCode event subscription (ctx.event.subscribe) ends when dispose() + aborts it or OpenCode closes it while unloading the plugin. Log nothing + here: host events can carry conversation and tool content. */ + } + })() + let disposed = false + return { + async dispose() { + if (disposed) return + disposed = true + abort.abort() + await Promise.all([rpc.dispose(), registered.dispose(), listener]) + }, + } +} diff --git a/packages/opencode/src/v2/server.ts b/packages/opencode/src/v2/server.ts new file mode 100644 index 000000000..f37528b6e --- /dev/null +++ b/packages/opencode/src/v2/server.ts @@ -0,0 +1,17 @@ +// Both OpenCode versions load the package's ./server export. Version 1 calls +// server; version 2 calls setup. Keep both methods on one plugin definition. +import { AnthropicAuthPlugin } from '../index' +import { ANTHROPIC_AUTH_PLUGIN_ID, createAnthropicAuthPlugin } from './setup' + +export type { AnthropicAuthV2Options } from './setup' +export { + ANTHROPIC_AUTH_PLUGIN_ID, + createAnthropicAuthPlugin, + setupAnthropicAuth, +} from './setup' + +export default { + id: ANTHROPIC_AUTH_PLUGIN_ID, + setup: createAnthropicAuthPlugin().setup, + server: AnthropicAuthPlugin, +} diff --git a/packages/opencode/src/v2/setup.ts b/packages/opencode/src/v2/setup.ts new file mode 100644 index 000000000..712cf5559 --- /dev/null +++ b/packages/opencode/src/v2/setup.ts @@ -0,0 +1,317 @@ +import { + authorize, + createNativeAccountRuntime, + createNativeUi, + disconnectNativeVaultForHost, + exchange, + logger, + type NativeAccountRuntime, + type NativeAccountRuntimeOptions, + type NativePoolPaths, + type NativeUiOptions, + readNativeEnrollmentMode, + resolveNativePoolPaths, +} from '@cortexkit/anthropic-auth-core' +import { + type PoolLoginMethod, + type RegisterOpenCode2AuthMethodsOptions, + registerOpenCode2AuthMethods, +} from '@cortexkit/common-auth/opencode2' +import type { Plugin } from '@opencode/plugin' +import type { Registration } from '@opencode/plugin/promise/registration' +import { + CLAUDE_POOL_LOGIN_METHOD_ID, + CLAUDE_VAULT_ACTIVATION_METHOD_ID, +} from './activation' +import { installNativeAnthropic } from './install' +import { startNativeAnthropicLifecycle } from './lifecycle' +import { createNativeMenuCapabilities } from './menu' +import { createNativeClaudeModelRules } from './models' +import { preparedAnthropicCredential } from './ownership' +import { createNativePrime } from './prime' +import { registerNativeMenu } from './rpc' +import { createNativeAnthropicSource, type NativeSource } from './source' +import { createNativeWirePolicy } from './wire' + +export const ANTHROPIC_AUTH_PLUGIN_ID = 'cortexkit-anthropic-auth' + +/** + * The account source vault enrollment selects: 'local' serves accounts stored + * in this machine's account pool, 'claustrum' serves accounts from the + * Claustrum vault this OpenCode is enrolled in. + */ +export type NativeEnrollmentMode = 'local' | 'claustrum' + +export interface AnthropicAuthV2Options { + readonly paths?: NativePoolPaths + readonly local?: NativeAccountRuntimeOptions['local'] + readonly vault?: NativeAccountRuntimeOptions['vault'] + readonly fetch?: typeof fetch +} + +/** The OAuth result a completed Claude Pro / Max sign-in hands to the pool. */ +export interface ClaudeOAuthLogin { + readonly access: string + readonly refresh: string + readonly expires: number +} + +export const LOCAL_LOGIN_REFUSED_IN_VAULT = + 'This OpenCode is enrolled in the Claude vault and uses only vault accounts. Choose "Claude vault accounts" instead; no local account was changed.' +export const VAULT_ACTIVATION_REFUSED_LOCALLY = + 'This OpenCode is not enrolled in the Claude vault. Enroll it first, or sign in with Claude Pro / Max.' +export const ENROLLMENT_UNREADABLE = + 'The Claude vault enrollment could not be read; repair it and retry. Nothing was changed.' + +export interface ClaudeLoginMethodsInput { + /** False once setup is disposed; every step refuses after that. */ + readonly isActive: () => boolean + readonly readMode: () => Promise + /** Verifies a completed sign-in's account and writes it to the local pool. */ + readonly runtime: Pick + readonly authorize?: typeof authorize + readonly exchange?: typeof exchange +} + +/** + * Builds the two login methods OpenCode 2 offers for the anthropic provider: + * the Claude Pro / Max browser sign-in, which writes to the local account + * pool, and vault activation, which writes nothing locally. Vault enrollment + * alone decides which one works. When OpenCode is enrolled in the vault, the + * browser sign-in refuses before it opens a browser and again before it + * writes, and only vault activation succeeds. When it is not enrolled, vault + * activation refuses. An enrollment that cannot be read refuses both, since + * neither account source can then be shown to be the right one. + */ +export function claudeLoginMethods( + input: ClaudeLoginMethodsInput, +): RegisterOpenCode2AuthMethodsOptions { + const start = input.authorize ?? authorize + const finish = input.exchange ?? exchange + const ensureActive = () => { + if (!input.isActive()) + throw new Error('Claude authentication setup is closed') + } + const mode = async () => { + let current: NativeEnrollmentMode + try { + current = await input.readMode() + } catch { + throw new Error(ENROLLMENT_UNREADABLE) + } + ensureActive() + return current + } + const requireLocal = async () => { + if ((await mode()) !== 'local') + throw new Error(LOCAL_LOGIN_REFUSED_IN_VAULT) + } + const loginMethods: PoolLoginMethod[] = [ + { + method: { + id: CLAUDE_POOL_LOGIN_METHOD_ID, + type: 'oauth', + label: 'Claude Pro / Max (native account pool)', + }, + async authorize() { + ensureActive() + await requireLocal() + const auth = await start('max') + ensureActive() + return { + mode: 'code', + url: auth.url, + instructions: 'Paste the authorization code returned by Claude.', + callback: async (code) => { + ensureActive() + const result = await finish( + code, + auth.verifier, + auth.redirectUri, + auth.state, + ) + if (!input.isActive() || result.type !== 'success') + throw new Error('Claude sign-in did not complete') + // Hand on only the token fields. The exchange result's own `type` + // field ('success') would replace the stored credential's + // `type: 'oauth'` when the pool copies these fields into it. + return { + access: result.access, + refresh: result.refresh, + expires: result.expires, + } + }, + } + }, + }, + ] + return { + integrationID: 'anthropic', + methods: loginMethods, + vault: { + method: { + id: CLAUDE_VAULT_ACTIVATION_METHOD_ID, + type: 'oauth', + label: 'Claude vault accounts', + }, + async activate() { + ensureActive() + if ((await mode()) !== 'claustrum') + throw new Error(VAULT_ACTIVATION_REFUSED_LOCALLY) + }, + }, + async onLogin(credential) { + ensureActive() + // Enrollment can change while the user is in the browser. Check it + // again immediately before writing anything to the local pool. + await requireLocal() + await input.runtime.loginOAuth({ + routeId: 'main', + credential, + replace: true, + }) + ensureActive() + }, + label: 'Claude native account pool', + } +} + +/** + * OpenCode's vault enrollment callbacks for the login methods and the + * `claude` menu. Both use only the enrollment files that `env` names. + * Disconnecting ends this OpenCode's vault enrollment and never reads or + * changes OpenCode's own stored sign-ins. + */ +export function nativeEnrollmentCallbacks(env: NodeJS.ProcessEnv) { + return { + readMode: (): Promise => + readNativeEnrollmentMode('opencode', env), + disconnectVault: () => + disconnectNativeVaultForHost({ host: 'opencode', env }), + } +} + +export function createAnthropicAuthPlugin( + options: AnthropicAuthV2Options = {}, +): Plugin.Plugin { + return { + id: ANTHROPIC_AUTH_PLUGIN_ID, + setup: (ctx) => setupAnthropicAuth(ctx, options), + } +} + +/** Install request hooks before credential transfer to the shared pool finishes; requests must refuse until that transfer commits, never reuse old host tokens. */ +export async function setupAnthropicAuth( + ctx: Plugin.Context, + options: AnthropicAuthV2Options = {}, +): Promise { + const paths = options.paths ?? (await resolveNativePoolPaths()) + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: options.local, + vault: options.vault, + }) + // Use the same enrollment files the account runtime reads, so the login + // methods, the menu and request serving always agree on the mode. + const enrollment = nativeEnrollmentCallbacks( + options.vault?.env ?? process.env, + ) + let active = true + const wire = createNativeWirePolicy() + const models = createNativeClaudeModelRules() + let lifecycle: ReturnType | undefined + let prime: ReturnType | undefined + const source: NativeSource = createNativeAnthropicSource({ + runtime, + paths, + fetch: options.fetch, + requestOptions: wire.requestOptions, + responseOptions: wire.responseOptions, + onRequestId: wire.onRequestId, + onForgetSession: wire.forgetSession, + resolveModelId: async (scope) => models.modelId(scope.modelID), + onOwnedRequest: () => { + lifecycle ??= startNativeAnthropicLifecycle(runtime, options.fetch) + prime ??= createNativePrime({ + runtime, + paths, + source, + fetch: options.fetch, + }) + }, + }) + const capabilities = createNativeMenuCapabilities({ + runtime, + source, + start: async (sessionID, text) => { + await ctx.session.synthetic({ + sessionID, + text, + description: 'Claude lane start', + resume: true, + }) + }, + }) + const registrations: Registration[] = [] + const installation = await installNativeAnthropic( + ctx, + { + source, + log: { warn: (message, data) => logger.warn('opencode2', message, data) }, + }, + { credential: preparedAnthropicCredential }, + ).catch((error: unknown) => { + capabilities.close() + source.close() + runtime.close() + throw error + }) + let cleanup: Promise | undefined + const dispose = () => { + if (cleanup) return cleanup + active = false + capabilities.close() + source.close() + cleanup = (async () => { + await Promise.allSettled([ + installation.dispose(), + lifecycle?.dispose(), + prime?.dispose(), + ...registrations.map((registration) => registration.dispose()), + ]) + runtime.close() + })() + return cleanup + } + try { + registrations.push(await wire.install(ctx)) + registrations.push(await models.install(ctx)) + registrations.push( + await registerOpenCode2AuthMethods( + ctx, + claudeLoginMethods({ + isActive: () => active, + readMode: enrollment.readMode, + runtime, + }), + ), + ) + const ui: NativeUiOptions = { + host: 'opencode', + dispatch: capabilities.dispatch, + readStatus: (command) => capabilities.readStatus(command), + readMode: enrollment.readMode, + disconnectVault: enrollment.disconnectVault, + interactive: true, + } + const command = createNativeUi(ui) + registrations.push(await registerNativeMenu(ctx, command, capabilities)) + // Only a completed, explicit Claude-pool login selects the plugin's inert + // OAuth connection. Startup never creates or replaces a host connection. + return dispose + } catch (error) { + await dispose() + throw error + } +} diff --git a/packages/opencode/src/v2/source.ts b/packages/opencode/src/v2/source.ts new file mode 100644 index 000000000..5a4b3dfee --- /dev/null +++ b/packages/opencode/src/v2/source.ts @@ -0,0 +1,811 @@ +import { + getClaudeCodeIdentityForVerifiedAccount, + getQuotaCheckIntervalMs, + getRoutingMode, + getStickyRoutingStatePath, + isQuotaBearingHeaderFrame, + mergeHeaderQuotaForPersistence, + type NativeAccountRuntime, + type NativeAccountSnapshot, + type NativeAccountView, + type NativeApiSubject, + type NativeCustodyReceipt, + type NativeLocalCredentialValidation as NativeKnownCredentialSubject, + type NativePoolPaths, + nativeAccountPolicy, + normalizeQuotaHeaders, + type OAuthQuotaSnapshot, + type ProviderAccountUuid, + quotaSnapshotCheckedAt, + quotaSnapshotHasStandardWindows, + remapModelId, + StickySessionRouter, + shouldFallbackStatus, + stickyQuotaSnapshotIsFresh, + stickyRouteFamilyForModel, +} from '@cortexkit/anthropic-auth-core' +import type { + ChooseAccountInput, + HeaderEdits, + RequestScope, +} from '@cortexkit/common-auth/opencode2' +import { extractAnthropicRequestId } from '../billing-lineage' +import { + type NativeAnthropicAuthorization, + type NativeAnthropicBodyOptions, + type NativeAnthropicRequestContext, + type NativeAnthropicResponseContext, + type NativeAnthropicResponseOptions, + type NativeAnthropicSource, + NativeAnthropicTransportRefusal, +} from './adapter' + +/** + * Identifies the account and credential version that served a send, so a + * later 401 or quota reading is recorded against that exact version. It never + * holds OAuth tokens, API keys or the private vault authorization receipt. + */ +export type NativeSourceProvenance = + | { readonly kind: 'local'; readonly subject: NativeKnownCredentialSubject } + | { + readonly kind: 'vault' + readonly credentialId: string + readonly recordVersion: number + } + | { readonly kind: 'api'; readonly subject: NativeApiSubject } + +type Authorization = NativeAnthropicAuthorization +type State = { + readonly scope: RequestScope + readonly active: AbortController + readonly account: NativeAccountView + readonly receipt?: NativeCustodyReceipt + readonly access?: string + readonly retry: boolean + readonly relayFallback: boolean + quota?: OAuthQuotaSnapshot +} +type Retry = { + readonly sessionID: string + readonly accountId: string + readonly direct?: boolean + readonly auth?: Authorization + readonly retry: boolean +} + +// Only the general OAuth windows can authorize paid API fallback. A model's +// scoped exhaustion alone must not open a paid route. +const quotaSnapshotIsExhausted = (quota: OAuthQuotaSnapshot | undefined) => + (['five_hour', 'seven_day'] as const).some( + (window) => (quota?.[window]?.remainingPercent ?? 1) <= 0, + ) + +export interface NativeSourceOptions { + readonly runtime: NativeAccountRuntime + readonly paths: NativePoolPaths + readonly requestOptions: ( + scope: NativeAnthropicRequestContext, + snapshot: NativeAccountSnapshot, + ) => NativeAnthropicBodyOptions + readonly responseOptions?: ( + scope: NativeAnthropicResponseContext, + ) => NativeAnthropicResponseOptions + readonly onRequestId?: ( + scope: NativeAnthropicResponseContext, + requestId: string, + ) => void + readonly onForgetSession?: (sessionID: string) => void + readonly onOwnedRequest?: () => void + readonly fetch?: typeof fetch + /** Resolve a host model alias to the upstream ID before request-local policy. */ + readonly resolveModelId?: (scope: RequestScope) => Promise +} + +export interface NativeSource + extends NativeAnthropicSource { + readonly close: () => void + readonly resetRouting: (sessionID: string) => Promise + readonly recordPrime: ( + provenance: NativeSourceProvenance, + usage: { inputTokens?: number; outputTokens?: number }, + ) => Promise +} + +/** The native account service supplies fresh credentials; existing quota and routing rules choose each OpenCode 2 request's route. */ +export function createNativeAnthropicSource( + options: NativeSourceOptions, +): NativeSource { + const { runtime } = options + // Vault mode keeps session affinity in its own file, named by the snapshot, + // so vault sessions never read or write the local account's routing state. + // Local mode leaves routingPath unset and uses the local routing file. + const routers = new Map() + const routerFor = (snapshot: NativeAccountSnapshot) => { + const path = + snapshot.routingPath ?? + getStickyRoutingStatePath(options.paths.legacyConfig) + let router = routers.get(path) + if (!router) { + router = new StickySessionRouter({ path }) + routers.set(path, router) + } + return router + } + const states = new WeakMap() + const retries = new Map() + const choices = new Map() + const sessions = new Map() + const reported = new WeakSet() + let closed = false + const boundRetries = () => { + while (retries.size > 512) { + const first = retries.keys().next() + if (!first.done) retries.delete(first.value) + } + } + + const key = (scope: RequestScope, accountId: string) => + JSON.stringify([scope.sessionID, scope.kind, scope.modelID, accountId]) + const controller = (scope: RequestScope) => { + if (closed) throw new Error('Native Anthropic source is closed') + let current = sessions.get(scope.sessionID) + if (!current) { + current = new AbortController() + sessions.set(scope.sessionID, current) + while (sessions.size > 512) { + const first = sessions.keys().next() + if (!first.done) source.forgetSession?.(first.value) + } + } + return current + } + const eligible = ( + account: NativeAccountView, + snapshot: NativeAccountSnapshot, + modelID: string, + ) => + account.enabled && + (account.type === 'api' || + nativeAccountPolicy(account, snapshot, remapModelId(modelID)).status === + 'allowed') + const admitted = async ( + accountId: string, + modelID: string, + route: 'oauth' | 'api', + active: AbortController, + ) => { + const current = await runtime.read() + active.signal.throwIfAborted() + assertTransport(current) + const account = current.accounts.find((row) => row.id === accountId) + if ( + !account || + account.type !== route || + !eligible(account, current, modelID) + ) + return false + if (route === 'api') { + const main = current.accounts.find( + (row) => row.id === 'main' && row.type === 'oauth', + ) + return ( + !!main && + stickyQuotaSnapshotIsFresh(main.quota, current.policyStorage) && + quotaSnapshotIsExhausted(main.quota) + ) + } + return true + } + + const assertTransport = (snapshot: NativeAccountSnapshot) => { + const selected = snapshot.policyStorage.relay + if (selected?.enabled === true && selected.transport !== 'http') + throw new NativeAnthropicTransportRefusal() + } + const dispatchFor = async ( + snapshot: NativeAccountSnapshot, + direct: boolean, + ) => { + assertTransport(snapshot) + if (direct || snapshot.policyStorage.relay?.enabled !== true) + return { dispatch: { kind: 'direct' as const }, relayFallback: false } + // Account and status data exclude the relay secret. Obtain it through the + // private transport accessor immediately before preparing the send. + const relay = await runtime.getRelayConfig() + if (!relay) + throw new Error( + 'request refused: native HTTP relay configuration is unavailable', + ) + if (relay.transport !== 'http') throw new NativeAnthropicTransportRefusal() + return { + dispatch: { + kind: 'http-relay' as const, + url: relay.url, + token: relay.token, + }, + relayFallback: relay.fallbackToDirect, + } + } + + async function publish( + provenance: NativeSourceProvenance, + state: State, + patch: { quota?: OAuthQuotaSnapshot; lastUsed?: number }, + ) { + if (provenance.kind === 'local') + await runtime.publishLocal(provenance.subject, patch) + else if (provenance.kind === 'vault' && state.receipt) + await runtime.vault.publish(state.receipt, patch) + else if (provenance.kind === 'api' && patch.lastUsed !== undefined) + await runtime.publishApi(provenance.subject, { lastUsed: patch.lastUsed }) + } + + const source: NativeSource = { + async chooseAccount(input: ChooseAccountInput) { + const active = controller(input) + options.onOwnedRequest?.() + let snapshot = await runtime.read() + active.signal.throwIfAborted() + // Account selection rejects a WebSocket relay before any OAuth token + // refresh or retrieval starts. + assertTransport(snapshot) + const modelID = remapModelId( + (await options.resolveModelId?.(input)) ?? input.modelID, + ) + active.signal.throwIfAborted() + if ( + snapshot.mode === 'claustrum' && + !snapshot.accounts.some((account) => account.type === 'oauth') + ) { + await runtime.vault.refresh(active.signal) + active.signal.throwIfAborted() + snapshot = await runtime.read() + active.signal.throwIfAborted() + } + if (snapshot.policyStorage.quota?.enabled !== false) { + let refreshed = false + for (const account of snapshot.accounts) { + if (!account.enabled || account.type !== 'oauth') continue + const fresh = quotaSnapshotHasStandardWindows(account.quota) + ? stickyQuotaSnapshotIsFresh( + account.quota, + snapshot.policyStorage, + Date.now(), + modelID, + ) + : Date.now() - quotaSnapshotCheckedAt(account.quota) < + getQuotaCheckIntervalMs(snapshot.policyStorage) + if (fresh) continue + try { + await runtime.fetchQuota(account.id, options.fetch, active.signal) + refreshed = true + } catch { + active.signal.throwIfAborted() + } + } + if (refreshed) snapshot = await runtime.read() + active.signal.throwIfAborted() + } + const retry = input.rerouteFrom + ? retries.get(input.rerouteFrom.limit.reason) + : undefined + if ( + input.rerouteFrom && + /^(vault-newer|local-newer|relay-direct):/.test( + input.rerouteFrom.limit.reason, + ) && + !retry + ) + return undefined + if (retry && input.rerouteFrom) { + retries.delete(input.rerouteFrom.limit.reason) + const account = snapshot.accounts.find( + (row) => row.id === retry.accountId, + ) + if (!account || !eligible(account, snapshot, modelID)) return undefined + const queueKey = key(input, retry.accountId) + const queue = choices.get(queueKey) ?? [] + queue.push(retry) + choices.set(queueKey, queue) + return retry.accountId + } + const oauth: NativeAccountView[] = [] + for (const account of snapshot.accounts) { + if (account.type !== 'oauth' || !eligible(account, snapshot, modelID)) + continue + if (account.source === 'local') { + // Skip a local account whose token refresh failed, so it is not + // selected. Check usability without keeping its token; the send + // obtains a fresh credential itself. + const admission = await runtime.authorizeLocal(account.id, { + signal: active.signal, + policy: (row, view) => nativeAccountPolicy(row, view, modelID), + }) + active.signal.throwIfAborted() + if (admission.status !== 'usable') continue + } + oauth.push(account) + } + const withoutRejected = oauth.filter( + (account) => account.id !== input.rerouteFrom?.accountId, + ) + if (getRoutingMode(snapshot.policyStorage) === 'sticky-balanced') { + const candidates = withoutRejected.map((account, order) => ({ + accountId: account.id, + order, + quota: account.quota ?? { kind: 'unknown' as const }, + })) + const selected = await routerFor(snapshot).resolve({ + sessionId: input.sessionID, + family: stickyRouteFamilyForModel(modelID), + modelId: modelID, + affinityModelId: input.modelID, + candidates, + retainAccountIds: new Set( + withoutRejected.map((account) => account.id), + ), + // Vault routes are bound to the verified account UUID they served, + // so a route whose UUID changed (a replaced vault default) is + // re-placed instead of keeping another account's session pin. List + // every vault account with a UUID, even ones quota excludes from + // `candidates`. Local routes omit the map and keep their old pins. + ...(snapshot.mode === 'claustrum' + ? { + accountIdentities: new Map( + snapshot.accounts.flatMap((account) => + account.accountIdentity + ? [[account.id, account.accountIdentity] as const] + : [], + ), + ), + } + : {}), + storage: snapshot.policyStorage, + inputBytes: 1, + preferredAccountId: input.previousAccountId, + }) + active.signal.throwIfAborted() + if (selected) return selected.accountId + } else { + const mode = getRoutingMode(snapshot.policyStorage) + const ordered = + mode === 'fallback-first' + ? [ + ...withoutRejected.filter((account) => account.id !== 'main'), + ...withoutRejected.filter((account) => account.id === 'main'), + ] + : [ + ...withoutRejected.filter((account) => account.id === 'main'), + ...withoutRejected.filter((account) => account.id !== 'main'), + ] + if (ordered[0]) return ordered[0].id + } + // Fall back to a pay-per-token API key account only when the primary + // Claude subscription account ('main') has run out: a recent usage + // reading for main's current account and credential version must show + // its five-hour or seven-day subscription limit at zero. A limit on one + // model only, or a 401, never moves a request onto paid API billing. + const main = snapshot.accounts.find( + (account) => account.id === 'main' && account.type === 'oauth', + ) + if (!main) return undefined + let quota = main.quota + if ( + input.rerouteFrom?.limit.status === 429 || + !stickyQuotaSnapshotIsFresh(quota, snapshot.policyStorage) + ) { + try { + quota = await runtime.fetchQuota('main', options.fetch, active.signal) + } catch { + return undefined + } + } + active.signal.throwIfAborted() + if (!quotaSnapshotIsExhausted(quota)) return undefined + return snapshot.accounts.find( + (account) => + account.type === 'api' && + account.enabled && + account.id !== input.rerouteFrom?.accountId, + )?.id + }, + async authorizeAccount(scope, accountId) { + const active = controller(scope) + const snapshot = await runtime.read() + active.signal.throwIfAborted() + assertTransport(snapshot) + const modelID = remapModelId( + (await options.resolveModelId?.(scope)) ?? scope.modelID, + ) + active.signal.throwIfAborted() + const account = snapshot.accounts.find((row) => row.id === accountId) + if (!account || !eligible(account, snapshot, modelID)) return undefined + const queueKey = key(scope, accountId) + const queue = choices.get(queueKey) + const choice = queue?.shift() + if (queue?.length === 0) choices.delete(queueKey) + const { dispatch, relayFallback } = await dispatchFor( + snapshot, + choice?.direct === true, + ) + active.signal.throwIfAborted() + if (choice?.auth) { + if (!(await admitted(accountId, modelID, choice.auth.route, active))) + return undefined + const served = states.get(choice.auth.provenance) + if ( + served?.receipt?.expiresAtMs !== undefined && + served.receipt.expiresAtMs !== null && + served.receipt.expiresAtMs < Date.now() + 300_000 + ) + return undefined + if (served) + states.set(choice.auth.provenance, { + ...served, + scope: { ...scope, modelID }, + account, + active, + relayFallback, + }) + return { ...choice.auth, dispatch } + } + const common = { + scope: { ...scope, modelID }, + active, + account, + retry: choice?.retry ?? false, + relayFallback, + quota: account.quota, + } + if (account.type === 'api') { + const main = snapshot.accounts.find( + (row) => row.id === 'main' && row.type === 'oauth', + ) + if ( + !main || + !stickyQuotaSnapshotIsFresh(main.quota, snapshot.policyStorage) || + !quotaSnapshotIsExhausted(main.quota) + ) + return undefined + const material = await runtime.authorizeApi(accountId, active.signal) + active.signal.throwIfAborted() + if (!(await admitted(accountId, modelID, 'api', active))) + return undefined + const provenance: NativeSourceProvenance = { + kind: 'api', + subject: material.subject, + } + states.set(provenance, common) + const headers: HeaderEdits = + material.authHeader === 'authorization-bearer' + ? { authorization: `Bearer ${material.apiKey}` } + : { 'x-api-key': material.apiKey } + return { + route: 'api', + headers, + baseURL: material.baseURL, + provenance, + dispatch, + } + } + if (account.source === 'vault') { + const receipt = await runtime.authorizeVault(accountId, active.signal) + active.signal.throwIfAborted() + if (!(await admitted(accountId, modelID, 'oauth', active))) + return undefined + const provenance: NativeSourceProvenance = { + kind: 'vault', + credentialId: receipt.credentialId, + recordVersion: receipt.recordVersion, + } + states.set(provenance, { ...common, receipt }) + const identity = receipt.assertedAccountIdentity + if (!identity) return undefined + return { + route: 'oauth', + accessToken: receipt.accessToken, + provenance, + dispatch, + identity: getClaudeCodeIdentityForVerifiedAccount( + identity, + identity as ProviderAccountUuid, + ), + } + } + const material = await runtime.authorizeLocal(accountId, { + signal: active.signal, + policy: (row, view) => nativeAccountPolicy(row, view, modelID), + }) + active.signal.throwIfAborted() + if (material.status !== 'usable') return undefined + if (!(await admitted(accountId, modelID, 'oauth', active))) + return undefined + const provenance: NativeSourceProvenance = { + kind: 'local', + subject: material.subject, + } + states.set(provenance, { ...common, access: material.access }) + const identity = material.subject.binding.identity + return { + route: 'oauth', + accessToken: material.access, + provenance, + dispatch, + identity: getClaudeCodeIdentityForVerifiedAccount( + identity, + identity as ProviderAccountUuid, + ), + } + }, + async requestOptions(input) { + const active = controller(input) + const snapshot = await runtime.read() + active.signal.throwIfAborted() + const account = snapshot.accounts.find( + (row) => row.id === input.accountId, + ) + if (!account || !eligible(account, snapshot, input.modelID)) + throw new Error( + 'request refused: native account is ineligible for this model', + ) + const state = states.get(input.provenance) + if (state) + states.set(input.provenance, { + ...state, + scope: { ...state.scope, modelID: input.modelID }, + }) + return options.requestOptions(input, snapshot) + }, + async admitRequest(input) { + const state = states.get(input.provenance) + if ( + !state || + !(await admitted( + input.accountId, + input.modelID, + input.route, + state.active, + )) + ) + throw new Error( + 'request refused: native account policy changed before dispatch', + ) + states.set(input.provenance, { + ...state, + scope: { ...state.scope, modelID: input.modelID }, + }) + }, + responseOptions(input) { + return options.responseOptions?.({ + ...input, + modelID: states.get(input.provenance)?.scope.modelID ?? input.modelID, + }) + }, + quotaFromHeaders(headers, input) { + const state = states.get(input.provenance) + const requestId = extractAnthropicRequestId(headers) + if (requestId) options.onRequestId?.(input, requestId) + if ( + !state || + input.route !== 'oauth' || + !isQuotaBearingHeaderFrame(headers) + ) + return undefined + const identity = + input.provenance.kind === 'local' + ? input.provenance.subject.binding.identity + : state.receipt?.assertedAccountIdentity + const incoming = { + ...normalizeQuotaHeaders(headers), + ...(identity ? { accountIdentity: identity } : {}), + } + state.quota = mergeHeaderQuotaForPersistence(state.quota, incoming) + // Save these quota headers against the exact account and credential + // version that served this request (its provenance), so another + // credential never inherits them. The write is started, not awaited: + // streaming never waits on file locks. onAttemptEnd awaits it, so any + // account choice after this attempt reads the saved quota. + const pending = publish(input.provenance, state, { quota: state.quota }) + publications.set(input.provenance, pending) + void pending.catch(() => {}) + return incoming + }, + async limitFromResponse(input) { + const state = states.get(input.provenance) + if (!state) return undefined + if (input.status === 401 && state.receipt) { + if (!reported.has(state.receipt)) { + reported.add(state.receipt) + await runtime.vault.reportFailure( + state.receipt, + 401, + input.dispatch === 'http-relay' ? 'relay_status_field' : 'direct', + ) + } + if (state.retry || state.active.signal.aborted) return undefined + const active = state.active + const result = await runtime.vault.prepareRetry( + state.receipt, + active.signal, + ) + active.signal.throwIfAborted() + if (!result.retry) return undefined + const receipt = result.receipt + const identity = receipt.assertedAccountIdentity + if (!identity) return undefined + const provenance: NativeSourceProvenance = { + kind: 'vault', + credentialId: receipt.credentialId, + recordVersion: receipt.recordVersion, + } + states.set(provenance, { ...state, receipt, retry: true }) + const reason = `vault-newer:${input.attemptId}` + retries.set(reason, { + sessionID: input.sessionID, + accountId: input.accountId, + retry: true, + auth: { + route: 'oauth', + accessToken: receipt.accessToken, + provenance, + identity: getClaudeCodeIdentityForVerifiedAccount( + identity, + identity as ProviderAccountUuid, + ), + }, + }) + boundRetries() + return { reason, status: 401 } + } + if ( + input.status === 401 && + input.provenance.kind === 'local' && + state.access && + !state.retry + ) { + if (state.active.signal.aborted) return undefined + const active = state.active + const result = await runtime.authorizeLocal(input.accountId, { + rejectedAccessToken: state.access, + signal: active.signal, + policy: (account, snapshot) => + nativeAccountPolicy(account, snapshot, state.scope.modelID), + }) + active.signal.throwIfAborted() + if (result.status !== 'usable' || result.access === state.access) + return undefined + const provenance: NativeSourceProvenance = { + kind: 'local', + subject: result.subject, + } + states.set(provenance, { ...state, access: result.access, retry: true }) + const identity = result.subject.binding.identity + const reason = `local-newer:${input.attemptId}` + retries.set(reason, { + sessionID: input.sessionID, + accountId: input.accountId, + retry: true, + auth: { + route: 'oauth', + accessToken: result.access, + provenance, + identity: getClaudeCodeIdentityForVerifiedAccount( + identity, + identity as ProviderAccountUuid, + ), + }, + }) + boundRetries() + return { reason, status: 401 } + } + const snapshot = await runtime.read() + if (shouldFallbackStatus(input.status, snapshot.policyStorage)) + return { reason: `provider-${input.status}`, status: input.status } + return undefined + }, + limitFromRelayFailure(input) { + const state = states.get(input.provenance) + if ( + !state?.relayFallback || + state.active.signal.aborted || + !(input.status === 401 || input.status >= 500) + ) + return undefined + const reason = `relay-direct:${input.attemptId}` + retries.set(reason, { + sessionID: input.sessionID, + accountId: input.accountId, + direct: true, + retry: false, + }) + boundRetries() + return { reason } + }, + limitFromStreamError(input) { + return input.errorType === 'rate_limit_error' + ? { reason: 'provider-rate-limit', status: 429 } + : undefined + }, + limitFromHostError(error, input) { + const state = states.get(input.provenance) + if ( + input.dispatch === 'http-relay' && + state?.relayFallback && + !state.active.signal.aborted && + error.status === undefined + ) { + const reason = `relay-direct:${input.attemptId}` + retries.set(reason, { + sessionID: input.sessionID, + accountId: input.accountId, + direct: true, + retry: false, + }) + boundRetries() + return { reason } + } + return undefined + }, + async onAttemptEnd(input, outcome) { + const state = states.get(input.provenance) + if (!state) return + try { + await publications.get(input.provenance) + if ( + outcome.status === 401 && + state.receipt && + !reported.has(state.receipt) + ) { + reported.add(state.receipt) + await runtime.vault.reportFailure( + state.receipt, + 401, + input.dispatch === 'http-relay' ? 'relay_status_field' : 'direct', + ) + } + if (!outcome.localRefusal && outcome.status !== undefined) + await publish(input.provenance, state, { lastUsed: Date.now() }) + } finally { + states.delete(input.provenance) + publications.delete(input.provenance) + } + }, + forgetSession(sessionID) { + sessions.get(sessionID)?.abort() + sessions.delete(sessionID) + for (const [reason, retry] of retries) + if (retry.sessionID === sessionID) retries.delete(reason) + for (const [queueKey, queue] of choices) + if (queue[0]?.sessionID === sessionID) choices.delete(queueKey) + options.onForgetSession?.(sessionID) + }, + resetRouting: async (sessionID) => + routerFor(await runtime.read()).clear(sessionID), + async recordPrime(provenance, usage) { + const state = states.get(provenance) + if (!state || state.active.signal.aborted) return false + if (provenance.kind === 'local') + return runtime.incrementPrimeUsage( + state.account.id, + provenance.subject, + usage, + ) + if (provenance.kind === 'vault' && state.receipt) + return runtime.incrementPrimeUsage( + state.account.id, + state.receipt, + usage, + ) + return false + }, + close() { + closed = true + for (const active of sessions.values()) active.abort() + sessions.clear() + choices.clear() + retries.clear() + }, + } + const publications = new WeakMap>() + return source +} diff --git a/packages/opencode/src/v2/tui.ts b/packages/opencode/src/v2/tui.ts new file mode 100644 index 000000000..cfe1dfce7 --- /dev/null +++ b/packages/opencode/src/v2/tui.ts @@ -0,0 +1,265 @@ +import type { + CommandApplyRequest, + CommandDialogPayload, + KnobValues, + MenuAction, +} from '@cortexkit/anthropic-auth-core' +import type { Plugin as Tui } from '@opencode/plugin/tui' +import type { KeyEvent, PasteEvent } from '@opentui/core' +import { NATIVE_MENU_RPC } from './rpc' + +/** + * Shows the `claude` account menu in OpenCode 2 dialogs. The menu's sections, + * items and actions come from the plugin server over NATIVE_MENU_RPC, built by + * the same Core menu code that OpenCode 1 and Pi render, so every host shows + * the same menu. + */ +export async function setupAnthropicTui(ctx: Tui.Context) { + const rpc = ctx.client.rpc(NATIVE_MENU_RPC) + let active = true + let rendering = false + const abort = new AbortController() + const currentSession = () => { + const route = ctx.ui.router.current() + return route.type === 'session' ? route.sessionID : undefined + } + function hiddenEntry(title: string): Promise { + // OC2's built-in prompt has no masked-input option. Collect protected + // values over an alert (no text field), swallowing input before it can + // reach the conversation prompt or keyboard commands. Never render it. + return new Promise((resolve) => { + let value = '' + let finished = false + const release = ctx.keymap.mode.push('cortexkit-protected-entry') + const finish = (accept: boolean, clear = true) => { + if (finished) return + finished = true + ctx.renderer.keyInput.off('keypress', key) + ctx.renderer.keyInput.off('paste', paste) + abort.signal.removeEventListener('abort', cancel) + release() + const result = accept ? value : undefined + value = '' + if (clear) ctx.ui.dialog.clear() + resolve(result) + } + const cancel = () => finish(false) + const key = (event: KeyEvent) => { + event.preventDefault() + event.stopPropagation() + if (event.name === 'escape' || (event.ctrl && event.name === 'c')) + return finish(false) + if (event.name === 'return' || event.name === 'enter') + return finish(true) + if (event.name === 'backspace') { + value = Array.from(value).slice(0, -1).join('') + return + } + if ( + !event.ctrl && + !event.meta && + !event.option && + Array.from(event.sequence).every( + (character) => + character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127, + ) + ) + value = (value + event.sequence).slice(0, 4096) + } + const paste = (event: PasteEvent) => { + event.preventDefault() + event.stopPropagation() + value = ( + value + + Array.from(new TextDecoder().decode(event.bytes)) + .filter( + (character) => + character.charCodeAt(0) >= 32 && + character.charCodeAt(0) !== 127, + ) + .join('') + ).slice(0, 4096) + } + ctx.renderer.keyInput.prependListener('keypress', key) + ctx.renderer.keyInput.prependListener('paste', paste) + abort.signal.addEventListener('abort', cancel, { once: true }) + void ctx.ui.dialog + .alert({ + title, + message: + 'Type or paste the value. Input is hidden. Enter accepts; Escape cancels.', + }) + .then( + () => finish(false, false), + () => finish(false, false), + ) + if (abort.signal.aborted) cancel() + }) + } + async function valuesFor( + action: MenuAction, + ): Promise { + const values: Record = {} + for (const knob of action.knobs) { + if (!active) return undefined + if (knob.kind === 'choice') { + const value = await ctx.ui.dialog.select({ + title: knob.label, + options: knob.choices.map((choice) => ({ + title: choice.label, + value: choice.value, + })), + current: knob.value, + }) + if (value === undefined) return undefined + values[knob.id] = value + } else if (knob.kind === 'toggle') { + const value = await ctx.ui.dialog.select({ + title: knob.label, + options: [ + { title: 'On', value: true }, + { title: 'Off', value: false }, + ], + current: knob.value, + }) + if (value === undefined) return undefined + values[knob.id] = value + } else { + const value = + knob.kind === 'text' && knob.masked + ? await hiddenEntry(knob.label) + : await ctx.ui.dialog.prompt({ + title: knob.label, + value: + knob.value === undefined ? undefined : String(knob.value), + placeholder: + knob.kind === 'text' ? knob.placeholder : undefined, + }) + if (value === undefined) return undefined + values[knob.id] = + value === '' && !knob.required + ? null + : knob.kind === 'number' + ? Number(value) + : value + } + } + return values + } + async function render(sessionId: string, initial: CommandDialogPayload) { + if (!active || rendering || currentSession() !== sessionId) return + rendering = true + let payload = initial + try { + while (active && currentSession() === sessionId) { + const section = await ctx.ui.dialog.select({ + title: payload.menu.title, + options: payload.menu.sections.map((entry) => ({ + title: entry.title, + description: entry.lines.join('\n'), + value: entry, + })), + }) + if (!section || !active || currentSession() !== sessionId) return + const actions = [ + ...section.actions.map((action) => ({ action, itemId: undefined })), + ...section.items.flatMap((item) => + item.actions.map((action) => ({ action, itemId: item.id })), + ), + ] + const selected = await ctx.ui.dialog.select({ + title: section.title, + options: actions.map((entry) => ({ + title: entry.action.label, + description: entry.action.description, + value: entry, + })), + }) + if (!selected) continue + const values = await valuesFor(selected.action) + if (!values || !active || currentSession() !== sessionId) return + const confirmed = selected.action.confirm + ? await ctx.ui.dialog.confirm({ + title: selected.action.label, + message: selected.action.confirm.message, + }) + : undefined + if (selected.action.confirm && confirmed !== true) continue + if (!active || currentSession() !== sessionId) return + const request: CommandApplyRequest & { sessionId: string } = { + command: 'claude', + sectionId: section.id, + itemId: selected.itemId, + actionId: selected.action.id, + sessionId, + values, + confirmed, + } + const result = await rpc.apply(request, { + signal: abort.signal, + location: ctx.location, + }) + if (!active || currentSession() !== sessionId) return + await ctx.ui.dialog.alert({ + title: result.ok ? 'Claude' : 'Claude action refused', + message: result.text, + }) + payload = { command: result.command, menu: result.menu } + } + } finally { + rendering = false + } + } + const remove = rpc.events.on( + 'opened', + (event) => render(event.data.sessionId, event.data.payload), + { signal: abort.signal }, + ) + const removeNotifications = rpc.events.on( + 'notification', + (event) => { + if (active && currentSession() === event.data.sessionId) + ctx.ui.toast.show({ + title: 'Claude', + sessionID: event.data.sessionId, + message: event.data.message, + variant: event.data.kind, + }) + }, + { signal: abort.signal }, + ) + ctx.keymap.layer(() => ({ + commands: [ + { + id: 'cortexkit.claude', + title: 'Claude', + group: 'Claude', + palette: true, + slash: { name: 'claude' }, + async run() { + const sessionId = currentSession() + if (!sessionId) { + await ctx.ui.dialog.alert({ + title: 'Claude', + message: 'Open a session before using the Claude menu.', + }) + return + } + const payload = await rpc.open( + { sessionId }, + { signal: abort.signal, location: ctx.location }, + ) + await render(sessionId, payload) + }, + }, + ], + })) + return () => { + active = false + abort.abort() + remove() + removeNotifications() + } +} + +export default { id: 'cortexkit-anthropic-auth', setup: setupAnthropicTui } diff --git a/packages/opencode/src/v2/wire.ts b/packages/opencode/src/v2/wire.ts new file mode 100644 index 000000000..68f9c34c0 --- /dev/null +++ b/packages/opencode/src/v2/wire.ts @@ -0,0 +1,201 @@ +import { + type NativeAccountSnapshot, + remapModelId, +} from '@cortexkit/anthropic-auth-core' +import type { Plugin } from '@opencode/plugin' +import type { Registration } from '@opencode/plugin/promise/registration' +import { + BILLING_LINEAGE_REQUEST_HEADER, + type BillingLineageContext, + BillingLineageTracker, +} from '../billing-lineage' +import { LANE_START_REQUEST_HEADER, LANE_START_TEXT } from '../lane-start' +import { resolveContentFilterFallbackMode } from '../server-fallback' +import type { + NativeAnthropicRequestContext, + NativeAnthropicResponseContext, +} from './adapter' +import type { NativeSourceProvenance } from './source' + +/** + * Claude Code's billing header names the user prompt a request belongs to and + * the provider request-id of the previous request for that prompt. Track that + * chain per user turn with OpenCode's message IDs: one session spans many + * turns, so a session ID alone cannot tell which turn a request serves. + */ +export function createNativeWirePolicy() { + const billing = new BillingLineageTracker() + const latest = new Map() + const contexts = new Map() + const messageIDs = new Map() + const laneStarts = new Set() + const laneAttempts = new Set() + const attemptSessions = new Map() + const bound = (map: Map) => { + while (map.size > 512) { + const first = map.keys().next() + if (!first.done) map.delete(first.value) + } + } + + return { + async install(ctx: Pick): Promise { + const registrations: Registration[] = [] + try { + registrations.push( + await ctx.session.hook( + 'context', + (draft) => { + const messages = draft.messages.map((message) => { + return { + info: { + id: message.id, + sessionID: draft.sessionID, + role: message.role, + agent: draft.agent, + model: { + providerID: draft.model.providerID, + modelID: draft.model.id, + }, + }, + parts: message.content.map((part) => ({ + type: part.type, + ...(part.type === 'text' ? { text: part.text } : {}), + })), + } + }) + billing.observeMessages(messages) + const user = draft.messages.findLast( + (message) => message.role === 'user' && message.id, + ) + if (user?.id) latest.set(draft.sessionID, user.id) + if ( + user?.content.some( + (part) => + part.type === 'text' && part.text === LANE_START_TEXT, + ) + ) + laneStarts.add(draft.sessionID) + else laneStarts.delete(draft.sessionID) + bound(latest) + }, + { providerID: 'anthropic' }, + ), + ) + registrations.push( + await ctx.session.hook( + 'model.request', + (draft) => { + if (draft.kind !== 'primary') return + const messageId = latest.get(draft.sessionID) + if (!messageId) return + billing.markHeaders({ + sessionId: draft.sessionID, + messageId, + agent: draft.agent, + headers: draft.headers, + }) + if (laneStarts.has(draft.sessionID)) + draft.headers[LANE_START_REQUEST_HEADER] = '1' + }, + { providerID: 'anthropic' }, + ), + ) + } catch (error) { + await Promise.allSettled( + registrations.map((registration) => registration.dispose()), + ) + throw error + } + return { + dispose: async () => { + await Promise.all( + registrations.map((registration) => registration.dispose()), + ) + }, + } + }, + requestOptions( + input: NativeAnthropicRequestContext, + snapshot: NativeAccountSnapshot, + ) { + const lineage = billing.resolveHeader( + input.correlation[BILLING_LINEAGE_REQUEST_HEADER], + ) + if (lineage) { + contexts.set(input.attemptId, lineage) + bound(contexts) + } + attemptSessions.set(input.attemptId, input.sessionID) + bound(attemptSessions) + const laneStart = input.correlation[LANE_START_REQUEST_HEADER] === '1' + if (laneStart) laneAttempts.add(input.attemptId) + return { + cache1hEnabled: + input.agent !== 'compaction' && + input.agent !== 'title' && + (snapshot.policyStorage.claudeCache?.enabled ?? true), + cache1hMode: snapshot.policyStorage.claudeCache?.mode ?? 'explicit', + fastModeEnabled: snapshot.policyStorage.claudeFast?.enabled === true, + thinkingPrefixMismatchBehavior: + snapshot.policyStorage.thinkingBinding?.prefixMismatchBehavior, + // When reasoning effort changes mid-conversation, OpenCode 1 needs this + // plugin to insert effort markers into history, which the transform + // turns into Anthropic effort system messages. OpenCode 2 already sends + // those system messages itself (with output_config). Adding markers too + // would insert each change twice and alter earlier history, so the + // cached prompt prefix would stop matching. + midConversationEffortEnabled: false, + serverSideFallbackEnabled: + resolveContentFilterFallbackMode( + process.env.OPENCODE_ANTHROPIC_AUTH_CONTENT_FILTER_FALLBACK, + ) === 'server', + laneStart, + billingLineage: lineage + ? { + promptId: lineage.promptId, + previousRequestId: lineage.previousRequestId, + } + : undefined, + cacheDiagnosticsPreviousMessageId: messageIDs.get(input.sessionID), + } + }, + responseOptions( + input: NativeAnthropicResponseContext, + ) { + return { + serverSideFallbackModel: remapModelId(input.modelID), + laneStart: laneAttempts.has(input.attemptId), + onMessageStart(message: Record) { + if (input.kind === 'primary' && typeof message.id === 'string') { + messageIDs.set(input.sessionID, message.id) + bound(messageIDs) + } + }, + onStreamEnd() { + contexts.delete(input.attemptId) + laneAttempts.delete(input.attemptId) + attemptSessions.delete(input.attemptId) + }, + } + }, + onRequestId( + input: NativeAnthropicResponseContext, + requestId: string, + ) { + billing.commit(contexts.get(input.attemptId), requestId) + }, + forgetSession(sessionID: string) { + billing.clearSession(sessionID) + latest.delete(sessionID) + messageIDs.delete(sessionID) + laneStarts.delete(sessionID) + for (const [attempt, session] of attemptSessions) + if (session === sessionID) { + contexts.delete(attempt) + laneAttempts.delete(attempt) + attemptSessions.delete(attempt) + } + }, + } +} From 4be73c978fe7ff1698baa3f16a89d102093c5890 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:28:21 +0200 Subject: [PATCH 147/169] mason: scope Pi SDK import guards by runtime entry Allow only named getAgentDir alongside the existing Pi extension SDK exports so slot restoration uses the host auth.json directory. Preserve all existing parser tests and unsupported-import checks. Standalone CLI verification instead bans every host SDK import and follows only the cli.js relative import, re-export and literal dynamic-chunk graph; unrelated plugin outputs are excluded. Actual Oh My Pi shim compatibility remains unaudited. --- scripts/check-pi-dist-imports.ts | 127 +++++++++++-- scripts/tests/check-pi-dist-imports.test.ts | 201 ++++++++++++++++++++ 2 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 scripts/tests/check-pi-dist-imports.test.ts diff --git a/scripts/check-pi-dist-imports.ts b/scripts/check-pi-dist-imports.ts index 02b6a8416..d5db94659 100644 --- a/scripts/check-pi-dist-imports.ts +++ b/scripts/check-pi-dist-imports.ts @@ -1,5 +1,5 @@ import { readdir, readFile } from 'node:fs/promises' -import { join, relative } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { parse } from '@babel/parser' /** @@ -15,16 +15,18 @@ import { parse } from '@babel/parser' * 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). + * shipped JavaScript. Transcript helpers missing from the host's compatibility + * surface are 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. + * The pi-ai exports use the host's legacy compat surface, not the build-time + * SDK's transcript helpers. getAgentDir uses the real host SDK to locate its + * auth.json; its Oh My Pi shim compatibility has not been verified. + * Standalone CLI modules may not use any of these imports. */ const ALLOWED_HOST_RUNTIME_IMPORTS: Record = { '@oh-my-pi/pi-ai': [ @@ -33,6 +35,10 @@ const ALLOWED_HOST_RUNTIME_IMPORTS: Record = { // Costs must use the host's pricing so its usage reporting agrees. 'calculateCost', ], + '@oh-my-pi/pi-coding-agent': [ + // Slot restoration must find auth.json in the host's actual agent directory. + 'getAgentDir', + ], } type AstNode = { type: string; [key: string]: unknown } @@ -63,6 +69,24 @@ function stringValue(node: unknown): string | undefined { : undefined } +function parseModule(source: string, origin: string): AstNode { + 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`) + } + return program +} + +function dynamicImportArgument(node: AstNode): unknown { + return node.type === 'ImportExpression' + ? node.source + : node.type === 'CallExpression' && + isNode(node.callee) && + node.callee.type === 'Import' + ? (node.arguments as unknown[])[0] + : undefined +} + /** Every node in the program, for dynamic imports that can appear anywhere. */ function* walk(root: AstNode): Generator { const stack: AstNode[] = [root] @@ -89,19 +113,28 @@ function* walk(root: AstNode): Generator { export function findHostImportViolations( source: string, origin: string, + runtime: 'pi-extension' | 'standalone-cli' = 'pi-extension', ): 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 program = parseModule(source, origin) const findings: string[] = [] const check = (specifier: string, names: string[] | 'opaque' | 'bare') => { const matched = HOST_PI_PACKAGE.exec(specifier) if (!matched) return + if (runtime === 'standalone-cli') { + findings.push( + `${origin}: standalone CLI must not import host Pi SDK '${specifier}'`, + ) + return + } const allowed = ALLOWED_HOST_RUNTIME_IMPORTS[`@oh-my-pi/${matched[1]}`] - if (!allowed) { + if ( + !allowed || + (matched[1] === 'pi-coding-agent' && + Array.isArray(names) && + names.some((name) => !allowed.includes(name))) + ) { findings.push( `${origin}: runtime import from '${specifier}' — port what is needed locally (see packages/pi/src/transcript.ts)`, ) @@ -128,7 +161,7 @@ export function findHostImportViolations( } } - for (const statement of program.body.filter(isNode)) { + for (const statement of (program.body as unknown[]).filter(isNode)) { if (statement.type === 'ImportDeclaration') { const specifier = stringValue(statement.source) if (specifier === undefined || statement.importKind === 'type') continue @@ -169,14 +202,7 @@ export function findHostImportViolations( } 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 argument = dynamicImportArgument(node) if (argument === undefined) continue const specifier = stringValue(argument) if (specifier !== undefined) { @@ -186,7 +212,7 @@ export function findHostImportViolations( // 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`, + `${origin}: runtime dynamic import cannot be verified ${runtime === 'pi-extension' ? 'against the host SDK allowlist' : 'for the standalone CLI'}`, ) } } @@ -195,6 +221,66 @@ export function findHostImportViolations( return [...new Set(findings)] } +/** Relative runtime edges include lazy chunks, not just top-level imports. */ +function relativeRuntimeImports(source: string, origin: string): string[] { + const imports = new Set() + for (const node of walk(parseModule(source, origin))) { + const specifier = stringValue( + node.type === 'ImportDeclaration' || + node.type === 'ExportNamedDeclaration' || + node.type === 'ExportAllDeclaration' + ? node.source + : dynamicImportArgument(node), + ) + if (specifier?.startsWith('./') || specifier?.startsWith('../')) { + imports.add(specifier) + } + } + return [...imports] +} + +export async function verifyStandaloneCliRuntimeImports( + entryPoint = join( + import.meta.dir, + '..', + 'packages', + 'opencode', + 'dist', + 'cli.js', + ), +): Promise { + const root = dirname(resolve(entryPoint)) + const pending = [resolve(entryPoint)] + const visited = new Set() + const findings: string[] = [] + while (pending.length > 0) { + const file = pending.pop() as string + if (visited.has(file)) continue + visited.add(file) + let source: string + try { + source = await readFile(file, 'utf8') + } catch { + throw new Error( + `Missing standalone CLI module ${relative(process.cwd(), file)}: run \`bun run build\` first`, + ) + } + const origin = relative(root, file) + findings.push(...findHostImportViolations(source, origin, 'standalone-cli')) + for (const specifier of relativeRuntimeImports(source, origin)) { + pending.push(resolve(dirname(file), specifier)) + } + } + if (findings.length > 0) { + throw new Error( + `Standalone CLI reaches into host Pi SDK packages at runtime:\n${findings + .map((finding) => ` ${finding}`) + .join('\n')}`, + ) + } + return `${relative(process.cwd(), entryPoint)}: ${visited.size} reachable files, no host Pi SDK runtime imports` +} + export async function verifyPiDistRuntimeImports( distRoot = join(import.meta.dir, '..', 'packages', 'pi', 'dist'), ): Promise { @@ -237,6 +323,7 @@ export async function verifyPiDistRuntimeImports( if (import.meta.main) { try { console.log(await verifyPiDistRuntimeImports()) + console.log(await verifyStandaloneCliRuntimeImports()) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) process.exitCode = 1 diff --git a/scripts/tests/check-pi-dist-imports.test.ts b/scripts/tests/check-pi-dist-imports.test.ts new file mode 100644 index 000000000..e9dcfe5e1 --- /dev/null +++ b/scripts/tests/check-pi-dist-imports.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + findHostImportViolations, + verifyPiDistRuntimeImports, + verifyStandaloneCliRuntimeImports, +} from '../check-pi-dist-imports.ts' + +const AGENT_DIR = `import { getAgentDir } from '@earendil-works/pi-coding-agent'` + +async function withModules( + modules: Record, + check: (root: string) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'runtime-import-guard-')) + try { + for (const [name, source] of Object.entries(modules)) { + const file = join(root, name) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, source) + } + await check(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +describe('entry-point host SDK import boundaries', () => { + test('allows only named getAgentDir from the Pi coding-agent SDK', () => { + for (const source of [ + AGENT_DIR, + `import { getAgentDir as dir } from '@earendil-works/pi-coding-agent'`, + `import { 'getAgentDir' as dir } from '@earendil-works/pi-coding-agent'`, + `export { getAgentDir as dir } from '@earendil-works/pi-coding-agent'`, + ]) { + expect(findHostImportViolations(source, 'commands.js')).toEqual([]) + } + }) + + test('does not allow unknown SDK names or missing transcript helpers', () => { + for (const source of [ + `import { getAgentDir, unknown } from '@earendil-works/pi-coding-agent'`, + `${AGENT_DIR}; import { unknown } from '@earendil-works/pi-coding-agent'`, + `export { 'unknown' as helper } from '@earendil-works/pi-coding-agent'`, + `import { getAgentDir } from '@earendil-works/pi-ai'`, + `import { collapseSystemMessages } from '@earendil-works/pi-ai'`, + `import { normalizeContext } from '@earendil-works/pi-ai'`, + ]) { + expect( + findHostImportViolations(source, 'commands.js').length, + ).toBeGreaterThan(0) + } + }) + + test('does not allow opaque or side-effect coding-agent SDK imports in Pi', () => { + for (const source of [ + `import sdk from '@earendil-works/pi-coding-agent'`, + `import sdk, { getAgentDir } from '@earendil-works/pi-coding-agent'`, + `import * as sdk from '@earendil-works/pi-coding-agent'`, + `import '@earendil-works/pi-coding-agent'`, + `export * from '@earendil-works/pi-coding-agent'`, + `export * as sdk from '@earendil-works/pi-coding-agent'`, + `async function load() { return import('@earendil-works/pi-coding-agent') }`, + 'async function load() { return import(`@earendil-works/pi-coding-agent`) }', + `async function load(name) { return import(name) }`, + ]) { + expect( + findHostImportViolations(source, 'commands.js').length, + ).toBeGreaterThan(0) + } + }) + + test('keeps the full Pi dist scan while allowing getAgentDir in commands', async () => { + await withModules({ 'commands.js': AGENT_DIR }, async (root) => { + await expect(verifyPiDistRuntimeImports(root)).resolves.toContain( + '1 files', + ) + await mkdir(join(root, 'nested')) + await writeFile( + join(root, 'nested', 'unsupported.js'), + `import { collapseSystemMessages } from '@earendil-works/pi-ai'`, + ) + await expect(verifyPiDistRuntimeImports(root)).rejects.toThrow( + 'collapseSystemMessages', + ) + }) + }) + + test('rejects getAgentDir directly in the standalone CLI entry point', async () => { + await withModules({ 'cli.js': AGENT_DIR }, async (root) => { + await expect( + verifyStandaloneCliRuntimeImports(join(root, 'cli.js')), + ).rejects.toThrow( + "cli.js: standalone CLI must not import host Pi SDK '@earendil-works/pi-coding-agent'", + ) + }) + }) + + test('rejects otherwise-allowed Pi exports under every host scope in the CLI', async () => { + for (const scope of ['earendil-works', 'oh-my-pi', 'mariozechner']) { + for (const source of [ + `import { getAgentDir } from '@${scope}/pi-coding-agent'`, + `import { calculateCost, createAssistantMessageEventStream } from '@${scope}/pi-ai'`, + `export { getAgentDir } from '@${scope}/pi-coding-agent'`, + `import * as sdk from '@${scope}/pi-coding-agent'`, + `import sdk from '@${scope}/pi-coding-agent'`, + `import '@${scope}/pi-coding-agent'`, + `export * from '@${scope}/pi-coding-agent'`, + `async function load() { return import('@${scope}/pi-coding-agent') }`, + `import { unknown } from '@${scope}/pi-tui'`, + ]) { + await withModules({ 'cli.js': source }, async (root) => { + await expect( + verifyStandaloneCliRuntimeImports(join(root, 'cli.js')), + ).rejects.toThrow('standalone CLI must not import host Pi SDK') + }) + } + } + }) + + test('rejects getAgentDir transitively through relative imports and re-exports', async () => { + for (const bridge of [ + `import './nested/host.js'`, + `export { getAgentDir } from './nested/host.js'`, + `export * from './nested/host.js'`, + ]) { + await withModules( + { + 'cli.js': `import './bridge.js'`, + 'bridge.js': bridge, + 'nested/host.js': AGENT_DIR, + }, + async (root) => { + await expect( + verifyStandaloneCliRuntimeImports(join(root, 'cli.js')), + ).rejects.toThrow( + "nested/host.js: standalone CLI must not import host Pi SDK '@earendil-works/pi-coding-agent'", + ) + }, + ) + } + }) + + test('rejects getAgentDir in a constant dynamic chunk reached from a nested module', async () => { + await withModules( + { + 'cli.js': `export * from './nested/bridge.js'`, + 'nested/bridge.js': `export async function load() { return import('../host.js') }`, + 'host.js': AGENT_DIR, + }, + async (root) => { + await expect( + verifyStandaloneCliRuntimeImports(join(root, 'cli.js')), + ).rejects.toThrow( + "host.js: standalone CLI must not import host Pi SDK '@earendil-works/pi-coding-agent'", + ) + }, + ) + }) + + test('rejects unverifiable dynamic CLI edges instead of silently skipping them', async () => { + await withModules( + { 'cli.js': `export async function load(name) { return import(name) }` }, + async (root) => { + await expect( + verifyStandaloneCliRuntimeImports(join(root, 'cli.js')), + ).rejects.toThrow('runtime dynamic import cannot be verified') + }, + ) + }) + + test('ignores unrelated emitted plugin modules and handles shared cyclic chunks', async () => { + await withModules( + { + 'cli.js': `import './nested/bridge.js'; export * from './shared.js'`, + 'nested/bridge.js': `export * from '../shared.js'`, + 'shared.js': `import './nested/bridge.js'; import { readFile } from 'node:fs/promises'`, + 'plugin.js': AGENT_DIR, + }, + async (root) => { + await expect( + verifyStandaloneCliRuntimeImports(join(root, 'cli.js')), + ).resolves.toContain('3 reachable files') + }, + ) + }) + + test('fails closed for missing CLI entry points or reachable chunks', async () => { + await withModules({}, async (root) => { + await expect( + verifyStandaloneCliRuntimeImports(join(root, 'cli.js')), + ).rejects.toThrow('Missing standalone CLI module') + await writeFile(join(root, 'cli.js'), `import './missing.js'`) + await expect( + verifyStandaloneCliRuntimeImports(join(root, 'cli.js')), + ).rejects.toThrow('missing.js') + }) + }) +}) From c78d4bd4714c639db682a6b6d7a4c923c67cac02 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:33:35 +0200 Subject: [PATCH 148/169] mason: repair native login and setup recovery fixtures oauth-callback-corrupt-store: Claude Pro/Max authorize() now reads the native pool before the browser flow, so the fixture runs the real offline migration first (migrateNativeOpencodeFixture). The original claim is kept: a malformed retired legacy settings file must not discard the exchanged login. The test now uses an sk-ant-oat token and a bootstrap answer for exactly that token, and asserts that the host gets only the inert activation marker, the native pool authorizes the exchanged token under the verified UUID, and the retired file is left untouched. A new case covers a malformed authoritative pool: the callback rejects with publication-refused before any identity lookup, leaves the pool bytes unchanged, and returns no secrets. Neither case logs the malformed canary or the exchanged secrets. setup-command: setup --login now verifies identity before writing, and setup has no resolver override, so the test body answers Claude Code's bootstrap lookup for exactly the fresh synthetic token while setup runs. It also asserts that this is the only token sent for lookup. The body owns its fetch and temp-dir cleanup through TestLifetime. The preserved legacy credentials and the refused missing-UUID migration assertions are unchanged. --- .../oauth-callback-corrupt-store.test.ts | 429 ++++++++++++++---- .../opencode/src/tests/setup-command.test.ts | 236 ++++++---- 2 files changed, 471 insertions(+), 194 deletions(-) diff --git a/packages/opencode/src/tests/oauth-callback-corrupt-store.test.ts b/packages/opencode/src/tests/oauth-callback-corrupt-store.test.ts index 00bfc9ca2..9f1554032 100644 --- a/packages/opencode/src/tests/oauth-callback-corrupt-store.test.ts +++ b/packages/opencode/src/tests/oauth-callback-corrupt-store.test.ts @@ -1,116 +1,347 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { describe, expect } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' - +import { + __setLogTestSink, + createNativeAccountRuntime, + custodyTombstoneOAuth, + getLogLevel, + type LogTestRecord, + type NativePoolPaths, + type ProviderAccountUuid, + resetClaudeCodeIdentityCachesForTest, + setLogLevel, +} from '@cortexkit/anthropic-auth-core' +import type { Hooks, PluginInput } from '@opencode-ai/plugin' +import { createOpencodeClient } from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { AnthropicAuthPlugin } from '../index' +import { drainSidebarWrites } from '../sidebar-state.ts' +import { migrateNativeOpencodeFixture } from './native-fixture.ts' import { extractUrl, TOKEN_URL } from './test-fetch' +import { createTimerTracking } from './timer-tracking' + +// The Claude Pro/Max sign-in saves the exchanged login into the migrated +// native account pool and hands OpenCode only an inert activation marker. +// These tests corrupt a file between authorize() (which requires a committed +// local migration) and the pasted-code callback, and check which corruptions +// the callback may tolerate: +// - the retired legacy settings file is an optional import source after the +// migration, so a malformed copy must not discard the exchanged login; +// - the native pool is the authority for credentials, so a malformed pool +// must refuse the save rather than guess, and still never return the +// exchanged secrets to OpenCode's auth slot. +// Neither case may log the malformed bytes, which carry a secret-shaped canary. + +const lifetimes = createTestLifetimeSuite() +const test = lifetimes.test +const timerTracking = createTimerTracking() + +// Every path variable that migrateNativeOpencodeFixture returns in its env, +// plus the profile-hydration switch and OPENCODE_AUTH_CONTENT, which this file +// sets or clears. Saved before each body and restored by its cleanup. +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR', + 'OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR', + 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR', + 'CLAUDE_CONFIG_DIR', + 'OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', +] as const -// House pattern: stub `globalThis.fetch` to answer `TOKEN_URL` with the -// exchange result the callback under test consumes. A previous revision of -// this test replaced the core module wholesale; Bun's runtime cannot undo -// that replacement from `afterAll`, so the stub leaked into later files in -// the same process and answered another suite's exchange. The repo records -// the same lesson at `accounts.test.ts:6396` (Bun's `mock.restore()` does -// not undo a module-level replacement registered with the runtime's module -// mocking API). Stubbing `fetch` avoids the leak entirely — the real -// `exchange()` reads its result from the global fetch. -// -// Captured at module load (after `setup.ts` has installed the guarded fetch); -// restored after every test so the global `afterEach` in `setup.ts` never sees -// a leaked fetch mock and throws. -const originalFetch = globalThis.fetch - -describe('OAuth exchange callback — corrupt account store between authorize() and callback()', () => { - let tempDir: string - let accountPath: string - const previousEnv: Record = {} - - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'oauth-callback-corrupt-')) - accountPath = join(tempDir, 'anthropic-auth.json') - previousEnv.OPENCODE_ANTHROPIC_AUTH_FILE = - process.env.OPENCODE_ANTHROPIC_AUTH_FILE - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath +const EXCHANGED_ACCESS = 'sk-ant-oat01-synthetic-exchanged-access' +const EXCHANGED_REFRESH = 'synthetic-exchanged-refresh' +const EXCHANGED_UUID = '0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0' +const BOOTSTRAP_URL = 'https://api.anthropic.com/api/claude_cli/bootstrap' +// Truncated JSON (unparseable) that still carries a secret-shaped value, so a +// log line or error that echoes the raw bytes is detectable. +const MALFORMED_CANARY = 'sk-ant-oat01-synthetic-malformed-canary' +const MALFORMED_BYTES = `{"accounts":[{"type":"oauth","access":"${MALFORMED_CANARY}"` +const CALLBACK_INPUT = 'code=user-code&state=test-state' + +/** + * The context OpenCode passes a server plugin, with a real SDK client whose + * HTTP transport is answered in this process, so nothing leaves the test. + */ +function hostContext(directory: string): PluginInput { + const client = createOpencodeClient({ + baseUrl: 'http://opencode.invalid', + fetch: async () => Response.json({ name: 'NotFound' }, { status: 404 }), }) + return { + client, + project: { + id: 'oauth-callback-corrupt-store', + worktree: directory, + time: { created: Date.now() }, + }, + directory, + worktree: directory, + serverUrl: new URL('http://opencode.invalid'), + experimental_workspace: { register() {} }, + $, + } +} + +/** Plugin construction starts a fallback-account refresh and exposes its promise for tests. */ +function fallbackRefreshReady(hooks: Hooks): Promise { + const ready = + '__fallbackRefreshReady' in hooks ? hooks.__fallbackRefreshReady : undefined + if (!(ready instanceof Promise)) + throw new Error('Plugin did not expose its startup fallback refresh') + return ready +} - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }) - if (previousEnv.OPENCODE_ANTHROPIC_AUTH_FILE === undefined) { - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - } else { - process.env.OPENCODE_ANTHROPIC_AUTH_FILE = - previousEnv.OPENCODE_ANTHROPIC_AUTH_FILE +function proMaxMethod(plugin: Hooks) { + const method = plugin.auth?.methods.find( + (candidate) => candidate.label === 'Claude Pro/Max', + ) + if (method?.type !== 'oauth') + throw new Error('Plugin offers no Claude Pro/Max sign-in') + return method +} + +/** + * Migrate an empty legacy installation through the real offline migration, + * point this process at the result, answer the token exchange and the + * identity bootstrap for the synthetic login, and start one plugin. + * + * Lifetime cleanups run in registration order, and the fixture registers the + * removal of its root when it is called. Registering the teardown below first + * means the plugin is disposed and the process-wide fetch, env, and log hooks + * are restored before any fixture file is deleted. + */ +async function migratedSignIn() { + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + const savedFetch = globalThis.fetch + const savedLogLevel = getLogLevel() + const plugins = new Set() + lifetimes.deferCleanup(async () => { + const results = await Promise.allSettled( + [...plugins].map((plugin) => + Promise.resolve().then(() => plugin.dispose?.()), + ), + ) + plugins.clear() + try { + await drainSidebarWrites() + } finally { + timerTracking.reset() + __setLogTestSink(null) + setLogLevel(savedLogLevel) + resetClaudeCodeIdentityCachesForTest() + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } } - globalThis.fetch = originalFetch + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + 'Plugin disposal failed', + ) }) - test('returns the exchanged credentials even when the account file is unreadable/malformed at callback time', async () => { - // No file present yet — plugin init must succeed and authorize() must - // pass its own loadAccounts-based claustrum-mode check (line ~8751). - globalThis.fetch = mock((input: unknown) => { - const url = extractUrl(input as string | URL | Request) - if (url === TOKEN_URL) { - return Promise.resolve( - Response.json({ - access_token: 'exchanged-access', - refresh_token: 'exchanged-refresh', - expires_in: 3600, - }), - ) + const requests: string[] = [] + // The real exchange() and the real identity lookup both read the global + // fetch. Only the exact synthetic login token is recognised by bootstrap; + // any other request is a test failure, never a network call. + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ): Promise => { + const url = extractUrl(input) + requests.push(url.split('?')[0] ?? url) + if (url === TOKEN_URL) + return Response.json({ + access_token: EXCHANGED_ACCESS, + refresh_token: EXCHANGED_REFRESH, + expires_in: 3600, + }) + if (url.startsWith(`${BOOTSTRAP_URL}?`)) { + const authorization = new Headers( + init?.headers ?? + (input instanceof Request ? input.headers : undefined), + ).get('authorization') + if (authorization !== `Bearer ${EXCHANGED_ACCESS}`) + return new Response('unauthorized', { status: 401 }) + return Response.json({ + oauth_account: { account_uuid: EXCHANGED_UUID }, + }) } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch - - const plugin = (await ( - AnthropicAuthPlugin as unknown as ( - context: unknown, - runtime: unknown, - ) => Promise - )( - { - client: { - auth: { set: mock(() => Promise.resolve()) }, - session: { promptAsync: mock(() => Promise.resolve()) }, - }, + throw new Error(`Unexpected test fetch: ${url}`) + }, + { preconnect: savedFetch.preconnect }, + ) + resetClaudeCodeIdentityCachesForTest() + timerTracking.reset() + + const root = await mkdtemp(join(tmpdir(), 'oauth-callback-corrupt-')) + // migrateNativeOpencodeFixture schedules removal of the directory only after + // it accepts it. Remove it here too, so a refused directory is not left behind. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [], + }, + }) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + // Startup otherwise fetches each OAuth account's profile in the background; + // that request is unrelated to the sign-in under test. + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + delete process.env.OPENCODE_AUTH_CONTENT + + const creation = AnthropicAuthPlugin(hostContext(fixture.root), { + ...timerTracking.disabledPluginTimerOverrides(), + authorize: async () => ({ + url: 'https://example.com/oauth/authorize', + verifier: 'test-verifier', + redirectUri: 'https://example.com/callback', + state: 'test-state', + }), + }) + lifetimes.trackDetached(creation) + const plugin = await creation + plugins.add(plugin) + lifetimes.trackDetached(fallbackRefreshReady(plugin)) + + const records: LogTestRecord[] = [] + // Capture every level, so a debug line echoing the malformed bytes is seen too. + setLogLevel('trace') + __setLogTestSink((record) => { + records.push(record) + }) + return { fixture, plugin, requests, records } +} + +/** Read the pool back through a fresh runtime that must not refresh or re-identify another token. */ +async function storedMainLogin(paths: NativePoolPaths) { + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + local: { + refreshToken: async () => { + throw new Error('Unexpected token refresh after fresh sign-in') }, - { - authorize: async () => ({ - url: 'https://example.com/oauth/authorize', - verifier: 'test-verifier', - redirectUri: 'https://example.com/callback', - state: 'test-state', - }), - setInterval: mock( - () => ({ unref() {} }) as unknown as ReturnType, - ) as unknown as typeof setInterval, - clearInterval: mock(() => {}) as unknown as typeof clearInterval, + resolveIdentity: async (access) => { + expect(access).toBe(EXCHANGED_ACCESS) + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: EXCHANGED_UUID as ProviderAccountUuid, + } }, - )) as any + }, + }) + try { + const snapshot = await runtime.read() + const main = snapshot.accounts.find((account) => account.id === 'main') + const authorized = await runtime.authorizeLocal('main') + return { main, authorized } + } finally { + runtime.close() + } +} + +function expectNoSecretIn(text: string) { + expect(text).not.toContain(MALFORMED_CANARY) + expect(text).not.toContain(EXCHANGED_ACCESS) + expect(text).not.toContain(EXCHANGED_REFRESH) +} + +describe('OAuth exchange callback — corrupt account files between authorize() and callback()', () => { + test('a malformed retired legacy settings file does not discard the exchanged native login', async () => { + const { fixture, plugin, requests, records } = await migratedSignIn() + const flow = await proMaxMethod(plugin).authorize() + if (flow.method !== 'code') + throw new Error('Expected code-based OAuth sign-in') + + // Corrupt the retired legacy settings AFTER authorize() returned its flow + // and BEFORE the user pastes the code: the re-login recovery scenario of a + // partially written or damaged file at the moment the callback runs. + await writeFile(fixture.paths.legacyConfig, MALFORMED_BYTES, 'utf8') + + const result = await flow.callback(CALLBACK_INPUT) + + // OpenCode stores only the inert activation marker; the exchanged + // credentials live in the native pool. + const activation = custodyTombstoneOAuth('anthropic') + expect(result).toEqual({ + type: 'success', + access: activation.access, + refresh: activation.refresh, + expires: activation.expires, + }) + expectNoSecretIn(JSON.stringify(result)) + + // The exchange ran once and the identity was verified for exactly the + // exchanged token before it was saved. + expect(requests).toEqual([TOKEN_URL, BOOTSTRAP_URL]) + + const { main, authorized } = await storedMainLogin(fixture.paths) + expect(main?.type).toBe('oauth') + expect(main?.accountIdentity).toBe(EXCHANGED_UUID) + expect(authorized).toMatchObject({ status: 'usable' }) + if (authorized.status !== 'usable') + throw new Error('Exchanged login did not authorize from the native pool') + expect(authorized.access).toBe(EXCHANGED_ACCESS) + expect(authorized.subject.binding.identity).toBe(EXCHANGED_UUID) - const method = plugin.auth.methods.find( - (m: { label?: string }) => m.label === 'Claude Pro/Max', + // The retired file is never rewritten, and nothing logged its bytes. + expect(await readFile(fixture.paths.legacyConfig, 'utf8')).toBe( + MALFORMED_BYTES, ) - expect(method).toBeDefined() - const flow = await method.authorize() - expect(flow.callback).toBeDefined() - - // Corrupt the account store AFTER authorize() returned its flow, but - // BEFORE the user pastes their code. This is the re-login recovery - // scenario the original bug describes: a partially-written or corrupt - // file at the moment the callback reads it. - await writeFile(accountPath, '{not valid json', 'utf8') - - // Discriminate the failure mode: the bug rejects the callback with the - // loadAccounts error, which loses the exchanged credentials. The fix - // must return the exchange result to the caller. - const result = await flow.callback('code=user-code&state=test-state') - - expect(result).toBeDefined() - expect(result.type).toBe('success') - if (result.type === 'success') { - expect(result.access).toBe('exchanged-access') - expect(result.refresh).toBe('exchanged-refresh') - } + expectNoSecretIn(JSON.stringify(records)) + }) + + test('a malformed native pool refuses the save and returns no credentials to OpenCode', async () => { + const { fixture, plugin, requests, records } = await migratedSignIn() + const flow = await proMaxMethod(plugin).authorize() + if (flow.method !== 'code') + throw new Error('Expected code-based OAuth sign-in') + + // The pool config is the credential authority once the migration has + // committed; a damaged copy must not be replaced by a guess. + await writeFile(fixture.paths.config, MALFORMED_BYTES, 'utf8') + + const outcome = await flow.callback(CALLBACK_INPUT).then( + (result) => ({ settled: 'resolved' as const, value: result }), + (error: unknown) => ({ settled: 'rejected' as const, value: error }), + ) + // The callback must not report success: OpenCode would otherwise record + // a sign-in that the pool never stored. + expect(outcome.settled).toBe('rejected') + // The refusal comes from reading the damaged pool, before the exchanged + // token is sent anywhere for the identity lookup. + expect(outcome.value).toMatchObject({ + name: 'NativeRuntimeError', + code: 'publication-refused', + }) + expect(requests).toEqual([TOKEN_URL]) + if (!(outcome.value instanceof Error)) + throw new Error('Callback rejected with a non-Error value') + expectNoSecretIn(outcome.value.message) + expectNoSecretIn(String(outcome.value.stack)) + + // The damaged authority is left for the user to repair, untouched. + expect(await readFile(fixture.paths.config, 'utf8')).toBe(MALFORMED_BYTES) + expectNoSecretIn(JSON.stringify(records)) }) }) diff --git a/packages/opencode/src/tests/setup-command.test.ts b/packages/opencode/src/tests/setup-command.test.ts index fd775b454..2f2c7bd75 100644 --- a/packages/opencode/src/tests/setup-command.test.ts +++ b/packages/opencode/src/tests/setup-command.test.ts @@ -8,11 +8,16 @@ import { createNativeAccountRuntime, type ProviderAccountUuid, readNativeMigrationJournal, + resetClaudeCodeIdentityCachesForTest, runNativeMigration, } from '@cortexkit/anthropic-auth-core' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { runSetupCommand } from '../setup/command.ts' import { resolveNativeSetupPaths } from '../setup/native-paths.ts' import type { CommandRunner, ProcessFence } from '../setup/types.ts' +import { extractUrl } from './test-fetch' + +const lifetimes = createTestLifetimeSuite() const testDirs: string[] = [] afterEach(async () => { @@ -686,103 +691,144 @@ test.each(['pending', 'blocked'] as const)( }, ) -test('explicit offline recovery login is performed before setup reports completion', async () => { - const root = await mkdtemp(join(tmpdir(), 'setup-offline-login-')) - testDirs.push(root) - const env: NodeJS.ProcessEnv = { - HOME: root, - XDG_CONFIG_HOME: join(root, '.config'), - XDG_DATA_HOME: join(root, '.local', 'share'), - PI_CODING_AGENT_DIR: join(root, 'absent-pi'), - OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( - root, - 'absent-connection', - ), - } - const plan = await resolveNativeSetupPaths('opencode', env) - await mkdir(dirname(plan.hostAuthPath), { recursive: true, mode: 0o700 }) - const oldAuth = JSON.stringify({ - anthropic: { - type: 'oauth', - access: 'sk-ant-oat01-old-unidentified', - refresh: 'old-refresh', - expires: Date.now() + 24 * 60 * 60_000, - }, - }) - await writeFile(plan.hostAuthPath, oldAuth, { mode: 0o600 }) - await expect( - runNativeMigration({ - ...plan, - env, - processFence: async () => {}, - requestVaultActivation: true, - removePiAnthropicAuth: false, - }), - ).rejects.toThrow('setup --login --no-claustrum') - expect(await readFile(plan.hostAuthPath, 'utf8')).toBe(oldAuth) - expect(await readNativeMigrationJournal(plan.paths)).toBeUndefined() - let logins = 0 - const options = { - env, - fence: createTestFence(), - runner: createMockRunner({ - opencode: () => ({ exitCode: 0, stdout: '1.18.30\n', stderr: '' }), - pi: () => ({ exitCode: 1, stdout: '', stderr: '' }), - ck: () => ({ exitCode: 1, stdout: '', stderr: '' }), - }), - localLogin: async () => { - logins++ - return { - accountIdentity: 'aabbccdd-1122-3344-5566-778899001122', - credential: { - type: 'oauth' as const, - access: 'sk-ant-oat01-fresh-main', - refresh: 'fresh-refresh', - expires: Date.now() + 24 * 60 * 60_000, - }, - } - }, - } - expect( - await runSetupCommand(['--yes', '--no-claustrum', '--login'], options), - ).toBe(0) - expect(logins).toBe(1) - const runtime = createNativeAccountRuntime({ - paths: plan.paths, - host: 'opencode', - local: { - refreshToken: async () => { - throw new Error('Unexpected token refresh after fresh sign-in') +lifetimes.test( + 'explicit offline recovery login is performed before setup reports completion', + async () => { + const FRESH_ACCESS = 'sk-ant-oat01-fresh-main' + const FRESH_UUID = 'aabbccdd-1122-3344-5566-778899001122' + const root = await mkdtemp(join(tmpdir(), 'setup-offline-login-')) + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + // Setup saves the sign-in only after the account's identity is looked up + // for the exchanged token itself; the UUID the sign-in step reports is not + // proof. Setup has no identity-lookup override, so answer the real lookup + // (Claude Code's bootstrap request) for exactly the fresh synthetic token. + // Any other token is refused, and any other request fails the test. + const savedFetch = globalThis.fetch + const identityLookups: string[] = [] + lifetimes.deferCleanup(() => { + globalThis.fetch = savedFetch + resetClaudeCodeIdentityCachesForTest() + }) + resetClaudeCodeIdentityCachesForTest() + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ): Promise => { + const url = new URL(extractUrl(input)) + if ( + url.origin !== 'https://api.anthropic.com' || + url.pathname !== '/api/claude_cli/bootstrap' + ) + throw new Error(`Unexpected setup fetch: ${url.href}`) + const authorization = + new Headers( + init?.headers ?? + (input instanceof Request ? input.headers : undefined), + ).get('authorization') ?? '' + identityLookups.push(authorization.replace(/^Bearer /, '')) + if (authorization !== `Bearer ${FRESH_ACCESS}`) + return new Response('unauthorized', { status: 401 }) + return Response.json({ oauth_account: { account_uuid: FRESH_UUID } }) }, - resolveIdentity: async (access) => { - expect(access).toBe('sk-ant-oat01-fresh-main') + { preconnect: savedFetch.preconnect }, + ) + const env: NodeJS.ProcessEnv = { + HOME: root, + XDG_CONFIG_HOME: join(root, '.config'), + XDG_DATA_HOME: join(root, '.local', 'share'), + PI_CODING_AGENT_DIR: join(root, 'absent-pi'), + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: join( + root, + 'absent-connection', + ), + } + const plan = await resolveNativeSetupPaths('opencode', env) + await mkdir(dirname(plan.hostAuthPath), { recursive: true, mode: 0o700 }) + const oldAuth = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'sk-ant-oat01-old-unidentified', + refresh: 'old-refresh', + expires: Date.now() + 24 * 60 * 60_000, + }, + }) + await writeFile(plan.hostAuthPath, oldAuth, { mode: 0o600 }) + await expect( + runNativeMigration({ + ...plan, + env, + processFence: async () => {}, + requestVaultActivation: true, + removePiAnthropicAuth: false, + }), + ).rejects.toThrow('setup --login --no-claustrum') + expect(await readFile(plan.hostAuthPath, 'utf8')).toBe(oldAuth) + expect(await readNativeMigrationJournal(plan.paths)).toBeUndefined() + let logins = 0 + const options = { + env, + fence: createTestFence(), + runner: createMockRunner({ + opencode: () => ({ exitCode: 0, stdout: '1.18.30\n', stderr: '' }), + pi: () => ({ exitCode: 1, stdout: '', stderr: '' }), + ck: () => ({ exitCode: 1, stdout: '', stderr: '' }), + }), + localLogin: async () => { + logins++ return { - deviceId: 'synthetic-device', - sessionId: 'synthetic-session', - accountUuid: - 'aabbccdd-1122-3344-5566-778899001122' as ProviderAccountUuid, + accountIdentity: FRESH_UUID, + credential: { + type: 'oauth' as const, + access: FRESH_ACCESS, + refresh: 'fresh-refresh', + expires: Date.now() + 24 * 60 * 60_000, + }, } }, - }, - }) - try { - const snapshot = await runtime.read() + } expect( - snapshot.accounts.find((account) => account.id === 'main') - ?.accountIdentity, - ).toBe('aabbccdd-1122-3344-5566-778899001122') - expect((await readNativeMigrationJournal(plan.paths))?.phase).toBe( - 'retired', - ) - const authorized = await runtime.authorizeLocal('main') - expect(authorized).toMatchObject({ status: 'usable' }) - if (authorized.status !== 'usable') - throw new Error('Fresh login did not authorize') - expect(authorized.access).toBe('sk-ant-oat01-fresh-main') - expect(authorized.subject.binding.identity).toBe( - 'aabbccdd-1122-3344-5566-778899001122', - ) - } finally { - runtime.close() - } -}) + await runSetupCommand(['--yes', '--no-claustrum', '--login'], options), + ).toBe(0) + expect(logins).toBe(1) + // The fresh token's identity was looked up while setup ran, before it + // reported completion. The offline migration itself sent no token anywhere, + // so the old unidentified login was never presented for lookup. + expect(identityLookups).toEqual([FRESH_ACCESS]) + const runtime = createNativeAccountRuntime({ + paths: plan.paths, + host: 'opencode', + local: { + refreshToken: async () => { + throw new Error('Unexpected token refresh after fresh sign-in') + }, + resolveIdentity: async (access) => { + expect(access).toBe(FRESH_ACCESS) + return { + deviceId: 'synthetic-device', + sessionId: 'synthetic-session', + accountUuid: FRESH_UUID as ProviderAccountUuid, + } + }, + }, + }) + try { + const snapshot = await runtime.read() + expect( + snapshot.accounts.find((account) => account.id === 'main') + ?.accountIdentity, + ).toBe(FRESH_UUID) + expect((await readNativeMigrationJournal(plan.paths))?.phase).toBe( + 'retired', + ) + const authorized = await runtime.authorizeLocal('main') + expect(authorized).toMatchObject({ status: 'usable' }) + if (authorized.status !== 'usable') + throw new Error('Fresh login did not authorize') + expect(authorized.access).toBe(FRESH_ACCESS) + expect(authorized.subject.binding.identity).toBe(FRESH_UUID) + } finally { + runtime.close() + } + }, +) From 2e3ece14df6ffb06b49e2f1a611232704bf149c7 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:49:32 +0200 Subject: [PATCH 149/169] fix: refuse native custody markers before refresh bookkeeping --- .../core/src/native-refresh-coordinator.ts | 5 ++ .../tests/native-refresh-coordinator.test.ts | 57 +++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index 90be2ab16..eeb8b29f9 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -18,6 +18,7 @@ import { import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from './auth.ts' import { resolveClaudeCodeIdentity } from './claude-code.ts' +import { isCustodyTombstoneValue } from './claustrum.ts' import { type NativeLocalCredentialValidation, type NativeLocalCredentialVersion, @@ -913,6 +914,10 @@ export function createNativeRefreshCoordinator( captured = row fingerprint = row.fingerprint credentialVersion = dispatchVersion(credential) + // Activation markers are not refresh credentials. Refuse them + // before the provider call so they cannot create an auth backoff. + if (isCustodyTombstoneValue(credential.refresh)) + throw new LocalRefreshRefusal('custody-tombstone') // This read is outside pool locks. Recheck policy after file-lock waits. policy = await readPolicy(subjectOf(binding, row)) context = policy.context diff --git a/packages/core/src/tests/native-refresh-coordinator.test.ts b/packages/core/src/tests/native-refresh-coordinator.test.ts index 406538806..9e6935047 100644 --- a/packages/core/src/tests/native-refresh-coordinator.test.ts +++ b/packages/core/src/tests/native-refresh-coordinator.test.ts @@ -38,14 +38,12 @@ import { type PoolRow, } from '@cortexkit/common-auth/store' -import { - ClaudeOAuthRefreshError, - type refreshClaudeOAuthToken, -} from '../auth.ts' +import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from '../auth.ts' import type { ProviderAccountUuid, resolveClaudeCodeIdentity, } from '../claude-code.ts' +import { custodyTombstoneKey } from '../claustrum.ts' import { type NativeLocalCredentialValidation, nativeLocalCredentialValidationMatches, @@ -3896,3 +3894,54 @@ for (const initialStamp of ['absent', 'zero'] as const) { expect(f.proofs.size).toBe(0) }) } + +for (const refreshToken of [ + custodyTombstoneKey('anthropic'), + custodyTombstoneKey('openai'), + custodyTombstoneKey('unrecognized'), +]) { + test(`native refresh refuses ${refreshToken} without provider failure bookkeeping`, async () => { + const f = await fixture() + await f.store.add({ + id: 'sentinel', + identity: 'account-main', + credential: { ...credential('sentinel'), refresh: refreshToken }, + }) + const before = await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]) + let providerCalls = 0 + const coordinator = f.coordinator({ + refreshToken: async (input) => { + providerCalls++ + return refreshClaudeOAuthToken(input) + }, + }) + const result = await coordinator.refresh({ + mode: 'local', + binding: await f.binding('sentinel'), + }) + expect(result).toEqual({ + status: 'refused', + reason: 'custody-tombstone', + persisted: false, + }) + expect(providerCalls).toBe(0) + expect(f.observations).toHaveLength(1) + expect(f.observations[0]).toMatchObject({ + status: 'refused', + reason: 'custody-tombstone', + consumed: false, + }) + expect(f.observations.some((event) => event.status === 'failed')).toBe( + false, + ) + expect( + await Promise.all([ + readFile(f.paths.config, 'utf8'), + readFile(f.paths.state, 'utf8'), + ]), + ).toEqual(before) + }) +} From 69ae2e00ade64309a3bef002de239598ed12ce7a Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:50:31 +0200 Subject: [PATCH 150/169] mason: drive custody tombstone plugin tests through native boundaries Five plugin-level tests seeded only a legacy account file and loaded the plugin with no native migration journal, so each failed with migration-required before reaching the boundary it names. Each now commits a real pool through migrateNativeOpencodeFixture, applies the fixture env, and owns its plugins, clock, fetch, env and directory via TestLifetime cleanup. Expectations changed because the old ones described pre-native behavior: - sentinel access: the bearer now comes from the vault, so a tombstone in the vault material is refused (NativeCustodyError invalid-material) after the vault read and before any outbound request. - local main tombstone: the exact placeholder activates the local pool rather than failing closed with custody_state_mismatch; the empty pool refuses with 'Native OAuth account is unavailable', no network, no accounts recorded. - foreign-provider tombstone: a real rotation stores another provider's tombstone as the refresh successor; after expiry the refresh is refused locally (authorization 'refused', not a failed token refresh), with no token call and no lastRefreshError. Depends on the coordinator guard in 2e3ece1. - reconciliation: vault authority comes from the enrollment token. A vault account replaced behind the default credential is refused as identity-changed with no provider traffic, then runtime.vault.refresh() rebinds main and the next request serves the vault bearer. - Pro/Max: a committed local pool starts sign-in; writing the owner-only enrollment token alone makes it refuse with 'Disconnect from the vault first.' before authorization. --- .../src/tests/custody-tombstone.test.ts | 857 ++++++++++++------ 1 file changed, 582 insertions(+), 275 deletions(-) diff --git a/packages/opencode/src/tests/custody-tombstone.test.ts b/packages/opencode/src/tests/custody-tombstone.test.ts index d9c060add..d56e191ef 100644 --- a/packages/opencode/src/tests/custody-tombstone.test.ts +++ b/packages/opencode/src/tests/custody-tombstone.test.ts @@ -1,28 +1,38 @@ import { afterEach, describe, expect, mock, test } from 'bun:test' -import { existsSync, readFileSync } from 'node:fs' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { assertNotCustodyTombstone, buildRefreshOperationError, CustodyTombstoneRefreshError, + createNativeAccountRuntime, custodyTombstoneKey, custodyTombstoneOAuth, FallbackAccountManager, fetchOAuthAccountProfile, fetchOAuthQuotaSnapshot, - getAccountStatePath, getFallbackReauthLabels, isCustodyTombstoneOAuth, isPermanentRefreshError, loadAccounts, + type NativeCustodyClient, refreshClaudeOAuthToken, + resetClaudeCodeIdentityCachesForTest, saveAccounts, } from '@cortexkit/anthropic-auth-core' - +import { + type Auth, + createOpencodeClient, + type Provider, +} from '@opencode-ai/sdk' +import { $ } from 'bun' +import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { AnthropicAuthPlugin } from '../index' +import { drainSidebarWrites } from '../sidebar-state' import { setOAuthHeaders } from '../transform' +import { migrateNativeOpencodeFixture } from './native-fixture' import { extractUrl, TOKEN_URL } from './test-fetch' const fixtureDir = join(import.meta.dir, 'fixtures', 'claustrum-golden') @@ -39,22 +49,320 @@ const oauthFixture = tombstoneFixture.fixtures.oauth const apiFixture = tombstoneFixture.fixtures.api const originalFetch = globalThis.fetch -function createMockClient() { +type PluginHooks = Awaited> +type PluginOptions = Parameters[1] +type NativeFixture = Awaited> +type OutboundRecord = { url: string; authorization: string } + +const HOUR_MS = 60 * 60_000 +const MAIN_IDENTITY = '11111111-1111-4111-8111-111111111111' +const REPLACEMENT_IDENTITY = '33333333-3333-4333-8333-333333333333' +const VAULT_MAIN_CREDENTIAL = 'oauth:anthropic' +const anthropicProvider: Provider = { + id: 'anthropic', + name: 'Anthropic', + source: 'custom', + env: [], + options: {}, + models: {}, +} + +// Every environment variable a native test body or the migration fixture may +// set. Each body saves and restores all of them, so no path leaks into the +// next test. +const nativeEnvKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_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_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', + 'CLAUDE_CONFIG_DIR', + 'XDG_DATA_HOME', +] as const + +async function disposeAll(plugins: PluginHooks[], message: string) { + const results = await Promise.allSettled( + plugins.map((plugin) => Promise.resolve().then(() => plugin.dispose?.())), + ) + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length) + throw new AggregateError( + failed.map((result) => result.reason), + message, + ) +} + +/** + * Claim a fresh owner-only directory for this body. Teardown first disposes + * the body's plugins and waits for sidebar writes, then restores fetch, the + * clock, the environment and identity caches, and only then removes the + * directory. + */ +async function startNativeBody() { + const savedFetch = globalThis.fetch + const savedNow = Date.now + const savedEnv = new Map(nativeEnvKeys.map((key) => [key, process.env[key]])) + const plugins = new Set() + livePlugins.add(plugins) + lifetimes.deferCleanup(async () => { + const remaining = [...plugins] + plugins.clear() + livePlugins.delete(plugins) + try { + await disposeAll(remaining, 'Custody tombstone plugin cleanup failed') + } finally { + await drainSidebarWrites() + globalThis.fetch = savedFetch + Date.now = savedNow + for (const key of nativeEnvKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + resetClaudeCodeIdentityCachesForTest() + } + }) + delete process.env.OPENCODE_AUTH_CONTENT + resetClaudeCodeIdentityCachesForTest() + const root = await mkdtemp(join(tmpdir(), 'anthropic-custody-native-')) + // migrateNativeOpencodeFixture also removes the root once it accepts it; + // this covers a body that fails before handing the root to it. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + return { root, plugins } +} + +/** + * Replace fetch with a synthetic provider that records every outbound request. + * The token endpoint answers with the caller's rotation, bootstrap names the + * main account, and any other unexpected path fails the request. + */ +function recordNetwork( + options: { token?: () => Record } = {}, +): OutboundRecord[] { + const records: OutboundRecord[] = [] + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ): Promise => { + const url = extractUrl(input) + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ) + records.push({ url, authorization: headers.get('authorization') ?? '' }) + if (url === TOKEN_URL && options.token) + return Response.json(options.token()) + if (url.includes('/api/claude_cli/bootstrap')) + return Response.json({ oauth_account: { account_uuid: MAIN_IDENTITY } }) + if (url.includes('/v1/messages')) + return Response.json({ + id: 'synthetic-message', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }) + throw new Error(`Unexpected synthetic provider path: ${url}`) + }, + { preconnect: originalFetch.preconnect }, + ) + return records +} + +function applyFixtureEnv(fixture: NativeFixture) { + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + // OpenCode's own login store for this body, never the user's. + process.env.XDG_DATA_HOME = join(fixture.root, 'opencode-data') +} + +/** Commit a local native pool through the real offline migration. */ +async function migrateLocalPool( + root: string, + hostAuth: Record, +): Promise { + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: { + version: 1, + refresh: { enabled: false }, + quota: { enabled: false }, + routing: { mode: 'main-first' }, + accounts: [], + }, + hostAuth, + }) + applyFixtureEnv(fixture) + expect((await readNativeAccounts(fixture)).mode).toBe('local') + return fixture +} + +function vaultMainRow(accountId: string) { return { - auth: { set: mock(() => Promise.resolve()) }, - session: { promptAsync: mock(() => Promise.resolve()) }, + id: VAULT_MAIN_CREDENTIAL, + accountId, + credentialType: 'oauth', + state: 'active' as const, + categories: ['anthropic-native'], + serves: ['anthropic'], + providerIds: [], + refreshAdapter: 'anthropic', + recordVersion: 1, + operations: ['read'], + createdAtMs: null, } } -function disabledTimerOverrides() { - return { - setInterval: mock( - () => ({ unref() {} }) as unknown as ReturnType, - ) as unknown as typeof setInterval, - clearInterval: mock(() => {}) as unknown as typeof clearInterval, +/** + * Commit a vault pool through the real offline migration. The fixture writes + * this host's owner-only enrollment token, which alone selects the vault. + * Discovery lists the given rows and refuses credential reads, so every vault + * read a test observes happened while serving. + */ +async function migrateVaultPool( + root: string, + rows: ReturnType[], +): Promise { + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: { + version: 1, + refresh: { enabled: false }, + quota: { enabled: false }, + accounts: [], + claustrum: { + mode: 'claustrum', + scopedRoster: true, + primaryAccount: { + credentialId: VAULT_MAIN_CREDENTIAL, + accountId: MAIN_IDENTITY, + state: 'active', + }, + }, + }, + hostAuth: { anthropic: custodyTombstoneOAuth(oauthFixture.provider) }, + custody: { + connect: async () => ({ + async listScoped() { + return { view: 'custody-tombstone-migration', rows } + }, + async getScoped() { + throw new Error('Migration discovery must not read credentials') + }, + async reportAuthFailureScoped() { + throw new Error('Migration discovery must not report credentials') + }, + close() {}, + }), + enrollment: { token: 'ab'.repeat(32), token_generation: 1 }, + }, + }) + applyFixtureEnv(fixture) + expect((await readNativeAccounts(fixture)).mode).toBe('claustrum') + return fixture +} + +async function readNativeAccounts(fixture: NativeFixture) { + const runtime = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + }) + try { + return await runtime.read() + } finally { + runtime.close() } } +async function createNativePlugin( + plugins: Set, + root: string, + options: PluginOptions = {}, +) { + const creation = AnthropicAuthPlugin( + { + client: createOpencodeClient({ + baseUrl: 'http://127.0.0.1:9', + fetch: globalThis.fetch, + }), + project: { + id: 'custody-tombstone-fixture', + worktree: root, + time: { created: Date.now() }, + }, + directory: root, + worktree: root, + serverUrl: new URL('http://127.0.0.1:9'), + experimental_workspace: { register() {} }, + $, + }, + { + scopedRosterPollIntervalMs: 0, + cacheKeepAggregateRefreshIntervalMs: 0, + ...options, + }, + ) + lifetimes.trackDetached(creation) + const plugin = await creation + plugins.add(plugin) + return plugin +} + +async function disposePlugin(plugins: Set, plugin: PluginHooks) { + plugins.delete(plugin) + await plugin.dispose?.() +} + +/** Load the plugin with OpenCode's current Anthropic slot and send one model request. */ +async function sendMessage( + plugin: PluginHooks, + activation: Auth = custodyTombstoneOAuth(oauthFixture.provider), +): Promise { + const loader = plugin.auth?.loader + if (!loader) throw new Error('Plugin did not install its auth loader') + const options: Record = await loader( + async () => structuredClone(activation), + anthropicProvider, + ) + const handler = options.fetch + if (typeof handler !== 'function') + throw new Error('Plugin did not install its request handler') + const response: unknown = await handler( + 'https://api.anthropic.com/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4-6', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + }, + ) + if (!(response instanceof Response)) + throw new Error('Plugin request handler did not return a Response') + return response +} + +function proMaxMethod(plugin: PluginHooks) { + const method = plugin.auth?.methods.find( + (candidate) => candidate.label === 'Claude Pro/Max', + ) + if (method?.type !== 'oauth') + throw new Error('Plugin offers no Claude Pro/Max sign-in') + return method +} + async function createTempStorage( storage: Record, callback: (path: string) => Promise, @@ -77,6 +385,20 @@ afterEach(() => { globalThis.fetch = originalFetch }) +const livePlugins = new Set>() +// Registered before the lifetime suite's own afterEach: dispose every native +// plugin (its timers and vault client) before that hook waits for a possibly +// still-running body and removes the body's files. +afterEach(async () => { + const plugins = [...livePlugins].flatMap((owned) => { + const current = [...owned] + owned.clear() + return current + }) + await disposeAll(plugins, 'Custody tombstone plugin cancellation failed') +}) +const lifetimes = createTestLifetimeSuite() + describe('Claustrum custody tombstones', () => { test('recognizes only provider-bound OAuth tombstones', () => { const provider = oauthFixture.provider @@ -202,65 +524,47 @@ describe('Claustrum custody tombstones', () => { expect(tokenEndpointCalls).toEqual([]) }) - test('refuses sentinel access during direct requests', async () => { - const messageAuthorizations: string[] = [] - globalThis.fetch = mock((input: unknown, init?: RequestInit) => { - const url = extractUrl(input as string | URL | Request) - if (url.includes('/v1/messages')) { - messageAuthorizations.push( - new Headers(init?.headers).get('authorization') ?? '', - ) - } - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch - - await createTempStorage( - { - version: 1, - main: { type: 'opencode', provider: oauthFixture.provider }, - refresh: { enabled: false }, - quota: { enabled: false }, - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - access: 'fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 60_000, - }, - ], + lifetimes.test('refuses sentinel access during direct requests', async () => { + const { root, plugins } = await startNativeBody() + const outbound = recordNetwork() + const fixture = await migrateVaultPool(root, [vaultMainRow(MAIN_IDENTITY)]) + expect( + (await readNativeAccounts(fixture)).accounts.find( + (account) => account.id === 'main', + )?.credentialId, + ).toBe(VAULT_MAIN_CREDENTIAL) + // Each direct request reads its credential from the vault, and this read + // answers with a tombstone instead of an access token. + const vaultReads: string[] = [] + const sentinelVault: NativeCustodyClient = { + async listScoped() { + return { + view: 'custody-tombstone', + rows: [vaultMainRow(MAIN_IDENTITY)], + } }, - async () => { - const auth = { - type: 'oauth', - access: 'ordinary-access', - refresh: 'ordinary-refresh', - expires: Date.now() + 60_000, + async getScoped({ credentialId }) { + vaultReads.push(credentialId) + return { + credentialId, + material: custodyTombstoneKey(oauthFixture.provider), + accountId: MAIN_IDENTITY, + recordVersion: 1, + expiresAtMs: Date.now() + 8 * HOUR_MS, } - const plugin = (await AnthropicAuthPlugin( - // @ts-expect-error: minimal mock for testing - { client: createMockClient() }, - disabledTimerOverrides(), - )) as any - const loaded = await plugin.auth.loader(() => Promise.resolve(auth), { - models: {}, - } as never) - auth.access = custodyTombstoneKey(oauthFixture.provider) - - await expect( - loaded.fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - body: JSON.stringify({ - model: 'claude-sonnet-4-5', - max_tokens: 1, - messages: [{ role: 'user', content: 'hello' }], - }), - }), - ).rejects.toBeInstanceOf(CustodyTombstoneRefreshError) - expect(messageAuthorizations).toEqual([]) - await plugin.dispose?.() }, - ) + async reportAuthFailureScoped() {}, + close() {}, + } + const plugin = await createNativePlugin(plugins, root, { + claustrumScopedConnect: async () => sentinelVault, + }) + + await expect(sendMessage(plugin)).rejects.toMatchObject({ + code: 'invalid-material', + }) + expect(vaultReads).toContain(VAULT_MAIN_CREDENTIAL) + expect(outbound).toEqual([]) }) test('refuses a tombstone before the quota poll fetch', async () => { @@ -307,224 +611,227 @@ describe('Claustrum custody tombstones', () => { ).toThrow(CustodyTombstoneRefreshError) }) - test('holds a local main tombstone dark without network or refresh state', async () => { - const fetchCalls: string[] = [] - globalThis.fetch = mock((input: unknown) => { - const url = extractUrl(input as string | URL | Request) - fetchCalls.push(url) - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch - - await createTempStorage( - { - version: 1, - main: { type: 'opencode', provider: oauthFixture.provider }, - refresh: { enabled: false }, - quota: { enabled: false }, - accounts: [], - }, - async (path) => { - const plugin = (await AnthropicAuthPlugin( - // @ts-expect-error: minimal mock for testing - { client: createMockClient() }, - disabledTimerOverrides(), - )) as any - const loaded = await plugin.auth.loader( - () => Promise.resolve(oauthFixture.entry as never), - { models: {} } as never, - ) - await expect( - loaded.fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - body: '{}', - }), - ).rejects.toMatchObject({ - code: 'custody_state_mismatch', - verdict: 'FAIL_CLOSED', - }) - expect(fetchCalls.filter((url) => url === TOKEN_URL)).toHaveLength(0) - - const statePath = getAccountStatePath(path) - if (existsSync(statePath)) { - const state = JSON.parse(await readFile(statePath, 'utf8')) as Record< - string, - unknown - > - expect(state).not.toHaveProperty('permanent') - expect(state).not.toHaveProperty('needsReauth') - expect(state).not.toHaveProperty('mainLastRefreshError') - expect(state).not.toHaveProperty('backoff') - } - await plugin.dispose?.() - }, - ) - }) - - test('never sends a foreign-provider main tombstone to the token endpoint after expiry', async () => { - const tokenCalls: string[] = [] - let currentAuth: Record = { - type: 'oauth', - access: 'live-main-access', - refresh: 'live-main-refresh', - expires: Date.now() + 60_000, - } - globalThis.fetch = mock((input: unknown) => { - const url = extractUrl(input as string | URL | Request) - if (url === TOKEN_URL) tokenCalls.push(url) - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch - - await createTempStorage( - { - version: 1, - main: { type: 'opencode', provider: oauthFixture.provider }, - refresh: { enabled: false }, - quota: { enabled: false }, - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - access: 'fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 60_000, - }, - ], - }, - async () => { - const plugin = (await AnthropicAuthPlugin( - // @ts-expect-error: minimal mock for testing - { client: createMockClient() }, - disabledTimerOverrides(), - )) as any - const loaded = await plugin.auth.loader( - () => Promise.resolve(currentAuth as never), - { models: {} } as never, - ) - - currentAuth = { - ...custodyTombstoneOAuth('openai'), - access: 'expired-main-access', - expires: 0, - } - await expect( - loaded.fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - body: '{}', - }), - ).rejects.toBeInstanceOf(CustodyTombstoneRefreshError) - expect(tokenCalls).toEqual([]) - await plugin.dispose?.() - }, - ) - }) + lifetimes.test( + 'holds a local main tombstone dark without network or refresh state', + async () => { + const { root, plugins } = await startNativeBody() + const outbound = recordNetwork() + // The committed local pool holds no Claude account: OpenCode's slot + // carries only the exact placeholder, which migration never imports as a + // credential. + const fixture = await migrateLocalPool(root, { + anthropic: custodyTombstoneOAuth(oauthFixture.provider), + }) + const plugin = await createNativePlugin(plugins, root) - test('reconciles a real main without a resolved binding before serving OAuth', async () => { - const fetchCalls: Array<{ url: string; authorization: string | null }> = [] - globalThis.fetch = mock((input: unknown, init?: RequestInit) => { - const request = input instanceof Request ? input : undefined - const headers = new Headers(init?.headers ?? request?.headers) - fetchCalls.push({ - url: extractUrl(input as string | URL | Request), - authorization: headers.get('authorization'), + // The placeholder activates the native local pool, which then refuses for + // want of an account instead of treating the placeholder as a token. + await expect(sendMessage(plugin)).rejects.toThrow( + 'Native OAuth account is unavailable', + ) + expect(outbound).toEqual([]) + + const snapshot = await readNativeAccounts(fixture) + expect(snapshot.mode).toBe('local') + expect(snapshot.accounts).toEqual([]) + expect( + JSON.parse(await readFile(fixture.hostAuthPath, 'utf8')).anthropic, + ).toEqual(custodyTombstoneOAuth(oauthFixture.provider)) + }, + ) + + lifetimes.test( + 'never sends a foreign-provider main tombstone to the token endpoint after expiry', + async () => { + const { root, plugins } = await startNativeBody() + const outbound = recordNetwork({ + // The rotation hands back another provider's tombstone as the refresh + // token successor, so the pool now stores it for the main account. + token: () => ({ + access_token: 'sk-ant-oat01-live-main-rotated', + refresh_token: custodyTombstoneKey('openai'), + expires_in: 8 * 60 * 60, + }), }) - return Promise.resolve(new Response('{}', { status: 200 })) - }) as unknown as typeof fetch + const fixture = await migrateLocalPool(root, { + anthropic: { + type: 'oauth', + access: 'sk-ant-oat01-live-main', + refresh: 'live-main-refresh', + expires: Date.now() + 8 * HOUR_MS, + }, + }) + const plugin = await createNativePlugin(plugins, root) + + // The first send validates the migrated credential through one real + // rotation and serves with its successor. + expect((await sendMessage(plugin)).status).toBe(200) + expect(outbound.map((record) => record.url)).toEqual([ + TOKEN_URL, + expect.stringContaining('/api/claude_cli/bootstrap'), + expect.stringContaining('/v1/messages'), + ]) + expect(outbound[2]?.authorization).toBe( + 'Bearer sk-ant-oat01-live-main-rotated', + ) + outbound.length = 0 + + // Move past the successor's expiry so the next send must refresh with + // the stored tombstone. + const realNow = Date.now + Date.now = () => realNow() + 9 * HOUR_MS + const refused = await sendMessage(plugin).catch((error: unknown) => error) + // The stored tombstone is refused locally as the refresh begins: the + // request fails as a local authorization refusal, not as a failed + // token refresh, and nothing reaches the token endpoint. + expect(refused).toBeInstanceOf(Error) + expect(String(refused)).toContain( + 'Native OAuth authorization refused: refused', + ) + expect(refused).toMatchObject({ + tokenRefreshFailed: false, + failure: undefined, + }) + expect(outbound).toEqual([]) - await createTempStorage( - { - version: 1, - main: { type: 'opencode', provider: oauthFixture.provider }, - claustrum: { mode: 'claustrum' }, - refresh: { enabled: true }, - quota: { enabled: true }, - accounts: [], - }, - async (path) => { - expect((await loadAccounts(path))?.claustrum?.mode).toBe('claustrum') - const plugin = (await AnthropicAuthPlugin( - // @ts-expect-error: minimal mock for testing - { client: createMockClient() }, - disabledTimerOverrides(), - )) as any - const loaded = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth' as const, - access: 'real-local-access', - refresh: 'real-local-refresh', - expires: Date.now() + 60_000, - }), - { models: {} } as never, - ) - - const refused = await loaded.fetch( - 'https://api.anthropic.com/v1/messages', - { - method: 'POST', - body: '{}', - }, - ) - expect(refused.status).toBe(503) - expect(await refused.text()).toContain('Claustrum') - expect(fetchCalls).toEqual([]) - await plugin.dispose?.() - }, - ) - }) + // Refusing a tombstone locally is not a provider refresh failure, so it + // must leave no refresh error or backoff on the main account. + const main = (await readNativeAccounts(fixture)).accounts.find( + (account) => account.id === 'main', + ) + expect(main?.accountIdentity).toBe(MAIN_IDENTITY) + expect(main?.lastRefreshError).toBeUndefined() + }, + ) + + lifetimes.test( + 'reconciles a real main without a resolved binding before serving OAuth', + async () => { + const { root, plugins } = await startNativeBody() + const outbound = recordNetwork() + let vaultIdentity = MAIN_IDENTITY + const vaultReads: string[] = [] + const listingGate = lifetimes.gate() + const vaultClient = ( + listing: 'answers' | 'waits', + ): NativeCustodyClient => ({ + async listScoped() { + if (listing === 'waits') await listingGate.wait + return { + view: 'custody-tombstone', + rows: [vaultMainRow(vaultIdentity)], + } + }, + async getScoped({ credentialId }) { + vaultReads.push(credentialId) + return { + credentialId, + material: `sk-ant-oat01-vault-${vaultIdentity}`, + accountId: vaultIdentity, + recordVersion: 1, + expiresAtMs: Date.now() + 8 * HOUR_MS, + } + }, + async reportAuthFailureScoped() {}, + close() {}, + }) + const fixture = await migrateVaultPool(root, [ + vaultMainRow(MAIN_IDENTITY), + ]) + expect( + (await readNativeAccounts(fixture)).accounts.find( + (account) => account.id === 'main', + )?.accountIdentity, + ).toBe(MAIN_IDENTITY) + + // The vault now holds a different account behind the default credential, + // so the migrated main binding no longer resolves to the account served. + vaultIdentity = REPLACEMENT_IDENTITY + // Hold the plugin's startup inventory refresh until the refusal is + // observed, so the request meets the stale binding deterministically. + const stale = await createNativePlugin(plugins, root, { + claustrumScopedConnect: async () => vaultClient('waits'), + }) + await expect(sendMessage(stale)).rejects.toMatchObject({ + code: 'identity-changed', + }) + expect(vaultReads).toContain(VAULT_MAIN_CREDENTIAL) + expect(outbound).toEqual([]) + listingGate.open() + await disposePlugin(plugins, stale) + + // The vault inventory refresh rebinds main to the account the vault serves. + const discovery = createNativeAccountRuntime({ + paths: fixture.paths, + host: 'opencode', + vault: { connect: async () => vaultClient('answers') }, + }) + try { + await discovery.vault.refresh() + expect( + (await discovery.read()).accounts.find( + (account) => account.id === 'main', + )?.accountIdentity, + ).toBe(REPLACEMENT_IDENTITY) + } finally { + discovery.close() + } - test('refuses Claude Pro/Max login before authorization while claustrum is committed', async () => { - const authorizeImpl = mock(() => - Promise.resolve({ + const reconciled = await createNativePlugin(plugins, root, { + claustrumScopedConnect: async () => vaultClient('answers'), + }) + expect((await sendMessage(reconciled)).status).toBe(200) + expect(outbound).toEqual([ + { + url: expect.stringContaining('/v1/messages'), + authorization: `Bearer sk-ant-oat01-vault-${REPLACEMENT_IDENTITY}`, + }, + ]) + }, + ) + + lifetimes.test( + 'refuses Claude Pro/Max login before authorization while claustrum is committed', + async () => { + const { root, plugins } = await startNativeBody() + const outbound = recordNetwork() + const authorizeImpl = mock(async () => ({ url: 'https://example.test/authorize', redirectUri: 'http://localhost/callback', state: 'state', verifier: 'verifier', - }), - ) - const findProMaxMethod = (plugin: any) => { - const method = plugin.auth.methods.find( - (candidate: { label: string }) => candidate.label === 'Claude Pro/Max', - ) - if (!method) throw new Error('missing Claude Pro/Max method') - return method - } - const storage = (mode: 'claustrum' | 'local') => ({ - version: 1, - main: { type: 'opencode', provider: oauthFixture.provider }, - claustrum: { mode }, - refresh: { enabled: false }, - quota: { enabled: false }, - accounts: [], - }) + })) + const fixture = await migrateLocalPool(root, { + anthropic: custodyTombstoneOAuth(oauthFixture.provider), + }) - await createTempStorage(storage('claustrum'), async () => { - const plugin = (await AnthropicAuthPlugin( - // @ts-expect-error: minimal mock for testing - { client: createMockClient() }, - { ...disabledTimerOverrides(), authorize: authorizeImpl } as any, - )) as any - await expect(findProMaxMethod(plugin).authorize()).rejects.toThrow( - 'Exit Claustrum mode first: /claude-account local', + // A committed local pool without an enrollment token signs in locally. + const local = await createNativePlugin(plugins, root, { + authorize: authorizeImpl, + }) + await expect(proMaxMethod(local).authorize()).resolves.toMatchObject({ + url: 'https://example.test/authorize', + }) + expect(authorizeImpl).toHaveBeenCalledTimes(1) + await disposePlugin(plugins, local) + + // Only this host's owner-only enrollment token commits it to the vault; + // the local pool and its settings are left exactly as migrated. + await writeFile( + fixture.enrollmentPath, + JSON.stringify({ token: 'ab'.repeat(32), token_generation: 1 }), + { mode: 0o600, flag: 'wx' }, ) - expect(authorizeImpl).not.toHaveBeenCalled() - await plugin.dispose?.() - }) - - await createTempStorage(storage('local'), async () => { - const plugin = (await AnthropicAuthPlugin( - // @ts-expect-error: minimal mock for testing - { client: createMockClient() }, - { ...disabledTimerOverrides(), authorize: authorizeImpl } as any, - )) as any - await expect(findProMaxMethod(plugin).authorize()).resolves.toMatchObject( - { - url: 'https://example.test/authorize', - }, + const enrolled = await createNativePlugin(plugins, root, { + authorize: authorizeImpl, + }) + await expect(proMaxMethod(enrolled).authorize()).rejects.toThrow( + 'Disconnect from the vault first.', ) expect(authorizeImpl).toHaveBeenCalledTimes(1) - await plugin.dispose?.() - }) - }) + expect(outbound).toEqual([]) + }, + ) test('fallback refresh rejects the tombstone without network or refresh classification', async () => { const fetchCalls: string[] = [] From 8f75c6dd1060676cff66dab5ecec0ccb97667d9f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:01:42 +0200 Subject: [PATCH 151/169] mason: restore custody tombstone fetch only after native bodies settle The file-wide afterEach that restores globalThis.fetch ran before the plugin-cancellation hook and the TestLifetime hook. A native body that outlived its timeout therefore resumed against the original fetch instead of the synthetic provider it installed. Register the restore after createTestLifetimeSuite() so it runs only after the body has settled and its own cleanup has restored fetch; cancellation still runs first. --- packages/opencode/src/tests/custody-tombstone.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tests/custody-tombstone.test.ts b/packages/opencode/src/tests/custody-tombstone.test.ts index d56e191ef..761d7f507 100644 --- a/packages/opencode/src/tests/custody-tombstone.test.ts +++ b/packages/opencode/src/tests/custody-tombstone.test.ts @@ -381,10 +381,6 @@ async function createTempStorage( } } -afterEach(() => { - globalThis.fetch = originalFetch -}) - const livePlugins = new Set>() // Registered before the lifetime suite's own afterEach: dispose every native // plugin (its timers and vault client) before that hook waits for a possibly @@ -398,6 +394,13 @@ afterEach(async () => { await disposeAll(plugins, 'Custody tombstone plugin cancellation failed') }) const lifetimes = createTestLifetimeSuite() +// Registered after the lifetime suite's afterEach, so it runs only once a +// timed-out native body has settled and its own cleanup has restored fetch. +// Restoring earlier would let that body resume against the original fetch +// instead of the synthetic provider it installed. +afterEach(() => { + globalThis.fetch = originalFetch +}) describe('Claustrum custody tombstones', () => { test('recognizes only provider-bound OAuth tombstones', () => { From e97e965c67f2043a409f5091d10fb243e28d049a Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:03:00 +0200 Subject: [PATCH 152/169] mason: expose packed RPC client and record failed host phases Export the existing dist/rpc/rpc-client artifact with declarations while retaining server and TUI discovery. Resolve all three entries from an extracted archive outside the workspace; removing the RPC export reproduces the CI missing-module error in exactly the new regression test (the other five remain green). Record dependency installation, host spawn/exit/deadline and first upstream/relay request/response clocks only on failure. Lifetime cleanup runs after cancellation and pipe joins; synthetic transport exceptions omit their original content and fixture secrets are scrubbed from host logs. No host deadline, skip, or OC1 stall behavior changes. The two actual OC1 CI failures had empty stdout/stderr and exit 143; no migration/stall cause is inferred. Verified on zero-network Linux with Bun 1.3.14: Core before OpenCode build, TypeScript 7.0.2 package+script checks, packed fixture 6 pass / 0 fail / 14 expectations, workspace-lock parity, and Biome 2.5.14 check on three files. Exports do not participate in workspace lock state, so bun.lock is unchanged. Sidekick comment review found no unclear comments. Actual OC1/OC2 host CI is not rerun; parent owns disposable network-enabled host verification. --- packages/opencode/package.json | 4 + .../scripts/check-bundle-globals.test.ts | 65 ++++- .../opencode/src/tests/opencode2-e2e.test.ts | 267 ++++++++++++------ 3 files changed, 249 insertions(+), 87 deletions(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 026c9af70..3a1c815b2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -22,6 +22,10 @@ "types": "./dist/v2/server.d.ts", "import": "./dist/v2/server.js" }, + "./rpc": { + "types": "./dist/rpc/rpc-client.d.ts", + "import": "./dist/rpc/rpc-client.js" + }, "./tui-prefs": { "types": "./dist/tui-preferences.d.ts", "import": "./dist/tui-preferences.js" diff --git a/packages/opencode/scripts/check-bundle-globals.test.ts b/packages/opencode/scripts/check-bundle-globals.test.ts index 8336b682a..0d00a35b9 100644 --- a/packages/opencode/scripts/check-bundle-globals.test.ts +++ b/packages/opencode/scripts/check-bundle-globals.test.ts @@ -1,7 +1,15 @@ import { expect, test } from 'bun:test' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { checkBundleGlobals } from './check-bundle-globals' async function fixture(run: (root: string) => Promise) { @@ -72,3 +80,56 @@ test('a chunk cycle is checked once without hiding a stale singleton', async () ) }) }) + +test('a fresh packed prefix resolves the RPC client alongside server and TUI', async () => { + await fixture(async (root) => { + const packageRoot = resolve(import.meta.dir, '..') + await Bun.$`${process.execPath} pm pack --destination ${root}` + .cwd(packageRoot) + .quiet() + const archives = (await readdir(root)).filter((name) => + name.endsWith('.tgz'), + ) + expect(archives).toHaveLength(1) + const prefix = join(root, 'prefix') + const packed = join( + prefix, + 'node_modules/@cortexkit/opencode-anthropic-auth', + ) + await mkdir(packed, { recursive: true }) + await Bun.$`tar -xzf ${join(root, archives[0]!)} -C ${packed} --strip-components=1`.quiet() + const manifest = JSON.parse( + await readFile(join(packed, 'package.json'), 'utf8'), + ) + + // Resolve from an extracted archive outside the workspace, so a source + // alias or workspace link cannot conceal a missing packed export. + const probe = join(prefix, 'resolve.mjs') + await writeFile( + probe, + `for (const subpath of ['rpc', 'server', 'tui']) console.log(import.meta.resolve('@cortexkit/opencode-anthropic-auth/' + subpath));`, + ) + const result = await Bun.$`${process.execPath} ${probe}`.cwd(prefix).quiet() + const entries = result + .text() + .trim() + .split('\n') + .map((entry) => fileURLToPath(entry)) + expect(entries).toEqual([ + join(packed, 'dist/rpc/rpc-client.js'), + join(packed, 'dist/v2/server.js'), + join(packed, 'src/tui/entry.mjs'), + ]) + for (const entry of entries) + expect((await readFile(entry, 'utf8')).length).toBeGreaterThan(0) + expect(manifest.exports['./rpc']).toEqual({ + types: './dist/rpc/rpc-client.d.ts', + import: './dist/rpc/rpc-client.js', + }) + expect(manifest['oc-plugin']).toEqual(['server', 'tui']) + expect(await readFile(entries[0]!, 'utf8')).toContain('createRpcClient') + expect( + await readFile(join(packed, manifest.exports['./rpc'].types), 'utf8'), + ).toContain('createRpcClient') + }) +}) diff --git a/packages/opencode/src/tests/opencode2-e2e.test.ts b/packages/opencode/src/tests/opencode2-e2e.test.ts index 4d272654b..60ab76eef 100644 --- a/packages/opencode/src/tests/opencode2-e2e.test.ts +++ b/packages/opencode/src/tests/opencode2-e2e.test.ts @@ -31,7 +31,7 @@ import { } from '../../../core/src/tests/test-lifetime' import { tokenFingerprint } from '../../../core/src/token-fingerprint' -const { test, gate, trackDetached } = createTestLifetimeSuite() +const { test, gate, trackDetached, deferCleanup } = createTestLifetimeSuite() const PACKAGE = '@cortexkit/opencode-anthropic-auth' const TOKEN = 'sk-ant-oat01-oc2-disposable-only' @@ -43,6 +43,28 @@ let cli = '' let oc1 = '' let packedProject = '' const children = new Set() + +function phaseClock() { + const started = performance.now() + const phases: Record = {} + return { + phases, + mark(name: string) { + phases[name] ??= { + at: new Date().toISOString(), + elapsedMs: Math.round(performance.now() - started), + } + }, + } +} +const installation = phaseClock() +type CommandClock = { clock: ReturnType; name: string } + +const redactFixtureSecrets = (text: string) => + [TOKEN, PASSWORD, 'synthetic-refresh', 'relay-disposable-only'].reduce( + (redacted, secret) => redacted.replaceAll(secret, '[redacted]'), + text, + ) /** * Host output is kept inside the fixture that ran it: the temporary directory * isolatedEnv gives each host as TMPDIR. Removing that fixture removes these @@ -81,21 +103,32 @@ async function command( env: Record, limit = 60_000, cancellation?: TestGate, + timing?: CommandClock, ) { + timing?.clock.mark(`${timing.name}.start`) const child = Bun.spawn(args, { cwd, env, stdout: 'pipe', stderr: 'pipe' }) children.add(child) const stdoutWork = new Response(child.stdout).text() const stderrWork = new Response(child.stderr).text() - const exitWork = child.exited + const exitWork = child.exited.then((exit) => { + timing?.clock.mark(`${timing.name}.exit`) + return exit + }) const completed = Promise.withResolvers() let settled = false const stopWork = cancellation ? Promise.race([cancellation.wait, completed.promise]).then(() => { - if (!settled && child.exitCode === null) child.kill('SIGKILL') + if (!settled && child.exitCode === null) { + timing?.clock.mark(`${timing.name}.cancelled`) + child.kill('SIGKILL') + } }) : Promise.resolve() if (cancellation) trackDetached(stopWork) - const timer = setTimeout(() => child.kill('SIGKILL'), limit) + const timer = setTimeout(() => { + timing?.clock.mark(`${timing.name}.deadline`) + child.kill('SIGKILL') + }, limit) try { const [stdout, stderr, exit] = await Promise.all([ stdoutWork, @@ -106,8 +139,14 @@ async function command( if (logs && (args[0] === cli || args[0] === oc1)) { await mkdir(logs, { recursive: true }) const prefix = `${Date.now()}-${args[0] === cli ? 'oc2' : 'oc1'}` - await writeFile(join(logs, `${prefix}-stdout.log`), stdout) - await writeFile(join(logs, `${prefix}-stderr.log`), stderr) + await writeFile( + join(logs, `${prefix}-stdout.log`), + redactFixtureSecrets(stdout), + ) + await writeFile( + join(logs, `${prefix}-stderr.log`), + redactFixtureSecrets(stderr), + ) } return { stdout, stderr, exit } } finally { @@ -123,16 +162,19 @@ async function requireCommand( args: string[], cwd: string, env: Record, + timing?: CommandClock, ) { - const result = await command(args, cwd, env) + const result = await command(args, cwd, env, 60_000, undefined, timing) if (result.exit !== 0) throw new Error( - `Disposable command failed: ${args[0]} ${args[1]}\n${result.stdout}\n${result.stderr}`, + redactFixtureSecrets( + `Disposable command failed: ${args[0]} ${args[1]}\n${result.stdout}\n${result.stderr}`, + ), ) return result } -beforeAll(async () => { +async function installPackedHosts() { const cache = join(workspace, 'node_modules', '.cache', 'opencode2-e2e') await mkdir(cache, { recursive: true }) scratch = await mkdtemp(join(cache, 'fixture-')) @@ -153,6 +195,8 @@ beforeAll(async () => { install, env, 10_000, + undefined, + { clock: installation, name: 'registry' }, ) if (registry.exit !== 0) throw new Error( @@ -166,6 +210,7 @@ beforeAll(async () => { ['npm', 'install', '--no-audit', '--no-fund', '@opencode/cli@2.0.22'], install, env, + { clock: installation, name: 'oc2.install' }, ) cli = join(install, 'node_modules', '.bin', 'opencode') const version = await requireCommand([cli, '--version'], install, env) @@ -180,6 +225,7 @@ beforeAll(async () => { ['npm', 'install', '--no-audit', '--no-fund', 'opencode-ai@1.18.31'], install1, await isolatedEnv(install1), + { clock: installation, name: 'oc1.install' }, ) oc1 = join(install1, 'node_modules', '.bin', 'opencode') packedProject = join(scratch, 'packed') @@ -191,6 +237,7 @@ beforeAll(async () => { ['bun', 'pm', 'pack', '--destination', pack], join(workspace, 'packages', pkg), env, + { clock: installation, name: `${pkg}.pack` }, ) } const archives = (await readdir(pack)) @@ -205,7 +252,22 @@ beforeAll(async () => { ['npm', 'install', '--no-audit', '--no-fund', ...archives], packedProject, env, + { clock: installation, name: 'packed.install' }, ) +} + +beforeAll(async () => { + installation.mark('dependencies.start') + try { + await installPackedHosts() + installation.mark('dependencies.ready') + } catch (error) { + installation.mark('dependencies.failed') + console.error( + JSON.stringify({ phaseClocks: { installation: installation.phases } }), + ) + throw error + } }, 180_000) afterAll(async () => { for (const child of children) @@ -431,12 +493,18 @@ interface RelayEnvelope extends Record { } readonly body: string } -async function scenario( +type ScenarioArgs = [ transport: 'direct' | 'http' | 'websocket', - host: 'oc1' | 'oc2' = 'oc2', - entry: 'package' | 'main' = 'package', + host?: 'oc1' | 'oc2', + entry?: 'package' | 'main', mode?: 'tool' | 'structured', +] +async function scenario( + args: ScenarioArgs, + clock: ReturnType, ) { + const [transport, host = 'oc2', entry = 'package', mode] = args + clock.mark('fixture.start') const root = await mkdtemp(join(scratch, 'scenario-')) const project = join(root, 'project') await mkdir(project) @@ -461,50 +529,76 @@ async function scenario( hostname: '127.0.0.1', port: 0, async fetch(request) { - const body = await request.text() - wire.push({ - body, - headers: new Headers(request.headers), - url: request.url, - }) - const payload = JSON.parse(body) - if ( - mode === 'tool' && - payload.tool_choice?.type === 'tool' && - !usedTool - ) { - usedTool = true - const tool = payload.tools.find( - (tool: { name: string }) => tool.name === payload.tool_choice.name, - ) - const key = tool?.input_schema?.properties?.path ? 'path' : 'filePath' - return toolReply(payload.model, payload.tool_choice.name, { - [key]: join(project, 'fixture.txt'), + clock.mark('upstream.first_request') + try { + const body = await request.text() + wire.push({ + body, + headers: new Headers(request.headers), + url: request.url, }) + const payload = JSON.parse(body) + if ( + mode === 'tool' && + payload.tool_choice?.type === 'tool' && + !usedTool + ) { + usedTool = true + const tool = payload.tools.find( + (tool: { name: string }) => tool.name === payload.tool_choice.name, + ) + const key = tool?.input_schema?.properties?.path ? 'path' : 'filePath' + const response = toolReply(payload.model, payload.tool_choice.name, { + [key]: join(project, 'fixture.txt'), + }) + clock.mark('upstream.first_response') + return response + } + const response = reply( + payload.model, + mode === 'structured' + ? '{"answer":"OC2_SYNTHETIC_OK"}' + : 'OC2_SYNTHETIC_OK', + ) + clock.mark('upstream.first_response') + return response + } catch { + clock.mark('upstream.transport_failure') + throw new Error( + 'Synthetic upstream transport failed (details redacted)', + ) } - return reply( - payload.model, - mode === 'structured' - ? '{"answer":"OC2_SYNTHETIC_OK"}' - : 'OC2_SYNTHETIC_OK', - ) }, }) const relay = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) { - const envelope = (await request.json()) as RelayEnvelope - envelopes.push(envelope) - expect(request.headers.get('authorization')).toBeNull() - expect(request.headers.get('x-relay-token')).toBe('relay-disposable-only') - return fetch(envelope.upstream.url, { - method: envelope.upstream.method, - headers: envelope.upstream.headers, - body: envelope.body, - }) + clock.mark('relay.first_request') + try { + const envelope = (await request.json()) as RelayEnvelope + envelopes.push(envelope) + expect(request.headers.get('authorization')).toBeNull() + expect(request.headers.get('x-relay-token')).toBe( + 'relay-disposable-only', + ) + const response = await fetch(envelope.upstream.url, { + method: envelope.upstream.method, + headers: envelope.upstream.headers, + body: envelope.body, + }) + clock.mark('relay.first_response') + return response + } catch { + clock.mark('relay.transport_failure') + throw new Error('Synthetic relay transport failed (details redacted)') + } }, }) + deferCleanup(async () => { + await upstream.stop(true) + await relay.stop(true) + }) const paths = await seed( env, transport === 'direct' ? undefined : { url: relay.url.href, transport }, @@ -590,55 +684,58 @@ export default {id:'cortexkit-anthropic-auth',async setup(ctx){appendFileSync('$ env, 60_000, gate(), + { clock, name: 'host' }, ) - if (result.exit !== 0) - console.error(JSON.stringify({ host, entry, mode, ...result })) - if (mode) - console.error( - JSON.stringify({ - mode, - audit: await readFile(join(project, 'hook-audit.jsonl'), 'utf8').catch( - () => 'not-created', - ), - stdout: result.stdout, - stderr: result.stderr, - models: wire.map((send) => ({ - model: JSON.parse(send.body).model, - toolChoice: JSON.parse(send.body).tool_choice, - outputConfig: JSON.parse(send.body).output_config, - })), - }), - ) - try { - return { result, wire, envelopes, paths, root, env } - } finally { - await upstream.stop(true) - await relay.stop(true) - } + return { result, wire, envelopes, paths, root, env } } /** - * Runs one host scenario and hands it to `body`, then deletes the scenario's - * temporary directory, including its host logs. If `body` fails, the logs are - * printed first so the failure can still be diagnosed after the deletion. + * Keep failure evidence until lifetime cancellation has drained the host. + * Successful scenarios stay quiet; deadline failures retain their last phase. */ async function withScenario( - args: Parameters, + args: ScenarioArgs, body: (run: Awaited>) => Promise | void, ) { - const run = await scenario(...args) - try { - await body(run) - } catch (error) { - const logs = hostLogDirectory(run.env) - for (const name of logs ? await readdir(logs).catch(() => []) : []) + const clock = phaseClock() + let passed = false + let run: Awaited> | undefined + deferCleanup(async () => { + if ( + !passed || + clock.phases['host.cancelled'] || + clock.phases['host.deadline'] + ) { + clock.mark('failure.report') console.error( - `${name}:\n${await readFile(join(logs ?? '', name), 'utf8')}`, + JSON.stringify({ + scenario: args, + phaseClocks: { + installation: installation.phases, + scenario: clock.phases, + }, + }), ) - throw error - } finally { - await rm(run.root, { recursive: true, force: true }) - } + if (run) { + const logs = hostLogDirectory(run.env) + for (const name of logs ? await readdir(logs).catch(() => []) : []) + console.error( + `${name}:\n${await readFile(join(logs ?? '', name), 'utf8')}`, + ) + if (args[3]) + console.error( + await readFile( + join(run.root, 'project/hook-audit.jsonl'), + 'utf8', + ).catch(() => 'audit not created'), + ) + } + } + if (run) await rm(run.root, { recursive: true, force: true }) + }) + run = await scenario(args, clock) + await body(run) + passed = true } describe('packed Anthropic plugin on pinned real hosts', () => { From ee1915371b290d170fec93a73c4eecfd93087b8e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:10:03 +0200 Subject: [PATCH 153/169] test: prepare process vault fixtures from enrollment authority --- .github/workflows/mutations.yml | 2 + packages/e2e-tests/src/opencode-runner.ts | 113 ++-- packages/e2e-tests/tests/custody-mode.test.ts | 19 - .../tests/native-process-fixture.test.ts | 578 ++++++++++++------ packages/opencode/src/tests/index.test.ts | 48 +- .../opencode/src/tests/native-fixture.test.ts | 9 +- .../src/tests/scoped-custody-serving.test.ts | 3 +- 7 files changed, 508 insertions(+), 264 deletions(-) diff --git a/.github/workflows/mutations.yml b/.github/workflows/mutations.yml index af7a561da..6e2920b8e 100644 --- a/.github/workflows/mutations.yml +++ b/.github/workflows/mutations.yml @@ -30,6 +30,8 @@ jobs: persist-credentials: false - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4 - run: bun install --frozen-lockfile + - name: Build shared Core for test preloads + run: bun run --cwd packages/core build - name: Install pinned mutation runner shell: bash run: | diff --git a/packages/e2e-tests/src/opencode-runner.ts b/packages/e2e-tests/src/opencode-runner.ts index 1591206b4..2f3d7e81b 100644 --- a/packages/e2e-tests/src/opencode-runner.ts +++ b/packages/e2e-tests/src/opencode-runner.ts @@ -26,18 +26,17 @@ import { setTimeout as delay } from 'node:timers/promises' import { createNativeAccountRuntime, custodyTombstoneOAuth, - discoverNativeVaultInventory, getStickyRoutingStatePath, type NativeMigrationJournal, type NativePoolPaths, type NativeRefreshResult, type ProviderAccountUuid, - publishNativeVaultRosterSeed, - publishNativeVaultRuntimeSeed, resolveNativePoolPaths, runNativeMigration, } from '@cortexkit/anthropic-auth-core' +import { nativeVaultPaths } from '../../core/src/native-vault-paths.ts' + const REPO_ROOT = resolve(import.meta.dir, '../../..') const PLUGIN_ENTRY = join(REPO_ROOT, 'packages/opencode/src/index.ts') const E2E_TEMP_PREFIX = 'anthropic-auth-e2e-' @@ -196,10 +195,11 @@ export type NativeAccountFixture = | { kind: 'vault' } | { kind: 'unmigrated'; hostAuth?: Record } -/** Credential paths and the journal recording completed transfer from host auth. */ -export type MigratedNativeAccounts = { +/** Only local OAuth fixtures have a migration journal; vault fixtures use enrollment without moving local credentials. */ +export type PreparedNativeAccounts = { + kind: 'local' | 'vault' paths: NativePoolPaths - journal: NativeMigrationJournal + journal?: NativeMigrationJournal hostAuthPath: string routingDestinationPath: string /** Credential authorization result for the test's primary local login, if present. */ @@ -209,14 +209,15 @@ export type MigratedNativeAccounts = { /** Account UUID returned by the fake bootstrap response for the primary test login. */ export const FIXTURE_MAIN_ACCOUNT_UUID = '11111111-1111-4111-8111-111111111111' /** Token returned by the fake refresh endpoint and used by the test OpenCode server. */ -export const FIXTURE_ADMITTED_ACCESS_TOKEN = 'test-access-token-admitted' +export const FIXTURE_ADMITTED_ACCESS_TOKEN = + 'sk-ant-oat01-test-access-token-admitted' export type SpawnedOpencode = { url: string port: number env: IsolatedEnv /** Undefined for an `unmigrated` fixture. */ - native?: MigratedNativeAccounts + native?: PreparedNativeAccounts kill: () => Promise stdout: () => string stderr: () => string @@ -583,13 +584,13 @@ async function nativeSetupLayout(env: IsolatedEnv): Promise { } } -async function migrateNativeAccounts(input: { +async function prepareNativeAccounts(input: { layout: NativeSetupLayout childEnv: Record fixture: Exclude options: SpawnOptions hostStarted: () => boolean -}): Promise { +}): Promise { const { layout, fixture } = input const { paths } = layout if ( @@ -607,7 +608,7 @@ async function migrateNativeAccounts(input: { : (fixture.hostAuth ?? { anthropic: { type: 'oauth', - access: 'test-access-token', + access: 'sk-ant-oat01-test-access-token', refresh: 'test-refresh-token', expires: Date.now() + 60 * 60 * 1000, }, @@ -618,6 +619,59 @@ async function migrateNativeAccounts(input: { flag: 'wx', }) + if (fixture.kind === 'vault') { + if (input.hostStarted()) + throw new Error('Host started before vault fixture preparation') + if (input.childEnv.OPENCODE_AUTH_CONTENT !== undefined) + throw new Error( + 'A vault fixture must activate through its real auth.json', + ) + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + vault: { env: input.childEnv }, + }) + try { + await runtime.vault.refresh() + const snapshot = await runtime.read() + if ( + snapshot.mode !== 'claustrum' || + !snapshot.accounts.some((account) => account.id === 'main') + ) + throw new Error( + 'Vault fixture did not discover an enrolled primary account', + ) + await runtime.updateSettings((settings) => ({ + ...settings, + quota: { enabled: false }, + refresh: { enabled: false }, + ...(input.options.quotaFeed + ? { quotaHeaderFeed: { enabled: true } } + : {}), + ...(input.options.hybridCache + ? { claudeCache: { enabled: true, mode: 'hybrid' } } + : {}), + })) + if (input.options.relay) + await runtime.updateRelay({ + enabled: true, + ...input.options.relay, + fallbackToDirect: false, + }) + const routingDestinationPath = nativeVaultPaths(paths).routing + input.childEnv.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE = + routingDestinationPath + return { + kind: 'vault', + paths, + hostAuthPath: layout.hostAuthPath, + routingDestinationPath, + } + } finally { + runtime.close() + } + } + // runNativeMigration calls processFence to confirm no host is running // before it reads the legacy files and again before it commits. The child // is spawned only after migration returns, so a started host here means @@ -640,27 +694,15 @@ async function migrateNativeAccounts(input: { env: migrationEnv, removePiAnthropicAuth: false, processFence, - ...(fixture.kind === 'vault' && { - custody: { - discover: () => - discoverNativeVaultInventory({ - paths, - host: 'opencode', - env: migrationEnv, - }), - publishSeed: publishNativeVaultRosterSeed, - publishRuntimeSeed: publishNativeVaultRuntimeSeed, - }, - }), }) if (fenceChecks === 0) throw new Error('Fixture migration did not check its process fence') if (journal.phase !== 'retired') throw new Error(`Fixture migration ended in phase ${journal.phase}`) - const mainAdmission = - fixture.kind === 'local' ? await admitLocalMain(paths) : undefined + const mainAdmission = await admitLocalMain(paths) return { + kind: 'local', paths, journal, hostAuthPath: layout.hostAuthPath, @@ -978,7 +1020,7 @@ export async function spawnOpencode( childEnv.OPENCODE_AUTH_CONTENT = JSON.stringify({ anthropic: { type: 'oauth', - access: 'test-access-token', + access: 'sk-ant-oat01-test-access-token', refresh: 'test-refresh-token', expires: Date.now() + 60 * 60 * 1000, }, @@ -989,12 +1031,10 @@ export async function spawnOpencode( ? undefined : await nativeSetupLayout(env) if (nativeLayout) { - // Offline setup moves the synthetic OAuth credentials into this test's - // common-auth account pool. OpenCode's auth.json retains only a non-secret - // OAuth activation value, which the host must read from disk. Remove the - // inherited OPENCODE_AUTH_CONTENT override: it supplies an auth snapshot - // instead, and the plugin refuses that credential source. A deliberate - // test override applied below still reaches migration's refusal check. + // Local fixtures move synthetic OAuth tokens into the test pool before + // startup. Vault fixtures use an enrolled daemon and an inert auth slot. + // Both read auth.json from disk. An explicit OPENCODE_AUTH_CONTENT override + // below must still fail the plugin's environment-credential refusal. delete childEnv.OPENCODE_AUTH_CONTENT // Session-to-account assignments move from the old sidecar directory // to nativeLayout.routingDestinationPath during migration. Give the @@ -1013,13 +1053,12 @@ export async function spawnOpencode( if (value === undefined) delete childEnv[key] else childEnv[key] = value } - // Import the synthetic OAuth credentials into the test-only common-auth - // pool before starting OpenCode. Use the final child environment, after - // test overrides, so setup and serving resolve the same account paths - // and forbidden auth snapshots cannot bypass the migration guard. + // Prepare the local OAuth pool or discover the enrolled vault accounts before + // starting the child. Use its final environment to resolve the same paths. + // Only local OAuth fixtures migrate; vault discovery leaves local files alone. const native = nativeLayout && nativeAccounts.kind !== 'unmigrated' - ? await migrateNativeAccounts({ + ? await prepareNativeAccounts({ layout: nativeLayout, childEnv, fixture: nativeAccounts, diff --git a/packages/e2e-tests/tests/custody-mode.test.ts b/packages/e2e-tests/tests/custody-mode.test.ts index 06ed99290..2c0d82146 100644 --- a/packages/e2e-tests/tests/custody-mode.test.ts +++ b/packages/e2e-tests/tests/custody-mode.test.ts @@ -77,11 +77,6 @@ describe('zero-bind scoped custody', () => { OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile, }, beforeSpawn: async (env) => { - const path = join(env.configDir, 'anthropic-auth.json') - await writeFile(path, JSON.stringify({ - version: 1, accounts: [], quota: { enabled: false }, - claustrum: { mode: 'claustrum', scopedRoster: true, primaryAccount: { credentialId: mainId, accountId: 'account-main', state: 'active' } }, - }), { mode: 0o600 }) await writeFile(join(env.configDir, 'claustrum-enrollment.json'), JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), { mode: 0o600 }) }, }) @@ -200,20 +195,6 @@ describe('scoped credential rotations in the OpenCode process', () => { OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile, }, beforeSpawn: async (env) => { - await writeFile(join(env.configDir, 'anthropic-auth.json'), JSON.stringify({ - version: 1, - accounts: [], - quota: { enabled: false }, - claustrum: { - mode: 'claustrum', - scopedRoster: true, - primaryAccount: { - credentialId: mainId, - accountId: main.account_id, - state: 'active', - }, - }, - }), { mode: 0o600 }) await writeFile(join(env.configDir, 'claustrum-enrollment.json'), JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), { mode: 0o600 }) diff --git a/packages/e2e-tests/tests/native-process-fixture.test.ts b/packages/e2e-tests/tests/native-process-fixture.test.ts index 736c41116..5bb693c23 100644 --- a/packages/e2e-tests/tests/native-process-fixture.test.ts +++ b/packages/e2e-tests/tests/native-process-fixture.test.ts @@ -1,6 +1,16 @@ /// -import { afterEach, describe, expect, it } from 'bun:test' -import { chmod, cp, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { describe, expect, it } from 'bun:test' +import { + chmod, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { @@ -9,6 +19,8 @@ import { readNativeMigrationJournal, resolveNativePoolPaths, } from '@cortexkit/anthropic-auth-core' +import { nativeVaultPaths } from '../../core/src/native-vault-paths.ts' +import { createTestLifetimeSuite } from '../../core/src/tests/test-lifetime.ts' import { startFakeClaustrumDaemon } from '../src/mock-claustrum.ts' import { FIXTURE_ADMITTED_ACCESS_TOKEN, @@ -23,14 +35,16 @@ import { // included, up to the host spawn. A stand-in `opencode` executable records the // environment it was started with and exits, so no OpenCode host runs here and // these tests say nothing about how a real host serves. -const cleanups: Array<() => Promise> = [] -afterEach(async () => { - for (const cleanup of cleanups.splice(0).reverse()) await cleanup() -}) +const lifetimes = createTestLifetimeSuite() +function deferCleanup(cleanup: () => Promise) { + lifetimes.deferCleanup(async () => { + await cleanup() + }) +} async function scratchDir(prefix: string) { const dir = await mkdtemp(join(tmpdir(), prefix)) - cleanups.push(() => rm(dir, { recursive: true, force: true })) + deferCleanup(() => rm(dir, { recursive: true, force: true })) return dir } @@ -63,7 +77,9 @@ async function prepareWithoutHost( const savedKeep = process.env.ANTHROPIC_AUTH_E2E_KEEP_TMP process.env.PATH = `${bin}:${savedPath ?? ''}` process.env.ANTHROPIC_AUTH_E2E_KEEP_TMP = '1' - const savedParent = Object.fromEntries(Object.keys(parentEnv).map((key) => [key, process.env[key]])) + const savedParent = Object.fromEntries( + Object.keys(parentEnv).map((key) => [key, process.env[key]]), + ) Object.assign(process.env, parentEnv) let failure: unknown try { @@ -72,7 +88,7 @@ async function prepareWithoutHost( anthropicBaseURL: 'http://127.0.0.1:9', beforeSpawn: async (isolated) => { env = isolated - cleanups.push(() => removeE2ETempDir(isolated.tempDir)) + deferCleanup(() => removeE2ETempDir(isolated.tempDir)) await options.beforeSpawn?.(isolated) }, }) @@ -88,7 +104,10 @@ async function prepareWithoutHost( else process.env.ANTHROPIC_AUTH_E2E_KEEP_TMP = savedKeep } if (!env) throw new Error('spawnOpencode did not reach beforeSpawn') - const recorded = await readFile(join(env.workdir, 'child-env.json'), 'utf8').then( + const recorded = await readFile( + join(env.workdir, 'child-env.json'), + 'utf8', + ).then( (text) => JSON.parse(text) as Record, () => undefined, ) @@ -119,171 +138,301 @@ function offlineOnly() { } describe('native process fixture preparation', () => { - it('migrates a local login before the host starts and leaves it servable without network', async () => { - const run = await prepareWithoutHost({ - relay: { url: 'http://127.0.0.1:9', token: 'relay-token', transport: 'websocket' }, - quotaFeed: true, - hybridCache: true, - }) - expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') - const child = run.recorded! - expect(child).toBeDefined() - expect(child.OPENCODE_AUTH_CONTENT).toBeUndefined() - expect(child.HOME).toBe(run.env.homeDir) - expect(child.USERPROFILE).toBe(run.env.homeDir) - expect(child.XDG_CONFIG_HOME).toBe(run.env.configDir) - expect(child.XDG_DATA_HOME).toBe(run.env.dataDir) - expect(child.OPENCODE_ANTHROPIC_AUTH_FILE).toBe(join(run.env.configDir, 'anthropic-auth.json')) - expect(child.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE).toBe( - join(await realpath(run.env.configDir), 'anthropic-auth-pool-routing-state.json'), - ) + it( + 'migrates a local login before the host starts and leaves it servable without network', + lifetimes.wrapBody(async () => { + const run = await prepareWithoutHost({ + relay: { + url: 'http://127.0.0.1:9', + token: 'relay-token', + transport: 'websocket', + }, + quotaFeed: true, + hybridCache: true, + }) + expect(String(run.failure)).toContain( + 'opencode exited before announcing its listener (code=7', + ) + const child = run.recorded! + expect(child).toBeDefined() + expect(child.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(child.HOME).toBe(run.env.homeDir) + expect(child.USERPROFILE).toBe(run.env.homeDir) + expect(child.XDG_CONFIG_HOME).toBe(run.env.configDir) + expect(child.XDG_DATA_HOME).toBe(run.env.dataDir) + expect(child.OPENCODE_ANTHROPIC_AUTH_FILE).toBe( + join(run.env.configDir, 'anthropic-auth.json'), + ) + expect(child.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE).toBe( + join( + await realpath(run.env.configDir), + 'anthropic-auth-pool-routing-state.json', + ), + ) - expect(await readNativeMigrationJournal(run.paths)).toMatchObject({ phase: 'retired' }) - // Retirement removed the legacy config. OpenCode's auth.json keeps only a - // non-secret marker that activates the plugin; the real access and - // refresh tokens moved to the shared credential store. - expect(await exists(run.paths.legacyConfig)).toBe(false) - const hostAuth = await readFile(run.hostAuthPath, 'utf8') - expect(isCustodyTombstoneOAuth(JSON.parse(hostAuth).anthropic, 'anthropic')).toBe(true) - expect(hostAuth).not.toContain('test-access-token') + expect(await readNativeMigrationJournal(run.paths)).toMatchObject({ + phase: 'retired', + }) + // Retirement removed the legacy config. OpenCode's auth.json keeps only a + // non-secret marker that activates the plugin; the real access and + // refresh tokens moved to the shared credential store. + expect(await exists(run.paths.legacyConfig)).toBe(false) + const hostAuth = await readFile(run.hostAuthPath, 'utf8') + expect( + isCustodyTombstoneOAuth(JSON.parse(hostAuth).anthropic, 'anthropic'), + ).toBe(true) + expect(hostAuth).not.toContain('test-access-token') - // A runtime that cannot reach any provider stands in for the host: the - // main account must be usable from the validation recorded before spawn. - const offline = offlineOnly() - const runtime = createNativeAccountRuntime({ paths: run.paths, host: 'opencode', local: offline.local }) - try { - const snapshot = await runtime.read() - expect(snapshot.mode).toBe('local') - expect(snapshot.accounts.map(({ id, type, source }) => ({ id, type, source }))).toEqual([ - { id: 'main', type: 'oauth', source: 'local' }, - ]) - const admitted = await runtime.authorizeLocal('main', { intent: 'serve' }) - expect(admitted).toMatchObject({ status: 'usable', access: FIXTURE_ADMITTED_ACCESS_TOKEN }) - expect(offline.calls).toEqual([]) - expect(await runtime.getRelayConfig()).toMatchObject({ - url: 'http://127.0.0.1:9', - token: 'relay-token', - transport: 'websocket', + // Preparation verified that this exact token belongs to the recorded + // account. The runtime must reuse that validation without a new lookup. + const offline = offlineOnly() + const runtime = createNativeAccountRuntime({ + paths: run.paths, + host: 'opencode', + local: offline.local, }) - } finally { - runtime.close() - } - }) + try { + const snapshot = await runtime.read() + expect(snapshot.mode).toBe('local') + expect( + snapshot.accounts.map(({ id, type, source }) => ({ + id, + type, + source, + })), + ).toEqual([{ id: 'main', type: 'oauth', source: 'local' }]) + const admitted = await runtime.authorizeLocal('main', { + intent: 'serve', + }) + expect(admitted).toMatchObject({ + status: 'usable', + access: FIXTURE_ADMITTED_ACCESS_TOKEN, + }) + expect(offline.calls).toEqual([]) + expect(await runtime.getRelayConfig()).toMatchObject({ + url: 'http://127.0.0.1:9', + token: 'relay-token', + transport: 'websocket', + }) + } finally { + runtime.close() + } + }), + ) - it('keeps an OpenCode API-key activation under host control', async () => { - const run = await prepareWithoutHost({ - nativeAccounts: { kind: 'local', hostAuth: { anthropic: { type: 'api', key: 'synthetic-api-key' } } }, - }) - expect(run.recorded?.OPENCODE_AUTH_CONTENT).toBeUndefined() - expect(await readNativeMigrationJournal(run.paths)).toMatchObject({ phase: 'retired' }) - expect(JSON.parse(await readFile(run.hostAuthPath, 'utf8')).anthropic).toEqual({ - type: 'api', - key: 'synthetic-api-key', - }) - const runtime = createNativeAccountRuntime({ paths: run.paths, host: 'opencode' }) - try { - expect((await runtime.read()).accounts).toEqual([]) - } finally { - runtime.close() - } - }) + it( + 'keeps an OpenCode API-key activation under host control', + lifetimes.wrapBody(async () => { + const run = await prepareWithoutHost({ + nativeAccounts: { + kind: 'local', + hostAuth: { anthropic: { type: 'api', key: 'synthetic-api-key' } }, + }, + }) + expect(run.recorded?.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(await readNativeMigrationJournal(run.paths)).toMatchObject({ + phase: 'retired', + }) + expect( + JSON.parse(await readFile(run.hostAuthPath, 'utf8')).anthropic, + ).toEqual({ + type: 'api', + key: 'synthetic-api-key', + }) + const runtime = createNativeAccountRuntime({ + paths: run.paths, + host: 'opencode', + }) + try { + expect((await runtime.read()).accounts).toEqual([]) + } finally { + runtime.close() + } + }), + ) - it('refuses to migrate, and never starts the host, while OPENCODE_AUTH_CONTENT is set', async () => { - const run = await prepareWithoutHost({ - childEnv: { - OPENCODE_AUTH_CONTENT: JSON.stringify({ anthropic: { type: 'api', key: 'synthetic-api-key' } }), - }, - }) - expect(String(run.failure)).toContain('OPENCODE_AUTH_CONTENT') - expect(run.recorded).toBeUndefined() - expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() - expect(await exists(run.paths.config)).toBe(false) - expect(await exists(run.paths.legacyConfig)).toBe(true) - }) + it( + 'refuses to migrate, and never starts the host, while OPENCODE_AUTH_CONTENT is set', + lifetimes.wrapBody(async () => { + const run = await prepareWithoutHost({ + childEnv: { + OPENCODE_AUTH_CONTENT: JSON.stringify({ + anthropic: { type: 'api', key: 'synthetic-api-key' }, + }), + }, + }) + expect(String(run.failure)).toContain('OPENCODE_AUTH_CONTENT') + expect(run.recorded).toBeUndefined() + expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() + expect(await exists(run.paths.config)).toBe(false) + expect(await exists(run.paths.legacyConfig)).toBe(true) + }), + ) - it('leaves a deliberately unmigrated fixture on its legacy files', async () => { - const run = await prepareWithoutHost({ nativeAccounts: { kind: 'unmigrated' } }) - expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') - expect(JSON.parse(run.recorded!.OPENCODE_AUTH_CONTENT!).anthropic).toMatchObject({ - type: 'oauth', - access: 'test-access-token', - }) - expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() - expect(await exists(run.paths.legacyConfig)).toBe(true) - expect(await exists(run.hostAuthPath)).toBe(false) - }) + it( + 'leaves a deliberately unmigrated fixture on its legacy files', + lifetimes.wrapBody(async () => { + const run = await prepareWithoutHost({ + nativeAccounts: { kind: 'unmigrated' }, + }) + expect(String(run.failure)).toContain( + 'opencode exited before announcing its listener (code=7', + ) + expect( + JSON.parse(run.recorded!.OPENCODE_AUTH_CONTENT!).anthropic, + ).toMatchObject({ + type: 'oauth', + access: 'sk-ant-oat01-test-access-token', + }) + expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() + expect(await exists(run.paths.legacyConfig)).toBe(true) + expect(await exists(run.hostAuthPath)).toBe(false) + }), + ) - it('gives a deliberately unmigrated fixture a real auth.json instead of OPENCODE_AUTH_CONTENT', async () => { - const run = await prepareWithoutHost({ - nativeAccounts: { kind: 'unmigrated', hostAuth: { anthropic: { type: 'oauth', access: '', refresh: 'claustrum-tombstone:v1:anthropic', expires: 0 } } }, - }) - expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') - expect(run.recorded?.OPENCODE_AUTH_CONTENT).toBeUndefined() - expect(isCustodyTombstoneOAuth(JSON.parse(await readFile(run.hostAuthPath, 'utf8')).anthropic, 'anthropic')).toBe(true) - expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() - expect(await exists(run.paths.legacyConfig)).toBe(true) - }) + it( + 'gives a deliberately unmigrated fixture a real auth.json instead of OPENCODE_AUTH_CONTENT', + lifetimes.wrapBody(async () => { + const run = await prepareWithoutHost({ + nativeAccounts: { + kind: 'unmigrated', + hostAuth: { + anthropic: { + type: 'oauth', + access: '', + refresh: 'claustrum-tombstone:v1:anthropic', + expires: 0, + }, + }, + }, + }) + expect(String(run.failure)).toContain( + 'opencode exited before announcing its listener (code=7', + ) + expect(run.recorded?.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect( + isCustodyTombstoneOAuth( + JSON.parse(await readFile(run.hostAuthPath, 'utf8')).anthropic, + 'anthropic', + ), + ).toBe(true) + expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() + expect(await exists(run.paths.legacyConfig)).toBe(true) + }), + ) - it('discovers vault accounts through the mock daemon named in the child environment', async () => { - const daemonDir = await scratchDir('anthropic-auth-e2e-fixture-vault-') - const daemon = await startFakeClaustrumDaemon({ - directory: daemonDir, - scopedCredentials: { - 'oauth:anthropic': { - payload: 'scoped-main', - account_id: 'account-main', - record_version: 1, - expires_at_ms: Date.now() + 3_600_000, + it( + 'discovers vault accounts without reading or migrating local files through the mock daemon named in the child environment', + lifetimes.wrapBody(async () => { + const preservedLocal = new Map() + const daemonDir = await scratchDir('anthropic-auth-e2e-fixture-vault-') + const daemon = await startFakeClaustrumDaemon({ + directory: daemonDir, + scopedCredentials: { + 'oauth:anthropic': { + payload: 'scoped-main', + account_id: 'account-main', + record_version: 1, + expires_at_ms: Date.now() + 3_600_000, + }, }, - }, - }) - cleanups.push(() => daemon.stop()) - const run = await prepareWithoutHost({ - nativeAccounts: { kind: 'vault' }, - childEnv: { OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile }, - beforeSpawn: async (env) => { - await writeFile(join(env.configDir, 'anthropic-auth.json'), JSON.stringify({ - version: 1, accounts: [], quota: { enabled: false }, - claustrum: { mode: 'claustrum', scopedRoster: true, primaryAccount: { credentialId: 'oauth:anthropic', accountId: 'account-main', state: 'active' } }, - }), { mode: 0o600 }) - await writeFile(join(env.configDir, 'claustrum-enrollment.json'), JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), { mode: 0o600 }) - }, - }) - expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') - expect(run.recorded?.OPENCODE_AUTH_CONTENT).toBeUndefined() - expect(await readNativeMigrationJournal(run.paths)).toMatchObject({ phase: 'retired' }) - expect(daemon.scopedLists).toBeGreaterThan(0) - expect(daemon.credentialGets).toEqual([]) - expect(isCustodyTombstoneOAuth(JSON.parse(await readFile(run.hostAuthPath, 'utf8')).anthropic, 'anthropic')).toBe(true) - const roster = await readFile(run.paths.roster, 'utf8') - expect(roster).toContain('oauth:anthropic') - expect(roster).not.toContain('scoped-main') - const runtime = createNativeAccountRuntime({ paths: run.paths, host: 'opencode' }) - try { - const snapshot = await runtime.read() - expect(snapshot.mode).toBe('claustrum') - expect(snapshot.accounts.find((account) => account.id === 'main')).toMatchObject({ - source: 'vault', - credentialId: 'oauth:anthropic', }) - } finally { - runtime.close() - } - }) + deferCleanup(() => daemon.stop()) + const run = await prepareWithoutHost({ + nativeAccounts: { kind: 'vault' }, + childEnv: { + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: + daemon.connectionFile, + }, + beforeSpawn: async (env) => { + const localPaths = await resolveNativePoolPaths( + join(env.configDir, 'anthropic-auth.json'), + join(env.configDir, 'anthropic-auth-state.json'), + ) + for (const path of [ + localPaths.config, + localPaths.state, + localPaths.runtime, + localPaths.roster, + ]) { + const bytes = `opaque local fixture ${path}` + await writeFile(path, bytes, { mode: 0o600 }) + preservedLocal.set(path, bytes) + } + preservedLocal.set( + localPaths.legacyConfig, + await readFile(localPaths.legacyConfig, 'utf8'), + ) + await writeFile( + join(env.configDir, 'claustrum-enrollment.json'), + JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), + { mode: 0o600 }, + ) + }, + }) + expect(String(run.failure)).toContain( + 'opencode exited before announcing its listener (code=7', + ) + expect(run.recorded?.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() + for (const [path, bytes] of preservedLocal) + expect(await readFile(path, 'utf8')).toBe(bytes) + expect(daemon.scopedLists).toBeGreaterThan(0) + expect(daemon.credentialGets).toEqual([]) + expect( + isCustodyTombstoneOAuth( + JSON.parse(await readFile(run.hostAuthPath, 'utf8')).anthropic, + 'anthropic', + ), + ).toBe(true) + const roster = await readFile(nativeVaultPaths(run.paths).roster, 'utf8') + expect(roster).toContain('oauth:anthropic') + expect(roster).not.toContain('scoped-main') + const runtime = createNativeAccountRuntime({ + paths: run.paths, + host: 'opencode', + vault: { env: run.recorded }, + }) + try { + const snapshot = await runtime.read() + expect(snapshot.mode).toBe('claustrum') + expect( + snapshot.accounts.find((account) => account.id === 'main'), + ).toMatchObject({ + source: 'vault', + credentialId: 'oauth:anthropic', + }) + } finally { + runtime.close() + } + }), + ) - it('refuses a vault fixture whose daemon connection is not named explicitly', async () => { - const run = await prepareWithoutHost({ nativeAccounts: { kind: 'vault' } }) - expect(String(run.failure)).toContain('OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE') - expect(run.recorded).toBeUndefined() - expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() - }) + it( + 'refuses a vault fixture whose daemon connection is not named explicitly', + lifetimes.wrapBody(async () => { + const run = await prepareWithoutHost({ + nativeAccounts: { kind: 'vault' }, + }) + expect(String(run.failure)).toContain( + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE', + ) + expect(run.recorded).toBeUndefined() + expect(await readNativeMigrationJournal(run.paths)).toBeUndefined() + }), + ) }) -const configDepsFixture = resolve(import.meta.dir, '../fixtures/opencode1-config-deps') +const configDepsFixture = resolve( + import.meta.dir, + '../fixtures/opencode1-config-deps', +) // The committed fixture holds only package.json and package-lock.json; a // runner provisions node_modules from that lockfile. Without it, the copy // cannot be checked here, and the test reports itself as skipped. -const configDepsInstalled = await lstat(join(configDepsFixture, 'node_modules', '.package-lock.json')).then( +const configDepsInstalled = await lstat( + join(configDepsFixture, 'node_modules', '.package-lock.json'), +).then( () => true, () => false, ) @@ -292,39 +441,96 @@ const configDepsInstalled = await lstat(join(configDepsFixture, 'node_modules', async function opencodeWouldSkipInstall(dir: string) { if (!(await exists(join(dir, 'node_modules')))) return false const manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) - const lock = JSON.parse(await readFile(join(dir, 'package-lock.json'), 'utf8')) + const lock = JSON.parse( + await readFile(join(dir, 'package-lock.json'), 'utf8'), + ) const names = (value: Record | undefined) => - ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].flatMap((field) => + [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + ].flatMap((field) => Object.keys((value?.[field] as Record | undefined) ?? {}), ) const locked = new Set(names(lock.packages?.[''])) - return [...names(manifest), '@opencode-ai/plugin'].every((name) => locked.has(name)) + return [...names(manifest), '@opencode-ai/plugin'].every((name) => + locked.has(name), + ) } describe('OpenCode config-directory dependency fixture', () => { - it.skipIf(!configDepsInstalled)('copies the installed plugin dependency tree into every OpenCode config directory', async () => { - const run = await prepareWithoutHost({}, { [OPENCODE_CONFIG_DEPS_ENV]: configDepsFixture }) - expect(String(run.failure)).toContain('opencode exited before announcing its listener (code=7') - const fixtureLock = JSON.parse(await readFile(join(configDepsFixture, 'package-lock.json'), 'utf8')) - for (const dir of [run.env.configDir, join(run.env.configDir, 'opencode')]) { - expect(await opencodeWouldSkipInstall(dir)).toBe(true) - expect(JSON.parse(await readFile(join(dir, 'package-lock.json'), 'utf8'))).toEqual(fixtureLock) - const plugin = JSON.parse(await readFile(join(dir, 'node_modules', '@opencode-ai', 'plugin', 'package.json'), 'utf8')) - expect(plugin.version).toBe('1.18.18') - expect((await lstat(join(dir, 'node_modules', '@opencode-ai', 'plugin', plugin.exports['.'].import))).size).toBeGreaterThan(0) - expect(JSON.parse(await readFile(join(dir, 'node_modules', '@opencode-ai', 'sdk', 'package.json'), 'utf8')).version).toBe('1.18.18') - } - }) + it.skipIf(!configDepsInstalled)( + 'copies the installed plugin dependency tree into every OpenCode config directory', + lifetimes.wrapBody(async () => { + const run = await prepareWithoutHost( + {}, + { [OPENCODE_CONFIG_DEPS_ENV]: configDepsFixture }, + ) + expect(String(run.failure)).toContain( + 'opencode exited before announcing its listener (code=7', + ) + const fixtureLock = JSON.parse( + await readFile(join(configDepsFixture, 'package-lock.json'), 'utf8'), + ) + for (const dir of [ + run.env.configDir, + join(run.env.configDir, 'opencode'), + ]) { + expect(await opencodeWouldSkipInstall(dir)).toBe(true) + expect( + JSON.parse(await readFile(join(dir, 'package-lock.json'), 'utf8')), + ).toEqual(fixtureLock) + const plugin = JSON.parse( + await readFile( + join(dir, 'node_modules', '@opencode-ai', 'plugin', 'package.json'), + 'utf8', + ), + ) + expect(plugin.version).toBe('1.18.18') + expect( + ( + await lstat( + join( + dir, + 'node_modules', + '@opencode-ai', + 'plugin', + plugin.exports['.'].import, + ), + ) + ).size, + ).toBeGreaterThan(0) + expect( + JSON.parse( + await readFile( + join(dir, 'node_modules', '@opencode-ai', 'sdk', 'package.json'), + 'utf8', + ), + ).version, + ).toBe('1.18.18') + } + }), + ) - it('refuses an uninstalled dependency fixture before the host starts', async () => { - const incomplete = await scratchDir('anthropic-auth-e2e-config-deps-') - for (const name of ['package.json', 'package-lock.json']) - await cp(join(configDepsFixture, name), join(incomplete, name)) - await mkdir(join(incomplete, 'node_modules')) - const run = await prepareWithoutHost({}, { [OPENCODE_CONFIG_DEPS_ENV]: incomplete }) - expect(String(run.failure)).toContain('has no npm-installed node_modules') - expect(run.recorded).toBeUndefined() - for (const dir of [run.env.configDir, join(run.env.configDir, 'opencode')]) - expect(await exists(join(dir, 'node_modules'))).toBe(false) - }) + it( + 'refuses an uninstalled dependency fixture before the host starts', + lifetimes.wrapBody(async () => { + const incomplete = await scratchDir('anthropic-auth-e2e-config-deps-') + for (const name of ['package.json', 'package-lock.json']) + await cp(join(configDepsFixture, name), join(incomplete, name)) + await mkdir(join(incomplete, 'node_modules')) + const run = await prepareWithoutHost( + {}, + { [OPENCODE_CONFIG_DEPS_ENV]: incomplete }, + ) + expect(String(run.failure)).toContain('has no npm-installed node_modules') + expect(run.recorded).toBeUndefined() + for (const dir of [ + run.env.configDir, + join(run.env.configDir, 'opencode'), + ]) + expect(await exists(join(dir, 'node_modules'))).toBe(false) + }), + ) }) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 606b67199..c2d8eae74 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -20497,27 +20497,37 @@ describe('killswitch fetch gate', () => { // (5h:10): a fallback at 30% passes routing policy but is killswitch-killed. // fallback-first must NOT serve from it — it should fall through to the // healthy main account instead. - await useTempAccountFile( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - quota: { - enabled: true, - checkIntervalMinutes: 5, - minimumRemaining: { five_hour: 10, seven_day: 20 }, - failClosedOnUnknownQuota: false, + const storage = createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: false, + }, + killswitch: { enabled: true, main: { five_hour: 50, seven_day: 10 } }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'sk-ant-oat01-fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, }, - killswitch: { enabled: true, main: { five_hour: 50, seven_day: 10 } }, - accounts: [ - { - id: 'fallback-1', - type: 'oauth', - access: 'sk-ant-oat01-fallback-access', - refresh: 'fallback-refresh', - expires: Date.now() + 5 * 60 * 60 * 1000, - }, - ], - }), + ], + }) + const fallback = storage.accounts.find( + (account) => account.id === 'fallback-1', + ) + if (!fallback || !isOAuthAccount(fallback)) + throw new Error('Missing killswitch fixture fallback') + delete fallback.quota + await useTempAccountFile(storage) + const initialFallback = (await readAccountStorage())?.accounts.find( + (account): account is OAuthAccount => + account.id === 'fallback-1' && isOAuthAccount(account), ) + expect(initialFallback?.quota).toBeUndefined() let servedAuth: string | undefined const quotaAuthorizations: string[] = [] diff --git a/packages/opencode/src/tests/native-fixture.test.ts b/packages/opencode/src/tests/native-fixture.test.ts index 1841cdddc..fca648a32 100644 --- a/packages/opencode/src/tests/native-fixture.test.ts +++ b/packages/opencode/src/tests/native-fixture.test.ts @@ -239,8 +239,13 @@ function syntheticVault() { async function readAccounts( paths: Awaited>, + env?: NodeJS.ProcessEnv, ) { - const runtime = createNativeAccountRuntime({ paths, host: 'opencode' }) + const runtime = createNativeAccountRuntime({ + paths, + host: 'opencode', + vault: { env }, + }) try { return await runtime.read() } finally { @@ -362,7 +367,7 @@ test('vault custody discovery publishes the roster and runtime seed through the }) expect(vault.calls.close).toBeGreaterThan(0) - const snapshot = await readAccounts(fixture.paths) + const snapshot = await readAccounts(fixture.paths, fixture.env) expect(snapshot.mode).toBe('claustrum') const fallback = snapshot.accounts.find( (account) => account.id === 'fallback-route', diff --git a/packages/opencode/src/tests/scoped-custody-serving.test.ts b/packages/opencode/src/tests/scoped-custody-serving.test.ts index d299e01f6..da87bdde4 100644 --- a/packages/opencode/src/tests/scoped-custody-serving.test.ts +++ b/packages/opencode/src/tests/scoped-custody-serving.test.ts @@ -9,6 +9,7 @@ import { } from '@cortexkit/anthropic-auth-core' import type { ScopedInventoryRow } from '@cortexkit/claustrum-client' import { acquireRefreshFileLock } from '@cortexkit/common-auth/fs' +import { nativeVaultPaths } from '../../../core/src/native-vault-paths.ts' import { createTestLifetimeSuite } from '../../../core/src/tests/test-lifetime.ts' import { AnthropicAuthPlugin } from '../index.ts' import { drainSidebarWrites, getSidebarStateFile } from '../sidebar-state.ts' @@ -578,7 +579,7 @@ test('loader and model request remain usable when a peer holds the scoped roster // native roster refresh must take before listing the vault. const lease = await acquireRefreshFileLock({ name: 'claustrum-roster', - path: fixture.paths.roster, + path: nativeVaultPaths(fixture.paths).roster, ttlMs: 30_000, renew: true, }) From 0e4751b65768ac95c2f62695bc974b75894b3e3b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:23:05 +0200 Subject: [PATCH 154/169] mason: drain both host fixtures and prove failure-phase reporting Start both synthetic server stops under Promise.allSettled, including synchronous throws, and report a redacted cleanup failure only after both settle. Exercise the owning command, withScenario, phase-clock, redactor and scenario-cleanup declarations extracted from current source with Babel and Bun.Transpiler; never import the E2E module or execute its host/install hooks. Four real stand-in children prove healthy silence, failed-child installation/start/exit clocks, cancellation and scheduled-deadline reports after both pipes drain, and all fixture-secret redactions. The deadline test invokes the captured callback scheduled for the unchanged 60000ms limit; no earlier watchdog or test-timeout change. A throwing upstream stop proves the relay still starts and drains before the cleanup failure is reported. Zero-network Linux final verification with Bun 1.3.14: Core before OpenCode builds; TypeScript 7.0.2 package and scripts typechecks; 11 tests pass, 0 fail, 85 expectations; Biome 2.5.14 checks both files; workspace-lock parity. Source mutations individually reddened only healthy-silence and concurrent-cleanup tests, respectively (10 other tests stayed green in each run), then restored. Sidekick comment review found no unclear comments. Actual OC1/OC2 host CI remains parent-owned and was not rerun. --- .../scripts/check-bundle-globals.test.ts | 380 ++++++++++++++++++ .../opencode/src/tests/opencode2-e2e.test.ts | 10 +- 2 files changed, 388 insertions(+), 2 deletions(-) diff --git a/packages/opencode/scripts/check-bundle-globals.test.ts b/packages/opencode/scripts/check-bundle-globals.test.ts index 0d00a35b9..4a15acf1c 100644 --- a/packages/opencode/scripts/check-bundle-globals.test.ts +++ b/packages/opencode/scripts/check-bundle-globals.test.ts @@ -10,6 +10,8 @@ import { import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { parse } from '@babel/parser' +import { type TestGate, TestLifetime } from '../../core/src/tests/test-lifetime' import { checkBundleGlobals } from './check-bundle-globals' async function fixture(run: (root: string) => Promise) { @@ -36,6 +38,271 @@ test('a small entry is valid when its imported chunk contains the implementation }) }) +interface PhaseClock { + phases: Record + mark(name: string): void +} +interface StandInRun { + root: string + env: Record + result: { stdout: string; stderr: string; exit: number } +} +interface CapturedHostHarness { + installation: PhaseClock + secrets: string[] + command( + args: string[], + cwd: string, + env: Record, + limit: number, + cancellation: TestGate, + timing: { clock: PhaseClock; name: string }, + ): Promise + withScenario(args: string[], body: (run: StandInRun) => void): Promise + phaseClock(): PhaseClock + stopServers( + upstream: { stop(force: boolean): unknown }, + relay: { stop(force: boolean): unknown }, + clock: PhaseClock, + ): Promise +} + +async function captureHostHarness(bindings: Record) { + const source = await readFile( + resolve(import.meta.dir, '../src/tests/opencode2-e2e.test.ts'), + 'utf8', + ) + const program = parse(source, { + sourceType: 'module', + plugins: ['typescript'], + }).program + const names = new Set([ + 'TOKEN', + 'PASSWORD', + 'phaseClock', + 'installation', + 'redactFixtureSecrets', + 'hostLogDirectory', + 'command', + 'withScenario', + ]) + const declarations: string[] = [] + const slice = (node: { start?: number | null; end?: number | null }) => { + if (node.start == null || node.end == null) + throw new Error('Missing source range') + return source.slice(node.start, node.end) + } + for (const node of program.body) { + const name = + node.type === 'FunctionDeclaration' + ? node.id?.name + : node.type === 'VariableDeclaration' && + node.declarations[0]?.id.type === 'Identifier' + ? node.declarations[0].id.name + : undefined + if (name && names.delete(name)) declarations.push(slice(node)) + } + if (names.size) + throw new Error(`Missing host declarations: ${[...names].join(', ')}`) + const scenario = program.body.find( + (node) => + node.type === 'FunctionDeclaration' && node.id?.name === 'scenario', + ) + if (scenario?.type !== 'FunctionDeclaration') + throw new Error('Missing scenario declaration') + const cleanup = scenario.body.body.find( + (node) => + node.type === 'ExpressionStatement' && + node.expression.type === 'CallExpression' && + node.expression.callee.type === 'Identifier' && + node.expression.callee.name === 'deferCleanup', + ) + if ( + cleanup?.type !== 'ExpressionStatement' || + cleanup.expression.type !== 'CallExpression' + ) + throw new Error('Missing server cleanup registration') + const callback = cleanup.expression.arguments[0] + if (callback?.type !== 'ArrowFunctionExpression') + throw new Error('Missing server cleanup callback') + + // Evaluate the current declarations, not an imported E2E module: importing + // that module would register real-host installation and execution hooks. + const captured = new Bun.Transpiler({ loader: 'ts' }).transformSync( + declarations.join('\n'), + ) + return new Function( + 'bindings', + ` + const { Bun, mkdir, readFile, readdir, rm, writeFile, join, children, cli, oc1, + trackDetached, deferCleanup, scenario, console, setTimeout, clearTimeout } = bindings; + ${captured} + return { installation, command, withScenario, phaseClock, + secrets: [TOKEN, PASSWORD, 'synthetic-refresh', 'relay-disposable-only'], + stopServers: (upstream, relay, clock) => (${slice(callback)})() }; + `, + )(bindings) as CapturedHostHarness +} + +async function standInHost( + mode: 'healthy' | 'failed' | 'cancelled' | 'deadline', +) { + let evidence: + | { + output: string[] + events: string[] + failed: boolean + childrenLeft: number + timerDelays: number[] + secrets: string[] + } + | undefined + await fixture(async (root) => { + const lifetime = new TestLifetime() + const children = new Set() + const output: string[] = [] + const events: string[] = [] + const ready = Promise.withResolvers() + const timerDelays: number[] = [] + let deadline: (() => void) | undefined + let stdoutTail = '' + const observingBun = { + spawn( + args: string[], + options: { + cwd: string + env: Record + stdout: 'pipe' + stderr: 'pipe' + }, + ) { + const child = Bun.spawn(args, options) + const observe = (stream: ReadableStream, name: string) => + stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (name === 'stdout') { + stdoutTail = ( + stdoutTail + new TextDecoder().decode(chunk) + ).slice(-100) + if (stdoutTail.includes('READY_FOR_CANCELLATION')) + ready.resolve() + } + controller.enqueue(chunk) + }, + flush() { + events.push(`${name}.drained`) + }, + }), + ) + const stdout = observe(child.stdout, 'stdout') + const stderr = observe(child.stderr, 'stderr') + return new Proxy(child, { + get(target, key) { + if (key === 'stdout') return stdout + if (key === 'stderr') return stderr + const value = Reflect.get(target, key, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + }, + } + let harness: CapturedHostHarness + harness = await captureHostHarness({ + Bun: observingBun, + mkdir, + readFile, + readdir, + rm, + writeFile, + join, + children, + cli: process.execPath, + oc1: '', + trackDetached: (work: Promise) => lifetime.trackDetached(work), + deferCleanup: (cleanup: () => Promise) => + lifetime.deferCleanup(cleanup), + console: { + error(message: string) { + if (children.size) throw new Error('Reported before child drainage') + events.push('report') + output.push(message) + }, + }, + setTimeout: + mode === 'deadline' + ? (callback: () => void, delay: number) => { + deadline = callback + timerDelays.push(delay) + return 1 + } + : globalThis.setTimeout, + clearTimeout: mode === 'deadline' ? () => {} : globalThis.clearTimeout, + scenario: async ( + _args: string[], + clock: PhaseClock, + ): Promise => { + const scenarioRoot = join(root, 'scenario') + const env = { + TMPDIR: join(scenarioRoot, 'tmp'), + PATH: process.env.PATH ?? '', + } + await mkdir(env.TMPDIR, { recursive: true }) + clock.mark('fixture.start') + const hold = mode === 'cancelled' || mode === 'deadline' + const script = ` + await Bun.write(Bun.stdout, ${JSON.stringify(harness.secrets.join('\n'))} + '\\n' + 'x'.repeat(65536) + '\\nSTDOUT_DRAINED\\n'); + await Bun.write(Bun.stderr, ${JSON.stringify(harness.secrets.join('\n'))} + '\\n' + 'y'.repeat(65536) + '\\nSTDERR_DRAINED\\n'); + ${hold ? "setInterval(() => {}, 1000); console.log('READY_FOR_CANCELLATION');" : `process.exit(${mode === 'failed' ? 7 : 0});`} + ` + const result = await harness.command( + [process.execPath, '-e', script], + root, + env, + 60_000, + lifetime.gate(), + { clock, name: 'host' }, + ) + return { root: scenarioRoot, env, result } + }, + }) + harness.installation.mark('dependencies.start') + harness.installation.mark('dependencies.ready') + const body = lifetime + .runBody(() => + harness.withScenario(['direct'], (run) => { + if (run.result.exit !== 0) throw new Error('Stand-in child failed') + }), + ) + .then( + () => false, + () => true, + ) + if (mode === 'cancelled') { + await ready.promise + await lifetime.finish() + } else if (mode === 'deadline') { + await ready.promise + // Fire the callback scheduled for the unchanged 60-second deadline; + // no earlier watchdog or host process is needed for this branch. + if (!deadline) throw new Error('Command did not schedule its deadline') + deadline() + } + const failed = await body + await lifetime.finish() + evidence = { + output, + events, + failed, + childrenLeft: children.size, + timerDelays, + secrets: harness.secrets, + } + }) + if (!evidence) throw new Error('Stand-in evidence missing') + return evidence +} + test('an unrelated chunk cannot supply the registry positive control', async () => { await fixture(async (root) => { await writeFile(join(root, 'index.js'), body('not-the-registry')) @@ -133,3 +400,116 @@ test('a fresh packed prefix resolves the RPC client alongside server and TUI', a ).toContain('createRpcClient') }) }) + +function assertFailedStandIn( + evidence: Awaited>, +) { + expect(evidence.failed).toBe(true) + expect(evidence.childrenLeft).toBe(0) + const report = JSON.parse(evidence.output[0]!) as { + phaseClocks: { + installation: PhaseClock['phases'] + scenario: PhaseClock['phases'] + } + } + for (const name of ['dependencies.start', 'dependencies.ready']) + expect(report.phaseClocks.installation[name]?.at).toMatch( + /^\d{4}-\d{2}-\d{2}T/, + ) + for (const name of ['host.start', 'host.exit']) + expect(report.phaseClocks.scenario[name]?.at).toMatch(/^\d{4}-\d{2}-\d{2}T/) + expect(evidence.events.indexOf('stdout.drained')).toBeLessThan( + evidence.events.indexOf('report'), + ) + expect(evidence.events.indexOf('stderr.drained')).toBeLessThan( + evidence.events.indexOf('report'), + ) + expect(evidence.events).toContain('stdout.drained') + expect(evidence.events).toContain('stderr.drained') + const output = evidence.output.join('\n') + expect(output).toContain('STDOUT_DRAINED') + expect(output).toContain('STDERR_DRAINED') + expect(output).toContain('[redacted]') + for (const secret of evidence.secrets) expect(output).not.toContain(secret) + return report.phaseClocks.scenario +} + +test('captured host phase reporting stays quiet for a healthy real child', async () => { + const evidence = await standInHost('healthy') + expect(evidence.failed).toBe(false) + expect(evidence.childrenLeft).toBe(0) + expect(evidence.events).toContain('stdout.drained') + expect(evidence.events).toContain('stderr.drained') + expect(evidence.output).toEqual([]) +}) + +test('captured host phase reporting includes failed child clocks and redacts fixture secrets', async () => { + const phases = assertFailedStandIn(await standInHost('failed')) + expect(phases['host.cancelled']).toBeUndefined() + expect(phases['host.deadline']).toBeUndefined() +}) + +test('captured host cancellation reports only after real child pipes drain', async () => { + const phases = assertFailedStandIn(await standInHost('cancelled')) + expect(phases['host.cancelled']).toBeDefined() + expect(phases['host.deadline']).toBeUndefined() +}) + +test('captured host scheduled deadline reports only after real child pipes drain', async () => { + const evidence = await standInHost('deadline') + const phases = assertFailedStandIn(evidence) + expect(evidence.timerDelays).toEqual([60_000]) + expect(phases['host.deadline']).toBeDefined() + expect(phases['host.cancelled']).toBeUndefined() +}) + +test('captured scenario cleanup drains both server stops before reporting failure', async () => { + const harness = await captureHostHarness({}) + const relayStarted = Promise.withResolvers() + const relayStopped = Promise.withResolvers() + const events: string[] = [] + const clock = harness.phaseClock() + const stopping = harness + .stopServers( + { + stop(force) { + expect(force).toBe(true) + events.push('upstream.stop') + throw new Error('private transport exception') + }, + }, + { + async stop(force) { + expect(force).toBe(true) + events.push('relay.stop') + relayStarted.resolve() + await relayStopped.promise + events.push('relay.drained') + }, + }, + clock, + ) + .then( + () => undefined, + (error: unknown) => { + events.push('failure.report') + return error + }, + ) + await Promise.race([relayStarted.promise, stopping.then(() => {})]) + expect(events).toEqual(['upstream.stop', 'relay.stop']) + relayStopped.resolve() + const error = await stopping + expect(error).toBeInstanceOf(Error) + expect(String(error)).toContain( + 'Synthetic transport cleanup failed (details redacted)', + ) + expect(String(error)).not.toContain('private transport exception') + expect(events).toEqual([ + 'upstream.stop', + 'relay.stop', + 'relay.drained', + 'failure.report', + ]) + expect(clock.phases['fixture.cleanup_failure']).toBeDefined() +}) diff --git a/packages/opencode/src/tests/opencode2-e2e.test.ts b/packages/opencode/src/tests/opencode2-e2e.test.ts index 60ab76eef..b0628a8b0 100644 --- a/packages/opencode/src/tests/opencode2-e2e.test.ts +++ b/packages/opencode/src/tests/opencode2-e2e.test.ts @@ -596,8 +596,14 @@ async function scenario( }, }) deferCleanup(async () => { - await upstream.stop(true) - await relay.stop(true) + const stopped = await Promise.allSettled([ + Promise.resolve().then(() => upstream.stop(true)), + Promise.resolve().then(() => relay.stop(true)), + ]) + if (stopped.some((result) => result.status === 'rejected')) { + clock.mark('fixture.cleanup_failure') + throw new Error('Synthetic transport cleanup failed (details redacted)') + } }) const paths = await seed( env, From 25c8de5c41c94abe84850a6352f7dc0fdb0de108 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:35:21 +0200 Subject: [PATCH 155/169] mason: bind stand-in children to owning test cancellation Wrap all eleven owning tests with createTestLifetimeSuite. A tracked outer cancellation gate starts private TestLifetime.finish before outer joins; readiness races child/body completion and outer cancellation. Every error path joins the private lifetime and cancellation hook before fixture removal. The cleanup control always releases relayStopped in finally before joining stopping, including assertion failure. Retain exact source capture, the 60000ms command deadline, and unchanged outer 5000ms default; no watchdog or host execution. Counterexample withholds READY from a real child: the owning cancellation test times out at 5000ms, then logs HELD_READY_OUTER_CANCEL; HELD_READY_JOINED children=0 stdout=true stderr=true fixturePresent=true; HELD_READY_FIXTURE_REMOVED exists=false. Run exits in 5.09s with only the selected test failing (ten filtered out), plus the expected late cancellation rejection. Counterexample/audit mutations were restored before final gates. Final zero-network Linux verification, Bun 1.3.14: Core build before OpenCode build, TypeScript 7.0.2 package+scripts typechecks, eleven tests pass/zero fail/85 expectations, Biome 2.5.14 checks one file. Sidekick staged-comment review found no unclear comments. Actual OC1/OC2 host CI remains parent-owned. --- .../scripts/check-bundle-globals.test.ts | 87 +++++++++++++++++-- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/packages/opencode/scripts/check-bundle-globals.test.ts b/packages/opencode/scripts/check-bundle-globals.test.ts index 4a15acf1c..410269d3d 100644 --- a/packages/opencode/scripts/check-bundle-globals.test.ts +++ b/packages/opencode/scripts/check-bundle-globals.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test' +import { expect } from 'bun:test' import { mkdir, mkdtemp, @@ -11,9 +11,15 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parse } from '@babel/parser' -import { type TestGate, TestLifetime } from '../../core/src/tests/test-lifetime' +import { + createTestLifetimeSuite, + type TestGate, + TestLifetime, +} from '../../core/src/tests/test-lifetime' import { checkBundleGlobals } from './check-bundle-globals' +const { test, gate, trackDetached } = createTestLifetimeSuite() + async function fixture(run: (root: string) => Promise) { const root = await mkdtemp(join(tmpdir(), 'anthropic-bundle-check-')) try { @@ -144,6 +150,41 @@ async function captureHostHarness(bindings: Record) { )(bindings) as CapturedHostHarness } +function withPrivateLifetime( + run: ( + root: string, + lifetime: TestLifetime, + cancellation: TestGate, + ) => Promise, +) { + return async (root: string) => { + const lifetime = new TestLifetime() + const cancellation = gate() + const completed = Promise.withResolvers() + let finished = false + const cancelWork = Promise.race([ + cancellation.wait, + completed.promise, + ]).then(async () => { + if (!finished) await lifetime.finish() + }) + trackDetached(cancelWork) + try { + await run(root, lifetime, cancellation) + } finally { + // The owning timeout must cancel the private child before joining it; + // fixture removal waits for the private lifetime and cancellation hook. + try { + await lifetime.finish() + } finally { + finished = true + completed.resolve() + await cancelWork + } + } + } +} + async function standInHost( mode: 'healthy' | 'failed' | 'cancelled' | 'deadline', ) { @@ -157,8 +198,7 @@ async function standInHost( secrets: string[] } | undefined - await fixture(async (root) => { - const lifetime = new TestLifetime() + const run = withPrivateLifetime(async (root, lifetime, cancellation) => { const children = new Set() const output: string[] = [] const events: string[] = [] @@ -166,6 +206,7 @@ async function standInHost( const timerDelays: number[] = [] let deadline: (() => void) | undefined let stdoutTail = '' + let spawned = false const observingBun = { spawn( args: string[], @@ -177,6 +218,7 @@ async function standInHost( }, ) { const child = Bun.spawn(args, options) + spawned = true const observe = (stream: ReadableStream, name: string) => stream.pipeThrough( new TransformStream({ @@ -266,6 +308,18 @@ async function standInHost( return { root: scenarioRoot, env, result } }, }) + lifetime.deferCleanup(() => { + if ( + children.size || + (spawned && + (!events.includes('stdout.drained') || + !events.includes('stderr.drained'))) + ) + throw new Error( + 'Private stand-in lifetime did not drain its child and pipes', + ) + events.push('private.drained') + }) harness.installation.mark('dependencies.start') harness.installation.mark('dependencies.ready') const body = lifetime @@ -278,11 +332,21 @@ async function standInHost( () => false, () => true, ) + const waitForReady = () => + Promise.race([ + ready.promise, + body.then(() => { + throw new Error('Stand-in child ended before readiness') + }), + cancellation.wait.then(() => { + throw new Error('Owning test cancelled before stand-in readiness') + }), + ]) if (mode === 'cancelled') { - await ready.promise + await waitForReady() await lifetime.finish() } else if (mode === 'deadline') { - await ready.promise + await waitForReady() // Fire the callback scheduled for the unchanged 60-second deadline; // no earlier watchdog or host process is needed for this branch. if (!deadline) throw new Error('Command did not schedule its deadline') @@ -299,6 +363,7 @@ async function standInHost( secrets: harness.secrets, } }) + await fixture(run) if (!evidence) throw new Error('Stand-in evidence missing') return evidence } @@ -496,9 +561,13 @@ test('captured scenario cleanup drains both server stops before reporting failur return error }, ) - await Promise.race([relayStarted.promise, stopping.then(() => {})]) - expect(events).toEqual(['upstream.stop', 'relay.stop']) - relayStopped.resolve() + try { + await Promise.race([relayStarted.promise, stopping.then(() => {})]) + expect(events).toEqual(['upstream.stop', 'relay.stop']) + } finally { + relayStopped.resolve() + await stopping + } const error = await stopping expect(error).toBeInstanceOf(Error) expect(String(error)).toContain( From ae327ef6afbd04bbfbd2ce7bbaecb9ee10c64682 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:39:28 +0200 Subject: [PATCH 156/169] mason: keep unsupported consumed OAuth successors without refresh backoff A token exchange can return a successor whose access value is not a Claude OAuth access token (for example a vault custody marker). The previous refresh token is already consumed, so the successor is still saved. It was then recorded as a failed account lookup, so it got a validation or refresh backoff of about 24 hours. - claude-code: ClaudeUnsupportedAccessError and resolveClaudeCodeAccountIdentity, which rejects non-OAuth access before any request. resolveClaudeCodeIdentity is unchanged. - coordinator: the default lookup is now the account variant. A lookup that throws that error or CustodyTombstoneRefreshError reports the bootstrap status 'unsupported-credential'. A saved successor with that status is refused as 'unsupported-access' (persisted: true). Stored unsupported access found while checking admission is refused the same way, with no retry. - runtime: a saved observation with that status is classified as 'unsupported-successor'. Publishing it records no error or backoff and ignores the caller's retry fields. It removes refresh-error and validation retry state no newer than its check and advances refreshErrorClearedAt. The existing row-credential, binding and reset fences still apply. - view: formatNativeAccountState, exported from the package index. No existing test was changed. --- packages/core/src/claude-code.ts | 32 +++ packages/core/src/index.ts | 1 + packages/core/src/native-account-view.ts | 17 ++ .../core/src/native-refresh-coordinator.ts | 71 +++++- packages/core/src/native-runtime.ts | 55 ++++- .../src/tests/native-account-runtime.test.ts | 133 +++++++++++- .../src/tests/native-account-view.test.ts | 30 +++ .../src/tests/native-refresh-failure.test.ts | 203 ++++++++++++++++++ 8 files changed, 531 insertions(+), 11 deletions(-) diff --git a/packages/core/src/claude-code.ts b/packages/core/src/claude-code.ts index 0a0086482..a6ebad736 100644 --- a/packages/core/src/claude-code.ts +++ b/packages/core/src/claude-code.ts @@ -315,6 +315,38 @@ export async function resolveClaudeCodeIdentity( return identity } +/** + * The access value is not a Claude OAuth access token, so Claude cannot + * identify its account. Asking again will not help; only a new sign-in + * replaces it. The message never includes the rejected value. + */ +export class ClaudeUnsupportedAccessError extends Error { + readonly code = 'unsupported-access' + + constructor() { + super( + 'Claude OAuth access credential is not supported; sign in to this account again', + ) + this.name = 'ClaudeUnsupportedAccessError' + } +} + +/** + * Account lookup for credentials that must belong to a Claude account. + * Unlike resolveClaudeCodeIdentity, which returns an identity without an + * account UUID for any other access value, this rejects such a value with + * ClaudeUnsupportedAccessError before any network request. + */ +export async function resolveClaudeCodeAccountIdentity( + accessToken: string, + model: string | undefined, + accountIdentity: string | undefined, +): Promise { + if (typeof accessToken !== 'string' || !accessToken.startsWith('sk-ant-oat')) + throw new ClaudeUnsupportedAccessError() + return resolveClaudeCodeIdentity(accessToken, model, accountIdentity) +} + function isRecord(value: unknown): value is Record { return value != null && typeof value === 'object' && !Array.isArray(value) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f00206206..88f86593a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,6 +39,7 @@ export type { NativeAccountSnapshot, NativeAccountView, } from './native-account-view.ts' +export { formatNativeAccountState } from './native-account-view.ts' export * from './native-credential-validation.ts' export type { NativeCustody, diff --git a/packages/core/src/native-account-view.ts b/packages/core/src/native-account-view.ts index 90b2c9b59..783d3e042 100644 --- a/packages/core/src/native-account-view.ts +++ b/packages/core/src/native-account-view.ts @@ -55,6 +55,23 @@ export interface NativeAccountView { authHeader?: 'authorization-bearer' | 'x-api-key' } +/** + * One-line status text for an account's state, shared by host status + * displays. A local account in 'unsupported-access' holds a saved access value + * that is not a Claude OAuth access token; it is never sent to Claude or + * refreshed, so the text names the remedy. Other states, such as vault + * credential states, are shown as recorded. Returns '' when there is no state. + */ +export function formatNativeAccountState( + view: Pick, +): string { + const state = view.state + if (state === undefined || state === '') return '' + if (state === 'unsupported-access') + return 'unsupported credential: the saved access token is not a Claude OAuth token; sign in to this account again' + return state +} + export interface NativeAccountSnapshot { mode: 'local' | 'claustrum' settings: Record diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index eeb8b29f9..02c328e25 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -17,8 +17,15 @@ import { } from '@cortexkit/common-auth/store' import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from './auth.ts' -import { resolveClaudeCodeIdentity } from './claude-code.ts' -import { isCustodyTombstoneValue } from './claustrum.ts' +import { + ClaudeUnsupportedAccessError, + resolveClaudeCodeAccountIdentity, + type resolveClaudeCodeIdentity, +} from './claude-code.ts' +import { + CustodyTombstoneRefreshError, + isCustodyTombstoneValue, +} from './claustrum.ts' import { type NativeLocalCredentialValidation, type NativeLocalCredentialVersion, @@ -76,7 +83,26 @@ export type NativeRefreshRestriction = credentialFingerprint: string } -export type NativeIdentityBootstrap = 'resolved' | 'unavailable' | 'failed' +/** + * Outcome of the account lookup for a credential. 'unsupported-credential' + * means the lookup refused the access value itself (it is not a Claude OAuth + * access token, or it is a vault custody marker): no lookup request was made + * and no retry can succeed until the account is signed in again. + */ +export type NativeIdentityBootstrap = + | 'resolved' + | 'unavailable' + | 'failed' + | 'unsupported-credential' + +// Errors that reject the access value itself rather than report a failed +// lookup. Retrying cannot help, so they must not start a refresh backoff. +function unsupportedCredential(error: unknown): boolean { + return ( + error instanceof ClaudeUnsupportedAccessError || + error instanceof CustodyTombstoneRefreshError + ) +} export type NativeRefreshHandoff = | 'not-needed' | 'acquired' @@ -514,7 +540,7 @@ export function createNativeRefreshCoordinator( const store = createNativePoolStore(options) const now = options.now ?? Date.now const provider = options.refreshToken ?? refreshClaudeOAuthToken - const bootstrap = options.resolveIdentity ?? resolveClaudeCodeIdentity + const bootstrap = options.resolveIdentity ?? resolveClaudeCodeAccountIdentity const onLockEvent = nativeLockObserver(options.onLockEvent) async function rowFor(binding: NativeLocalPoolBinding) { @@ -842,6 +868,22 @@ export function createNativeRefreshCoordinator( classification: 'transient', } } catch (caught) { + if (unsupportedCredential(caught)) { + // The stored access value can never identify an account, so + // record no validation retry. Only a new sign-in replaces it. + bootstrapStatus = 'unsupported-credential' + await reconcile({ + ...observation(), + status: 'refused', + reason: 'unsupported-access', + consumed: false, + }) + return { + status: 'refused', + reason: 'unsupported-access', + persisted: false, + } + } bootstrapStatus = 'failed' validationFailure = { kind: 'validation', @@ -948,8 +990,12 @@ export function createNativeRefreshCoordinator( ? returnedIdentity : undefined bootstrapStatus = identity ? 'resolved' : 'unavailable' - } catch { - bootstrapStatus = 'failed' + } catch (caught) { + // An unsupported successor is still saved below: the provider + // has already consumed the previous refresh token. + bootstrapStatus = unsupportedCredential(caught) + ? 'unsupported-credential' + : 'failed' } if (binding.identity === undefined && identity) { aliasKey = nativeLocalRefreshJobKey({ ...binding, identity }) @@ -1061,6 +1107,19 @@ export function createNativeRefreshCoordinator( ) switch (outcome.status) { case 'rotated': + // The saved successor cannot be served or refreshed; report why + // instead of the missing validation that admission would cite. + // The status is assigned inside the store callback above, so + // widen it: TypeScript does not track that assignment. + if ( + (bootstrapStatus as NativeIdentityBootstrap) === + 'unsupported-credential' + ) + return { + status: 'refused', + reason: 'unsupported-access', + persisted: true, + } return { status: 'completed', source: 'rotated', diff --git a/packages/core/src/native-runtime.ts b/packages/core/src/native-runtime.ts index fc4e70702..df4bee9c4 100644 --- a/packages/core/src/native-runtime.ts +++ b/packages/core/src/native-runtime.ts @@ -1058,6 +1058,19 @@ export type NativeLocalFailureAttribution = readonly target: 'captured' | 'committed' readonly subject: NativeLocalValidationRetry['subject'] } + | { + /** + * The exchange saved a successor whose access value is not a supported + * Claude OAuth credential. It is kept because the previous refresh token + * is consumed, but it is never validated or retried; only a new sign-in + * replaces it. Publishing it removes refresh-error and retry state no + * newer than its check, advancing the refresh-error clear marker to the + * check time when it removes any, and adds no error or backoff. + */ + readonly kind: 'unsupported-successor' + readonly target: 'committed' + readonly subject: NativeRefreshSubject + } export type NativeLocalFailureSupersededReason = | 'row-binding' @@ -1253,6 +1266,8 @@ function classifyCapturedLocalFailure( version: { ...material.version }, } const target = saved ? 'committed' : 'captured' + if (saved && event.bootstrap === 'unsupported-credential') + return { kind: 'unsupported-successor', target: 'committed', subject } if ( (saved && subject.binding.identity !== undefined) || (event.status === 'failed' && event.failure.kind === 'validation') @@ -1317,8 +1332,9 @@ function failureMaterialMatches( /** * Publish the failure only for its captured credential version or saved successor. - * This write neither validates credentials nor repairs identity, clears earlier - * errors, or changes their clear timestamps. Credentials and runtime metadata + * This write neither validates credentials nor repairs identity. Only an + * unsupported successor, which records no error of its own, clears earlier + * errors and advances the refresh-error clear timestamp. Credentials and runtime metadata * are saved separately; a thrown runtime write does not prove credential rollback. */ export async function publishNativeLocalRefreshFailure( @@ -1348,7 +1364,8 @@ export async function publishNativeLocalRefreshFailure( (!counter(policy.nextRetryAt) || policy.nextRetryAt < policy.checkedAt)) || (policy.retryCount !== undefined && !counter(policy.retryCount)) || - ((attribution.kind === 'validation-retry' || !attribution.permanent) && + ((attribution.kind === 'validation-retry' || + (attribution.kind === 'refresh-error' && !attribution.permanent)) && policy.nextRetryAt === undefined) ) throw new NativeRuntimeError('publication-refused') @@ -1376,7 +1393,37 @@ export async function publishNativeLocalRefreshFailure( const entry: NativeRuntimeEntry = old ? { ...old } : { binding: subject.binding } - if (attribution.kind === 'validation-retry') { + if (attribution.kind === 'unsupported-successor') { + // An unsupported successor is not a failure: the policy's retry + // time and count are not recorded, and only its check time is used. + // Remove refresh and validation retry state recorded no later than + // this check, so the account shows its unsupported credential, not + // a backoff. A newer record came from a later operation and stays. + // The runtime writer requires a clear marker covering any removed + // error, as an explicit backoff reset does. + // Validation evidence, quota and profile metadata are left as they + // are; nothing here validates the successor. + let cleared = false + if ( + entry.lastRefreshError && + entry.lastRefreshError.checkedAt <= policy.checkedAt + ) { + delete entry.lastRefreshError + cleared = true + } + if ( + entry.validationRetry && + entry.validationRetry.checkedAt <= policy.checkedAt + ) { + delete entry.validationRetry + cleared = true + } + if (cleared) + entry.refreshErrorClearedAt = Math.max( + entry.refreshErrorClearedAt ?? 0, + policy.checkedAt, + ) + } else if (attribution.kind === 'validation-retry') { const nextRetryAt = policy.nextRetryAt if (nextRetryAt === undefined) throw new NativeRuntimeError('publication-refused') diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts index a652b51ff..73af2a495 100644 --- a/packages/core/src/tests/native-account-runtime.test.ts +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -4,8 +4,13 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { disconnectClaustrumEnrollment } from '@cortexkit/common-auth/claustrum' import { ClaudeOAuthRefreshError } from '../auth.ts' -import type { ProviderAccountUuid } from '../claude-code.ts' +import { + type ProviderAccountUuid, + resetClaudeCodeIdentityCachesForTest, +} from '../claude-code.ts' +import { custodyTombstoneKey } from '../claustrum.ts' import { getHostClaustrumEnrollmentPaths } from '../claustrum-enrollment.ts' +import { TOKEN_URL } from '../constants.ts' import { __setLogTestSink, getLogLevel, @@ -1584,3 +1589,129 @@ test('enrollment rejects captured local metadata and Prime writes before reopeni expect(await f.runtime.incrementPrimeUsage('main', prior.subject)).toBe(false) expect(await Promise.all(files.map((path) => readFile(path)))).toEqual(before) }) + +// The token endpoint can complete an exchange whose successor access is not a +// Claude OAuth access token. The old refresh token is gone at that point, so +// the successor must be kept, but it must never be served or retried. +test('consumed unsupported OAuth successor is kept, refused without refresh backoff, and replaced by verified sign-in', async () => { + resetClaudeCodeIdentityCachesForTest() + deferCleanup(() => resetClaudeCodeIdentityCachesForTest()) + const requests: string[] = [] + const http = spyOn(globalThis, 'fetch').mockImplementation((async ( + input: Parameters[0], + init?: RequestInit, + ) => { + const url = new URL(String(input)) + requests.push(`${init?.method ?? 'GET'} ${url.origin}${url.pathname}`) + if (url.href === TOKEN_URL) + return Response.json({ + access_token: custodyTombstoneKey('anthropic'), + refresh_token: 'synthetic-consumed-successor', + expires_in: 3600, + }) + if (url.pathname === '/api/claude_cli/bootstrap') + return Response.json({ oauth_account: { account_uuid: uuid } }) + return new Response('{}', { status: 404 }) + }) as typeof fetch) + deferCleanup(() => http.mockRestore()) + // Use the production token exchange and Claude account lookup; only the + // network is synthetic. + const f = await fixture({ + refreshToken: undefined, + resolveIdentity: undefined, + }) + + const refreshed = await f.runtime.authorizeLocal('main', { + intent: 'refresh', + }) + expect(refreshed).toEqual({ + status: 'refused', + reason: 'unsupported-access', + persisted: true, + }) + // One token exchange; an unsupported successor is never sent to Claude. + expect(requests).toEqual([`POST ${TOKEN_URL}`]) + + const saved = await f.store.read() + if (saved.status !== 'ready') throw new Error('Pool was not readable') + const row = saved.rows.find((candidate) => candidate.id === 'imported-main') + expect(row?.credential).toEqual({ + type: 'oauth', + access: custodyTombstoneKey('anthropic'), + refresh: 'synthetic-consumed-successor', + expires: now + 3_600_000, + // The store records its own monotonic refresh time. + lastRefreshedAt: expect.any(Number), + }) + expect(row?.identity).toBe(uuid) + expect(row?.enabled).toBe(true) + + const view = (await f.runtime.read()).accounts.find( + (account) => account.id === 'main', + ) + expect(view?.state).toBe('unsupported-access') + expect(view?.lastRefreshError).toBeUndefined() + const metadata = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (metadata.status !== 'ready') throw new Error('Runtime was not readable') + const entry = metadata.state.accounts['imported-main'] + expect(entry?.lastRefreshError).toBeUndefined() + expect(entry?.validationRetry).toBeUndefined() + expect(entry?.credentialValidation).toBeUndefined() + + for (const intent of ['serve', 'refresh'] as const) + expect(await f.runtime.authorizeLocal('main', { intent })).toEqual({ + status: 'refused', + reason: 'unsupported-access', + persisted: false, + }) + expect(requests).toEqual([`POST ${TOKEN_URL}`]) + + await f.runtime.loginOAuth({ + routeId: 'main', + credential: { + access: 'sk-ant-oat01-synthetic-fresh-signin', + refresh: 'synthetic-fresh-signin-refresh', + expires: now + 18_000_000, + }, + }) + const replaced = (await f.runtime.read()).accounts.find( + (account) => account.id === 'main', + ) + expect(replaced?.state).toBeUndefined() + expect(replaced?.lastRefreshError).toBeUndefined() + const served = await f.runtime.authorizeLocal('main') + if (served.status !== 'usable') + throw new Error(`Fresh sign-in was not served: ${JSON.stringify(served)}`) + expect(served.access).toBe('sk-ant-oat01-synthetic-fresh-signin') + expect( + requests.slice(1).every((request) => request.includes('/bootstrap')), + ).toBe(true) +}) + +// A supported successor whose account lookup fails keeps the existing recovery +// path: the successor is saved, a validation retry is scheduled, and it is not +// served until a later lookup proves the account. +test('supported OAuth successor with a failed account lookup keeps validation recovery', async () => { + let lookups = 0 + const f = await fixture({ + resolveIdentity: async () => { + lookups++ + throw new Error('synthetic lookup outage') + }, + }) + const refreshed = await f.runtime.authorizeLocal('main', { + intent: 'refresh', + }) + expect(refreshed).toMatchObject({ status: 'refused', persisted: true }) + expect(refreshed).not.toMatchObject({ reason: 'unsupported-access' }) + expect(lookups).toBe(1) + const view = (await f.runtime.read()).accounts.find( + (account) => account.id === 'main', + ) + expect(view?.state).toBeUndefined() + const metadata = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (metadata.status !== 'ready') throw new Error('Runtime was not readable') + const entry = metadata.state.accounts['imported-main'] + expect(entry?.validationRetry?.nextRetryAt).toBeGreaterThan(now) + expect(entry?.credentialValidation).toBeUndefined() +}) diff --git a/packages/core/src/tests/native-account-view.test.ts b/packages/core/src/tests/native-account-view.test.ts index 0a7171673..62326394a 100644 --- a/packages/core/src/tests/native-account-view.test.ts +++ b/packages/core/src/tests/native-account-view.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'bun:test' import { fingerprintOf, type PoolRow } from '@cortexkit/common-auth/store' import { applyNativeMetadataPatch, + formatNativeAccountState, nativeAccountReadError, nativeQuotaFailure, projectNativeAccountViews, @@ -325,6 +326,35 @@ for (const access of ['sk-ant-oat01-synthetic', 'synthetic-api-key', '']) { }) } +test('account state text explains unsupported access and its remedy and keeps other states', () => { + const unsupported = projectNativeAccountViews({ + paths, + rows: [ + { + ...row, + credential: { + type: 'oauth', + access: 'claustrum-tombstone:v1:anthropic', + refresh: 'synthetic-refresh', + expires: 1000, + }, + }, + ], + settings: { mainAccountId: row.id }, + }).accounts[0] + if (!unsupported) throw new Error('Missing projected account') + const text = formatNativeAccountState(unsupported) + expect(text).toBe( + 'unsupported credential: the saved access token is not a Claude OAuth token; sign in to this account again', + ) + expect(text).not.toContain('claustrum-tombstone') + expect(text).not.toContain('synthetic-refresh') + expect(formatNativeAccountState({})).toBe('') + expect(formatNativeAccountState({ state: '' })).toBe('') + expect(formatNativeAccountState({ state: 'active' })).toBe('active') + expect(formatNativeAccountState({ state: 'revoked' })).toBe('revoked') +}) + for (const roster of [ undefined, { version: 1 as const, complete: false, rows: [], declined: [] }, diff --git a/packages/core/src/tests/native-refresh-failure.test.ts b/packages/core/src/tests/native-refresh-failure.test.ts index 793cd31b6..5f7f8dbe7 100644 --- a/packages/core/src/tests/native-refresh-failure.test.ts +++ b/packages/core/src/tests/native-refresh-failure.test.ts @@ -14,6 +14,8 @@ import { lockPathFor } from '@cortexkit/common-auth/fs' import { fingerprintOf, type PoolRow } from '@cortexkit/common-auth/store' import { ClaudeOAuthRefreshError } from '../auth.ts' +import { ClaudeUnsupportedAccessError } from '../claude-code.ts' +import { CustodyTombstoneRefreshError } from '../claustrum.ts' import { createNativeLocalRuntimeReaders } from '../native-local-runtime-readers.ts' import { nativeQuotaCodec } from '../native-quota-codec.ts' import { @@ -1528,3 +1530,204 @@ test('failure classifier malformed actionable plain records stay inside the clos }), ).toEqual({ kind: 'none', reason: 'not-attributable' }) }) + +// Seed refresh and validation retry records for the predecessor credential. +// The reset markers stay as captured so publication fences are unaffected. +async function seedRetryState(f: Fixture, checkedAt: number) { + await updateNativeRuntime(f.paths.runtime, f.paths.storageId, (current) => { + const entry = current.accounts.row! + entry.lastRefreshError = { + message: 'Local refresh failed', + credentialFingerprint: f.subject.credentialFingerprint, + accountIdentity: uuid, + permanent: false, + checkedAt, + nextRetryAt: checkedAt + 1_000, + retryCount: 2, + } + entry.validationRetry = { + subject: { + ...f.subject, + binding: { ...f.subject.binding, identity: uuid }, + } as NonNullable['subject'], + checkedAt, + nextRetryAt: checkedAt + 1_000, + } + entry.lastQuotaRefreshError = { + message: 'synthetic-quota', + checkedAt: 3, + permanent: false, + } + entry.profile = { + tier: 'synthetic-tier', + orgType: 'synthetic-org', + checkedAt: 6, + } + return current + }) + const read = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (read.status !== 'ready') throw new Error('Missing seeded runtime') + return read.state.accounts.row! +} + +async function unsupportedSuccessor( + f: Fixture, +): Promise { + return { + ...(await savedSuccessor(f)), + bootstrap: 'unsupported-credential', + } +} + +for (const [label, seededAt, cleared] of [ + ['older', 50, true], + ['same-time', 100, true], + ['newer', 150, false], +] as const) { + test(`unsupported successor records no failure and ${cleared ? 'clears' : 'keeps'} ${label} retry state`, async () => { + const f = await fixture() + const seeded = await seedRetryState(f, seededAt) + const event = await unsupportedSuccessor(f) + if (event.status !== 'persisted') + throw new Error('Expected saved successor') + const successor = { + binding: f.subject.binding, + credentialFingerprint: event.committed.credentialFingerprint, + version: event.committed.version, + } + expect(classifyNativeLocalFailure(event)).toEqual({ + kind: 'unsupported-successor', + target: 'committed', + subject: successor, + }) + // The caller's retry schedule is ignored: the successor is not a failure. + const result = await publishNativeLocalRefreshFailure( + f.paths, + event, + policy, + ) + expect(result).toMatchObject({ + status: 'published', + attribution: { kind: 'unsupported-successor', subject: successor }, + }) + const saved = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (saved.status !== 'ready') throw new Error('Missing runtime') + const expected = { ...seeded } + if (cleared) { + delete expected.lastRefreshError + delete expected.validationRetry + // Removing an error advances its clear marker to the check time. + expected.refreshErrorClearedAt = policy.checkedAt + } + expect(saved.state.accounts.row).toEqual(expected) + expect(saved.state.accounts.row?.credentialValidation).toBeUndefined() + expect(saved.state.accounts.unrelated).toEqual(f.runtime.accounts.unrelated) + }) +} + +test('unsupported successor publication refuses a stale reset context or credential', async () => { + const reset = await fixture() + await seedRetryState(reset, 50) + const resetEvent = await unsupportedSuccessor(reset) + await updateNativeRuntime( + reset.paths.runtime, + reset.paths.storageId, + (current) => { + // An explicit error reset recorded after resetEvent captured its reset + // markers, so the publication must be superseded. The marker stays + // earlier than the seeded refresh error because the runtime file + // rejects an error older than its own clear marker. + current.accounts.row!.refreshErrorClearedAt = 40 + return current + }, + ) + await unchanged( + reset, + () => publishNativeLocalRefreshFailure(reset.paths, resetEvent, policy), + { status: 'superseded', reason: 'reset-context' }, + ) + + const rotated = await fixture() + await seedRetryState(rotated, 50) + const rotatedEvent = await unsupportedSuccessor(rotated) + await rotated.store.rotate('row', { + ...credential, + access: 'synthetic-later-access', + refresh: 'synthetic-later-refresh', + }) + await unchanged( + rotated, + () => publishNativeLocalRefreshFailure(rotated.paths, rotatedEvent, policy), + { status: 'superseded', reason: 'row-credential' }, + ) +}) + +for (const [label, lookupError, unsupported] of [ + ['Claude unsupported access', new ClaudeUnsupportedAccessError(), true], + ['custody marker', new CustodyTombstoneRefreshError('anthropic'), true], + ['lookup outage', new Error('synthetic lookup outage'), false], +] as const) { + test(`coordinator saves a consumed successor after ${label} lookup error`, async () => { + const f = await fixture() + const observations: NativeRefreshObservation[] = [] + let lookups = 0 + const readers = createNativeLocalRuntimeReaders({ + paths: f.paths, + now: () => 100, + externalPolicy: () => ({ status: 'allowed' }), + }) + const c = createNativeRefreshCoordinator({ + paths: f.paths, + quota: nativeQuotaCodec, + ...readers, + refreshToken: async () => ({ + access: 'synthetic-unsupported-successor-access', + refresh: 'synthetic-unsupported-successor-refresh', + expires: credential.expires, + expiresIn: 3600, + }), + resolveIdentity: async () => { + lookups++ + throw lookupError + }, + reconcile: async (event) => { + observations.push(event) + }, + }) + const result = await c.authorize({ + binding: f.subject.binding, + intent: 'refresh', + mode: 'local', + }) + expect(lookups).toBe(1) + if (unsupported) + expect(result).toEqual({ + status: 'refused', + reason: 'unsupported-access', + persisted: true, + }) + else + expect(result).toEqual({ + status: 'refused', + reason: 'proof-missing', + persisted: true, + }) + expect(observations).toHaveLength(1) + expect(observations[0]).toMatchObject({ + status: 'persisted', + bootstrap: unsupported ? 'unsupported-credential' : 'failed', + }) + const read = await f.store.read() + if (read.status !== 'ready') throw new Error('Missing pool') + expect(read.rows[0]?.credential).toMatchObject({ + type: 'oauth', + access: 'synthetic-unsupported-successor-access', + refresh: 'synthetic-unsupported-successor-refresh', + }) + expect(read.rows[0]?.enabled).toBe(true) + expect(classifyNativeLocalFailure(observations[0]!)).toMatchObject({ + kind: unsupported ? 'unsupported-successor' : 'validation-retry', + target: 'committed', + }) + }) +} From f0424bf5c769ff6f0e2b907e84d606954b7dbb0b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:49:00 +0200 Subject: [PATCH 157/169] mason: cover unsupported successor for a row with no confirmed account UUID The regression for a consumed unsupported OAuth successor now runs twice: once for a row with a known account UUID (which used to get a validation retry) and once for a row stored without one (which used to get a status-less refresh error with a long backoff). Both use the real token exchange and account lookup against a synthetic network. The sign-in check now requires at least one Claude account lookup request, not just that every later request was one. In the coordinator, a small helper reads the lookup status after the store callback in place of a type assertion. --- .../core/src/native-refresh-coordinator.ts | 14 +- .../src/tests/native-account-runtime.test.ts | 200 ++++++++++-------- 2 files changed, 118 insertions(+), 96 deletions(-) diff --git a/packages/core/src/native-refresh-coordinator.ts b/packages/core/src/native-refresh-coordinator.ts index 02c328e25..388a912c7 100644 --- a/packages/core/src/native-refresh-coordinator.ts +++ b/packages/core/src/native-refresh-coordinator.ts @@ -715,6 +715,13 @@ export function createNativeRefreshCoordinator( let aliasKey: string | undefined let reconciliationFailed = false + // Read the lookup status through a function. The store callback assigns + // it, but TypeScript does not see that assignment, so a direct comparison + // after the callback would be type-checked against the earlier value. + function lookupRejectedCredential(): boolean { + return bootstrapStatus === 'unsupported-credential' + } + function observation(): ObservationBase { return { binding, @@ -1109,12 +1116,7 @@ export function createNativeRefreshCoordinator( case 'rotated': // The saved successor cannot be served or refreshed; report why // instead of the missing validation that admission would cite. - // The status is assigned inside the store callback above, so - // widen it: TypeScript does not track that assignment. - if ( - (bootstrapStatus as NativeIdentityBootstrap) === - 'unsupported-credential' - ) + if (lookupRejectedCredential()) return { status: 'refused', reason: 'unsupported-access', diff --git a/packages/core/src/tests/native-account-runtime.test.ts b/packages/core/src/tests/native-account-runtime.test.ts index 73af2a495..8fc13c6de 100644 --- a/packages/core/src/tests/native-account-runtime.test.ts +++ b/packages/core/src/tests/native-account-runtime.test.ts @@ -38,6 +38,9 @@ const uuid = '11111111-2222-4333-8444-555555555555' as ProviderAccountUuid const now = 1_000_000 async function fixture( local: Partial = {}, + // Store the main row without an account UUID, as a row whose account was + // never confirmed. + unresolved = false, ) { const parent = new URL( '../../../../node_modules/.cache/native-account-runtime/', @@ -56,7 +59,7 @@ async function fixture( await store.initialize() await store.add({ id: 'imported-main', - identity: uuid, + ...(unresolved ? {} : { identity: uuid }), credential: { type: 'oauth', access: 'sk-ant-oat01-synthetic-access', @@ -1592,101 +1595,118 @@ test('enrollment rejects captured local metadata and Prime writes before reopeni // The token endpoint can complete an exchange whose successor access is not a // Claude OAuth access token. The old refresh token is gone at that point, so -// the successor must be kept, but it must never be served or retried. -test('consumed unsupported OAuth successor is kept, refused without refresh backoff, and replaced by verified sign-in', async () => { - resetClaudeCodeIdentityCachesForTest() - deferCleanup(() => resetClaudeCodeIdentityCachesForTest()) - const requests: string[] = [] - const http = spyOn(globalThis, 'fetch').mockImplementation((async ( - input: Parameters[0], - init?: RequestInit, - ) => { - const url = new URL(String(input)) - requests.push(`${init?.method ?? 'GET'} ${url.origin}${url.pathname}`) - if (url.href === TOKEN_URL) - return Response.json({ - access_token: custodyTombstoneKey('anthropic'), - refresh_token: 'synthetic-consumed-successor', - expires_in: 3600, - }) - if (url.pathname === '/api/claude_cli/bootstrap') - return Response.json({ oauth_account: { account_uuid: uuid } }) - return new Response('{}', { status: 404 }) - }) as typeof fetch) - deferCleanup(() => http.mockRestore()) - // Use the production token exchange and Claude account lookup; only the - // network is synthetic. - const f = await fixture({ - refreshToken: undefined, - resolveIdentity: undefined, - }) - - const refreshed = await f.runtime.authorizeLocal('main', { - intent: 'refresh', - }) - expect(refreshed).toEqual({ - status: 'refused', - reason: 'unsupported-access', - persisted: true, - }) - // One token exchange; an unsupported successor is never sent to Claude. - expect(requests).toEqual([`POST ${TOKEN_URL}`]) - - const saved = await f.store.read() - if (saved.status !== 'ready') throw new Error('Pool was not readable') - const row = saved.rows.find((candidate) => candidate.id === 'imported-main') - expect(row?.credential).toEqual({ - type: 'oauth', - access: custodyTombstoneKey('anthropic'), - refresh: 'synthetic-consumed-successor', - expires: now + 3_600_000, - // The store records its own monotonic refresh time. - lastRefreshedAt: expect.any(Number), - }) - expect(row?.identity).toBe(uuid) - expect(row?.enabled).toBe(true) - - const view = (await f.runtime.read()).accounts.find( - (account) => account.id === 'main', - ) - expect(view?.state).toBe('unsupported-access') - expect(view?.lastRefreshError).toBeUndefined() - const metadata = await readNativeRuntime(f.paths.runtime, f.paths.storageId) - if (metadata.status !== 'ready') throw new Error('Runtime was not readable') - const entry = metadata.state.accounts['imported-main'] - expect(entry?.lastRefreshError).toBeUndefined() - expect(entry?.validationRetry).toBeUndefined() - expect(entry?.credentialValidation).toBeUndefined() +// the successor must be kept, but it must never be served or retried. A row +// whose account UUID was never confirmed used to record a refresh error with a +// long backoff here; a row with a known UUID used to record a validation retry. +for (const unresolved of [false, true]) { + test(`consumed unsupported OAuth successor is kept, refused without refresh backoff, and replaced by verified sign-in (${unresolved ? 'unresolved' : 'known'} account UUID)`, async () => { + resetClaudeCodeIdentityCachesForTest() + deferCleanup(() => resetClaudeCodeIdentityCachesForTest()) + const requests: string[] = [] + const http = spyOn(globalThis, 'fetch').mockImplementation((async ( + input: Parameters[0], + init?: RequestInit, + ) => { + const url = new URL(String(input)) + requests.push(`${init?.method ?? 'GET'} ${url.origin}${url.pathname}`) + if (url.href === TOKEN_URL) + return Response.json({ + access_token: custodyTombstoneKey('anthropic'), + refresh_token: 'synthetic-consumed-successor', + expires_in: 3600, + }) + if (url.pathname === '/api/claude_cli/bootstrap') + return Response.json({ oauth_account: { account_uuid: uuid } }) + return new Response('{}', { status: 404 }) + }) as typeof fetch) + deferCleanup(() => http.mockRestore()) + // Use the production token exchange and Claude account lookup; only the + // network is synthetic. + const f = await fixture( + { refreshToken: undefined, resolveIdentity: undefined }, + unresolved, + ) + const before = (await f.runtime.read()).accounts.find( + (account) => account.id === 'main', + ) + expect(before?.binding?.identity).toBe(unresolved ? undefined : uuid) - for (const intent of ['serve', 'refresh'] as const) - expect(await f.runtime.authorizeLocal('main', { intent })).toEqual({ + const refreshed = await f.runtime.authorizeLocal('main', { + intent: 'refresh', + }) + expect(refreshed).toEqual({ status: 'refused', reason: 'unsupported-access', - persisted: false, + persisted: true, }) - expect(requests).toEqual([`POST ${TOKEN_URL}`]) + // One token exchange; an unsupported successor is never sent to Claude. + expect(requests).toEqual([`POST ${TOKEN_URL}`]) - await f.runtime.loginOAuth({ - routeId: 'main', - credential: { - access: 'sk-ant-oat01-synthetic-fresh-signin', - refresh: 'synthetic-fresh-signin-refresh', - expires: now + 18_000_000, - }, + const saved = await f.store.read() + if (saved.status !== 'ready') throw new Error('Pool was not readable') + const row = saved.rows.find((candidate) => candidate.id === 'imported-main') + expect(row?.credential).toEqual({ + type: 'oauth', + access: custodyTombstoneKey('anthropic'), + refresh: 'synthetic-consumed-successor', + expires: now + 3_600_000, + // The store records its own monotonic refresh time. + lastRefreshedAt: expect.any(Number), + }) + expect(row?.identity).toBe(unresolved ? undefined : uuid) + expect(row?.enabled).toBe(true) + + const view = (await f.runtime.read()).accounts.find( + (account) => account.id === 'main', + ) + expect(view?.state).toBe('unsupported-access') + expect(view?.lastRefreshError).toBeUndefined() + const metadata = await readNativeRuntime(f.paths.runtime, f.paths.storageId) + if (metadata.status !== 'ready') throw new Error('Runtime was not readable') + const entry = metadata.state.accounts['imported-main'] + expect(entry?.lastRefreshError).toBeUndefined() + expect(entry?.validationRetry).toBeUndefined() + expect(entry?.credentialValidation).toBeUndefined() + + for (const intent of ['serve', 'refresh'] as const) + expect(await f.runtime.authorizeLocal('main', { intent })).toEqual({ + status: 'refused', + reason: 'unsupported-access', + persisted: false, + }) + expect(requests).toEqual([`POST ${TOKEN_URL}`]) + + await f.runtime.loginOAuth({ + routeId: 'main', + credential: { + access: 'sk-ant-oat01-synthetic-fresh-signin', + refresh: 'synthetic-fresh-signin-refresh', + expires: now + 18_000_000, + }, + }) + const replaced = (await f.runtime.read()).accounts.find( + (account) => account.id === 'main', + ) + expect(replaced?.state).toBeUndefined() + expect(replaced?.lastRefreshError).toBeUndefined() + expect(replaced?.accountIdentity).toBe(uuid) + const served = await f.runtime.authorizeLocal('main') + if (served.status !== 'usable') + throw new Error(`Fresh sign-in was not served: ${JSON.stringify(served)}`) + expect(served.access).toBe('sk-ant-oat01-synthetic-fresh-signin') + expect(served.binding.identity).toBe(uuid) + // Sign-in verifies the new access with Claude's account lookup; nothing + // else, and no further token exchange, is sent. + const later = requests.slice(1) + expect( + later.filter((request) => request.endsWith('/api/claude_cli/bootstrap')) + .length, + ).toBeGreaterThan(0) + expect( + later.every((request) => request.endsWith('/api/claude_cli/bootstrap')), + ).toBe(true) }) - const replaced = (await f.runtime.read()).accounts.find( - (account) => account.id === 'main', - ) - expect(replaced?.state).toBeUndefined() - expect(replaced?.lastRefreshError).toBeUndefined() - const served = await f.runtime.authorizeLocal('main') - if (served.status !== 'usable') - throw new Error(`Fresh sign-in was not served: ${JSON.stringify(served)}`) - expect(served.access).toBe('sk-ant-oat01-synthetic-fresh-signin') - expect( - requests.slice(1).every((request) => request.includes('/bootstrap')), - ).toBe(true) -}) +} // A supported successor whose account lookup fails keeps the existing recovery // path: the successor is saved, a validation retry is scheduled, and it is not From 45d853bcb1cbcfa5804af811c3dbc8fcb21d2659 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:13:24 +0200 Subject: [PATCH 158/169] mason: repair three native E2E fixture contracts Use the incoming explicit executable seam for an owned missing binary instead of mutating PATH. Read custody fixtures with their enrollment environment and native vault paths, keeping host auth and local pool files untouched. Read relay quota via the native runtime and assert 78/40 utilization, account UUID, real synthetic request bearer attribution, and secret-free native metadata. Existing feed and no-quota contracts remain in place; no quotaToken status wrapper is fabricated. Give the touched fixture bodies Core test lifetimes and join underlying SDK prompt transports before fixture disposal, with teardown gates aborting unresolved prompts. Verification used a parent-supplied, verification-only runner/locked OpenCode 1.18.18 overlay, excluded from this commit. Core and host builds plus E2E typecheck passed under Bun 1.3.14 / TypeScript 7.0.2; 16 hygiene cases and the late-cleanup regression passed. Actual custody discovery and WS quota replay both stopped at pre-body host project bootstrap with the existing 60s TimeoutError; quota publication is not declared closed pending parent combined-host replay. No deadlines were changed. --- packages/e2e-tests/tests/custody-mode.test.ts | 141 +++++++++++------ .../tests/quota-header-relay.test.ts | 147 ++++++++++++------ packages/e2e-tests/tests/tmp-hygiene.test.ts | 41 +++-- 3 files changed, 213 insertions(+), 116 deletions(-) diff --git a/packages/e2e-tests/tests/custody-mode.test.ts b/packages/e2e-tests/tests/custody-mode.test.ts index 2c0d82146..386e08728 100644 --- a/packages/e2e-tests/tests/custody-mode.test.ts +++ b/packages/e2e-tests/tests/custody-mode.test.ts @@ -1,28 +1,57 @@ /// -import { afterEach, describe, expect, it } from 'bun:test' +import { describe, expect } from 'bun:test' import { createNativeAccountRuntime, custodyTombstoneOAuth, type NativePoolPaths, readNativeMigrationJournal, resolveNativePoolPaths } from '@cortexkit/anthropic-auth-core' +import { createTestLifetimeSuite } from '../../core/src/tests/test-lifetime.ts' +import { nativeVaultPaths } from '../../core/src/native-vault-paths.ts' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { E2EHarness } from '../src/harness.ts' import { startFakeClaustrumDaemon, type FakeClaustrumCredential } from '../src/mock-claustrum.ts' -let harness: E2EHarness | null = null -const roots: string[] = [] -const daemons: Array<{ stop: () => Promise }> = [] -async function disposeFixture() { - // A timed-out hook may finish after the next test has installed its own - // resources. Claim all of this test's slots before awaiting teardown so - // its eventual completion cannot erase a newer harness or stop its daemon. - const finished = harness - harness = null - const retiredDaemons = daemons.splice(0) - const retiredRoots = roots.splice(0) +const { test: it, deferCleanup, trackDetached, gate } = createTestLifetimeSuite() +type Fixture = { + harness: E2EHarness | null + roots: string[] + daemons: Array<{ stop: () => Promise }> +} +function createFixture(): Fixture { + const fixture: Fixture = { harness: null, roots: [], daemons: [] } + deferCleanup(() => disposeFixture(fixture)) + return fixture +} +function ownHarness(fixture: Fixture, harness: E2EHarness) { + fixture.harness = harness + // A prompt timeout does not cancel the SDK transport. Abort at teardown, + // then join the real request before disposing the child and its fixtures. + const prompt = harness.client.session.prompt.bind(harness.client.session) + harness.client.session.prompt = (options) => { + let settled = false + const stop = gate() + const cancellation = stop.wait.then(async () => { + if (!settled) await harness.abortSession(options.path.id) + }) + trackDetached(cancellation) + const work = prompt(options).finally(() => { + settled = true + stop.open() + }) + trackDetached(work) + return work + } + return harness +} +async function disposeFixture(fixture: Fixture) { + // Claim this fixture's slots before awaiting teardown; a late cleanup must + // not erase resources installed while its previous harness is draining. + const finished = fixture.harness + fixture.harness = null + const retiredDaemons = fixture.daemons.splice(0) + const retiredRoots = fixture.roots.splice(0) await finished?.dispose() await Promise.all(retiredDaemons.map((daemon) => daemon.stop())) await Promise.all(retiredRoots.map((root) => rm(root, { recursive: true, force: true }))) } -afterEach(disposeFixture) const credential = (access: string, accountId: string, recordVersion: number): FakeClaustrumCredential => ({ payload: access, account_id: accountId, record_version: recordVersion, @@ -31,19 +60,18 @@ const credential = (access: string, accountId: string, recordVersion: number): F const mainId = 'oauth:anthropic' const workId = 'oauth:anthropic:work' -// Read migrated account settings and cached quota/profile data through the -// native account runtime, not the retired sidecar files. Reading this status -// must not connect to Claustrum or retrieve an OAuth bearer token. -async function readNativeAccounts(paths: NativePoolPaths) { - const runtime = createNativeAccountRuntime({ paths, host: 'opencode' }) +// Read vault account settings and cached metadata in the child's enrollment +// namespace. Status reads must not connect to Claustrum or retrieve OAuth tokens. +async function readNativeAccounts(paths: NativePoolPaths, env: NodeJS.ProcessEnv) { + const runtime = createNativeAccountRuntime({ paths, host: 'opencode', vault: { env } }) try { return await runtime.read() } finally { runtime.close() } } -async function setNativeRoutingMode(mode: string, paths: NativePoolPaths) { - const runtime = createNativeAccountRuntime({ paths, host: 'opencode' }) +async function setNativeRoutingMode(mode: string, paths: NativePoolPaths, env: NodeJS.ProcessEnv) { + const runtime = createNativeAccountRuntime({ paths, host: 'opencode', vault: { env } }) try { await runtime.updateSettings((settings) => ({ ...settings, @@ -64,6 +92,8 @@ async function readIfPresent(path: string) { describe('zero-bind scoped custody', () => { it('discovers, authorizes and serves a new account without handles or a restart', async () => { + const fixture = createFixture() + const { roots, daemons } = fixture const root = await mkdtemp(join(tmpdir(), 'anthropic-auth-scoped-e2e-')) roots.push(root) const credentials: Record = { @@ -71,15 +101,19 @@ describe('zero-bind scoped custody', () => { } const daemon = await startFakeClaustrumDaemon({ directory: root, scopedCredentials: credentials }) daemons.push(daemon) - harness = await E2EHarness.create({ + const childEnv: NodeJS.ProcessEnv = { + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile, + } + const harness = ownHarness(fixture, await E2EHarness.create({ nativeAccounts: { kind: 'vault' }, - childEnv: { - OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile, - }, + childEnv, beforeSpawn: async (env) => { - await writeFile(join(env.configDir, 'claustrum-enrollment.json'), JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), { mode: 0o600 }) + childEnv.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE = join(env.configDir, 'claustrum-enrollment.json') + childEnv.HOME = env.homeDir + childEnv.XDG_CONFIG_HOME = env.configDir + await writeFile(childEnv.OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE, JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), { mode: 0o600 }) }, - }) + })) harness.script([{ type: 'text', text: 'main served' }, { type: 'text', text: 'new account served' }]) const first = await harness.createSession() await harness.sendPrompt(first, 'serve main') @@ -89,19 +123,22 @@ describe('zero-bind scoped custody', () => { expect(daemon.scopedLists).toBeGreaterThan(0) credentials[workId] = credential('scoped-work', 'account-work', 12) const pool = harness.opencode.native!.paths + const hostAuthBefore = await readFile(harness.opencode.native!.hostAuthPath, 'utf8') await harness.waitFor(async () => { - const snapshot = await readNativeAccounts(pool) + const snapshot = await readNativeAccounts(pool, childEnv) return hasVaultAccount(snapshot, workId) ? true : undefined }, { timeoutMs: 15_000, label: 'new scoped account persisted' }) - const beforeMode = await readNativeAccounts(pool) + const beforeMode = await readNativeAccounts(pool, childEnv) expect(hasVaultAccount(beforeMode, workId), JSON.stringify({ rows: beforeMode.accounts.map((a) => [a.id, a.source === 'vault' ? a.credentialId : a.type]), - roster: await readIfPresent(pool.roster), + roster: await readIfPresent(nativeVaultPaths(pool).roster), scopedLists: daemon.scopedLists, vaultIds: Object.keys(credentials) }), ).toBe(true) - await setNativeRoutingMode('fallback-first', pool) - const afterMode = await readNativeAccounts(pool) + expect(beforeMode.mode).toBe('claustrum') + await setNativeRoutingMode('fallback-first', pool, childEnv) + const afterMode = await readNativeAccounts(pool, childEnv) expect(hasVaultAccount(afterMode, workId)).toBe(true) + expect(await readFile(harness.opencode.native!.hostAuthPath, 'utf8')).toBe(hostAuthBefore) const second = await harness.createSession() await harness.sendPrompt(second, 'serve new fallback') await harness.waitForSessionText(second, 'new account served') @@ -112,22 +149,29 @@ describe('zero-bind scoped custody', () => { // token-exclusion assertion pass vacuously. Inspect configuration, // credential and roster files too when present; this vault-only fixture // does not require a file containing local OAuth credentials. + const vaultPaths = nativeVaultPaths(pool) const state = [ - await readFile(pool.runtime, 'utf8'), - ...(await Promise.all([pool.config, pool.state, pool.roster].map(readIfPresent))), + await readFile(vaultPaths.runtime, 'utf8'), + await readFile(vaultPaths.roster, 'utf8'), + ...(await Promise.all([vaultPaths.config, vaultPaths.state].map(readIfPresent))), ].join('\n') expect(state).not.toContain('scoped-main') expect(state).not.toContain('scoped-work') + for (const path of [pool.config, pool.state, pool.runtime, pool.journal, pool.roster]) { + await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + } }, 120_000) it('refuses a legacy handle-only config with native migration-required authority until explicit setup', async () => { + const fixture = createFixture() + const { roots, daemons } = fixture const root = await mkdtemp(join(tmpdir(), 'anthropic-auth-legacy-e2e-')) roots.push(root) const daemon = await startFakeClaustrumDaemon({ directory: root, scopedCredentials: { [mainId]: credential('must-not-send', 'account-main', 1) }, }) daemons.push(daemon) - harness = await E2EHarness.create({ + const harness = ownHarness(fixture, await E2EHarness.create({ // Deliberately unmigrated: offline setup refuses this handle-only config // (custody mode without a primary account). OpenCode's real auth.json // holds a non-secret OAuth-shaped marker that activates this plugin, and @@ -141,7 +185,7 @@ describe('zero-bind scoped custody', () => { beforeSpawn: async (env) => { await writeFile(join(env.configDir, 'anthropic-auth.json'), JSON.stringify({ version: 1, accounts: [], claustrum: { mode: 'claustrum' } }), { mode: 0o600 }) }, - }) + })) const session = await harness.createSession() await harness.startPrompt(session, 'must fail before transport') try { @@ -181,6 +225,8 @@ describe('zero-bind scoped custody', () => { describe('scoped credential rotations in the OpenCode process', () => { it('replays only the refused turn when the vault rotates before the first 401 arrives', async () => { + const fixture = createFixture() + const { roots, daemons } = fixture const root = await mkdtemp(join(tmpdir(), 'anthropic-auth-scoped-rotation-e2e-')) roots.push(root) const main = credential('scoped-main-v1', 'account-main', 11) @@ -189,7 +235,7 @@ describe('scoped credential rotations in the OpenCode process', () => { scopedCredentials: { [mainId]: main }, }) daemons.push(daemon) - harness = await E2EHarness.create({ + const harness = ownHarness(fixture, await E2EHarness.create({ nativeAccounts: { kind: 'vault' }, childEnv: { OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE: daemon.connectionFile, @@ -199,7 +245,7 @@ describe('scoped credential rotations in the OpenCode process', () => { JSON.stringify({ token: 'aa'.repeat(32), token_generation: 1 }), { mode: 0o600 }) }, - }) + })) harness.script([ { type: 'error', @@ -228,23 +274,26 @@ describe('scoped credential rotations in the OpenCode process', () => { it('late fixture cleanup cannot dispose the next test’s harness, daemon or directory', async () => { + const fixture = createFixture() + const { roots, daemons } = fixture const previousRoot = await mkdtemp(join(tmpdir(), 'anthropic-auth-retired-')) const nextRoot = await mkdtemp(join(tmpdir(), 'anthropic-auth-next-')) let entered!: () => void - let release!: () => void + const cleanupGate = gate() + const release = () => cleanupGate.open() const started = new Promise((resolve) => { entered = resolve }) - const gate = new Promise((resolve) => { release = resolve }) let previousStops = 0 let nextStops = 0 - harness = { - dispose: async () => { entered(); await gate }, + fixture.harness = { + dispose: async () => { entered(); await cleanupGate.wait }, } as unknown as E2EHarness roots.push(previousRoot) daemons.push({ stop: async () => { previousStops++ } }) - const retiring = disposeFixture() + const retiring = disposeFixture(fixture) + trackDetached(retiring) try { await started - harness = { + fixture.harness = { dispose: async () => { nextStops++ }, } as unknown as E2EHarness await writeFile(join(nextRoot, 'marker'), 'new fixture') @@ -254,7 +303,7 @@ it('late fixture cleanup cannot dispose the next test’s harness, daemon or dir await retiring expect(previousStops).toBe(1) expect(nextStops).toBe(0) - expect(harness).not.toBeNull() + expect(fixture.harness).not.toBeNull() expect(await readFile(join(nextRoot, 'marker'), 'utf8')).toBe('new fixture') } finally { release() @@ -262,7 +311,7 @@ it('late fixture cleanup cannot dispose the next test’s harness, daemon or dir await rm(previousRoot, { recursive: true, force: true }) if (!roots.includes(nextRoot)) await rm(nextRoot, { recursive: true, force: true }) - // If nextRoot remains registered, afterEach stops its harness and daemon - // and removes that directory after this test. + // If nextRoot remains registered, the lifetime stops its harness and daemon + // and removes that directory after the body and detached work settle. } }) diff --git a/packages/e2e-tests/tests/quota-header-relay.test.ts b/packages/e2e-tests/tests/quota-header-relay.test.ts index bb13f576e..3211b70ae 100644 --- a/packages/e2e-tests/tests/quota-header-relay.test.ts +++ b/packages/e2e-tests/tests/quota-header-relay.test.ts @@ -1,40 +1,98 @@ /// -import { afterEach, describe, expect, it } from 'bun:test' -import { readFile } from 'node:fs/promises' -import { readdir } from 'node:fs/promises' +import { describe, expect } from 'bun:test' +import { createNativeAccountRuntime, type NativeAccountSnapshot } from '@cortexkit/anthropic-auth-core' +import { readFile, readdir } from 'node:fs/promises' +import { createTestLifetimeSuite } from '../../core/src/tests/test-lifetime.ts' import { join } from 'node:path' -import { E2EHarness } from '../src/harness.ts' - -let harness: E2EHarness | null = null - -type PersistedQuotaState = { - main?: { - quota?: { - source?: string - accountIdentity?: string - five_hour?: { usedPercent?: number } - seven_day?: { usedPercent?: number } - } - quotaToken?: string +import { E2EHarness, type E2EHarnessOptions } from '../src/harness.ts' +import { FIXTURE_ADMITTED_ACCESS_TOKEN, FIXTURE_MAIN_ACCOUNT_UUID } from '../src/opencode-runner.ts' + +const { test: it, deferCleanup, trackDetached, gate } = createTestLifetimeSuite() + +async function createHarness(options: E2EHarnessOptions) { + const harness = await E2EHarness.create(options) + // A prompt timeout does not cancel the SDK transport. Abort at teardown, + // then join the real request before disposing the child and its fixtures. + const prompt = harness.client.session.prompt.bind(harness.client.session) + harness.client.session.prompt = (options) => { + let settled = false + const stop = gate() + const cancellation = stop.wait.then(async () => { + if (!settled) await harness.abortSession(options.path.id) + }) + trackDetached(cancellation) + const work = prompt(options).finally(() => { + settled = true + stop.open() + }) + trackDetached(work) + return work } + deferCleanup(() => harness.dispose()) + return harness } async function readQuotaState(harness: E2EHarness) { - const statePath = join( - harness.opencode.env.configDir, - 'anthropic-auth-state.json', - ) - return readFile(statePath, 'utf8') - .then((value) => JSON.parse(value) as PersistedQuotaState) - .catch(() => null) + const native = harness.opencode.native + if (!native) throw new Error('Quota fixture has no prepared native accounts') + const runtime = createNativeAccountRuntime({ + paths: native.paths, + host: 'opencode', + vault: { env: { + HOME: harness.opencode.env.homeDir, + XDG_CONFIG_HOME: harness.opencode.env.configDir, + OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE: join( + harness.opencode.env.configDir, 'claustrum-enrollment.json', + ), + } }, + }) + try { + return await runtime.read() + } finally { + runtime.close() + } +} + +function mainAccount(state: NativeAccountSnapshot) { + expect(state.mode).toBe('local') + const main = state.accounts.find((account) => account.id === 'main') + if (!main) throw new Error('Quota fixture has no native main account') + expect(main.source).toBe('local') + expect(main.accountIdentity).toBe(FIXTURE_MAIN_ACCOUNT_UUID) + expect(main.binding?.identity).toBe(FIXTURE_MAIN_ACCOUNT_UUID) + return main +} + +async function assertNativeHeaderQuota(harness: E2EHarness, state: NativeAccountSnapshot) { + const main = mainAccount(state) + expect(main.quota?.source).toBe('headers') + expect(main.quota?.five_hour?.usedPercent).toBe(78) + expect(main.quota?.seven_day?.usedPercent).toBe(40) + expect(main.quota?.accountIdentity).toBe(FIXTURE_MAIN_ACCOUNT_UUID) + const requests = harness.anthropic.requests() + .filter((request) => request.body.model === 'claude-sonnet-4-5') + expect(requests.length).toBeGreaterThan(0) + expect(requests.map((request) => request.headers.authorization)) + .toEqual(requests.map(() => `Bearer ${FIXTURE_ADMITTED_ACCESS_TOKEN}`)) + await assertSecretFreeMetadata(harness) +} + +async function assertSecretFreeMetadata(harness: E2EHarness) { + // A missing metadata file must fail rather than pass secret-exclusion checks. + const metadata = await readFile(harness.opencode.native!.paths.runtime, 'utf8') + expect(metadata).not.toContain('quotaToken') + expect(metadata).not.toContain(FIXTURE_ADMITTED_ACCESS_TOKEN) + expect(metadata).not.toContain('sk-ant-oat01-test-access-token') + expect(metadata).not.toContain('test-refresh-token') + expect(metadata).not.toContain('relay-token') } async function waitForHeaderQuotaState(harness: E2EHarness) { - let state: PersistedQuotaState | null = null + let state: NativeAccountSnapshot | undefined for (let attempt = 0; attempt < 100; attempt++) { state = await readQuotaState(harness) - if (state?.main?.quota?.source === 'headers') return state + if (mainAccount(state).quota?.source === 'headers') return state await Bun.sleep(50) } throw new Error( @@ -73,15 +131,9 @@ const quotaHeaders = { 'anthropic-ratelimit-unified-fallback': 'available', } -afterEach(async () => { - const finished = harness - harness = null - await finished?.dispose() -}) - describe('quota headers through relay', () => { it('harvests quota headers from a websocket relay response', async () => { - harness = await E2EHarness.create({ relay: 'websocket', quotaFeed: true }) + const harness = await createHarness({ relay: 'websocket', quotaFeed: true }) harness.script([ { type: 'text', @@ -92,25 +144,22 @@ describe('quota headers through relay', () => { const sessionId = await harness.createSession() await harness.sendPrompt(sessionId, 'return the relay response') - await harness.waitFor(() => (harness?.relay?.acceptedRequests() ?? 0) >= 1, { + await harness.waitFor(() => (harness.relay?.acceptedRequests() ?? 0) >= 1, { label: 'relay request accepted', }) const state = await waitForHeaderQuotaState(harness) - expect(state?.main?.quota?.source).toBe('headers') - expect(state?.main?.quota?.five_hour?.usedPercent).toBe(78) - expect(state?.main?.quota?.seven_day?.usedPercent).toBe(40) - expect(state?.main?.quota?.accountIdentity).toEqual(expect.any(String)) - expect(state?.main?.quotaToken).toBeUndefined() + await assertNativeHeaderQuota(harness, state) }, 90_000) it('writes one equivalent schema-v3 feed record for an HTTP relay response', async () => { - harness = await E2EHarness.create({ relay: 'http', quotaFeed: true }) + const harness = await createHarness({ relay: 'http', quotaFeed: true }) harness.script([{ type: 'text', text: 'http relay response', headers: quotaHeaders }]) const sessionId = await harness.createSession() await harness.sendPrompt(sessionId, 'return the http relay response') - await harness.waitFor(() => (harness?.relay?.acceptedRequests() ?? 0) >= 1) + await harness.waitFor(() => (harness.relay?.acceptedRequests() ?? 0) >= 1) + await assertNativeHeaderQuota(harness, await waitForHeaderQuotaState(harness)) const files = await waitForFeed(harness) const record = JSON.parse(files[0]!) expect(record.version).toBe(3) @@ -123,7 +172,7 @@ describe('quota headers through relay', () => { }, 90_000) it('does not publish optimistic websocket headers before response_start', async () => { - harness = await E2EHarness.create({ + const harness = await createHarness({ relay: 'websocket', quotaFeed: true, relayResponseStartDelayMs: 250, @@ -131,12 +180,14 @@ describe('quota headers through relay', () => { harness.script([{ type: 'text', text: 'delayed relay response', headers: quotaHeaders }]) const sessionId = await harness.createSession() const prompt = harness.sendPrompt(sessionId, 'return the delayed relay response') - await harness.waitFor(() => (harness?.relay?.acceptedRequests() ?? 0) >= 1) + trackDetached(prompt) + await harness.waitFor(() => (harness.relay?.acceptedRequests() ?? 0) >= 1) await Bun.sleep(50) - // The optimistic envelope has no quota-bearing headers, so this pins that - // guard; only genuine response_start headers are staged for publication. + // The optimistic envelope has no quota-bearing headers. Only genuine + // response_start headers may populate native metadata and the quota feed. expect(await readFeedFiles(harness)).toHaveLength(0) await prompt + await assertNativeHeaderQuota(harness, await waitForHeaderQuotaState(harness)) const files = await waitForFeed(harness) expect(files).toHaveLength(1) const record = JSON.parse(files[0]!) @@ -153,18 +204,18 @@ describe('quota headers through relay', () => { }, 90_000) it('leaves quota state untouched when websocket relay headers have no quota data', async () => { - harness = await E2EHarness.create({ relay: 'websocket', quotaFeed: true }) + const harness = await createHarness({ relay: 'websocket', quotaFeed: true }) harness.script([{ type: 'text', text: 'relay response without quota' }]) const sessionId = await harness.createSession() await harness.sendPrompt(sessionId, 'return the relay response') await harness.waitForSessionText(sessionId, 'relay response without quota') - await harness.waitFor(() => (harness?.relay?.acceptedRequests() ?? 0) >= 1, { + await harness.waitFor(() => (harness.relay?.acceptedRequests() ?? 0) >= 1, { label: 'relay request accepted', }) const state = await readQuotaState(harness) - expect(state?.main?.quota).toBeUndefined() - expect(state?.main?.quotaToken).toBeUndefined() + expect(mainAccount(state).quota).toBeUndefined() + await assertSecretFreeMetadata(harness) }, 90_000) }) diff --git a/packages/e2e-tests/tests/tmp-hygiene.test.ts b/packages/e2e-tests/tests/tmp-hygiene.test.ts index 62396f637..864f8f3f9 100644 --- a/packages/e2e-tests/tests/tmp-hygiene.test.ts +++ b/packages/e2e-tests/tests/tmp-hygiene.test.ts @@ -1,6 +1,7 @@ /// -import { afterEach, describe, expect, it } from 'bun:test' +import { describe, expect } from 'bun:test' +import { createTestLifetimeSuite } from '../../core/src/tests/test-lifetime.ts' import type { ChildProcess } from 'node:child_process' import { EventEmitter } from 'node:events' import { @@ -25,15 +26,11 @@ import { terminateChildProcess, } from '../src/opencode-runner.ts' -const roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) +const { test: it, deferCleanup, trackDetached } = createTestLifetimeSuite() async function createRoot() { const root = await mkdtemp(join(tmpdir(), 'anthropic-auth-e2e-hygiene-test-')) - roots.push(root) + deferCleanup(() => rm(root, { recursive: true, force: true })) return root } @@ -166,6 +163,7 @@ describe('e2e temporary directory hygiene', () => { resolved = true }, ) + trackDetached(termination) await Bun.sleep(0) expect(child.killSignals).toEqual(['SIGTERM']) @@ -198,6 +196,7 @@ describe('e2e temporary directory hygiene', () => { }).then(() => { resolved = true }) + trackDetached(termination) await Bun.sleep(20) expect(child.killSignals).toEqual(['SIGTERM', 'SIGKILL']) @@ -345,7 +344,7 @@ describe('e2e temporary directory hygiene', () => { const root = await createRoot() const env = createIsolatedEnv(root) const externalDir = await mkdtemp(join(tmpdir(), 'e2e-pid-target-')) - roots.push(externalDir) + deferCleanup(() => rm(externalDir, { recursive: true, force: true })) const externalRunPid = join(externalDir, 'run.pid') await writeFile(externalRunPid, 'do not overwrite') await rm(env.tempDir, { recursive: true, force: true }) @@ -394,22 +393,20 @@ describe('e2e temporary directory hygiene', () => { }) it('surfaces spawn errors and removes the temp directory', async () => { - const originalPath = process.env.PATH + const root = await createRoot() + const executable = join(root, 'missing-opencode') let tempDir = '' - process.env.PATH = '' + expect(await Bun.file(executable).exists()).toBe(false) - try { - await expect( - spawnOpencode({ - anthropicBaseURL: 'http://127.0.0.1:1', - beforeSpawn: (env) => { - tempDir = env.tempDir - }, - }), - ).rejects.toThrow(/Executable not found|ENOENT|spawn opencode/) - } finally { - process.env.PATH = originalPath - } + const spawning = spawnOpencode({ + executable, + anthropicBaseURL: 'http://127.0.0.1:1', + beforeSpawn: (env) => { + tempDir = env.tempDir + }, + }) + await expect(spawning).rejects.toThrow(/Executable not found|ENOENT/) + await expect(spawning).rejects.toThrow(executable) expect(tempDir).not.toBe('') expect(await Bun.file(tempDir).exists()).toBe(false) From 260b47c35fbcde7d287c51b02e5029d89598a495 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:15:12 +0200 Subject: [PATCH 159/169] mason: admit package-local mutation test catalogue paths --- scripts/run-mutation-bun.ts | 38 +++++++++++--------- scripts/tests/run-mutation-bun.test.ts | 49 ++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/scripts/run-mutation-bun.ts b/scripts/run-mutation-bun.ts index 4e02edd2c..c1bb1ca18 100644 --- a/scripts/run-mutation-bun.ts +++ b/scripts/run-mutation-bun.ts @@ -7,7 +7,7 @@ import { writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { resolve } from 'node:path' +import { posix, resolve, win32 } from 'node:path' // Credential fixtures check every ancestor. Use system temporary storage, // not a checkout that may have a legitimate group-writable parent. @@ -68,6 +68,12 @@ export function testEnvironment( return env } +const PACKAGE_ROOTS = [ + 'packages/core', + 'packages/opencode', + 'packages/pi', +] as const + export function selectionFor(file: string, name: string) { if ( !name || @@ -79,25 +85,23 @@ export function selectionFor(file: string, name: string) { ) ) throw new Error('Invalid full test name') - // Limit execution to the owning files and their reviewed package preloads. - // Accepting arbitrary paths could load a test with different safety setup. + // Canonical package-local test paths keep traversal away from other configs. if ( - ![ - 'packages/core/src/tests/request-history.test.ts', - 'packages/core/src/tests/fast.test.ts', - 'packages/core/src/tests/native-runtime.test.ts', - 'packages/core/src/tests/native-display-profile.test.ts', - 'packages/core/src/tests/native-account-runtime.test.ts', - 'packages/opencode/src/tests/index.test.ts', - 'packages/pi/src/tests/convert.test.ts', - ].includes(file) + file !== posix.normalize(file) || + posix.isAbsolute(file) || + win32.isAbsolute(file) || + file.includes('\\') ) throw new Error(`Unsafe or unsupported test file: ${file}`) - const cwd = file.startsWith('packages/core/') - ? 'packages/core' - : file.startsWith('packages/opencode/') - ? 'packages/opencode' - : 'packages/pi' + const packageRoot = PACKAGE_ROOTS.find((root) => { + const testPrefix = `${root}/src/tests/` + return ( + file.startsWith(testPrefix) && + /^[^/]+\.test\.ts$/.test(file.slice(testPrefix.length)) + ) + }) + if (!packageRoot) throw new Error(`Unsafe or unsupported test file: ${file}`) + const cwd = packageRoot return { cwd, file: file.slice(cwd.length + 1), diff --git a/scripts/tests/run-mutation-bun.test.ts b/scripts/tests/run-mutation-bun.test.ts index 0c3585f69..2f15ca706 100644 --- a/scripts/tests/run-mutation-bun.test.ts +++ b/scripts/tests/run-mutation-bun.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'bun:test' -import { realpath, rm, stat } from 'node:fs/promises' +import { readFile, realpath, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname } from 'node:path' +import { dirname, resolve } from 'node:path' import { createTestLifetimeSuite } from '../../packages/core/src/tests/test-lifetime.ts' import { createMutationSandbox, @@ -382,7 +382,11 @@ test('invalid names and unsafe paths refuse before spawning', async () => { { file, name: 'name\u0085' }, { file: '../fast.test.ts', name }, { file: '/tmp/fast.test.ts', name }, + { file: 'C:\\tmp\\fast.test.ts', name }, { file: 'packages/core/../pi/src/tests/convert.test.ts', name }, + { file: 'packages/core/src/tests/../fast.test.ts', name }, + { file: 'packages/core/src/tests/nested/fast.test.ts', name }, + { file: 'packages/other/src/tests/fast.test.ts', name }, { file: 'packages/core/src/tests/setup.ts', name }, { file: 'constructor', name }, ] @@ -730,6 +734,45 @@ test('missing, empty, garbage and mismatched report identities refuse', () => { ).toThrow() }) +test('every mutation catalogue command selects its package-local test under the owning config', async () => { + const parsed: unknown = Bun.TOML.parse( + await readFile(resolve(import.meta.dir, '../../mutations.toml'), 'utf8'), + ) + if ( + !parsed || + typeof parsed !== 'object' || + !('control' in parsed) || + !Array.isArray(parsed.control) || + parsed.control.length === 0 + ) + throw new Error('Expected mutation catalogue controls') + + for (const control of parsed.control) { + if (!control || typeof control !== 'object' || !('command' in control)) + throw new Error('Expected a mutation catalogue command') + const command = control.command + if ( + !Array.isArray(command) || + command[0] !== 'bun' || + command[1] !== 'scripts/run-mutation-bun.ts' || + command.length !== 4 || + typeof command[2] !== 'string' || + command[3] !== '{test}' + ) + throw new Error('Unexpected mutation catalogue test command') + + const file = command[2] + const packageRoot = ( + ['packages/core', 'packages/opencode', 'packages/pi'] as const + ).find((root) => file.startsWith(`${root}/`)) + if (!packageRoot) + throw new Error(`Unowned mutation catalogue test file: ${file}`) + const selected = selectionFor(file, 'catalogue selection probe') + expect(selected.cwd).toBe(packageRoot) + expect(selected.file).toBe(file.slice(packageRoot.length + 1)) + } +}) + test('native guard selection uses the actual owning package and reviewed preload config', () => { for (const [file, cwd, relative] of [ [ @@ -752,7 +795,7 @@ test('native guard selection uses the actual owning package and reviewed preload 'packages/opencode', 'src/tests/index.test.ts', ], - ]) { + ] as const) { if (!file || !cwd || !relative) throw new Error('Expected complete test selection') const name = From 75171dd0413f7df016ab74371e63b5ae273eff2f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:18:27 +0200 Subject: [PATCH 160/169] mason: seed OC1 1.18.31 startup dependencies in packed host fixture Both packed OC1 scenarios in CI run 38144975952 timed out before upstream.first_request with empty stdout/stderr, 25 s after installation had finished. Upstream opencode v1.18.31 source shows two registry installs that run before the model is dispatched, and both are blocked by the scenario's dead proxy (127.0.0.1:9): - config/config.ts forks Npm.install(@opencode-ai/plugin@) for every ConfigPaths.directories entry, and plugin/index.ts waits for those installs before loading plugins. core/src/npm.ts skips the install only when node_modules exists and the package-lock root names every declared dependency. - plugin/shared.ts resolvePluginTarget sends a bare package spec to Npm.add("@latest"), which installs into XDG_CACHE_HOME/opencode/packages/@latest and never reads the project's node_modules. It reuses an existing install found there. beforeAll now reads the installed opencode-ai version (asserted to be 1.18.31) and npm-installs @opencode-ai/plugin at exactly that version, with --ignore-scripts as OC1 does. seedOc1Bootstrap refuses an SDK fixture declaring any other version. It then reuses the shared seedOpencodeConfigDependencies helper from e2e-tests, which verifies the fixture and copies the manifest, lockfile and installed tree, to fill every OC1 config directory. For the bare './server' spec it also places the packed node_modules in OC1's package cache. All of this lives inside the scenario root, and scenario() now defers that root's removal itself. The 1.18.31 pin, the 60 s deadlines, the command() private lifetime and the allSettled server drainage are unchanged. Failure reports now include hostBootstrapReport: OC1's own log under XDG_DATA_HOME/opencode/log, with fixture secrets redacted, and the plugin SDK version found in the isolated config directory. The earlier capture read only TMPDIR/host-logs (stdout/stderr), which OC1 left empty. check-bundle-globals.test.ts captures seedOc1Bootstrap and hostBootstrapReport from the current E2E source, and refuses a scenario that does not seed OC1 before its host command. Against the genuine installed opencode1-config-deps fixture (1.18.18), an unseeded layout fails OC1's install-skip check in all three config directory kinds and has no cached plugin; once seeded, every directory passes, with the fixture's lockfile and an SDK entry file. Negative controls: the 1.18.18 fixture is refused for host 1.18.31 before anything is copied; a manifest and lockfile without an installed tree is refused; and the bootstrap report includes the host log with fixture secrets redacted. Mutations each reddened only their matching test. Remote runs have no network, so the real packed hosts were not run here. CI must still confirm that the real OC1 hosts now pass. --- .../scripts/check-bundle-globals.test.ts | 331 ++++++++++++++++-- .../opencode/src/tests/opencode2-e2e.test.ts | 144 +++++++- 2 files changed, 453 insertions(+), 22 deletions(-) diff --git a/packages/opencode/scripts/check-bundle-globals.test.ts b/packages/opencode/scripts/check-bundle-globals.test.ts index 410269d3d..0de999316 100644 --- a/packages/opencode/scripts/check-bundle-globals.test.ts +++ b/packages/opencode/scripts/check-bundle-globals.test.ts @@ -1,5 +1,7 @@ -import { expect } from 'bun:test' +import { expect, test as registerTest } from 'bun:test' import { + cp, + lstat, mkdir, mkdtemp, readdir, @@ -16,9 +18,10 @@ import { type TestGate, TestLifetime, } from '../../core/src/tests/test-lifetime' +import { seedOpencodeConfigDependencies } from '../../e2e-tests/src/opencode-runner' import { checkBundleGlobals } from './check-bundle-globals' -const { test, gate, trackDetached } = createTestLifetimeSuite() +const { test, gate, trackDetached, wrapBody } = createTestLifetimeSuite() async function fixture(run: (root: string) => Promise) { const root = await mkdtemp(join(tmpdir(), 'anthropic-bundle-check-')) @@ -73,7 +76,12 @@ interface CapturedHostHarness { ): Promise } -async function captureHostHarness(bindings: Record) { +/** + * Read the real-host E2E source and return the named top-level declarations + * as runnable code. Evaluating current source, rather than importing the E2E + * module, never registers its real-host installation and execution hooks. + */ +async function captureDeclarations(wanted: string[]) { const source = await readFile( resolve(import.meta.dir, '../src/tests/opencode2-e2e.test.ts'), 'utf8', @@ -82,16 +90,7 @@ async function captureHostHarness(bindings: Record) { sourceType: 'module', plugins: ['typescript'], }).program - const names = new Set([ - 'TOKEN', - 'PASSWORD', - 'phaseClock', - 'installation', - 'redactFixtureSecrets', - 'hostLogDirectory', - 'command', - 'withScenario', - ]) + const names = new Set(wanted) const declarations: string[] = [] const slice = (node: { start?: number | null; end?: number | null }) => { if (node.start == null || node.end == null) @@ -116,12 +115,33 @@ async function captureHostHarness(bindings: Record) { ) if (scenario?.type !== 'FunctionDeclaration') throw new Error('Missing scenario declaration') - const cleanup = scenario.body.body.find( + const code = new Bun.Transpiler({ loader: 'ts' }).transformSync( + declarations.join('\n'), + ) + return { code, scenario: scenario.body.body, slice } +} + +async function captureHostHarness(bindings: Record) { + const { code, scenario, slice } = await captureDeclarations([ + 'TOKEN', + 'PASSWORD', + 'phaseClock', + 'installation', + 'redactFixtureSecrets', + 'hostLogDirectory', + 'hostBootstrapReport', + 'command', + 'withScenario', + ]) + // scenario() also defers removal of its root directory, so select the + // cleanup that stops the synthetic upstream and relay servers. + const cleanup = scenario.find( (node) => node.type === 'ExpressionStatement' && node.expression.type === 'CallExpression' && node.expression.callee.type === 'Identifier' && - node.expression.callee.name === 'deferCleanup', + node.expression.callee.name === 'deferCleanup' && + slice(node).includes('relay.stop'), ) if ( cleanup?.type !== 'ExpressionStatement' || @@ -132,17 +152,12 @@ async function captureHostHarness(bindings: Record) { if (callback?.type !== 'ArrowFunctionExpression') throw new Error('Missing server cleanup callback') - // Evaluate the current declarations, not an imported E2E module: importing - // that module would register real-host installation and execution hooks. - const captured = new Bun.Transpiler({ loader: 'ts' }).transformSync( - declarations.join('\n'), - ) return new Function( 'bindings', ` const { Bun, mkdir, readFile, readdir, rm, writeFile, join, children, cli, oc1, trackDetached, deferCleanup, scenario, console, setTimeout, clearTimeout } = bindings; - ${captured} + ${code} return { installation, command, withScenario, phaseClock, secrets: [TOKEN, PASSWORD, 'synthetic-refresh', 'relay-disposable-only'], stopServers: (upstream, relay, clock) => (${slice(callback)})() }; @@ -582,3 +597,277 @@ test('captured scenario cleanup drains both server stops before reporting failur ]) expect(clock.phases['fixture.cleanup_failure']).toBeDefined() }) + +interface CapturedOc1Bootstrap { + PACKAGE: string + secrets: string[] + seedOc1Bootstrap( + root: string, + project: string, + env: Record, + plugin: string, + dependencies: { fixture: string; version: string }, + ): Promise + hostBootstrapReport(env: Record): Promise +} + +/** + * Capture the OC1 startup seeding and failure report from the real-host E2E + * source, with the shared config-dependency helper it calls. Refuse a + * scenario that does not seed OC1 before it starts the host command. + */ +async function captureOc1Bootstrap() { + const { code, scenario, slice } = await captureDeclarations([ + 'PACKAGE', + 'TOKEN', + 'PASSWORD', + 'redactFixtureSecrets', + 'seedOc1Bootstrap', + 'hostBootstrapReport', + ]) + const seedAt = scenario.findIndex( + (node) => + node.type === 'IfStatement' && + slice(node.test) === "host === 'oc1'" && + slice(node.consequent).includes('seedOc1Bootstrap('), + ) + const hostAt = scenario.findIndex( + (node) => + node.type === 'VariableDeclaration' && + slice(node).includes("name: 'host'"), + ) + if (seedAt < 0 || hostAt < 0 || seedAt > hostAt) + throw new Error('The E2E scenario does not seed OC1 before starting it') + return new Function( + 'bindings', + ` + const { cp, readFile, readdir, join, seedOpencodeConfigDependencies } = bindings; + ${code} + return { PACKAGE, seedOc1Bootstrap, hostBootstrapReport, + secrets: [TOKEN, PASSWORD, 'synthetic-refresh', 'relay-disposable-only'] }; + `, + )({ + cp, + readFile, + readdir, + join, + seedOpencodeConfigDependencies, + }) as CapturedOc1Bootstrap +} + +/** + * OpenCode 1.18.31 skips installing @opencode-ai/plugin into a config + * directory only when node_modules exists and the package-lock.json root + * names every dependency the manifest declares, plus the SDK itself + * (Npm.install in packages/core/src/npm.ts of opencode v1.18.31). Otherwise + * it installs from the registry, which these isolated fixtures cannot reach. + */ +async function opencode1WouldSkipInstall(dir: string) { + const exists = await lstat(join(dir, 'node_modules')).then( + () => true, + () => false, + ) + if (!exists) return false + const json = (name: string) => + readFile(join(dir, name), 'utf8').then( + (text) => JSON.parse(text) as Record, + () => ({}) as Record, + ) + const names = (value: Record | undefined) => + [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + ].flatMap((field) => + Object.keys((value?.[field] as Record | undefined) ?? {}), + ) + const lock = (await json('package-lock.json')) as { + packages?: Record> + } + const locked = new Set(names(lock.packages?.[''])) + return [...names(await json('package.json')), '@opencode-ai/plugin'].every( + (name) => locked.has(name), + ) +} + +// The genuine npm-installed OC1 1.18.18 SDK tree the process E2E suite uses. +// Its committed manifest and lockfile are always present; a runner provisions +// node_modules from that lockfile. +const genuineConfigDeps = resolve( + import.meta.dir, + '../../e2e-tests/fixtures/opencode1-config-deps', +) +const genuineConfigDepsInstalled = await lstat( + join(genuineConfigDeps, 'node_modules', '.package-lock.json'), +).then( + () => true, + () => false, +) + +/** An OC1 scenario root with every kind of config directory OC1 reads. */ +async function oc1Layout(root: string, pkg: string) { + const env = { + PATH: '', + HOME: join(root, 'home'), + XDG_CONFIG_HOME: join(root, 'config'), + XDG_DATA_HOME: join(root, 'data'), + XDG_CACHE_HOME: join(root, 'cache'), + TMPDIR: join(root, 'tmp'), + } + const project = join(root, 'project') + const configDirectories = [ + join(env.XDG_CONFIG_HOME, 'opencode'), + join(project, '.opencode'), + join(env.HOME, '.opencode'), + ] + for (const directory of [ + ...configDirectories, + env.XDG_DATA_HOME, + env.XDG_CACHE_HOME, + env.TMPDIR, + ]) + await mkdir(directory, { recursive: true }) + const packed = join(project, 'node_modules', pkg) + await mkdir(packed, { recursive: true }) + await writeFile( + join(packed, 'package.json'), + JSON.stringify({ name: pkg, version: '0.0.0-packed' }), + ) + const cachedPlugin = join( + env.XDG_CACHE_HOME, + 'opencode', + 'packages', + `${pkg}@latest`, + 'node_modules', + pkg, + 'package.json', + ) + return { env, project, configDirectories, cachedPlugin } +} + +const present = (path: string) => + lstat(path).then( + () => true, + () => false, + ) + +registerTest.skipIf(!genuineConfigDepsInstalled)( + 'captured OC1 seeding gives every config directory and the plugin cache a complete installed tree', + wrapBody(async () => { + const captured = await captureOc1Bootstrap() + await fixture(async (root) => { + const layout = await oc1Layout(root, captured.PACKAGE) + // Missing-dependency reproduction: unseeded, OC1 would run a registry + // install in each config directory and for the bare plugin name. + for (const directory of layout.configDirectories) + expect(await opencode1WouldSkipInstall(directory)).toBe(false) + expect(await present(layout.cachedPlugin)).toBe(false) + expect(await captured.hostBootstrapReport(layout.env)).toContain( + 'config plugin SDK: missing', + ) + + const targets = await captured.seedOc1Bootstrap( + root, + layout.project, + layout.env, + captured.PACKAGE, + { fixture: genuineConfigDeps, version: '1.18.18' }, + ) + expect([...targets].sort()).toEqual( + layout.configDirectories.map((directory) => resolve(directory)).sort(), + ) + const fixtureLock = JSON.parse( + await readFile(join(genuineConfigDeps, 'package-lock.json'), 'utf8'), + ) + for (const directory of layout.configDirectories) { + expect(await opencode1WouldSkipInstall(directory)).toBe(true) + expect( + JSON.parse( + await readFile(join(directory, 'package-lock.json'), 'utf8'), + ), + ).toEqual(fixtureLock) + const sdk = join(directory, 'node_modules', '@opencode-ai', 'plugin') + const manifest = JSON.parse( + await readFile(join(sdk, 'package.json'), 'utf8'), + ) + expect(manifest.version).toBe('1.18.18') + expect( + (await lstat(join(sdk, manifest.exports['.'].import))).size, + ).toBeGreaterThan(0) + } + expect( + JSON.parse(await readFile(layout.cachedPlugin, 'utf8')).version, + ).toBe('0.0.0-packed') + expect(await captured.hostBootstrapReport(layout.env)).toContain( + 'config plugin SDK: 1.18.18', + ) + }) + }), +) + +test('captured OC1 seeding refuses an SDK tree from another OC1 version before copying', async () => { + const captured = await captureOc1Bootstrap() + await fixture(async (root) => { + const layout = await oc1Layout(root, captured.PACKAGE) + // The committed fixture holds the SDK for OpenCode 1.18.18. The E2E host + // is pinned to 1.18.31, so seeding must refuse an older SDK tree rather + // than silently pair the host with another version's plugin SDK. + await expect( + captured.seedOc1Bootstrap( + root, + layout.project, + layout.env, + captured.PACKAGE, + { fixture: genuineConfigDeps, version: '1.18.31' }, + ), + ).rejects.toThrow('the config dependency fixture declares 1.18.18') + for (const directory of layout.configDirectories) { + expect(await present(join(directory, 'node_modules'))).toBe(false) + expect(await opencode1WouldSkipInstall(directory)).toBe(false) + } + expect(await present(layout.cachedPlugin)).toBe(false) + }) +}) + +test('captured OC1 seeding refuses a manifest and lockfile without the installed tree', async () => { + const captured = await captureOc1Bootstrap() + await fixture(async (root) => { + const layout = await oc1Layout(root, captured.PACKAGE) + const incomplete = join(root, 'incomplete-deps') + await mkdir(join(incomplete, 'node_modules'), { recursive: true }) + for (const name of ['package.json', 'package-lock.json']) + await cp(join(genuineConfigDeps, name), join(incomplete, name)) + await expect( + captured.seedOc1Bootstrap( + root, + layout.project, + layout.env, + captured.PACKAGE, + { fixture: incomplete, version: '1.18.18' }, + ), + ).rejects.toThrow('has no npm-installed node_modules') + for (const directory of layout.configDirectories) + expect(await present(join(directory, 'node_modules'))).toBe(false) + expect(await present(layout.cachedPlugin)).toBe(false) + }) +}) + +test('captured OC1 failure report includes the host bootstrap log with fixture secrets redacted', async () => { + const captured = await captureOc1Bootstrap() + await fixture(async (root) => { + const layout = await oc1Layout(root, captured.PACKAGE) + const logs = join(layout.env.XDG_DATA_HOME, 'opencode', 'log') + await mkdir(logs, { recursive: true }) + await writeFile( + join(logs, '2026-10-11T135743.log'), + `WARN service=config background dependency install failed dir=${layout.configDirectories[0]}\n${captured.secrets.join('\n')}\n`, + ) + const report = (await captured.hostBootstrapReport(layout.env)).join('\n') + expect(report).toContain('config plugin SDK: missing') + expect(report).toContain('host log 2026-10-11T135743.log:') + expect(report).toContain('background dependency install failed') + expect(report).toContain('[redacted]') + for (const secret of captured.secrets) expect(report).not.toContain(secret) + }) +}) diff --git a/packages/opencode/src/tests/opencode2-e2e.test.ts b/packages/opencode/src/tests/opencode2-e2e.test.ts index b0628a8b0..155c8ad52 100644 --- a/packages/opencode/src/tests/opencode2-e2e.test.ts +++ b/packages/opencode/src/tests/opencode2-e2e.test.ts @@ -2,6 +2,7 @@ // CLI installation failure is a failure, not a skipped interoperability test. import { afterAll, beforeAll, describe, expect } from 'bun:test' import { + cp, mkdir, mkdtemp, readdir, @@ -30,6 +31,7 @@ import { type TestGate, } from '../../../core/src/tests/test-lifetime' import { tokenFingerprint } from '../../../core/src/token-fingerprint' +import { seedOpencodeConfigDependencies } from '../../../e2e-tests/src/opencode-runner' const { test, gate, trackDetached, deferCleanup } = createTestLifetimeSuite() @@ -42,6 +44,8 @@ let scratch = '' let cli = '' let oc1 = '' let packedProject = '' +/** The plugin SDK tree OpenCode 1 (OC1) expects in each config directory, at OC1's own version. */ +let oc1ConfigDependencies = { fixture: '', version: '' } const children = new Set() function phaseClock() { @@ -221,13 +225,38 @@ async function installPackedHosts() { join(install1, 'package.json'), JSON.stringify({ private: true }), ) + const env1 = await isolatedEnv(install1) await requireCommand( ['npm', 'install', '--no-audit', '--no-fund', 'opencode-ai@1.18.31'], install1, - await isolatedEnv(install1), + env1, { clock: installation, name: 'oc1.install' }, ) oc1 = join(install1, 'node_modules', '.bin', 'opencode') + const oc1Version = JSON.parse( + await readFile( + join(install1, 'node_modules', 'opencode-ai', 'package.json'), + 'utf8', + ), + ).version + expect(oc1Version).toBe('1.18.31') + // OC1 installs @opencode-ai/plugin at its own version into each config + // directory before it loads plugins. Install that tree once here, while + // the registry is reachable, the same way OC1 does (npm, no install + // scripts), so each OC1 scenario can be given a complete copy. + const sdk = join(scratch, 'oc1-config-deps') + await mkdir(sdk, { recursive: true }) + await writeFile( + join(sdk, 'package.json'), + JSON.stringify({ dependencies: { '@opencode-ai/plugin': oc1Version } }), + ) + await requireCommand( + ['npm', 'install', '--no-audit', '--no-fund', '--ignore-scripts'], + sdk, + env1, + { clock: installation, name: 'oc1.sdk.install' }, + ) + oc1ConfigDependencies = { fixture: sdk, version: oc1Version } packedProject = join(scratch, 'packed') await mkdir(packedProject, { recursive: true }) const pack = join(scratch, 'archives') @@ -381,6 +410,112 @@ async function seed( return paths } +/** + * OpenCode 1.18.31 prepares two dependency trees before it loads plugins and + * sends the model request, and installs either one from the npm registry + * when it is missing. The scenario's proxy blocks the registry, and npm's + * retry backoff can outlast the 60-second host deadline, so the host never + * reaches the synthetic upstream. Seed both trees from packages installed + * earlier in this run, only inside the scenario root the test body removes: + * + * - Every config directory OC1 reads gets @opencode-ai/plugin at OC1's own + * version as a manifest, lockfile and installed tree. OC1 skips its install + * when node_modules exists and the lockfile root names every dependency. + * - OC1 installs a plugin named by bare package name into its own package + * cache, never resolving it from the project's node_modules, and reuses an + * existing install there. That cache gets the packed plugin and its + * dependencies, so OC1 loads the packed build rather than fetching the + * published `latest` one. + */ +async function seedOc1Bootstrap( + root: string, + project: string, + env: Record, + plugin: string, + dependencies: { fixture: string; version: string }, +) { + const declared = JSON.parse( + await readFile(join(dependencies.fixture, 'package.json'), 'utf8'), + ).dependencies?.['@opencode-ai/plugin'] + if (declared !== dependencies.version) + throw new Error( + `OC1 ${dependencies.version} needs @opencode-ai/plugin ${dependencies.version}; the config dependency fixture declares ${declared}`, + ) + const configDirectory = join(env.XDG_CONFIG_HOME!, 'opencode') + const seeded = await seedOpencodeConfigDependencies( + { + tempDir: root, + workdir: project, + homeDir: env.HOME!, + configDir: env.XDG_CONFIG_HOME!, + dataDir: env.XDG_DATA_HOME!, + cacheDir: env.XDG_CACHE_HOME!, + quotaFeedDir: join(root, 'quota-feed'), + }, + // OC1 runs here without OPENCODE_CONFIG_DIR, but the shared helper always + // adds that variable's directory as a target. Name the XDG config + // directory, which is already a target, so an unset value cannot resolve + // to the test process's working directory. + { ...env, OPENCODE_CONFIG_DIR: configDirectory }, + dependencies.fixture, + ) + if (plugin === PACKAGE) + await cp( + join(project, 'node_modules'), + join( + env.XDG_CACHE_HOME!, + 'opencode', + 'packages', + `${PACKAGE}@latest`, + 'node_modules', + ), + { + recursive: true, + errorOnExist: true, + force: false, + verbatimSymlinks: true, + }, + ) + return seeded.targets +} + +/** + * Startup evidence that host stdout and stderr do not carry. OC1 writes its + * own log, including any failed background dependency install, under + * XDG_DATA_HOME/opencode/log. Report that log with fixture secrets redacted, + * and the plugin SDK version installed in the isolated config directory. + */ +async function hostBootstrapReport(env: Record) { + const lines: string[] = [] + if (env.XDG_CONFIG_HOME) + lines.push( + `config plugin SDK: ${await readFile( + join( + env.XDG_CONFIG_HOME, + 'opencode', + 'node_modules', + '@opencode-ai', + 'plugin', + 'package.json', + ), + 'utf8', + ).then( + (text) => String(JSON.parse(text).version), + () => 'missing', + )}`, + ) + const logs = env.XDG_DATA_HOME + ? join(env.XDG_DATA_HOME, 'opencode', 'log') + : undefined + for (const name of logs ? await readdir(logs).catch(() => []) : []) + lines.push( + `host log ${name}:\n${redactFixtureSecrets( + await readFile(join(logs ?? '', name), 'utf8'), + )}`, + ) + return lines +} + const frame = (type: string, data: unknown) => `event: ${type}\ndata: ${JSON.stringify(data)}\n\n` function reply(model: string, text = 'OC2_SYNTHETIC_OK') { @@ -506,6 +641,9 @@ async function scenario( const [transport, host = 'oc2', entry = 'package', mode] = args clock.mark('fixture.start') const root = await mkdtemp(join(scratch, 'scenario-')) + // Remove everything this scenario seeds even if setup fails before it + // returns; withScenario reports failure evidence before this runs. + deferCleanup(() => rm(root, { recursive: true, force: true })) const project = join(root, 'project') await mkdir(project) await requireCommand(['git', 'init', '-q'], project, { @@ -672,6 +810,8 @@ export default {id:'cortexkit-anthropic-auth',async setup(ctx){appendFileSync('$ ), ) const executable = host === 'oc2' ? cli : oc1 + if (host === 'oc1') + await seedOc1Bootstrap(root, project, env, plugin, oc1ConfigDependencies) const result = await command( [ executable, @@ -735,6 +875,8 @@ async function withScenario( 'utf8', ).catch(() => 'audit not created'), ) + for (const line of await hostBootstrapReport(run.env)) + console.error(line) } } if (run) await rm(run.root, { recursive: true, force: true }) From 09f495eaac199df1933159feb8c9fd1d3f4abd18 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:19:46 +0200 Subject: [PATCH 161/169] test: lock the OC1 executable without install scripts --- bun.lock | 27 ++++++ package.json | 1 + packages/e2e-tests/src/opencode-executable.ts | 32 +++++++ packages/e2e-tests/src/opencode-runner.ts | 5 +- .../tests/native-process-fixture.test.ts | 4 +- .../tests/opencode-executable.test.ts | 86 +++++++++++++++++++ 6 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 packages/e2e-tests/src/opencode-executable.ts create mode 100644 packages/e2e-tests/tests/opencode-executable.test.ts diff --git a/bun.lock b/bun.lock index 8112e7e7f..25633e323 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "jsonc-parser": "^3.3.1", "lefthook": "2.1.14", "miniflare": "^4.20260730.0", + "opencode-ai": "1.18.18", "pi-coding-agent-1": "npm:@earendil-works/pi-coding-agent@1.0.4", "typescript": "7.0.2", }, @@ -958,6 +959,32 @@ "openai": ["openai@6.40.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA=="], + "opencode-ai": ["opencode-ai@1.18.18", "", { "optionalDependencies": { "opencode-darwin-arm64": "1.18.18", "opencode-darwin-x64": "1.18.18", "opencode-darwin-x64-baseline": "1.18.18", "opencode-linux-arm64": "1.18.18", "opencode-linux-arm64-musl": "1.18.18", "opencode-linux-x64": "1.18.18", "opencode-linux-x64-baseline": "1.18.18", "opencode-linux-x64-baseline-musl": "1.18.18", "opencode-linux-x64-musl": "1.18.18", "opencode-windows-arm64": "1.18.18", "opencode-windows-x64": "1.18.18", "opencode-windows-x64-baseline": "1.18.18" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "opencode": "bin/opencode.exe" } }, "sha512-J+5HFq8tf+wPBBpBpMPSNjSytF2/EkNWYfFZh4si1d9auFbQriqDyqZv+vFUsLWERfdMU32Eajwuiq3rKBvZLQ=="], + + "opencode-darwin-arm64": ["opencode-darwin-arm64@1.18.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VkG+bz8u8Xqg9NzPK+2/71nEd4DKKlo2NLZurQ1eLAzDnmb1CMYZif/o6Shl8YFuTuYU/30k6yufl4Zr0Ij64g=="], + + "opencode-darwin-x64": ["opencode-darwin-x64@1.18.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-xox5XJJ1bI5qDEbxWWR9pWY2Gak5IuSfDcObyWunRUiN7J8OiwyazsJs1HGsTSkVhisk8SNbFIr0/0xQGhxSZg=="], + + "opencode-darwin-x64-baseline": ["opencode-darwin-x64-baseline@1.18.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-NYlIeOOxKPrqY6rdVIjQV4h+eE1AFCHzEcoxXhc8zSvqnCVIO0hMtdNm3l3HhrDucWmdh1BsQBUoZlrBOvAl4w=="], + + "opencode-linux-arm64": ["opencode-linux-arm64@1.18.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-e8D3g0qJEIzawEg2+ygW3vkZjAYL2ssyAx4GbihjwXwZFvlZZy5zRWWzdz5KLBoHSTl0FB73vNtnNeXONyHpVQ=="], + + "opencode-linux-arm64-musl": ["opencode-linux-arm64-musl@1.18.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dp3XByFRRZngPAQotNbOwr22HgZej4r9Ck0Iv/rOVU+oO5fLD9YSpHEwx80FTZOZmFmd/vriCdrR/dGdzabICw=="], + + "opencode-linux-x64": ["opencode-linux-x64@1.18.18", "", { "os": "linux", "cpu": "x64" }, "sha512-WmeUnhljYJ252wywKTiW4bNDzsas2njpjPUEh0jM6HKNI4vFxJtREtzaWViY4AKEAcOkLWT8Ll17ixvcHz3AnA=="], + + "opencode-linux-x64-baseline": ["opencode-linux-x64-baseline@1.18.18", "", { "os": "linux", "cpu": "x64" }, "sha512-6GvFarhP0pDiXcE9Au8PWoqb9+ZLBIGKrhZxeVAmOJp/gJ8lLL1Eno8O1qpqRb1KjOfOaOD71jHu9xNaqmjEtw=="], + + "opencode-linux-x64-baseline-musl": ["opencode-linux-x64-baseline-musl@1.18.18", "", { "os": "linux", "cpu": "x64" }, "sha512-cXD1gAZ+TTmdE3tnWy5qPstZjrnbPg0rPUwtcHeLaeB+KYxQWEoie1c88oYmZfQ+RWI6+/+LLF0FU29Uyd1Nrw=="], + + "opencode-linux-x64-musl": ["opencode-linux-x64-musl@1.18.18", "", { "os": "linux", "cpu": "x64" }, "sha512-lWiWxotqyVTJYiu2dN90KDNDSrwmyhZk8ajzyfAFjwms2TBVx4L0Ly6gkdhz8nxsxwPRv751W8+yUpzetB8AMA=="], + + "opencode-windows-arm64": ["opencode-windows-arm64@1.18.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fj1LfP3kXUeD34N0FOw6vebS1oK2YmxA2nviAKOAPo8jLFjEgq5djAJ/WroPmS71VOP1EHlKhRshnK3Sx8pI2w=="], + + "opencode-windows-x64": ["opencode-windows-x64@1.18.18", "", { "os": "win32", "cpu": "x64" }, "sha512-wbsBZsHDgfHaw9zJogFrrSfWpObtXMFjiA6xl6IFyeVGXsdKi6E8AWIMRZUxKVkf9X0P48mYUuaP1EHgmJCbpA=="], + + "opencode-windows-x64-baseline": ["opencode-windows-x64-baseline@1.18.18", "", { "os": "win32", "cpu": "x64" }, "sha512-IeTrXqbIDbXD5VXpeupRa8aD+l4lSNeq02/K0jnaEPvR5adv42tiN1rOENiTaO2uX+d9cQ/csCaJPBFiQvwoRg=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/package.json b/package.json index 65c665ef0..cc8a0de08 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "jsonc-parser": "^3.3.1", "lefthook": "2.1.14", "miniflare": "^4.20260730.0", + "opencode-ai": "1.18.18", "pi-coding-agent-1": "npm:@earendil-works/pi-coding-agent@1.0.4", "typescript": "7.0.2" } diff --git a/packages/e2e-tests/src/opencode-executable.ts b/packages/e2e-tests/src/opencode-executable.ts new file mode 100644 index 000000000..4f3ddb50b --- /dev/null +++ b/packages/e2e-tests/src/opencode-executable.ts @@ -0,0 +1,32 @@ +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +const require = createRequire(import.meta.url) + +/** Use the locked platform binary directly; installs intentionally omit postinstall scripts. */ +export async function resolveOpencodeTestExecutable(): Promise { + const root = JSON.parse( + await readFile(resolve(import.meta.dir, '../../../package.json'), 'utf8'), + ) + const version: unknown = root.devDependencies?.['opencode-ai'] + if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) + throw new Error( + 'E2E OpenCode requires an exact repository dependency version', + ) + const platform = process.platform === 'win32' ? 'windows' : process.platform + // Baseline builds also run on x64 hosts without AVX2. + const packageName = `opencode-${platform}-${process.arch}${process.arch === 'x64' ? '-baseline' : ''}` + const sdkRequire = createRequire(require.resolve('opencode-ai/package.json')) + const manifestPath = sdkRequire.resolve(`${packageName}/package.json`) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + if (manifest.version !== version) + throw new Error( + 'E2E OpenCode platform binary differs from the repository version', + ) + return join( + dirname(manifestPath), + 'bin', + process.platform === 'win32' ? 'opencode.exe' : 'opencode', + ) +} diff --git a/packages/e2e-tests/src/opencode-runner.ts b/packages/e2e-tests/src/opencode-runner.ts index 2f3d7e81b..e272f4076 100644 --- a/packages/e2e-tests/src/opencode-runner.ts +++ b/packages/e2e-tests/src/opencode-runner.ts @@ -36,6 +36,7 @@ import { } from '@cortexkit/anthropic-auth-core' import { nativeVaultPaths } from '../../core/src/native-vault-paths.ts' +import { resolveOpencodeTestExecutable } from './opencode-executable.ts' const REPO_ROOT = resolve(import.meta.dir, '../../..') const PLUGIN_ENTRY = join(REPO_ROOT, 'packages/opencode/src/index.ts') @@ -225,6 +226,8 @@ export type SpawnedOpencode = { export type SpawnOptions = { anthropicBaseURL: string + /** Stand-in child used by preparation tests; ordinary runs use the locked host binary. */ + executable?: string hybridCache?: boolean fallbackMode?: 'server' | 'legacy' relay?: { @@ -1084,7 +1087,7 @@ export async function spawnOpencode( await seedOpencodeConfigDependencies(env, childEnv, configDependencies) child = spawn( - 'opencode', + options.executable ?? (await resolveOpencodeTestExecutable()), ['serve', '--port', String(requestedPort), '--hostname', '127.0.0.1'], { cwd: env.workdir, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }, ) diff --git a/packages/e2e-tests/tests/native-process-fixture.test.ts b/packages/e2e-tests/tests/native-process-fixture.test.ts index 5bb693c23..272a7105b 100644 --- a/packages/e2e-tests/tests/native-process-fixture.test.ts +++ b/packages/e2e-tests/tests/native-process-fixture.test.ts @@ -73,9 +73,7 @@ async function prepareWithoutHost( ) await chmod(recorder, 0o700) let env: IsolatedEnv | undefined - const savedPath = process.env.PATH const savedKeep = process.env.ANTHROPIC_AUTH_E2E_KEEP_TMP - process.env.PATH = `${bin}:${savedPath ?? ''}` process.env.ANTHROPIC_AUTH_E2E_KEEP_TMP = '1' const savedParent = Object.fromEntries( Object.keys(parentEnv).map((key) => [key, process.env[key]]), @@ -85,6 +83,7 @@ async function prepareWithoutHost( try { await spawnOpencode({ ...options, + executable: recorder, anthropicBaseURL: 'http://127.0.0.1:9', beforeSpawn: async (isolated) => { env = isolated @@ -95,7 +94,6 @@ async function prepareWithoutHost( } catch (error) { failure = error } finally { - process.env.PATH = savedPath for (const [key, value] of Object.entries(savedParent)) { if (value === undefined) delete process.env[key] else process.env[key] = value diff --git a/packages/e2e-tests/tests/opencode-executable.test.ts b/packages/e2e-tests/tests/opencode-executable.test.ts new file mode 100644 index 000000000..3bec1b96c --- /dev/null +++ b/packages/e2e-tests/tests/opencode-executable.test.ts @@ -0,0 +1,86 @@ +import { expect } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createTestLifetimeSuite } from '../../core/src/tests/test-lifetime' +import type { resolveOpencodeTestExecutable } from '../src/opencode-executable' + +const lifetime = createTestLifetimeSuite() + +async function capturedResolver(required: string, installed: string) { + const root = await mkdtemp(join(tmpdir(), 'anthropic-host-binary-')) + lifetime.deferCleanup(() => rm(root, { recursive: true, force: true })) + const sourceDir = join(root, 'packages/e2e-tests/src') + const sdkDir = join(root, 'node_modules/opencode-ai') + const platform = process.platform === 'win32' ? 'windows' : process.platform + const name = `opencode-${platform}-${process.arch}${process.arch === 'x64' ? '-baseline' : ''}` + const nativeDir = join(root, 'node_modules', name) + for (const path of [sourceDir, sdkDir, join(nativeDir, 'bin')]) + await mkdir(path, { recursive: true }) + await writeFile( + join(root, 'package.json'), + JSON.stringify({ + private: true, + devDependencies: { 'opencode-ai': required }, + }), + ) + await writeFile( + join(sdkDir, 'package.json'), + JSON.stringify({ name: 'opencode-ai', version: installed }), + ) + await writeFile( + join(nativeDir, 'package.json'), + JSON.stringify({ name, version: installed }), + ) + const executable = join( + nativeDir, + 'bin', + process.platform === 'win32' ? 'opencode.exe' : 'opencode', + ) + await writeFile(executable, 'synthetic binary; never executed') + // Copy the current resolver so these fixtures exercise its actual version guards. + const copy = join(sourceDir, 'opencode-executable.ts') + await writeFile( + copy, + await readFile( + join(import.meta.dir, '../src/opencode-executable.ts'), + 'utf8', + ), + ) + const module: { + resolveOpencodeTestExecutable: typeof resolveOpencodeTestExecutable + } = await import(pathToFileURL(copy).href) + return { resolve: module.resolveOpencodeTestExecutable, executable } +} + +lifetime.test( + 'test host resolves its locked platform binary without a postinstall wrapper', + async () => { + const fixture = await capturedResolver('1.18.18', '1.18.18') + expect(await fixture.resolve()).toBe(fixture.executable) + expect(await readFile(fixture.executable, 'utf8')).toBe( + 'synthetic binary; never executed', + ) + }, +) + +lifetime.test( + 'test host refuses a different platform binary version', + async () => { + const fixture = await capturedResolver('1.18.18', '1.18.17') + await expect(fixture.resolve()).rejects.toThrow( + 'platform binary differs from the repository version', + ) + }, +) + +lifetime.test( + 'test host requires an exact repository dependency pin', + async () => { + const fixture = await capturedResolver('^1.18.18', '1.18.18') + await expect(fixture.resolve()).rejects.toThrow( + 'exact repository dependency version', + ) + }, +) From 21be4103e884deecfe5b11796fc98dde7d25a2b0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:30:02 +0200 Subject: [PATCH 162/169] mason: require the genuine OC1 SDK fixture and prove seeded links stay owned The genuine dependency-tree control is now an unconditional TestLifetime test, no longer registerTest.skipIf on a top-level probe. When packages/e2e-tests/fixtures/opencode1-config-deps/node_modules is not provisioned, the test fails and names the missing .package-lock.json and the npm ci step. The unused registerTest, wrapBody and probe are removed. The packed OC1 host stays pinned at 1.18.31, and this control keeps the committed fixture's real 1.18.18 tree; no 1.18.31 tree is fabricated. Seeding copies symlinks verbatim. The control now lists every symlink in each seeded config directory and in the plugin-cache copy, and asserts that each one is relative and resolves inside the directory holding it. The fixture's 7 npm .bin links count, and so does a relative .bin link added to the packed stand-in, so none of these checks can pass on an empty list. Non-vacuity checks on remote Linux with Bun 1.3.14 (filter: captured OC1): with verbatimSymlinks false on the cache copy, only the seeded-tree test failed (isAbsolute expected false, received true). Pointing the probe at an absent file failed only the same test, with the named npm ci message. Both changes were restored. --- .../scripts/check-bundle-globals.test.ts | 170 +++++++++++------- 1 file changed, 109 insertions(+), 61 deletions(-) diff --git a/packages/opencode/scripts/check-bundle-globals.test.ts b/packages/opencode/scripts/check-bundle-globals.test.ts index 0de999316..d7f69cf5f 100644 --- a/packages/opencode/scripts/check-bundle-globals.test.ts +++ b/packages/opencode/scripts/check-bundle-globals.test.ts @@ -1,4 +1,4 @@ -import { expect, test as registerTest } from 'bun:test' +import { expect } from 'bun:test' import { cp, lstat, @@ -6,11 +6,13 @@ import { mkdtemp, readdir, readFile, + readlink, rm, + symlink, writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { parse } from '@babel/parser' import { @@ -21,7 +23,7 @@ import { import { seedOpencodeConfigDependencies } from '../../e2e-tests/src/opencode-runner' import { checkBundleGlobals } from './check-bundle-globals' -const { test, gate, trackDetached, wrapBody } = createTestLifetimeSuite() +const { test, gate, trackDetached } = createTestLifetimeSuite() async function fixture(run: (root: string) => Promise) { const root = await mkdtemp(join(tmpdir(), 'anthropic-bundle-check-')) @@ -692,18 +694,41 @@ async function opencode1WouldSkipInstall(dir: string) { } // The genuine npm-installed OC1 1.18.18 SDK tree the process E2E suite uses. -// Its committed manifest and lockfile are always present; a runner provisions -// node_modules from that lockfile. +// Its committed manifest and lockfile are always present; the runner must +// provision node_modules from that lockfile before this suite runs. const genuineConfigDeps = resolve( import.meta.dir, '../../e2e-tests/fixtures/opencode1-config-deps', ) -const genuineConfigDepsInstalled = await lstat( - join(genuineConfigDeps, 'node_modules', '.package-lock.json'), -).then( - () => true, - () => false, -) + +/** Every symlink under `directory`, with its target exactly as written. */ +async function symlinksUnder(directory: string) { + const links: { path: string; target: string }[] = [] + for (const entry of await readdir(directory, { + recursive: true, + withFileTypes: true, + })) + if (entry.isSymbolicLink()) { + const path = join(entry.parentPath, entry.name) + links.push({ path, target: await readlink(path) }) + } + return links +} +/** + * Seeding copies symlinks verbatim, so each must be relative and resolve + * inside `root`. An absolute or escaping link would point the scenario at + * files outside the directory the test body removes. + */ +function expectLinksOwnedBy( + root: string, + links: { path: string; target: string }[], +) { + for (const link of links) { + expect(isAbsolute(link.target)).toBe(false) + const inside = relative(root, resolve(dirname(link.path), link.target)) + expect(inside.startsWith('..') || inside.startsWith(sep)).toBe(false) + } +} /** An OC1 scenario root with every kind of config directory OC1 reads. */ async function oc1Layout(root: string, pkg: string) { @@ -734,6 +759,15 @@ async function oc1Layout(root: string, pkg: string) { join(packed, 'package.json'), JSON.stringify({ name: pkg, version: '0.0.0-packed' }), ) + // npm links package binaries with relative .bin symlinks; give the packed + // stand-in one so the cache copy is checked for owned links too. + await mkdir(join(packed, 'dist')) + await writeFile(join(packed, 'dist', 'cli.js'), '') + await mkdir(join(project, 'node_modules', '.bin')) + await symlink( + join('..', pkg, 'dist', 'cli.js'), + join(project, 'node_modules', '.bin', 'opencode-anthropic-auth'), + ) const cachedPlugin = join( env.XDG_CACHE_HOME, 'opencode', @@ -752,59 +786,73 @@ const present = (path: string) => () => false, ) -registerTest.skipIf(!genuineConfigDepsInstalled)( - 'captured OC1 seeding gives every config directory and the plugin cache a complete installed tree', - wrapBody(async () => { - const captured = await captureOc1Bootstrap() - await fixture(async (root) => { - const layout = await oc1Layout(root, captured.PACKAGE) - // Missing-dependency reproduction: unseeded, OC1 would run a registry - // install in each config directory and for the bare plugin name. - for (const directory of layout.configDirectories) - expect(await opencode1WouldSkipInstall(directory)).toBe(false) - expect(await present(layout.cachedPlugin)).toBe(false) - expect(await captured.hostBootstrapReport(layout.env)).toContain( - 'config plugin SDK: missing', - ) +test('captured OC1 seeding gives every config directory and the plugin cache a complete installed tree', async () => { + const installed = join( + genuineConfigDeps, + 'node_modules', + '.package-lock.json', + ) + if (!(await present(installed))) + throw new Error( + `${installed} is missing: run npm ci in ${genuineConfigDeps} before this suite`, + ) + const captured = await captureOc1Bootstrap() + await fixture(async (root) => { + const layout = await oc1Layout(root, captured.PACKAGE) + // Missing-dependency reproduction: unseeded, OC1 would run a registry + // install in each config directory and for the bare plugin name. + for (const directory of layout.configDirectories) + expect(await opencode1WouldSkipInstall(directory)).toBe(false) + expect(await present(layout.cachedPlugin)).toBe(false) + expect(await captured.hostBootstrapReport(layout.env)).toContain( + 'config plugin SDK: missing', + ) - const targets = await captured.seedOc1Bootstrap( - root, - layout.project, - layout.env, - captured.PACKAGE, - { fixture: genuineConfigDeps, version: '1.18.18' }, - ) - expect([...targets].sort()).toEqual( - layout.configDirectories.map((directory) => resolve(directory)).sort(), - ) - const fixtureLock = JSON.parse( - await readFile(join(genuineConfigDeps, 'package-lock.json'), 'utf8'), - ) - for (const directory of layout.configDirectories) { - expect(await opencode1WouldSkipInstall(directory)).toBe(true) - expect( - JSON.parse( - await readFile(join(directory, 'package-lock.json'), 'utf8'), - ), - ).toEqual(fixtureLock) - const sdk = join(directory, 'node_modules', '@opencode-ai', 'plugin') - const manifest = JSON.parse( - await readFile(join(sdk, 'package.json'), 'utf8'), - ) - expect(manifest.version).toBe('1.18.18') - expect( - (await lstat(join(sdk, manifest.exports['.'].import))).size, - ).toBeGreaterThan(0) - } + const targets = await captured.seedOc1Bootstrap( + root, + layout.project, + layout.env, + captured.PACKAGE, + { fixture: genuineConfigDeps, version: '1.18.18' }, + ) + expect([...targets].sort()).toEqual( + layout.configDirectories.map((directory) => resolve(directory)).sort(), + ) + const fixtureLock = JSON.parse( + await readFile(join(genuineConfigDeps, 'package-lock.json'), 'utf8'), + ) + for (const directory of layout.configDirectories) { + expect(await opencode1WouldSkipInstall(directory)).toBe(true) expect( - JSON.parse(await readFile(layout.cachedPlugin, 'utf8')).version, - ).toBe('0.0.0-packed') - expect(await captured.hostBootstrapReport(layout.env)).toContain( - 'config plugin SDK: 1.18.18', + JSON.parse( + await readFile(join(directory, 'package-lock.json'), 'utf8'), + ), + ).toEqual(fixtureLock) + const sdk = join(directory, 'node_modules', '@opencode-ai', 'plugin') + const manifest = JSON.parse( + await readFile(join(sdk, 'package.json'), 'utf8'), ) - }) - }), -) + expect(manifest.version).toBe('1.18.18') + expect( + (await lstat(join(sdk, manifest.exports['.'].import))).size, + ).toBeGreaterThan(0) + // The fixture's npm .bin links arrive verbatim and stay relative. + const links = await symlinksUnder(join(directory, 'node_modules')) + expect(links.length).toBeGreaterThan(0) + expectLinksOwnedBy(directory, links) + } + expect( + JSON.parse(await readFile(layout.cachedPlugin, 'utf8')).version, + ).toBe('0.0.0-packed') + const cache = dirname(dirname(dirname(layout.cachedPlugin))) + const cacheLinks = await symlinksUnder(cache) + expect(cacheLinks.length).toBeGreaterThan(0) + expectLinksOwnedBy(cache, cacheLinks) + expect(await captured.hostBootstrapReport(layout.env)).toContain( + 'config plugin SDK: 1.18.18', + ) + }) +}) test('captured OC1 seeding refuses an SDK tree from another OC1 version before copying', async () => { const captured = await captureOc1Bootstrap() From 827572a6eb3f2757496bea62f7d8a4b60ef0227b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:50:48 +0200 Subject: [PATCH 163/169] fix: recognize genuine Bun promise assertion failures --- scripts/run-mutation-bun.ts | 116 +++++- scripts/tests/run-mutation-bun.test.ts | 518 ++++++++++++++++++++++++- 2 files changed, 626 insertions(+), 8 deletions(-) diff --git a/scripts/run-mutation-bun.ts b/scripts/run-mutation-bun.ts index c1bb1ca18..b401251a9 100644 --- a/scripts/run-mutation-bun.ts +++ b/scripts/run-mutation-bun.ts @@ -129,6 +129,109 @@ export type ExecutionEvent = { occurrence: number } +const TEST_FILE_HEADER = /^(?:::group::)?(src\/tests\/[^:\r\n]+\.test\.ts):$/ + +// Bun 1.3.14 reports a .rejects/.resolves matcher whose promise settled the +// other way with a blank "error: " title instead of the usual +// "error: expect(received).matcher(...)" line: +// +// 7 | ).rejects.toMatchObject({ code: 'invalid-source' }) +// ^ +// error: +// +// Expected promise that rejects +// Received promise that resolved: Promise { } +// +// at (/checkout/packages/core/src/tests/x.test.ts:7:13) +// (fail) selected test [1.00ms] +// +// A blank title is also what any thrown error named "error" with an empty +// message prints, so the text alone is not enough. Accept it only when every +// part agrees: the exact expected/received pair names opposite outcomes, the +// stack frames all sit in the test file printed in the current file header, +// the first frame is the line and column that Bun's source excerpt marks with +// its caret, the marked column starts a matcher call chained directly after +// .rejects. or .resolves. (optionally .not.) for the expected outcome, and the +// block ends at its owning (fail) line. Under GitHub Actions Bun inserts a +// blank line and an ::error annotation repeating the same message and frames. +// Hook failures and timeouts are still refused by the checks after this one. +function isPromiseOutcomeFailure( + lines: readonly string[], + index: number, + end: number, +) { + const line = (offset: number) => lines[index + offset] ?? '' + const expected = /^Expected promise that (rejects|resolves)$/.exec(line(2)) + const kind = expected?.[1] + if (!kind || line(0) !== 'error: ' || line(1) !== '' || line(4) !== '') + return false + const settled = kind === 'rejects' ? 'resolved' : 'rejected' + if (line(3) !== `Received promise that ${settled}: Promise { <${settled}> }`) + return false + let testFile: string | undefined + for (let position = index - 1; position >= 0 && !testFile; position--) { + const header = lines[position] ?? '' + if (header === '::endgroup::') return false + testFile = TEST_FILE_HEADER.exec(header)?.[1] + } + if (!testFile) return false + const frames: string[] = [] + const locations: { line: number; column: number }[] = [] + for (let position = index + 5; ; position++) { + const frame = + /^ {6}at (?:|[A-Za-z_$][\w$.]*) \((\/[^()\r\n]+):([1-9]\d*):([1-9]\d*)\)$/.exec( + lines[position] ?? '', + ) + if (!frame) break + if (!frame[1]?.endsWith(`/${testFile}`)) return false + frames.push(lines[position] ?? '') + locations.push({ line: Number(frame[2]), column: Number(frame[3]) }) + } + const location = locations[0] + if (!location) return false + let next = index + 5 + frames.length + if (!lines[next]?.startsWith('(fail) ')) { + const annotation = + /^::error file=([^,\r\n]+),line=(\d+),col=(\d+),title=error: ::(.*)$/.exec( + lines[next + 1] ?? '', + ) + if ( + lines[next] !== '' || + !annotation || + (annotation[1] !== testFile && + !annotation[1]?.endsWith(`/${testFile}`)) || + Number(annotation[2]) !== location.line || + Number(annotation[3]) !== location.column || + annotation[4] !== [line(2), line(3), '', ...frames].join('%0A') + ) + return false + next += 2 + } + if (next >= end || !lines[next]?.startsWith('(fail) ')) return false + const caret = /^ *\^$/.exec(line(-1)) + const source = /^( *([1-9]\d*) \| )(.*)$/.exec(line(-2)) + if ( + !caret || + !source || + Number(source[2]) !== location.line || + line(-1).length - (source[1] ?? '').length !== location.column + ) + return false + const text = source[3] ?? '' + if (!/^[A-Za-z]+\(/.test(text.slice(location.column - 1))) return false + // A matcher on its own line belongs to a chain started on earlier excerpt + // lines, so join the consecutively numbered lines that precede it. + const chain = [text.slice(0, location.column - 1)] + for (let offset = 3; ; offset++) { + const earlier = /^ *([1-9]\d*) \| (.*)$/.exec(line(-offset)) + if (!earlier || Number(earlier[1]) !== location.line - offset + 2) break + chain.unshift(earlier[2] ?? '') + } + return new RegExp(`\\.${kind}\\.(?:not\\.)?$`).test( + chain.join('').replace(/\s+/g, ''), + ) +} + // Count Bun's (pass) and (fail) lines, excluding filtered or discovered tests. // A command selecting one named test must produce one matching result. export function parseExecution( @@ -175,8 +278,7 @@ export function parseExecution( const ordinals = new Map() const events: ExecutionEvent[] = [] for (const line of eventLines) { - const fileHeader = - /^(?:::group::)?(src\/tests\/[^:\r\n]+\.test\.ts):$/.exec(line) + const fileHeader = TEST_FILE_HEADER.exec(line) if (fileHeader) { testFile = fileHeader[1] continue @@ -240,14 +342,16 @@ export function parseExecution( !/^ (?:\d+ snapshots?, )?[1-9]\d* expect\(\) calls$/.test(expects[0] ?? '') ) throw new Error('No verified assertion executed') - const errors = matches(/^error:/) + const errors = lines.flatMap((line, index) => + /^error:/.test(line) ? [index] : [], + ) if ( errors.length !== failed || errors.some( - (line) => + (index) => !/^error: expect\(received\)\.(?:not\.)?[A-Za-z]+\([^\r\n]*\)$/.test( - line, - ), + lines[index] ?? '', + ) && !isPromiseOutcomeFailure(lines, index, recapIndex ?? lines.length), ) ) throw new Error('Not a genuine selected expect assertion failure') diff --git a/scripts/tests/run-mutation-bun.test.ts b/scripts/tests/run-mutation-bun.test.ts index 2f15ca706..271e82308 100644 --- a/scripts/tests/run-mutation-bun.test.ts +++ b/scripts/tests/run-mutation-bun.test.ts @@ -1,8 +1,18 @@ import { expect, test } from 'bun:test' -import { readFile, realpath, rm, stat } from 'node:fs/promises' +import { + mkdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, resolve } from 'node:path' -import { createTestLifetimeSuite } from '../../packages/core/src/tests/test-lifetime.ts' +import { + createTestLifetimeSuite, + TestLifetime, +} from '../../packages/core/src/tests/test-lifetime.ts' import { createMutationSandbox, type ExecutionEvent, @@ -1241,3 +1251,507 @@ test('genuine negated assertion failures retain the normal selected-test contrac }), ).toThrow('genuine selected expect assertion') }) + +// Byte-exact stderr from the pinned Bun 1.3.14 mutation CI child that selected +// this native-host-auth-read test after the mutation made its expected +// rejection resolve; that child's stdout held only the version header. +const promiseOutcomeName = + 'group-readable authentication refuses even when the process can physically read it' +const promiseOutcomeStack = + ' at (/home/runner/work/anthropic-auth/anthropic-auth/packages/core/src/tests/native-host-auth-read.test.ts:100:13)' +function promiseOutcomeEvidence(): Transcript { + return { + stdout: header, + stderr: [ + '', + '::group::src/tests/native-host-auth-read.test.ts:', + ' 95 | const f = await fixture()', + " 96 | await writeFile(f.path, '{}', { mode: 0o600 })", + ' 97 | await chmod(f.path, 0o640)', + ' 98 | await expect(', + " 99 | readNativeHostAuthKind('opencode', f.path),", + "100 | ).rejects.toMatchObject({ code: 'invalid-source' })", + ' ^', + 'error: ', + '', + 'Expected promise that rejects', + 'Received promise that resolved: Promise { }', + '', + promiseOutcomeStack, + '', + `::error file=packages/core/src/tests/native-host-auth-read.test.ts,line=100,col=13,title=error: ::Expected promise that rejects%0AReceived promise that resolved: Promise { }%0A%0A${promiseOutcomeStack}`, + `(fail) ${promiseOutcomeName} [6.23ms]`, + '', + '::endgroup::', + '', + ' 0 pass', + ' 11 filtered out', + ' 1 fail', + ' 1 expect() calls', + 'Ran 1 test across 1 file. [41.00ms]', + '', + ].join('\n'), + exitCode: 1, + signal: null, + } +} +// The same failure as Bun prints it outside GitHub Actions: no group markers, +// and the (fail) line follows the stack frame directly without an annotation. +function plainPromiseOutcomeEvidence(): Transcript { + const evidence = promiseOutcomeEvidence() + return { + ...evidence, + stderr: evidence.stderr + .replace('::group::', '') + .replace(/\n\n::error [^\n]+\n/, '\n') + .replace('\n::endgroup::\n', ''), + } +} +function edited(transcript: Transcript, from: string | RegExp, to: string) { + const stderr = transcript.stderr.replace(from, to) + if (stderr === transcript.stderr) + throw new Error(`Fixture edit did not apply: ${String(from)}`) + return { ...transcript, stderr } +} + +test('accepts the pinned Bun promise-outcome failure bound to its matcher, stack and owning result', async () => { + for (const transcript of [ + promiseOutcomeEvidence(), + plainPromiseOutcomeEvidence(), + ]) { + expect(parseExecution(promiseOutcomeName, transcript)).toEqual({ + count: 1, + events: [ + { + name: promiseOutcomeName, + file: 'src/tests/native-host-auth-read.test.ts', + status: 'fail', + occurrence: 1, + }, + ], + }) + const output = sinks() + expect( + await runTest( + 'packages/core/src/tests/native-host-auth-read.test.ts', + promiseOutcomeName, + { ...output, spawn: () => childFor(transcript) }, + new AbortController().signal, + ), + ).toBe(1) + expect(output.err()).toBe(transcript.stderr) + expect(output.out()).toBe(`${header}CKDEV executed 1 tests\n`) + } +}) + +test('refuses promise-outcome text without its exact labels, source matcher, stack or owning result', () => { + const grouped = promiseOutcomeEvidence() + const plain = plainPromiseOutcomeEvidence() + const variants: [string, Transcript][] = [ + [ + 'arbitrary blank error and prose without a source excerpt', + edited(plain, /\n 95 \|[\s\S]*?\n {18}\^\n/, '\n'), + ], + ['missing stack frame', edited(plain, `${promiseOutcomeStack}\n`, '')], + [ + 'promise outcome matched by an expected-label substring only', + edited( + plain, + 'Expected promise that rejects', + 'Expected promise that rejects or resolves', + ), + ], + [ + 'expected label naming the other matcher', + edited( + edited( + plain, + 'Expected promise that rejects', + 'Expected promise that resolves', + ), + 'Received promise that resolved: Promise { }', + 'Received promise that rejected: Promise { }', + ), + ], + [ + 'received outcome matching the expected outcome', + edited( + plain, + 'Received promise that resolved: Promise { }', + 'Received promise that rejected: Promise { }', + ), + ], + [ + 'received promise state contradicting its label', + edited(plain, 'Promise { }', 'Promise { }'), + ], + [ + 'stack frame in another test file', + edited(plain, '/native-host-auth-read.test.ts:100', '/other.test.ts:100'), + ], + ['stack frame on another line', edited(plain, ':100:13)', ':99:13)')], + [ + 'caret at another column', + edited(plain, '\n ^\n', '\n ^\n'), + ], + [ + 'matcher not chained after .rejects', + edited(plain, ').rejects.toMatchObject', ').catches.toMatchObject'), + ], + [ + 'expected text displaced after its owning result', + edited( + plain, + /(error: \n\nExpected[\s\S]*?\.test\.ts:100:13\)\n)(\(fail\) [^\n]+\n)/, + '$2$1', + ), + ], + [ + 'unrelated output between the stack and the owning result', + edited( + plain, + `${promiseOutcomeStack}\n`, + `${promiseOutcomeStack}\nnoise\n`, + ), + ], + [ + 'annotation at another column', + edited(grouped, 'line=100,col=13', 'line=100,col=14'), + ], + [ + 'annotation repeating a different message', + edited( + grouped, + '::Expected promise that rejects%0A', + '::Expected promise%0A', + ), + ], + [ + 'annotation missing after its blank separator', + edited(grouped, /\n::error [^\n]+\n/, '\n'), + ], + [ + 'runtime error title instead of the blank assertion title', + edited(plain, '\nerror: \n', '\nError: \n'), + ], + [ + 'timeout reported in the owning block', + edited( + plain, + `${promiseOutcomeStack}\n`, + `${promiseOutcomeStack.replace('', 'timeout')}\n`, + ), + ], + ] + const refusal = (transcript: Transcript) => { + try { + parseExecution(promiseOutcomeName, transcript) + return 'accepted' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + } + const genuine = 'Not a genuine selected expect assertion failure' + expect( + variants.map(([label, transcript]) => [label, refusal(transcript)]), + ).toEqual( + variants.map(([label]) => [ + label, + label.startsWith('timeout') + ? 'Timeout, hook or unhandled error' + : genuine, + ]), + ) +}) + +// Real pinned Bun children print the promise-outcome format end to end. Each +// test body owns a private sandbox holding its fixture test files, and joins +// both pipes and the exit status of every child before the sandbox is removed. +const promiseOutcomeFixture = `import { expect, test } from 'bun:test' + +const resolved = async () => ({ code: 'fine' }) +const rejected = async () => { + throw Object.assign(new Error('refused'), { code: 'invalid-source' }) +} + +test('rejects receives a resolved promise', async () => { + await expect( + resolved(), + ).rejects.toMatchObject({ code: 'invalid-source' }) +}) + +test('resolves receives a rejected promise', async () => { + await expect(rejected()) + .resolves + .toEqual({ code: 'fine' }) +}) + +test('negated rejects receives a resolved promise after a counted assertion', async () => { + expect(await resolved()).toEqual({ code: 'fine' }) + await expect(resolved()).rejects.not.toBeNull() +}) + +test('rejects companion receives a rejected promise', async () => { + await expect(rejected()).rejects.toMatchObject({ code: 'invalid-source' }) +}) + +test('resolves companion receives a resolved promise', async () => { + await expect(resolved()).resolves.toEqual({ code: 'fine' }) +}) +` +const promiseRefusalFixture = `import { beforeEach, describe, expect, test } from 'bun:test' + +const resolved = async () => ({ code: 'fine' }) + +describe('hooked', () => { + beforeEach(async () => { + await expect(resolved()).rejects.toThrow() + }) + test('inner companion', () => { + expect(1).toBe(1) + }) +}) + +test('runtime error repeating promise outcome prose', async () => { + expect(await resolved()).toEqual({ code: 'fine' }) + const error = new Error( + '\\n\\nExpected promise that rejects\\nReceived promise that resolved: Promise { }\\n', + ) + error.name = 'error' + throw error +}) + +test('negated rejects receives a resolved promise without a counted assertion', async () => { + await expect(resolved()).rejects.not.toBeNull() +}) +` +async function promiseFixtureSandbox() { + const sandbox = await createMutationSandbox() + filesystemTests.deferCleanup(() => + rm(sandbox, { recursive: true, force: true }), + ) + await mkdir(resolve(sandbox, 'src/tests'), { recursive: true }) + await writeFile( + resolve(sandbox, 'src/tests/promise-outcome.test.ts'), + promiseOutcomeFixture, + ) + await writeFile( + resolve(sandbox, 'src/tests/promise-refusal.test.ts'), + promiseRefusalFixture, + ) + return sandbox +} +async function pinnedChild( + sandbox: string, + file: string, + selected: string | undefined, + github: boolean, + lifetime: Pick = filesystemTests, +): Promise { + const env = testEnvironment(sandbox, process.env) + delete env.GITHUB_ACTIONS + if (github) env.GITHUB_ACTIONS = 'true' + const child = Bun.spawn( + [ + process.execPath, + 'test', + '--no-orphans', + `./src/tests/${file}`, + ...(selected === undefined + ? [] + : [ + '--test-name-pattern', + exactPattern(selected.replaceAll(' > ', ' ')), + ]), + ], + { cwd: sandbox, env, stdout: 'pipe', stderr: 'pipe' }, + ) + // Register cancellation before waiting: when Bun tears down a timed-out + // body, the lifetime opens this gate and kills the child if it is still + // running, so the pipe and exit joins below finish before sandbox cleanup. + const cancel = lifetime.gate() + const cancellation = cancel.wait.then(() => { + if (child.exitCode === null && child.signalCode === null) child.kill() + }) + lifetime.trackDetached(cancellation) + const [stdout, stderr, exited] = await Promise.allSettled([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + cancel.open() + await cancellation + if (stdout.status === 'rejected') throw stdout.reason + if (stderr.status === 'rejected') throw stderr.reason + if (exited.status === 'rejected') throw exited.reason + return { + stdout: stdout.value, + stderr: stderr.value, + exitCode: exited.value, + signal: child.signalCode, + } +} + +filesystemTests.test( + 'real pinned Bun promise-outcome failures count beside healthy companions', + async () => { + const sandbox = await promiseFixtureSandbox() + const file = 'src/tests/promise-outcome.test.ts' + for (const github of [false, true]) { + for (const [selected, status] of [ + ['rejects receives a resolved promise', 'fail'], + ['resolves receives a rejected promise', 'fail'], + [ + 'negated rejects receives a resolved promise after a counted assertion', + 'fail', + ], + ['rejects companion receives a rejected promise', 'pass'], + ['resolves companion receives a resolved promise', 'pass'], + ] as const) { + const transcript = await pinnedChild( + sandbox, + 'promise-outcome.test.ts', + selected, + github, + ) + expect(transcript.exitCode).toBe(status === 'fail' ? 1 : 0) + expect(transcript.stderr).toContain( + status === 'fail' ? '\nerror: \n\nExpected promise that ' : '(pass) ', + ) + expect(parseExecution(selected, transcript)).toEqual({ + count: 1, + events: [{ name: selected, file, status, occurrence: 1 }], + }) + } + const whole = await pinnedChild( + sandbox, + 'promise-outcome.test.ts', + undefined, + github, + ) + expect(whole.exitCode).toBe(1) + expect(parseExecution(undefined, whole).events).toEqual([ + { + name: 'rejects receives a resolved promise', + file, + status: 'fail', + occurrence: 1, + }, + { + name: 'resolves receives a rejected promise', + file, + status: 'fail', + occurrence: 1, + }, + { + name: 'negated rejects receives a resolved promise after a counted assertion', + file, + status: 'fail', + occurrence: 1, + }, + { + name: 'rejects companion receives a rejected promise', + file, + status: 'pass', + occurrence: 1, + }, + { + name: 'resolves companion receives a resolved promise', + file, + status: 'pass', + occurrence: 1, + }, + ]) + } + }, +) + +filesystemTests.test( + 'real pinned Bun hook and runtime errors with promise-outcome text still refuse', + async () => { + const sandbox = await promiseFixtureSandbox() + for (const github of [false, true]) { + for (const [selected, reason] of [ + ['hooked > inner companion', 'Timeout, hook or unhandled error'], + [ + 'runtime error repeating promise outcome prose', + 'Not a genuine selected expect assertion failure', + ], + // Bun 1.3.14 does not count a negated promise matcher whose promise + // settled the other way, so the positive expect-count guard refuses. + [ + 'negated rejects receives a resolved promise without a counted assertion', + 'No verified assertion executed', + ], + ] as const) { + const transcript = await pinnedChild( + sandbox, + 'promise-refusal.test.ts', + selected, + github, + ) + expect(transcript.exitCode).toBe(1) + expect(transcript.stderr).toContain( + '\nerror: \n\nExpected promise that rejects\nReceived promise that resolved: Promise { }\n\n at (/', + ) + expect(() => parseExecution(selected, transcript)).toThrow(reason) + } + } + }, +) + +// A fixture that never finishes on its own, so only cancellation ends its child. +const heldFixture = `import { test } from 'bun:test' + +test('held until cancelled', async () => { + await new Promise(() => setInterval(() => {}, 1_000)) +}, 600_000) +` + +filesystemTests.test( + 'a held pinned Bun child is killed by its cancelled test lifetime before cleanup', + async () => { + const sandbox = await promiseFixtureSandbox() + await writeFile( + resolve(sandbox, 'src/tests/promise-held.test.ts'), + heldFixture, + ) + // finish() opens cancellation gates before it joins the body and cleans up. + // The child's gate kills it so its body and pipe joins can finish. Calling + // finish() after registration reproduces timeout teardown without a timer. + const lifetime = new TestLifetime() + const events: string[] = [] + lifetime.deferCleanup(() => { + events.push('cleanup') + }) + let registered!: () => void + const cancellationRegistered = new Promise((resolve) => { + registered = resolve + }) + const body = lifetime.runBody(async () => { + const transcript = await pinnedChild( + sandbox, + 'promise-held.test.ts', + 'held until cancelled', + false, + { + gate: () => { + const gate = lifetime.gate() + registered() + return gate + }, + trackDetached: (work) => lifetime.trackDetached(work), + }, + ) + events.push('joined') + return transcript + }) + await cancellationRegistered + events.push('cancel') + await lifetime.finish() + const transcript = await body + expect(events).toEqual(['cancel', 'joined', 'cleanup']) + expect(transcript.signal).toBe('SIGTERM') + expect(transcript.stderr).not.toContain('(pass) held until cancelled') + }, +) From b58687443ff24adaacb0e651b9a2213753f37d27 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:59:10 +0200 Subject: [PATCH 164/169] fix: require quota evidence for enabled killswitch floors --- mutations.toml | 64 ++ packages/core/src/accounts.ts | 22 +- packages/core/src/tests/killswitch.test.ts | 162 ++- packages/opencode/src/index.ts | 59 +- packages/opencode/src/tests/accounts.test.ts | 15 +- packages/opencode/src/tests/index.test.ts | 12 +- .../src/tests/killswitch-preflight.test.ts | 951 ++++++++++++++++++ .../opencode/src/tests/killswitch.test.ts | 8 +- 8 files changed, 1246 insertions(+), 47 deletions(-) create mode 100644 packages/opencode/src/tests/killswitch-preflight.test.ts diff --git a/mutations.toml b/mutations.toml index 6ae038d0e..9fdbf6b68 100644 --- a/mutations.toml +++ b/mutations.toml @@ -486,3 +486,67 @@ expect_message = "error: expect(received).toBeUndefined()" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] broad_report = "tmp/mutations/core.xml" broad_id = "{name}" + +[[control]] +id = "killswitch-positive-floor-requires-standard-reading" +guards = "Unknown standard quota cannot bypass an enabled user floor through soft fail-open policy." +file = "packages/core/src/accounts.ts" +old = "if (thresholds[key] > 0) return false" +new = "if (false) return false /* NON-VACUITY BREAK: admit an unproved account floor */" +test_file = "packages/core/src/tests/killswitch.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/killswitch.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["killswitch account floors require quota evidence > unknown quota cannot bypass an enabled floor through fail-open policy", "killswitch account floors require quota evidence > each enabled floor requires its own finite remaining reading", "killswitch account floors require quota evidence > an account override without floors does not inherit the main floor"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "killswitch-scoped-floor-requires-metadata" +guards = "A positive model floor requires scoped quota metadata; an explicit empty list still proves no carve-out." +file = "packages/core/src/accounts.ts" +old = "if (thresholds.scoped > 0 && quota?.scoped === undefined) return false" +new = "if (false) return false /* NON-VACUITY BREAK: admit missing model quota metadata */" +test_file = "packages/core/src/tests/killswitch.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/killswitch.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["explicit scoped floors require model quota evidence > missing model metadata refuses but an explicit no-carve-out list passes"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "killswitch-scoped-floor-requires-finite-reading" +guards = "A matching model quota must have finite remaining usage before it can satisfy a positive scoped floor." +file = "packages/core/src/accounts.ts" +old = "!Number.isFinite(scopedWindow.remainingPercent) &&\n thresholds.scoped > 0" +new = "false /* NON-VACUITY BREAK: admit non-finite model quota */" +test_file = "packages/core/src/tests/killswitch.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/killswitch.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["explicit scoped floors require model quota evidence > a matching scoped floor needs finite remaining quota above its inclusive boundary"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "killswitch-refused-preflight-preserves-nofloor-policy" +guards = "A refused fallback quota preflight must not exclude a coherent replacement with zero floors and explicit fail-open policy." +file = "packages/opencode/src/index.ts" +old = "killswitchPassesPolicy(\n undefined,\n storageArg,\n account.id,\n options.modelId,\n )" +new = "false /* NON-VACUITY BREAK: exclude all refused preflight accounts */" +test_file = "packages/opencode/src/tests/killswitch-preflight.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/killswitch-preflight.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["a fallback with no killswitch floors stays routable after a torn cross-process replacement refuses its preflight"] +expect_message = "error: expect(received).toEqual(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"] +broad_report = "tmp/mutations/opencode.xml" +broad_id = "{name}" diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index dfc84cae4..e76065728 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -3626,14 +3626,12 @@ export function killswitchPassesPolicy( let sawUnknownWindow = false for (const key of ['five_hour', 'seven_day'] as const) { const window = quota?.[key] - // Defer the unknown-window decision: a quota snapshot can legally carry - // only one window, and a present window below its threshold must still - // block even if the other window is missing. - if (!window) { - sawUnknownWindow = true - continue - } - if (!Number.isFinite(window.remainingPercent)) { + // Each configured floor needs its own reading. A present window below + // its floor blocks even when the other window has no enabled floor. + if (!window || !Number.isFinite(window.remainingPercent)) { + // A user floor requires a reading that proves the account meets it. + // Unknown-quota fail-open cannot override an enabled account floor. + if (thresholds[key] > 0) return false sawUnknownWindow = true continue } @@ -3648,11 +3646,15 @@ export function killswitchPassesPolicy( // PRESENT window at/below threshold blocks. The comparison is inclusive // (`<=`) so the default 0 fires at exhaustion. if (modelId) { + // An explicit empty scope list proves there is no model carve-out; + // absent scope metadata cannot prove a configured model floor is met. + if (thresholds.scoped > 0 && quota?.scoped === undefined) return false const scopedWindow = getScopedQuotaWindowForModel(quota, modelId) if ( scopedWindow && - Number.isFinite(scopedWindow.remainingPercent) && - scopedWindow.remainingPercent <= thresholds.scoped + ((!Number.isFinite(scopedWindow.remainingPercent) && + thresholds.scoped > 0) || + scopedWindow.remainingPercent <= thresholds.scoped) ) { return false } diff --git a/packages/core/src/tests/killswitch.test.ts b/packages/core/src/tests/killswitch.test.ts index 19c51dd16..1ebe36b6f 100644 --- a/packages/core/src/tests/killswitch.test.ts +++ b/packages/core/src/tests/killswitch.test.ts @@ -385,15 +385,14 @@ describe('killswitchPassesPolicy — scoped model dimension', () => { // failClosedOnUnknownQuota=false, the function must STILL evaluate the // scoped check — an exhausted scoped window is its own block reason, // independent of the unknown 5h/7d fail-closed decision. - test('scoped check runs even when 5h/7d is unknown and fail-OPEN (MUST-1)', () => { + test('scoped exhaustion blocks unknown standard quota when standard floors are disabled', () => { const storage = baseStorage() storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } storage.killswitch = { enabled: true, - main: { five_hour: 5, seven_day: 10, scoped: 0 }, + main: { five_hour: 0, seven_day: 0, scoped: 0 }, } - // Both 5h/7d absent → sawUnknownWindow=true. With fail-OPEN the old - // code would short-circuit to true and skip the scoped check. + // Disabled standard floors let this assertion isolate the scoped limit. const quota: OAuthQuotaSnapshot = { scoped: [ scopeWindow(0, { name: 'Claude Fable 5', id: 'claude-fable-5' }), @@ -404,14 +403,13 @@ describe('killswitchPassesPolicy — scoped model dimension', () => { ).toBe(false) }) - test('scoped check runs when one 5h/7d window is non-finite + fail-OPEN (MUST-1 variant)', () => { + test('scoped exhaustion blocks non-finite standard quota when standard floors are disabled', () => { const storage = baseStorage() storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } storage.killswitch = { enabled: true, - main: { five_hour: 5, seven_day: 10, scoped: 0 }, + main: { five_hour: 0, seven_day: 0, scoped: 0 }, } - // five_hour present + non-finite (sawUnknownWindow=true), seven_day absent. const quota: OAuthQuotaSnapshot = { five_hour: { usedPercent: 0, @@ -427,16 +425,13 @@ describe('killswitchPassesPolicy — scoped model dimension', () => { ).toBe(false) }) - test('healthy scoped window with unknown 5h/7d + fail-OPEN still passes (MUST-1 complement)', () => { + test('healthy scoped quota permits unknown standard quota only without standard floors', () => { const storage = baseStorage() storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } storage.killswitch = { enabled: true, - main: { five_hour: 5, seven_day: 10, scoped: 0 }, + main: { five_hour: 0, seven_day: 0, scoped: 0 }, } - // Unknown 5h/7d, but the Fable window is HEALTHY (above threshold). - // The scoped check passes, then the unknown-5h/7d fail-OPEN decision - // also passes. Final: true. const quota: OAuthQuotaSnapshot = { scoped: [ scopeWindow(50, { name: 'Claude Fable 5', id: 'claude-fable-5' }), @@ -447,15 +442,13 @@ describe('killswitchPassesPolicy — scoped model dimension', () => { ).toBe(true) }) - test('absent scoped window with unknown 5h/7d + fail-OPEN still passes (MUST-1 complement)', () => { + test('absent scoped quota permits unknown standard quota only without standard floors', () => { const storage = baseStorage() storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } storage.killswitch = { enabled: true, - main: { five_hour: 5, seven_day: 10, scoped: 0 }, + main: { five_hour: 0, seven_day: 0, scoped: 0 }, } - // No scoped array at all (Sonnet-style: no carve-out). The scoped check - // doesn't fire, then the unknown-5h/7d fail-OPEN decision passes. const quota: OAuthQuotaSnapshot = {} expect( killswitchPassesPolicy(quota, storage, undefined, 'claude-sonnet-5'), @@ -697,3 +690,140 @@ describe('killswitchRetryAfterSeconds — scoped resetsAt', () => { expect(seconds).toBe(300) }) }) + +describe('killswitch account floors require quota evidence', () => { + const failOpenStorage = (): AccountStorage => ({ + ...baseStorage(), + quota: { ...baseStorage().quota, failClosedOnUnknownQuota: false }, + killswitch: { enabled: true, main: { five_hour: 50, seven_day: 10 } }, + }) + + test('unknown quota cannot bypass an enabled floor through fail-open policy', () => { + const storage = failOpenStorage() + expect(killswitchPassesPolicy(undefined, storage, 'fallback-1')).toBe(false) + expect(killswitchPassesPolicy({}, storage, 'fallback-1')).toBe(false) + expect(killswitchPassesPolicy(healthy5h7d(), storage, 'fallback-1')).toBe( + true, + ) + expect( + killswitchPassesPolicy( + { + ...healthy5h7d(), + five_hour: { + usedPercent: 70, + remainingPercent: 30, + checkedAt: Date.now(), + }, + }, + storage, + 'fallback-1', + ), + ).toBe(false) + }) + + test('each enabled floor requires its own finite remaining reading', () => { + const storage = failOpenStorage() + expect( + killswitchPassesPolicy({ five_hour: healthy5h7d().five_hour }, storage), + ).toBe(false) + expect( + killswitchPassesPolicy({ seven_day: healthy5h7d().seven_day }, storage), + ).toBe(false) + expect( + killswitchPassesPolicy( + { + ...healthy5h7d(), + five_hour: { + usedPercent: 0, + remainingPercent: Number.NaN, + checkedAt: Date.now(), + }, + }, + storage, + ), + ).toBe(false) + }) + + test('unknown quota can fail open only when no account floor is enabled', () => { + const storage = failOpenStorage() + storage.killswitch = { enabled: true, main: { five_hour: 0, seven_day: 0 } } + expect(killswitchPassesPolicy(undefined, storage)).toBe(true) + storage.quota = { ...storage.quota, failClosedOnUnknownQuota: true } + expect(killswitchPassesPolicy(undefined, storage)).toBe(false) + storage.killswitch.enabled = false + expect(killswitchPassesPolicy(undefined, storage)).toBe(true) + }) + + test('an account override without floors does not inherit the main floor', () => { + const storage = failOpenStorage() + storage.killswitch = { + ...storage.killswitch, + enabled: true, + accounts: { 'fallback-1': { five_hour: 0, seven_day: 0 } }, + } + expect(killswitchPassesPolicy(undefined, storage, 'fallback-1')).toBe(true) + expect(killswitchPassesPolicy(undefined, storage, 'fallback-2')).toBe(false) + }) +}) + +describe('explicit scoped floors require model quota evidence', () => { + const scopedStorage = (): AccountStorage => ({ + ...baseStorage(), + quota: { ...baseStorage().quota, failClosedOnUnknownQuota: false }, + killswitch: { + enabled: true, + main: { five_hour: 0, seven_day: 0, scoped: 20 }, + }, + }) + + test('missing model metadata refuses but an explicit no-carve-out list passes', () => { + const storage = scopedStorage() + expect( + killswitchPassesPolicy( + healthy5h7d(), + storage, + undefined, + 'claude-fable-5', + ), + ).toBe(false) + expect( + killswitchPassesPolicy( + { ...healthy5h7d(), scoped: [] }, + storage, + undefined, + 'claude-fable-5', + ), + ).toBe(true) + expect( + killswitchPassesPolicy( + { + ...healthy5h7d(), + scoped: [ + scopeWindow(0, { name: 'Claude Fable 5', id: 'claude-fable-5' }), + ], + }, + storage, + undefined, + 'claude-sonnet-5', + ), + ).toBe(true) + }) + + test('a matching scoped floor needs finite remaining quota above its inclusive boundary', () => { + const storage = scopedStorage() + for (const remaining of [Number.NaN, 0, 20, 21]) { + const quota = { + ...healthy5h7d(), + scoped: [ + scopeWindow(remaining, { + name: 'Claude Fable 5', + id: 'claude-fable-5', + }), + ], + } + expect( + killswitchPassesPolicy(quota, storage, undefined, 'claude-fable-5'), + ).toBe(remaining === 21) + } + }) +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1cfd591a0..2b4d369c3 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -2646,20 +2646,27 @@ const anthropicAuthPlugin = async ( const operation = fallbackQuotaRefreshQueue.then(() => pollFallbackQuotas(force, requiredByPolicy), ) - fallbackQuotaRefreshQueue = operation.catch(() => {}) + fallbackQuotaRefreshQueue = operation.then( + () => {}, + () => {}, + ) return operation } async function pollFallbackQuotas(force: boolean, requiredByPolicy: boolean) { const snapshot = await nativeAccounts.read() + const unavailable = new Set() if ( !force && !requiredByPolicy && snapshot.policyStorage.quota?.enabled === false ) - return + return unavailable for (const account of snapshot.accounts) { - if (account.id === 'main' || account.type !== 'oauth' || !account.enabled) + if (account.id === 'main' || account.type !== 'oauth') continue + if (!account.enabled) { + if (requiredByPolicy) unavailable.add(account.id) continue + } if ( !force && getQuotaNextRefreshAt( @@ -2670,7 +2677,7 @@ const anthropicAuthPlugin = async ( ) continue try { - await quotaManager.refreshFallback( + const reading = await quotaManager.refreshFallback( account.id, '', snapshot.policyStorage.accounts.find( @@ -2678,19 +2685,37 @@ const anthropicAuthPlugin = async ( candidate.id === account.id && isOAuthAccount(candidate), ) as OAuthAccount | undefined, ) + if (!reading) { + unavailable.add(account.id) + logger.debug( + 'quota', + 'fallback quota preflight returned no reading', + { accountId: account.id, requiredByPolicy }, + ) + } } catch { - // The runtime already recorded the quota-fetch failure for its account. - // Keep any separate refresh-token rejection; a usage poll cannot fix it. + // A refused account read need not persist a quota error. Keep that + // missing reading visible to admission without poisoning token state. + unavailable.add(account.id) + logger.warn( + 'quota', + 'fallback quota preflight could not obtain a reading', + { accountId: account.id, requiredByPolicy }, + ) } } // Poll results and their backoff are already stored. Publish them without // waiting for sidebar I/O or changing the active request's route. void refreshSidebarQuota().catch(() => {}) + return unavailable } const fallbackManager = { - refreshQuotaForDueAccounts: () => refreshFallbackQuotas(), - refreshQuotaForAllAccounts: (_options?: { force?: boolean }) => - refreshFallbackQuotas(true), + refreshQuotaForDueAccounts: async () => { + await refreshFallbackQuotas() + }, + refreshQuotaForAllAccounts: async (_options?: { force?: boolean }) => { + await refreshFallbackQuotas(true) + }, refreshAccount: async ( account: OAuthAccount, _storage: AccountStorage, @@ -7338,10 +7363,24 @@ const anthropicAuthPlugin = async ( // fallback quotas before applying hard limits, without acquiring // main credentials or polling accounts whose quotas are fresh. if (isKillswitchEnabled(storageArg)) { - await refreshFallbackQuotas(false, true) + const unavailable = await refreshFallbackQuotas(false, true) // Polling can also bind a newly discovered account UUID. Filter // the published quotas, not the request's earlier account view. storageArg = await loadAccounts(accountStoragePath) + if (storageArg && unavailable.size) + storageArg = { + ...storageArg, + accounts: storageArg.accounts.filter( + (account) => + !unavailable.has(account.id) || + killswitchPassesPolicy( + undefined, + storageArg, + account.id, + options.modelId, + ), + ), + } } const usableOAuth = await fallbackManager.getUsableFallbackAccounts( storageArg, diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 6a210ecc4..0614fb53f 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -6313,26 +6313,25 @@ describe('NaN quota utilization guards', () => { expect(killswitchPassesPolicy(quotaBoth, loaded)).toBe(true) }) - test('mapUsageWindow returns undefined for NaN utilization', () => { - // mapUsageWindow is internal, but we test the observable behaviour: - // a quota snapshot fetched with NaN utilization would produce an - // undefined window, which killswitchPassesPolicy treats as unknown - // (fail-closed blocking when failClosedOnUnknownQuota is true). + test('missing decoded windows cannot satisfy enabled account floors', () => { + // Invalid usage data can leave both decoded windows absent. This checks + // admission of that missing reading, not the wire decoder itself. const storage = baseStorage() storage.killswitch = { enabled: true, main: { five_hour: 5, seven_day: 10 }, } - // Simulate the result of mapUsageWindow returning undefined for both windows + // Neither standard window provides evidence for its configured floor. const quota: OAuthQuotaSnapshot = { five_hour: undefined, seven_day: undefined, } - // Both windows unknown → failClosedOnUnknownQuota=true → blocked + // Enabled floors require readings even when soft quota policy fails open. expect(killswitchPassesPolicy(quota, storage)).toBe(false) - // When failClosedOnUnknownQuota=false, unknown windows pass storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } + expect(killswitchPassesPolicy(quota, storage)).toBe(false) + storage.killswitch.main = { five_hour: 0, seven_day: 0 } expect(killswitchPassesPolicy(quota, storage)).toBe(true) }) }) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 3c4c77641..f2678be59 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -13082,7 +13082,9 @@ describe('auth.loader', () => { const firstClient = createMockClient() const secondClient = createMockClient() + let secondTimerCalls = 0 const secondRefreshWait = mock(() => { + secondTimerCalls += 1 throw new Error('second plugin entered the cross-process refresh wait') }) as unknown as typeof setTimeout const firstPlugin = await getPlugin(firstClient, '/project-a') @@ -13118,7 +13120,15 @@ describe('auth.loader', () => { ), ) - const responses = await Promise.all([firstFetch, secondFetch]) + const responses = await Promise.all([firstFetch, secondFetch]).catch( + (error) => { + console.error('shared native refresh mismatch', { + tokenRefreshCount, + secondTimerCalls, + }) + throw error + }, + ) expect(responses.map((response) => response.status)).toEqual([200, 200]) expect(tokenRefreshCount).toBe(1) expect(secondRefreshWait).not.toHaveBeenCalled() diff --git a/packages/opencode/src/tests/killswitch-preflight.test.ts b/packages/opencode/src/tests/killswitch-preflight.test.ts new file mode 100644 index 000000000..42056f1ec --- /dev/null +++ b/packages/opencode/src/tests/killswitch-preflight.test.ts @@ -0,0 +1,951 @@ +import { expect, spyOn } from 'bun:test' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as Core from '@cortexkit/anthropic-auth-core' +import { + type NativeAccountSnapshot, + type OAuthQuotaSnapshot, + resetClaudeCodeIdentityCachesForTest, + tokenFingerprint, +} from '@cortexkit/anthropic-auth-core' +import { createOpencodeClient, type Provider } from '@opencode-ai/sdk' +import { $ } from 'bun' +import { + createTestLifetimeSuite, + TestLifetime, +} from '../../../core/src/tests/test-lifetime.ts' +import { AnthropicAuthPlugin } from '../index.ts' +import { drainSidebarWrites } from '../sidebar-state.ts' +import { + migrateNativeOpencodeFixture, + type NativeOpencodeFixture, +} from './native-fixture.ts' + +/* + * A killswitch-enabled request polls every due fallback quota before it may + * route to that fallback. The first test makes that poll fail for a real + * reason: a second process is half-way through replacing the fallback's + * login. Its new credential is in the pool state file, but the config write + * that completes the row has not happened yet, so the row is "torn" and the + * runtime refuses to read its quota. The writer then finishes, so by the + * time the request chooses a route the row is coherent again but still has + * no quota reading. The second test lets the same writer finish before the + * poll, which then reads the new login's quota. + * + * Settings: fallback-first routing, so the fallback is considered before + * main; killswitch floors 5% (five-hour) and 10% (seven-day), which the + * fallback inherits; quota.enabled=false and refresh.enabled=false, so no + * startup poll or proactive refresh touches the fallback (the killswitch + * still requires its own poll); failClosedOnUnknownQuota=false, so an + * unknown quota alone is not a refusal. + */ + +type PluginHooks = Awaited> +type PluginFetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise +type AuthLoader = NonNullable['loader']> +type HostAuth = Awaited[0]>> + +const MAIN_UUID = '00000000-0000-4000-8000-00000000a001' +const FALLBACK_UUID = '00000000-0000-4000-8000-00000000b001' +const FALLBACK_ID = 'preflight-fallback' +const MAIN_ACCESS = 'sk-ant-oat01-preflight-main-access' +const MAIN_REFRESH = 'sk-ant-ort01-preflight-main-refresh' +const FALLBACK_ACCESS = 'sk-ant-oat01-preflight-fallback-access' +const FALLBACK_REFRESH = 'sk-ant-ort01-preflight-fallback-refresh' +// The login the second process writes over the fallback. While the fallback +// has no quota reading, a request must not reach the model endpoint with it. +const REPLACEMENT_ACCESS = 'sk-ant-oat01-preflight-fallback-replacement' +const REPLACEMENT_REFRESH = 'sk-ant-ort01-preflight-fallback-replacement' +const MESSAGES_URL = 'https://api.anthropic.com/v1/messages' + +const accountForBearer = new Map([ + [`Bearer ${MAIN_ACCESS}`, MAIN_UUID], + [`Bearer ${FALLBACK_ACCESS}`, FALLBACK_UUID], + [`Bearer ${REPLACEMENT_ACCESS}`, FALLBACK_UUID], +]) + +// Every variable a body sets; each body restores all of them. +const envKeys = [ + 'OPENCODE_ANTHROPIC_AUTH_FILE', + 'OPENCODE_ANTHROPIC_AUTH_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE', + 'OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_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_DISABLE_PROFILE_HYDRATION', + 'OPENCODE_AUTH_CONTENT', + 'CLAUDE_CONFIG_DIR', + 'XDG_DATA_HOME', +] as const + +const provider: Provider = { + id: 'anthropic', + name: 'Anthropic', + source: 'custom', + env: [], + options: {}, + models: {}, +} + +const lifetimes = createTestLifetimeSuite() +const test = lifetimes.test + +/** The parts of a fallback's public account view this file compares. */ +type RowState = { + present: boolean + enabled: boolean + credentialEpoch?: number + accountIdentity?: string + hasQuota: boolean +} + +function rowState(snapshot: NativeAccountSnapshot, id: string): RowState { + const row = snapshot.accounts.find((account) => account.id === id) + return { + present: row !== undefined, + enabled: row?.enabled === true, + ...(row?.binding ? { credentialEpoch: row.binding.credentialEpoch } : {}), + ...(row?.accountIdentity ? { accountIdentity: row.accountIdentity } : {}), + hasQuota: row?.quota !== undefined, + } +} + +type OutboundRecord = { url: string; authorization: string } + +function usageReading(now: number) { + return { + five_hour: { + utilization: 10, + resets_at: new Date(now + 60 * 60_000).toISOString(), + }, + seven_day: { + utilization: 10, + resets_at: new Date(now + 3 * 24 * 60 * 60_000).toISOString(), + }, + } +} + +const opencodePackageRoot = new URL('../..', import.meta.url).pathname +const quotaCodecModule = new URL( + '../../../core/src/native-quota-codec.ts', + import.meta.url, +).pathname + +/* + * The second process. It replaces the fallback's login through the public + * runtime's putAccount. A replacement writes the pool state file first and + * the config file second, and the runtime calls beforePoolWrite before each + * of those writes without saying which one. So at each call the writer reads + * the pool: when its row already shows as torn, the state write has landed + * and this call precedes the config write. In "hold" mode the writer reports + * the torn row on stdout and waits there until stdin delivers "release"; + * in "complete" mode it never waits. + */ +const WRITER_SCRIPT = ` +import { + createNativeAccountRuntime, + createNativePoolStore, +} from '@cortexkit/anthropic-auth-core' +import { nativeQuotaCodec } from ${JSON.stringify(quotaCodecModule)} + +const input = JSON.parse(process.env.KILLSWITCH_PREFLIGHT_WRITER) +const emit = (event) => process.stdout.write(JSON.stringify(event) + '\\n') +const released = input.hold + ? new Promise((resolve, reject) => { + let text = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', (chunk) => { + text += chunk + if (text.includes('release\\n')) resolve() + }) + process.stdin.on('end', () => + reject(new Error('writer input closed before release')), + ) + }) + : Promise.resolve() +released.catch(() => {}) +const store = createNativePoolStore({ paths: input.paths, quota: nativeQuotaCodec }) +const observed = [] +let held = false +const runtime = createNativeAccountRuntime({ + paths: input.paths, + host: 'opencode', + beforePoolWrite: async () => { + const read = await store.read() + const row = + read.status === 'ready' + ? read.rows.find((candidate) => candidate.id === input.routeId) + : undefined + observed.push(row?.torn === true ? 'torn' : 'coherent') + if (!input.hold || held || row?.torn !== true) return + held = true + emit({ + event: 'held', + row: { + torn: row.torn, + candidate: row.candidate, + credentialEpoch: row.credentialEpoch, + identity: row.identity, + hasQuota: row.quota !== undefined, + }, + }) + await released + }, +}) +try { + const view = await runtime.putAccount({ + routeId: input.routeId, + credential: input.credential, + accountIdentity: input.accountIdentity, + replace: true, + }) + emit({ + event: 'done', + held, + observed, + view: { + id: view.id, + enabled: view.enabled, + credentialEpoch: view.binding?.credentialEpoch, + }, + }) +} finally { + runtime.close() +} +` + +type WriterEvent = { event: string; [key: string]: unknown } +type WriterResult = { + exitCode: number | null + signalCode: string | null + events: WriterEvent[] + stderr: string +} + +/** Collect a child's stdout as lines; next() waits for the next line or end. */ +function lineReader(stream: ReadableStream) { + const lines: string[] = [] + let cursor = 0 + let ended = false + let wake: (() => void) | undefined + const done = (async () => { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + try { + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + buffer += decoder.decode(chunk.value, { stream: true }) + for ( + let end = buffer.indexOf('\n'); + end >= 0; + end = buffer.indexOf('\n') + ) { + lines.push(buffer.slice(0, end)) + buffer = buffer.slice(end + 1) + wake?.() + } + } + buffer += decoder.decode() + if (buffer) lines.push(buffer) + } finally { + ended = true + wake?.() + } + return lines + })() + return { + done, + async next(): Promise { + while (cursor >= lines.length && !ended) + await new Promise((resolve) => { + wake = resolve + }) + return cursor < lines.length ? lines[cursor++] : undefined + }, + } +} + +function parseEvent(line: string | undefined): WriterEvent { + if (line === undefined) return { event: 'stdout-ended' } + try { + const value: unknown = JSON.parse(line) + if ( + value && + typeof value === 'object' && + 'event' in value && + typeof value.event === 'string' + ) + return { ...value, event: value.event } + } catch {} + return { event: 'unparsed', line } +} + +/** + * Start the writer child, owned by `lifetime` (this test body's by + * default). Its cancellation gate opens either when join() has seen the exit + * and both pipes end, or when that lifetime starts closing, which happens + * before the lifetime waits for its body. In the second case the child is + * killed if it is still running, so a body waiting on it (or on pool locks it + * holds) can finish. The lifetime waits for the cancellation, the exit and + * both pipes, and fails teardown if any of them rejected, even when the body + * never called join(). + */ +function startWriter( + fixture: NativeOpencodeFixture, + mode: 'hold' | 'complete', + lifetime: Pick = lifetimes, +) { + const child = Bun.spawn([process.execPath, '--eval', WRITER_SCRIPT], { + cwd: opencodePackageRoot, + env: { + ...process.env, + ...fixture.env, + KILLSWITCH_PREFLIGHT_WRITER: JSON.stringify({ + paths: fixture.paths, + routeId: FALLBACK_ID, + hold: mode === 'hold', + accountIdentity: FALLBACK_UUID, + credential: { + type: 'oauth', + access: REPLACEMENT_ACCESS, + refresh: REPLACEMENT_REFRESH, + expires: Date.now() + 8 * 60 * 60_000, + }, + }), + }, + stdin: mode === 'hold' ? 'pipe' : 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }) + const cancel = lifetime.gate() + const cancellation = cancel.wait.then(() => { + if (child.exitCode === null && child.signalCode === null) child.kill() + }) + lifetime.trackDetached(cancellation) + const stdout = lineReader(child.stdout) + const joined = Promise.allSettled([ + child.exited, + stdout.done, + new Response(child.stderr).text(), + ]) + lifetime.trackDetached( + joined.then((results) => { + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ) + if (failures.length) + throw new AggregateError(failures, 'Writer process join failed') + }), + ) + let released = false + return { + next: async () => parseEvent(await stdout.next()), + release() { + if (released || !child.stdin) return + released = true + // A child that already exited has closed its input; that exit is + // reported by join() instead. + try { + child.stdin.write('release\n') + child.stdin.end() + } catch {} + }, + async join(): Promise { + const [exit, lines, stderr] = await joined + // The child has exited and both pipes have ended; the gate is no + // longer needed for cancellation. + cancel.open() + await cancellation + return { + exitCode: exit.status === 'fulfilled' ? exit.value : null, + signalCode: child.signalCode, + events: + lines.status === 'fulfilled' + ? lines.value.map((line) => parseEvent(line)) + : [{ event: 'stdout-failed' }], + stderr: + stderr.status === 'fulfilled' ? stderr.value : 'stderr-read-failed', + } + }, + } +} + +type FallbackFloors = { five_hour: number; seven_day: number; scoped: number } + +/** What the wrapped fallback quota fetch observed during its first call. */ +type PreflightEvidence = { + before?: RowState + held?: WriterEvent + during?: RowState + outcome?: + | { status: 'fulfilled'; value: OAuthQuotaSnapshot } + | { status: 'rejected'; name: unknown; code: unknown } + writer?: WriterResult + after?: RowState +} + +type PreflightStep = (call: { + /** Read the pool's public account views, outside the plugin. */ + read: () => Promise + /** The unwrapped fetchQuota for this call's arguments. */ + fetchQuota: () => Promise + fixture: NativeOpencodeFixture + evidence: PreflightEvidence +}) => Promise + +/** + * Migrate a private local pool for this test body through the shared native + * fixture: main plus one OAuth fallback, neither with a quota reading. + * `fallbackFloors` gives the fallback its own killswitch floors instead of + * inheriting main's. Process environment is left unchanged. + */ +async function migratePool(fallbackFloors?: FallbackFloors) { + const root = await mkdtemp(join(tmpdir(), 'killswitch-preflight-')) + // The fixture removes the root once it accepts it; this covers a body that + // fails before that. + lifetimes.deferCleanup(() => rm(root, { recursive: true, force: true })) + const now = Date.now() + const fixture = await migrateNativeOpencodeFixture({ + root, + lifetime: lifetimes, + legacyConfig: { + version: 1, + main: { + type: 'opencode', + provider: 'anthropic', + // A profile observed for the main access token lets migration bind + // main to its account, which bootstrap then confirms. + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: now, + providerAccountUuid: MAIN_UUID, + tokenFingerprint: tokenFingerprint(MAIN_ACCESS), + }, + }, + fallbackOn: [401, 403, 429], + routing: { mode: 'fallback-first' }, + refresh: { enabled: false }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + killswitch: { + enabled: true, + main: { five_hour: 5, seven_day: 10 }, + ...(fallbackFloors && { accounts: { [FALLBACK_ID]: fallbackFloors } }), + }, + claudeCache: { enabled: false }, + accounts: [ + { + id: FALLBACK_ID, + label: FALLBACK_ID, + type: 'oauth', + enabled: true, + access: FALLBACK_ACCESS, + refresh: FALLBACK_REFRESH, + expires: now + 8 * 60 * 60_000, + anthropicAccountUuid: FALLBACK_UUID, + }, + ], + }, + hostAuth: { + anthropic: { + type: 'oauth', + access: MAIN_ACCESS, + refresh: MAIN_REFRESH, + expires: now + 8 * 60 * 60_000, + }, + }, + }) + return { fixture, now } +} + +/** + * Migrate a private local pool (main plus one OAuth fallback, neither with a + * quota reading), install the transport mock, and load the plugin with the + * fallback's first quota fetch routed through `step`. The step runs only + * once the request starts, so plugin startup cannot consume it. + * `fallbackFloors` gives the fallback its own killswitch floors instead of + * inheriting main's. + */ +async function startBody(step: PreflightStep, fallbackFloors?: FallbackFloors) { + const savedFetch = globalThis.fetch + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + let stopPlugin: (() => Promise) | undefined + // Runs after the body and its tracked work. The plugin has normally been + // stopped already through its gate; stopping again is a no-op. + lifetimes.deferCleanup(async () => { + try { + await stopPlugin?.() + } finally { + try { + await drainSidebarWrites() + } finally { + globalThis.fetch = savedFetch + for (const key of envKeys) { + const value = savedEnv.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + resetClaudeCodeIdentityCachesForTest() + } + } + }) + const { fixture, now } = await migratePool(fallbackFloors) + for (const [key, value] of Object.entries(fixture.env)) + process.env[key] = value + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + process.env.XDG_DATA_HOME = join(fixture.root, 'synthetic-data') + delete process.env.OPENCODE_AUTH_CONTENT + resetClaudeCodeIdentityCachesForTest() + + const records: OutboundRecord[] = [] + globalThis.fetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ): Promise => { + const url = + input instanceof Request + ? input.url + : input instanceof URL + ? input.href + : input + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ) + const authorization = headers.get('authorization') ?? '' + records.push({ url, authorization }) + const account = accountForBearer.get(authorization) + if (url.includes('/claude_cli/bootstrap')) + return account + ? Response.json({ oauth_account: { account_uuid: account } }) + : new Response('unknown token', { status: 401 }) + if (url.includes('/api/oauth/usage')) + return account + ? Response.json(usageReading(Date.now())) + : new Response('unknown token', { status: 401 }) + if (url.includes('/v1/messages')) + return Response.json({ + id: 'msg_preflight', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }) + return new Response('unexpected request', { status: 599 }) + }, + { preconnect: savedFetch.preconnect }, + ) + + const evidence: PreflightEvidence = {} + let armed = false + let consumed = false + const factory = Core.createNativeAccountRuntime + // The test reads public account views through its own runtime, which the + // spy below does not wrap and which stays open until the body has settled. + const observer = factory({ paths: fixture.paths, host: 'opencode' }) + lifetimes.deferCleanup(() => observer.close()) + const read = () => observer.read() + const spy = spyOn(Core, 'createNativeAccountRuntime').mockImplementation( + (options) => { + const runtime = factory(options) + const fetchQuota = runtime.fetchQuota.bind(runtime) + runtime.fetchQuota = (routeId, fetchImpl, signal, dispatch) => { + if (routeId !== FALLBACK_ID || !armed || consumed) + return fetchQuota(routeId, fetchImpl, signal, dispatch) + consumed = true + return step({ + read, + fetchQuota: () => fetchQuota(routeId, fetchImpl, signal, dispatch), + fixture, + evidence, + }) + } + return runtime + }, + ) + lifetimes.deferCleanup(() => spy.mockRestore()) + + // Stop the plugin when the lifetime starts closing, before it waits for the + // body, so a body blocked on plugin work is released. + const stopping = lifetimes.gate() + const creation = AnthropicAuthPlugin( + { + client: createOpencodeClient({ + baseUrl: 'http://127.0.0.1:9', + fetch: globalThis.fetch, + }), + project: { + id: 'killswitch-preflight-fixture', + worktree: fixture.root, + time: { created: now }, + }, + directory: fixture.root, + worktree: fixture.root, + serverUrl: new URL('http://127.0.0.1:9'), + experimental_workspace: { register() {} }, + $, + }, + { scopedRosterPollIntervalMs: 0, cacheKeepAggregateRefreshIntervalMs: 0 }, + ) + let disposal: Promise | undefined + const stop = () => { + disposal ??= creation.then(async (created) => { + await created.dispose?.() + }) + return disposal + } + stopPlugin = stop + lifetimes.trackDetached(stopping.wait.then(stop)) + const plugin = await creation + const loader: AuthLoader | undefined = plugin.auth?.loader + if (!loader) throw new Error('Plugin did not install its auth loader') + // OpenCode passes the auth entry the migration left in its auth.json. + const options = await loader(async () => { + const stored: { anthropic: HostAuth } = JSON.parse( + await readFile(fixture.hostAuthPath, 'utf8'), + ) + return stored.anthropic + }, provider) + const handler = options.fetch + if (typeof handler !== 'function') + throw new Error('Plugin did not install its request handler') + const send: PluginFetch = async (input, init) => { + const response: unknown = await handler(input, init) + if (!(response instanceof Response)) + throw new Error('Plugin request handler did not return a Response') + return response + } + return { + fixture, + records, + evidence, + read, + /** Send one replayable model request; the fallback preflight is armed for it. */ + async request() { + armed = true + const response = await send(MESSAGES_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4-6', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + return { status: response.status, body: await response.text() } + }, + } +} + +function bearersFor(records: OutboundRecord[], path: string) { + return records + .filter((record) => record.url.includes(path)) + .map((record) => record.authorization) +} + +/** + * Run the plugin's first fallback quota fetch (the poll the killswitch + * requires before it may route to the fallback) while the writer process is + * held between its two writes, so the real fetch meets a torn row. Then let + * the writer finish and wait for it to exit before returning the fetch's own + * outcome to the plugin. The plugin's poll therefore receives a genuine + * refusal, and its next pool read finds a coherent row with the new + * credential and no quota reading. + */ +const fetchDuringTornReplacement: PreflightStep = async ({ + read, + fetchQuota, + fixture, + evidence, +}) => { + evidence.before = rowState(await read(), FALLBACK_ID) + const writer = startWriter(fixture, 'hold') + evidence.held = await writer.next() + evidence.during = rowState(await read(), FALLBACK_ID) + let outcome: + | { status: 'fulfilled'; value: OAuthQuotaSnapshot } + | { status: 'rejected'; reason: unknown } + try { + outcome = { status: 'fulfilled', value: await fetchQuota() } + } catch (reason) { + outcome = { status: 'rejected', reason } + } + writer.release() + evidence.writer = await writer.join() + evidence.after = rowState(await read(), FALLBACK_ID) + if (outcome.status === 'fulfilled') { + evidence.outcome = outcome + return outcome.value + } + const reason = outcome.reason + evidence.outcome = { + status: 'rejected', + name: reason instanceof Error ? reason.name : typeof reason, + code: + reason && typeof reason === 'object' && 'code' in reason + ? reason.code + : undefined, + } + throw reason +} + +/** Let the writer finish completely, then run the real quota fetch. */ +const fetchAfterCompletedReplacement: PreflightStep = async ({ + read, + fetchQuota, + fixture, + evidence, +}) => { + evidence.before = rowState(await read(), FALLBACK_ID) + const writer = startWriter(fixture, 'complete') + evidence.writer = await writer.join() + evidence.during = rowState(await read(), FALLBACK_ID) + const value = await fetchQuota() + evidence.outcome = { status: 'fulfilled', value } + evidence.after = rowState(await read(), FALLBACK_ID) + return value +} + +/** + * The fallback's state before, during and after a held cross-process + * replacement whose torn row refused the plugin's first quota fetch. + * `migrated` is the fallback as the fixture's migration left it. + */ +function expectTornPreflight(evidence: PreflightEvidence, migrated: RowState) { + // The preflight started from a coherent row with no reading. + expect(evidence.before).toEqual(migrated) + // The writer process stopped after its state write, leaving the row torn. + expect(evidence.held).toEqual({ + event: 'held', + row: { + torn: true, + candidate: false, + credentialEpoch: expect.any(Number), + identity: FALLBACK_UUID, + hasQuota: false, + }, + }) + expect(evidence.during).toMatchObject({ present: true, enabled: false }) + // The real fetch refused the torn row; this error reached the plugin. + expect(evidence.outcome).toEqual({ + status: 'rejected', + name: 'NativeCustodyError', + code: 'route-unavailable', + }) + // The writer finished its replacement and exited before the plugin saw + // the refusal. + expect(evidence.writer).toEqual({ + exitCode: 0, + signalCode: null, + stderr: '', + events: [ + expect.objectContaining({ event: 'held' }), + { + event: 'done', + held: true, + observed: ['coherent', 'torn'], + view: { + id: FALLBACK_ID, + enabled: true, + credentialEpoch: expect.any(Number), + }, + }, + ], + }) + // Routing then reads a coherent, enabled row with the new login and still + // no quota reading. + expect(evidence.after).toEqual({ + present: true, + enabled: true, + credentialEpoch: expect.any(Number), + accountIdentity: FALLBACK_UUID, + hasQuota: false, + }) + expect(evidence.after?.credentialEpoch).toBeGreaterThan( + migrated.credentialEpoch ?? Number.POSITIVE_INFINITY, + ) +} + +/** The fallback as migration left it: enabled, bound, and with no reading. */ +async function expectMigratedFallback(body: { + read: () => Promise +}) { + const migrated = rowState(await body.read(), FALLBACK_ID) + expect(migrated).toEqual({ + present: true, + enabled: true, + credentialEpoch: migrated.credentialEpoch, + accountIdentity: FALLBACK_UUID, + hasQuota: false, + }) + expect(migrated.credentialEpoch).toBeNumber() + return migrated +} + +test('a fallback whose required killswitch preflight was refused by a torn cross-process replacement is not routed', async () => { + const body = await startBody(fetchDuringTornReplacement) + const migrated = await expectMigratedFallback(body) + + const response = await body.request() + const { evidence, records } = body + expectTornPreflight(evidence, migrated) + + // Main is above its floor, so it serves the request with its own login. + // The fallback's new login never reaches the model endpoint. + expect(response.status).toBe(200) + expect(bearersFor(records, '/v1/messages')).toEqual([`Bearer ${MAIN_ACCESS}`]) + expect(bearersFor(records, '/api/oauth/usage')).toContain( + `Bearer ${MAIN_ACCESS}`, + ) + const main = (await body.read()).accounts.find( + (account) => account.id === 'main', + ) + expect(main?.quota?.accountIdentity).toBe(MAIN_UUID) + expect(main?.quota?.five_hour?.remainingPercent).toBe(90) + expect(main?.quota?.seven_day?.remainingPercent).toBe(90) +}) + +test('a fallback with no killswitch floors stays routable after a torn cross-process replacement refuses its preflight', async () => { + // This fallback's own floors are all zero, so it has no floor that a + // reading would have to prove, and failClosedOnUnknownQuota=false lets it + // serve without one. Main keeps its positive 5% and 10% floors. + const body = await startBody(fetchDuringTornReplacement, { + five_hour: 0, + seven_day: 0, + scoped: 0, + }) + const migrated = await expectMigratedFallback(body) + + const response = await body.request() + const { evidence, records } = body + expectTornPreflight(evidence, migrated) + + // The refused quota poll does not exclude this floorless fallback. With + // fallback-first routing the request goes to the fallback, using its new + // login, and is never sent with main's login. + expect(response.status).toBe(200) + expect(bearersFor(records, '/v1/messages')).toEqual([ + `Bearer ${REPLACEMENT_ACCESS}`, + ]) +}) + +test('a fallback replaced by another process before its killswitch preflight is read and routed', async () => { + const body = await startBody(fetchAfterCompletedReplacement) + const migrated = rowState(await body.read(), FALLBACK_ID) + + const response = await body.request() + const { evidence, records } = body + + expect(evidence.before).toEqual(migrated) + expect(evidence.writer).toEqual({ + exitCode: 0, + signalCode: null, + stderr: '', + events: [ + { + event: 'done', + held: false, + observed: ['coherent', 'torn'], + view: { + id: FALLBACK_ID, + enabled: true, + credentialEpoch: expect.any(Number), + }, + }, + ], + }) + expect(evidence.during).toEqual({ + present: true, + enabled: true, + credentialEpoch: expect.any(Number), + accountIdentity: FALLBACK_UUID, + hasQuota: false, + }) + expect(evidence.during?.credentialEpoch).toBeGreaterThan( + migrated.credentialEpoch ?? Number.POSITIVE_INFINITY, + ) + // The preflight read the new login's quota: 90% left on both windows, + // above the 5% and 10% floors. + expect(evidence.outcome).toMatchObject({ + status: 'fulfilled', + value: { + accountIdentity: FALLBACK_UUID, + five_hour: { remainingPercent: 90 }, + seven_day: { remainingPercent: 90 }, + }, + }) + expect(evidence.after).toMatchObject({ + present: true, + enabled: true, + hasQuota: true, + }) + expect(bearersFor(records, '/api/oauth/usage')).toContain( + `Bearer ${REPLACEMENT_ACCESS}`, + ) + + // Fallback-first routing sends the request with the fallback's new login. + expect(response.status).toBe(200) + expect(bearersFor(records, '/v1/messages')).toEqual([ + `Bearer ${REPLACEMENT_ACCESS}`, + ]) +}) + +test('finishing the owning lifetime kills a held writer and joins its exit and both pipes before cleanup', async () => { + // No plugin and no request: this case checks only the writer's + // cancellation. It uses its own TestLifetime, finished directly by the + // test rather than by a Bun timeout. Outer cancellation also finishes the + // private owner before joining this body, including a wait for child stdout. + const owner = new TestLifetime() + const outerClosing = lifetimes.gate() + lifetimes.trackDetached(outerClosing.wait.then(() => owner.finish())) + lifetimes.deferCleanup(() => owner.finish()) + const { fixture } = await migratePool() + const writer = startWriter(fixture, 'hold', owner) + // The child has written the new credential to the state file and is now + // waiting before the config write, holding the pool locks. + expect(await writer.next()).toEqual({ + event: 'held', + row: { + torn: true, + candidate: false, + credentialEpoch: expect.any(Number), + identity: FALLBACK_UUID, + hasQuota: false, + }, + }) + + let joinedResult: WriterResult | undefined + const joining = owner.runBody(async () => { + joinedResult = await writer.join() + return joinedResult + }) + // Cleanup runs only after the body and all tracked work have settled. + // Record what had settled by then. + let atCleanup: WriterResult | undefined + owner.deferCleanup(() => { + atCleanup = joinedResult + }) + // Nothing releases the writer, so only the gate's kill lets join() end. + await owner.finish() + + // Bun reports a SIGTERM exit as status 143 (128 + 15). + const killed = { + exitCode: 143, + signalCode: 'SIGTERM', + stderr: '', + events: [expect.objectContaining({ event: 'held' })], + } + expect(atCleanup).toEqual(killed) + expect(await joining).toEqual(killed) +}) diff --git a/packages/opencode/src/tests/killswitch.test.ts b/packages/opencode/src/tests/killswitch.test.ts index 187d119e5..25d6b8ea1 100644 --- a/packages/opencode/src/tests/killswitch.test.ts +++ b/packages/opencode/src/tests/killswitch.test.ts @@ -241,13 +241,15 @@ describe('killswitchPassesPolicy', () => { expect(killswitchPassesPolicy({}, storage)).toBe(false) }) - test('missing quota without failClosedOnUnknownQuota returns true', () => { + test('missing quota can fail open only without enabled account floors', () => { const storage = baseStorage() storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } storage.killswitch = { enabled: true, main: { five_hour: 5, seven_day: 10 }, } + expect(killswitchPassesPolicy(undefined, storage)).toBe(false) + storage.killswitch.main = { five_hour: 0, seven_day: 0 } expect(killswitchPassesPolicy(undefined, storage)).toBe(true) }) @@ -270,7 +272,7 @@ describe('killswitchPassesPolicy', () => { expect(killswitchPassesPolicy(quota, storage)).toBe(false) }) - test('passes a present above-threshold window when the other is missing (failClosed=false)', () => { + test('a missing standard window blocks only when its account floor is enabled', () => { const storage = baseStorage() storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } storage.killswitch = { @@ -284,6 +286,8 @@ describe('killswitchPassesPolicy', () => { checkedAt: Date.now(), }, } + expect(killswitchPassesPolicy(quota, storage)).toBe(false) + storage.killswitch.main = { five_hour: 0, seven_day: 10 } expect(killswitchPassesPolicy(quota, storage)).toBe(true) }) }) From 5e63f31ed7972361221a9c1420acee7f8620bf4d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:18:56 +0200 Subject: [PATCH 165/169] build: adopt verified common-auth 0.14.2 --- bun.lock | 4 ++-- packages/core/package.json | 2 +- packages/core/scripts/check-native-type-closure.test.ts | 4 ++-- packages/core/scripts/check-native-type-closure.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 25633e323..a5b4a6434 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ "xxhash-wasm": "^1.1.0", }, "devDependencies": { - "@cortexkit/common-auth": "0.14.1", + "@cortexkit/common-auth": "0.14.2", }, }, "packages/e2e-tests": { @@ -243,7 +243,7 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.6.2", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-9eZwP08q10r2t3YgZQLE0e5qL861L+IevkcEik0G6E2ZT2pXJwpygxnc6o1AFc1vF4N3jqA0PMOf4NzTmiXViw=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.14.1", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-QNoECbEdYHMx6tv6SK2FFOq+lnweB6Krr+1SDrOT3jCntfSMs4JpuCDYixnprsbnVmfqjezc4UmbkgoQpWd3LQ=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.14.2", "", { "dependencies": { "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.5.11", "@opentui/solid": ">=0.5.11", "solid-js": "1.9.12" }, "optionalPeers": ["@cortexkit/claustrum-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-cGG0Ye6MsLL7K5cTddI4UShOoVR6Zas2G1aSspJZURDyHwiMY1qzCVav7p1DF49uBp5qTK3t3cahZ3cvjxVyHQ=="], "@cortexkit/opencode-anthropic-auth": ["@cortexkit/opencode-anthropic-auth@workspace:packages/opencode"], diff --git a/packages/core/package.json b/packages/core/package.json index 52581f103..7794a6d34 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "prepublishOnly": "bun run build" }, "devDependencies": { - "@cortexkit/common-auth": "0.14.1" + "@cortexkit/common-auth": "0.14.2" }, "dependencies": { "@cortexkit/claustrum-client": "0.6.2", diff --git a/packages/core/scripts/check-native-type-closure.test.ts b/packages/core/scripts/check-native-type-closure.test.ts index fbfd8a133..1cccf5366 100644 --- a/packages/core/scripts/check-native-type-closure.test.ts +++ b/packages/core/scripts/check-native-type-closure.test.ts @@ -98,7 +98,7 @@ test('rejects unsupported published producer versions before copying declaration ) await expect(closeNativeTypeDeclarations(dist, producer)).rejects.toThrow( - 'Expected published common-auth 0.14.1, got 0.9.0', + 'Expected published common-auth 0.14.2, got 0.9.0', ) expect(await readdir(dist)).not.toContain('internal-types') }) @@ -111,7 +111,7 @@ test('copies the complete published relative declaration graph including cycles producer, 'package.json', JSON.stringify({ - version: '0.14.1', + version: '0.14.2', exports: { './store': { types: './dist/store/index.d.ts' } }, }), ) diff --git a/packages/core/scripts/check-native-type-closure.ts b/packages/core/scripts/check-native-type-closure.ts index 6c831cdbd..b475089de 100644 --- a/packages/core/scripts/check-native-type-closure.ts +++ b/packages/core/scripts/check-native-type-closure.ts @@ -185,9 +185,9 @@ export async function closeNativeTypeDeclarations( version: string exports: Record } - if (manifest.version !== '0.14.1') + if (manifest.version !== '0.14.2') throw new Error( - `Expected published common-auth 0.14.1, got ${manifest.version}`, + `Expected published common-auth 0.14.2, got ${manifest.version}`, ) const producerDist = join(producerRoot, 'dist') const assets = join(distRoot, TYPE_ASSETS) From a7f0a1f47d29235e227eb4aa2f6e824c6852ccb4 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:32:05 +0200 Subject: [PATCH 166/169] test: migrate the real Pi tool fixture before host startup --- scripts/check-pi-tool-mapping.ts | 569 +++++++++++++++++++++++-------- 1 file changed, 418 insertions(+), 151 deletions(-) diff --git a/scripts/check-pi-tool-mapping.ts b/scripts/check-pi-tool-mapping.ts index e4b25ed30..c2822f6da 100644 --- a/scripts/check-pi-tool-mapping.ts +++ b/scripts/check-pi-tool-mapping.ts @@ -1,37 +1,272 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' +import type { ProviderAccountUuid } from '@cortexkit/anthropic-auth-core' // Real Pi host, real built-in bash tool, deterministic in-process Anthropic SSE. // No user credentials, project configuration, Anthropic traffic, or saved session. -const repo = join(import.meta.dir, '..') -const piCli = process.env.PI_TEST_CLI ?? join(repo, 'node_modules/.bin/pi') -const extension = resolve( - process.env.PI_TEST_EXTENSION ?? join(repo, 'packages/pi/dist/index.js'), -) -const root = await mkdtemp(join(tmpdir(), 'pi-tool-mapping-')) -const agent = join(root, 'agent') -const temp = join(root, 'tmp') -const report = join(root, 'requests.jsonl') -const proof = join(root, 'tool-proof.txt') -const mockExtension = join(root, 'mock-upstream.mjs') -await mkdir(agent, { recursive: true }) -await mkdir(temp, { recursive: true }) +// +// The Pi extension serves requests only from an account pool created by the +// one-time account migration that `opencode-anthropic-auth setup` runs while +// hosts are stopped. On a fresh agent directory it refuses to load with +// "Anthropic account migration is required; run setup". This script therefore +// starts two processes, both with the same strict environment built in main(): +// 1. this file again with PREPARE_FLAG. It calls runNativeMigration, the +// migration that setup runs, exported by @cortexkit/anthropic-auth-core, +// then stores one synthetic OAuth account. A local stand-in for the +// Anthropic account lookup confirms that account's UUID; +// 2. the real Pi CLI, started only after step 1 exits successfully. +const PREPARE_FLAG = '--prepare-native-pool' +// Synthetic values only. The access token carries the OAuth prefix the account +// runtime accepts; the mock upstream checks that this exact token, and not the +// placeholder API key passed to Pi, authorizes every model request. +const FIXTURE_ACCESS_TOKEN = 'sk-ant-oat01-pi-host-fixture-access' +const FIXTURE_REFRESH_TOKEN = 'pi-host-fixture-refresh' +const FIXTURE_ACCOUNT_UUID = '7c4e2a91-3b5d-4f60-8e17-a2c9d04b6f35' +// The UUID shape `opencode-anthropic-auth setup` requires before it stores a +// signed-in account. +const ACCOUNT_UUID_PATTERN = + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i +const CHILD_DEADLINE_MS = 30_000 -const toolCommand = `printf pi-tool-proof > ${JSON.stringify(proof)}` -const source = `import { appendFileSync } from 'node:fs' +interface ChildOutcome { + stdout: string + stderr: string + code: number | null + signal: string | null + timedOut: boolean + joinFailures: string[] +} + +/** + * Run one isolated child and collect its output. At the deadline the child is + * killed and both pipe readers are cancelled before anything waits on them, + * so a descendant that inherited the pipes cannot keep the joins pending. + * Each join (stdout, stderr, exit) settles on its own; one failing does not + * leave the others running while the caller deletes the fixture directory. + */ +async function runIsolatedChild( + cmd: string[], + env: Record, + cwd: string, +): Promise { + const child = Bun.spawn({ + cmd, + cwd, + env, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }) + const stdoutReader = child.stdout.getReader() + const stderrReader = child.stderr.getReader() + const drain = async (reader: typeof stdoutReader) => { + const decoder = new TextDecoder() + let text = '' + for (;;) { + const { done, value } = await reader.read() + if (done) return text + decoder.decode() + text += decoder.decode(value, { stream: true }) + } + } + let timedOut = false + const deadline = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + for (const reader of [stdoutReader, stderrReader]) + void reader.cancel().catch(() => {}) + }, CHILD_DEADLINE_MS) + const [stdout, stderr, exited] = await Promise.allSettled([ + drain(stdoutReader), + drain(stderrReader), + child.exited, + ]) + clearTimeout(deadline) + const joins: Array<[string, PromiseSettledResult]> = [ + ['stdout', stdout], + ['stderr', stderr], + ['exit', exited], + ] + const joinFailures = joins.flatMap(([name, join]) => + join.status === 'rejected' ? [`${name}: ${String(join.reason)}`] : [], + ) + return { + stdout: stdout.status === 'fulfilled' ? stdout.value : '', + stderr: stderr.status === 'fulfilled' ? stderr.value : '', + code: child.exitCode, + signal: child.signalCode ?? null, + timedOut, + joinFailures, + } +} + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim() + if (!value) throw new Error(`Preparation needs ${name}`) + return resolve(value) +} + +/** + * Runs in a child process whose entire environment is the one main() builds, + * so every path @cortexkit/anthropic-auth-core derives from process.env + * (HOME, XDG_*, PI_*) lands inside the temporary fixture root. + * Prints one JSON summary line on success. + */ +async function prepareNativePool(): Promise { + const core = await import('@cortexkit/anthropic-auth-core') + const env: NodeJS.ProcessEnv = { ...process.env } + const agentDir = requiredEnv('PI_CODING_AGENT_DIR') + // Resolve the same files `opencode-anthropic-auth setup` resolves for Pi: + // the extension's account file, its sibling state file, Pi's own auth.json, + // and the sticky routing file beside the account file before migration and + // beside the pool file after it. + const legacyConfigPath = requiredEnv('PI_ANTHROPIC_AUTH_FILE') + if ( + !legacyConfigPath.endsWith(core.ACCOUNT_FILE_NAME) || + env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE || + env.PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE + ) + throw new Error('Preparation expects the default Pi storage layout') + const legacyStatePath = join( + dirname(legacyConfigPath), + core.ACCOUNT_STATE_FILE_NAME, + ) + const paths = await core.resolveNativePoolPaths( + legacyConfigPath, + legacyStatePath, + ) + // Migration calls processFence before it reads and before it commits, to + // refuse while a host could be using the files. The Pi CLI starts only after + // this process exits, so no host can be running here. The callback still + // applies setup's other refusals (a supervised host auth snapshot in the + // environment, or Pi enrolled with a Claustrum vault), and the count shows + // migration actually called it. + let fenceChecks = 0 + const processFence = async () => { + fenceChecks++ + core.requireNoSupervisedAuthContentSnapshot(env) + if ((await core.readNativeEnrollmentMode('pi', env)) !== 'local') + throw new Error('Fixture Pi enrollment is not local') + } + const journal = await core.runNativeMigration({ + paths, + legacyConfigPath, + legacyStatePath, + host: 'pi', + hostAuthPath: join(agentDir, 'auth.json'), + routingSourcePath: resolve( + core.getStickyRoutingStatePath(legacyConfigPath), + ), + routingDestinationPath: resolve( + core.getStickyRoutingStatePath(paths.config), + ), + env, + processFence, + removePiAnthropicAuth: false, + }) + if (fenceChecks === 0) + throw new Error('Fixture migration did not check its process fence') + if (journal.phase !== 'retired') + throw new Error(`Fixture migration ended in phase ${journal.phase}`) + + if (!ACCOUNT_UUID_PATTERN.test(FIXTURE_ACCOUNT_UUID)) + throw new Error('Fixture account identity is not a UUID') + // Signing in and the first authorization both look up the account UUID with + // Anthropic. Answer those lookups locally, for the synthetic token only. + // authorizeLocal records that this token was verified for that UUID, and the + // Pi host later reuses that record instead of contacting Anthropic. Any token + // refresh attempt is a fixture error. + const runtime = core.createNativeAccountRuntime({ + paths, + host: 'pi', + local: { + refreshToken: async () => { + throw new Error('Fixture account must not need a token refresh') + }, + resolveIdentity: async (accessToken) => { + if (accessToken !== FIXTURE_ACCESS_TOKEN) + throw new Error('Fixture account lookup received an unknown token') + return { + deviceId: 'pi-host-fixture-device', + sessionId: 'pi-host-fixture-session', + accountUuid: FIXTURE_ACCOUNT_UUID as ProviderAccountUuid, + } + }, + }, + }) + try { + await runtime.loginOAuth({ + routeId: 'main', + replace: true, + accountIdentity: FIXTURE_ACCOUNT_UUID, + credential: { + access: FIXTURE_ACCESS_TOKEN, + refresh: FIXTURE_REFRESH_TOKEN, + expires: Date.now() + 60 * 60 * 1000, + }, + }) + const admitted = await runtime.authorizeLocal('main', { intent: 'serve' }) + if (admitted.status !== 'usable') + throw new Error( + `Fixture account was not admitted: ${JSON.stringify(admitted)}`, + ) + if (admitted.binding.identity !== FIXTURE_ACCOUNT_UUID) + throw new Error('Fixture account was admitted with another identity') + const snapshot = await runtime.read() + console.log( + JSON.stringify({ + phase: journal.phase, + fenceChecks, + mode: snapshot.mode, + accounts: snapshot.accounts.map((account) => ({ + id: account.id, + type: account.type, + source: account.source, + enabled: account.enabled, + accountIdentity: account.accountIdentity, + })), + }), + ) + } finally { + runtime.close() + } +} + +async function main(): Promise { + const repo = join(import.meta.dir, '..') + const piCli = process.env.PI_TEST_CLI ?? join(repo, 'node_modules/.bin/pi') + const extension = resolve( + process.env.PI_TEST_EXTENSION ?? join(repo, 'packages/pi/dist/index.js'), + ) + const root = await mkdtemp(join(tmpdir(), 'pi-tool-mapping-')) + const agent = join(root, 'agent') + const temp = join(root, 'tmp') + const report = join(root, 'requests.jsonl') + const proof = join(root, 'tool-proof.txt') + const mockExtension = join(root, 'mock-upstream.mjs') + await mkdir(agent, { recursive: true }) + await mkdir(temp, { recursive: true }) + + const toolCommand = `printf pi-tool-proof > ${JSON.stringify(proof)}` + const source = `import { appendFileSync } from 'node:fs' export default function () { let turn = 0 const frame = (type, data) => 'event: ' + type + '\\ndata: ' + JSON.stringify(data) + '\\n\\n' globalThis.fetch = async (input, init) => { const url = input instanceof Request ? input.url : String(input) - if (!url.includes('/v1/messages')) throw new Error('Unmocked network attempt: ' + url) + if (!url.includes('/v1/messages')) { + appendFileSync(${JSON.stringify(report)}, JSON.stringify({ unmocked: url }) + '\\n') + throw new Error('Unmocked network attempt: ' + url) + } turn++ const body = JSON.parse(String(init?.body)) + const headers = new Headers(init?.headers) + const servedByMigratedAccount = headers.get('authorization') === ${JSON.stringify(`Bearer ${FIXTURE_ACCESS_TOKEN}`)} && !headers.has('x-api-key') const advertised = body.tools?.map((tool) => tool.name) ?? [] const hasPrompt = JSON.stringify(body.system).includes('Your name is Wren') || JSON.stringify(body.messages).includes('Your name is Wren') const hasToolResult = body.messages.some((message) => JSON.stringify(message).includes('toolu_pi_probe') && JSON.stringify(message).includes('tool_result')) - appendFileSync(${JSON.stringify(report)}, JSON.stringify({ turn, model: body.model, advertised, hasPrompt, hasToolResult }) + '\\n') + appendFileSync(${JSON.stringify(report)}, JSON.stringify({ turn, model: body.model, advertised, hasPrompt, hasToolResult, servedByMigratedAccount }) + '\\n') + if (!servedByMigratedAccount) throw new Error('Request was not authorized by the migrated fixture account') if (turn === 1 && (!advertised.includes('Bash') || !hasPrompt)) throw new Error('Host prompt/tools did not reach Anthropic request') if (turn === 2 && !hasToolResult) throw new Error('Pi did not execute and return the tool result') if (turn > 2) throw new Error('Unexpected third provider turn') @@ -46,143 +281,175 @@ export default function () { } } ` -await writeFile(mockExtension, source) + await writeFile(mockExtension, source) -// Construct a strict environment rather than inheriting Pi's auth, extensions, -// home directory or Claustrum connector from the operator's live process. -const env: Record = { - PATH: process.env.PATH ?? '/usr/bin:/bin', - HOME: root, - USERPROFILE: root, - TMPDIR: temp, - XDG_CONFIG_HOME: join(root, 'config'), - XDG_DATA_HOME: join(root, 'data'), - XDG_STATE_HOME: join(root, 'state'), - PI_CODING_AGENT_DIR: agent, - PI_ANTHROPIC_AUTH_FILE: join(agent, 'anthropic-auth.json'), - PI_OFFLINE: '1', - PI_SKIP_VERSION_CHECK: '1', - PI_TELEMETRY: '0', - ANTHROPIC_API_KEY: 'pi-local-mock-key', -} -const args = [ - piCli, - '-p', - '--mode', - 'json', - '--no-session', - '--no-extensions', - '--no-skills', - '--no-prompt-templates', - '--no-context-files', - '--no-approve', - '-e', - extension, - '-e', - mockExtension, - '--provider', - 'anthropic', - '--model', - 'claude-opus-5-5', - '--api-key', - 'pi-local-mock-key', - '--tools', - 'bash', - '--system-prompt', - 'Your name is Wren. Always state your name first.', - 'Run the bash tool once with the command provided by the service, then state the result.', -] -const child = Bun.spawn({ - cmd: args, - cwd: root, - env, - stdout: 'pipe', - stderr: 'pipe', -}) -const deadline = setTimeout(() => child.kill(), 30_000) -let stdout = '' -let stderr = '' -let code = -1 -try { - ;[stdout, stderr, code] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]) -} finally { - clearTimeout(deadline) -} -const requests = (await readFile(report, 'utf8').catch(() => '')) - .split('\n') - .filter(Boolean) - .map( - (line) => - JSON.parse(line) as { - turn: number - model: string - advertised: string[] - hasPrompt: boolean - hasToolResult: boolean - }, + // Construct a strict environment rather than inheriting Pi's auth, extensions, + // home directory or Claustrum connector from the operator's live process. + // Preparation and the Pi host share this exact environment, so both resolve + // the same account pool inside the fixture root. + const env: Record = { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: root, + USERPROFILE: root, + TMPDIR: temp, + XDG_CONFIG_HOME: join(root, 'config'), + XDG_DATA_HOME: join(root, 'data'), + XDG_STATE_HOME: join(root, 'state'), + PI_CODING_AGENT_DIR: agent, + PI_ANTHROPIC_AUTH_FILE: join(agent, 'anthropic-auth.json'), + PI_OFFLINE: '1', + PI_SKIP_VERSION_CHECK: '1', + PI_TELEMETRY: '0', + ANTHROPIC_API_KEY: 'pi-local-mock-key', + } + const preparation = await runIsolatedChild( + [process.execPath, import.meta.path, PREPARE_FLAG], + env, + root, ) -const marker = await readFile(proof, 'utf8').catch(() => null) -const events = stdout - .split('\n') - .filter(Boolean) - .flatMap((line) => { - try { - return [ + const prepared = + preparation.code === 0 && + !preparation.timedOut && + preparation.joinFailures.length === 0 + const host = prepared + ? await runIsolatedChild( + [ + piCli, + '-p', + '--mode', + 'json', + '--no-session', + '--no-extensions', + '--no-skills', + '--no-prompt-templates', + '--no-context-files', + '--no-approve', + '-e', + extension, + '-e', + mockExtension, + '--provider', + 'anthropic', + '--model', + 'claude-opus-5-5', + '--api-key', + 'pi-local-mock-key', + '--tools', + 'bash', + '--system-prompt', + 'Your name is Wren. Always state your name first.', + 'Run the bash tool once with the command provided by the service, then state the result.', + ], + env, + root, + ) + : undefined + + const lines = (await readFile(report, 'utf8').catch(() => '')) + .split('\n') + .filter(Boolean) + .map( + (line) => JSON.parse(line) as { - type: string - toolName?: string - isError?: boolean - result?: { content?: Array<{ text?: string }> } + unmocked?: string + turn: number + model: string + advertised: string[] + hasPrompt: boolean + hasToolResult: boolean + servedByMigratedAccount: boolean }, - ] - } catch { - return [] - } - }) -const executions = events.filter((event) => event.type === 'tool_execution_end') -const passed = - code === 0 && - requests.length === 2 && - requests[0]?.model === 'claude-opus-5-5' && - requests[0]?.hasPrompt === true && - requests[0]?.advertised.includes('Bash') === true && - requests[1]?.hasToolResult === true && - marker === 'pi-tool-proof' && - executions.some( - (event) => event.toolName === 'bash' && event.isError === false, - ) -if (passed) { - await rm(root, { recursive: true, force: true }) - console.log( - 'Pi tool round-trip: mapped Bash → bash, executed the tool, and replayed its result', + ) + const unmocked = lines.flatMap((line) => + line.unmocked === undefined ? [] : [line.unmocked], ) -} else { - console.error( - JSON.stringify( - { - error: 'Pi host tool round-trip failed', - code, - piCli, - extension, - requests, - marker, - executions: executions.map((event) => ({ - toolName: event.toolName, - isError: event.isError, - error: event.isError - ? event.result?.content?.[0]?.text?.slice(0, 240) - : undefined, - })), - stderr: stderr.slice(-1000), - artifacts: root, - }, - null, - 2, - ), + const requests = lines.filter((line) => line.unmocked === undefined) + const marker = await readFile(proof, 'utf8').catch(() => null) + const events = (host?.stdout ?? '') + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [ + JSON.parse(line) as { + type: string + toolName?: string + isError?: boolean + result?: { content?: Array<{ text?: string }> } + }, + ] + } catch { + return [] + } + }) + const executions = events.filter( + (event) => event.type === 'tool_execution_end', ) - process.exitCode = 1 + const passed = + prepared && + host !== undefined && + !host.timedOut && + host.joinFailures.length === 0 && + host.code === 0 && + unmocked.length === 0 && + requests.length === 2 && + requests.every((request) => request.servedByMigratedAccount === true) && + requests[0]?.model === 'claude-opus-5-5' && + requests[0]?.hasPrompt === true && + requests[0]?.advertised.includes('Bash') === true && + requests[1]?.hasToolResult === true && + marker === 'pi-tool-proof' && + executions.some( + (event) => event.toolName === 'bash' && event.isError === false, + ) + if (passed) { + // Both children and all of their pipe joins have settled above, so + // nothing still writes into the directory being removed. + await rm(root, { recursive: true, force: true }) + console.log( + 'Pi tool round-trip: migrated a synthetic account offline, mapped Bash → bash, executed the tool, and replayed its result', + ) + } else { + console.error( + JSON.stringify( + { + error: 'Pi host tool round-trip failed', + piCli, + extension, + preparation: { + code: preparation.code, + signal: preparation.signal, + timedOut: preparation.timedOut, + joinFailures: preparation.joinFailures, + stdout: preparation.stdout.slice(-1000), + stderr: preparation.stderr.slice(-1000), + }, + host: host && { + code: host.code, + signal: host.signal, + timedOut: host.timedOut, + joinFailures: host.joinFailures, + stderr: host.stderr.slice(-1000), + }, + unmocked, + requests, + marker, + executions: executions.map((event) => ({ + toolName: event.toolName, + isError: event.isError, + error: event.isError + ? event.result?.content?.[0]?.text?.slice(0, 240) + : undefined, + })), + artifacts: root, + }, + null, + 2, + ), + ) + process.exitCode = 1 + } } + +if (process.argv[2] === PREPARE_FLAG) await prepareNativePool() +else await main() From 0b66927826f3190149495d20c110c529364b7edf Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:33:34 +0200 Subject: [PATCH 167/169] docs: describe native account and vault setup accurately --- README.md | 397 +++++++++++++++---------------- docs/custody-state-machine.md | 141 +++++++++-- packages/opencode/README.md | 427 +++++++++++++++++++--------------- packages/pi/README.md | 102 ++++---- 4 files changed, 605 insertions(+), 462 deletions(-) diff --git a/README.md b/README.md index 1ff5b51df..e20122493 100644 --- a/README.md +++ b/README.md @@ -2,74 +2,91 @@ Claude Pro/Max OAuth support for both [OpenCode](https://opencode.ai) and [Pi](https://pi.dev), maintained by CortexKit. -This repo is a Bun workspace monorepo with two user-facing integrations and one shared core package. The OpenCode package is a CortexKit-maintained fork of the original `@ex-machina/opencode-anthropic-auth` plugin. The Pi package is a native Pi provider extension that overrides Pi's built-in Anthropic provider. Both integrations share the same Anthropic OAuth, fallback-account, quota, prompt-cache, relay, dump, and request-signing logic through `@cortexkit/anthropic-auth-core`. +This repo is a Bun workspace monorepo with two user-facing integrations and one shared core package. The OpenCode package is a CortexKit-maintained fork of the original `@ex-machina/opencode-anthropic-auth` plugin. The Pi package is a native Pi extension that replaces the implementation of Pi's built-in Anthropic provider while keeping its `anthropic` provider ID and model IDs. Both integrations share the same Anthropic OAuth, account-pool, quota, prompt-cache, relay, dump, and request-signing logic through `@cortexkit/anthropic-auth-core`. + +> [!IMPORTANT] +> These instructions describe the upcoming native release, which is not yet published. ## Packages | Package | Agent | Purpose | | --- | --- | --- | | `@cortexkit/opencode-anthropic-auth` | OpenCode | OpenCode plugin and CLI for Claude OAuth, request rewriting, fallback accounts, quotas, cache controls, dumps, and relay setup. | -| `@cortexkit/pi-anthropic-auth` | Pi | Pi package/extension that registers a CortexKit Anthropic provider under Pi's built-in `anthropic` provider ID. | +| `@cortexkit/pi-anthropic-auth` | Pi | Pi extension that replaces the implementation of Pi's built-in Anthropic provider, keeping the `anthropic` provider ID and model IDs. | | `@cortexkit/anthropic-auth-core` | Shared | Reusable OAuth, account, quota, cache, relay, dump, SSE, and request-signing logic used by both integrations. | ## Support matrix | Capability | OpenCode | Pi | | --- | --- | --- | -| Primary Claude Pro/Max OAuth | OpenCode `/connect anthropic` | Pi `/login anthropic` | -| Provider integration point | OpenCode plugin fetch/request transform | Pi `registerProvider("anthropic")` provider override | -| Sidecar config | `~/.config/opencode/anthropic-auth.json` | `~/.pi/agent/anthropic-auth.json` | -| Runtime state | `~/.config/opencode/anthropic-auth-state.json` | next to the Pi sidecar as `anthropic-auth-state.json` | -| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-start`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-prime` (status only), `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | +| Claude Pro/Max OAuth (local) | Local account pool, filled by `setup` migration, `setup --login`, or `/connect anthropic` | Local account pool, filled by `setup` migration, `setup --login`, or the `/claude` menu | +| Claude accounts from the Claustrum vault | Optional, enrolled separately through `setup` | Optional, enrolled separately through `setup` | +| Provider integration point | OpenCode plugin fetch/request transform (OpenCode 1 and OpenCode 2) | Pi `registerProvider("anthropic")`, replacing the built-in implementation | +| Local account pool | `~/.config/opencode/anthropic-auth-pool.json` | `~/.pi/agent/anthropic-auth-pool.json` | +| Old sidecar (migration source only) | `~/.config/opencode/anthropic-auth.json` | `~/.pi/agent/anthropic-auth.json` | +| Slash command | `/claude` menu | `/claude` menu | +| Menu actions not available in Pi | — | Refresh quota, killswitch, Prime on/off, lane start | | Quota sidebar widget | OpenCode TUI plugin via `tui.json` | Not available | -| Fallback accounts, quota routing, killswitch, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar | +| Fallback accounts, quota routing, relay, dumps, fast mode | Supported | Supported through the same shared core | ## What CortexKit adds over the original plugin -- **Fallback Claude accounts**: keep each agent's normal Anthropic login as the primary account, then route to ordered fallback OAuth accounts on auth/quota/rate-limit failures. -- **Routing modes**: use `/claude-routing sticky-balanced` to distribute cold sessions by current OAuth quota headroom while keeping each session on one account, or choose `main-first` / `fallback-first` ordering. +- **Fallback Claude accounts**: keep a main Claude account and route to ordered fallback OAuth accounts or API-key routes on auth/quota/rate-limit failures. +- **Routing modes**: choose **Routing mode** → `sticky-balanced` in the `/claude` menu to spread new sessions across OAuth accounts by current quota headroom while keeping each session on one account, or choose `main-first` / `fallback-first` ordering. - **Quota-aware routing**: skip main or fallback accounts when their 5-hour or 7-day Claude quota falls below your configured minimum. -- **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes. -- **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-8`, `claude-opus-5`, and `claude-opus-5-5`). +- **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from the `/claude` menu's Cache group with explicit, automatic, or hybrid modes. +- **Cache keepalive**: use **Keep cache warm always** or **Cache keep window** to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. +- **Quota window priming**: opt in with **Enable Prime** 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 **Fire lane start** to send one synthetic, one-token turn through the current session's normal model, agent, variant, quota, routing, cache, and request pipeline. +- **Fast mode toggle**: use **Enable fast mode** / **Disable fast mode** 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/5.5, and Sonnet 5.5. OpenCode exposes native `low`, `medium`, `high`, `xhigh`, and `max` effort variants for Fable 5.1, Opus 5/5.5, and Sonnet 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. - **Claude Haiku 5.5**: `claude-haiku-5-5` supports a 1M context, 128K output and native `low` through `max` adaptive effort. Forced tool choice remains supported. Standard input/output rates are $0.10/$0.50 per million tokens; all rates increase fivefold above 100K total input tokens, including cached input. Quota priming still uses Haiku 4.5. - **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. +- **Live quota visibility**: the `/claude` menu's Quota group shows main and fallback quota state, reset times, and refresh errors. - **Host-local quota feed (OpenCode)**: optionally publish sanitized response-header quota observations so another local CortexKit process can reuse fresh account state without polling Anthropic's rate-limited usage endpoint. - **Quota sidebar widget**: register the OpenCode TUI plugin in `tui.json` to render a live sidebar with per-account quota, routing, cache, and health state. -- **Killswitch**: per-account hard-block thresholds that stop requests before hitting Anthropic's rate limits, with synthetic 429 retry-after when all accounts are exhausted. +- **Killswitch (OpenCode menu)**: per-account hard-block thresholds that stop requests before hitting Anthropic's rate limits, with synthetic 429 retry-after when all accounts are exhausted. - **User-owned Cloudflare relay**: optionally provision your own Worker relay to reduce repeated client upload bytes for large OpenCode or Pi requests. -- **Claude-compatible request hardening**: Claude Code 2.1.258 identity and final-body billing signing, user-turn prompt and request lineage across OpenCode tool loops, live-session-stable billing suffixes, Fable 5.1 thinking-prefix recovery, safer token refresh persistence, replay-safe fallback retries, and subagent cache isolation. +- **Optional Claustrum vault custody**: enroll OpenCode or Pi with setup so it serves Claude accounts from the vault instead of its local account pool. +- **Claude-compatible request hardening**: Claude Code 2.1.280 identity and final-body billing signing, user-turn prompt and request lineage across OpenCode tool loops, live-session-stable billing suffixes, Fable 5.1 thinking-prefix recovery, safer token refresh persistence, replay-safe fallback retries, and subagent cache isolation. ## What these integrations do - Let OpenCode and Pi use Claude Pro/Max OAuth credentials instead of an Anthropic API key. - In OpenCode, intercept the final Anthropic request and rewrite it into the Claude-compatible shape expected by Anthropic OAuth access. -- In Pi, replace Pi's built-in Anthropic provider with a CortexKit provider override that uses the same Claude-compatible request path. +- In Pi, replace the implementation of Pi's built-in Anthropic provider, keeping its provider and model IDs, with one that uses the same Claude-compatible request path. - Add Claude billing headers with stable `cc_version`, OpenCode user-turn `cc_prompt_id`/`cc_prev_req` lineage, and body-derived `cch` signing. -- Support fallback Claude accounts stored in a local per-agent sidecar file. -- Keep fallback OAuth tokens fresh in the background. +- Keep Claude OAuth accounts and API-key routes in a per-host local account pool, or serve accounts from an enrolled Claustrum vault. +- Keep local OAuth tokens fresh in the background. - Apply quota thresholds before routing to main or fallback accounts. -- Add `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-start`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands to OpenCode. +- Add one `/claude` menu for accounts, quota, routing, limits, cache, diagnostics, fast mode, Prime, and lane start. - Optionally relay large requests through a Cloudflare Worker owned by the user. ## Quick setup (recommended) -Set up OpenCode, Pi, and optional Claustrum custody in one command: +In this guide, a *host* is OpenCode or Pi. Setup is an *offline* step: every OpenCode, LiteCode, and Pi process must be closed while it runs. Offline does not mean without network; setup still uses the network, for example to install packages and to sign in to Claude. Quit those processes, then run: ```bash bunx @cortexkit/opencode-anthropic-auth setup ``` -The interactive setup wizard: -- Detects OpenCode, Pi, `ck`, and the running Claustrum daemon -- Configures `@cortexkit/opencode-anthropic-auth` in `opencode.jsonc` and `tui.jsonc` -- Installs `@cortexkit/pi-anthropic-auth` into Pi via `pi install` -- Optionally activates Claustrum vault custody for both OpenCode and Pi (approving consumer enrollment, granting `category:anthropic-native`, discovering accounts, and installing OpenCode's activation tombstone) -- All accounts logged in via `ck auth login --provider anthropic` are discovered automatically without manual binds, manifest files, or restarts +What setup does: + +- detects OpenCode, Pi, `ck`, and a running Claustrum daemon (Claustrum is a local credential-vault service; `ck` is its command-line tool), and lets you select which hosts to configure; +- adds `@cortexkit/opencode-anthropic-auth` to `opencode.jsonc` (or `opencode.json`) and `tui.jsonc` (or `tui.json`), and installs `@cortexkit/pi-anthropic-auth` with `pi install`; +- migrates each selected host to its local account pool, unless you choose Claustrum vault custody; +- enrolls each selected host in the vault when you choose vault custody (it asks, and defaults to no). + +Setup refuses to change anything while an OpenCode, LiteCode, or Pi process is running. + +| Flag | Effect | +| --- | --- | +| `--yes`, `-y` | Do not prompt. Selects every detected host and keeps local custody unless `--claustrum` is also given. | +| `--dry-run` | Print the planned actions and change nothing. | +| `--login` | Sign in to Claude during setup and store that account as the main account in the local pool. | +| `--claustrum` | Choose vault custody. Takes effect only when the Claustrum daemon is running and `ck` is installed; otherwise setup keeps local custody. Cannot be combined with `--login`. | +| `--no-claustrum` | Keep local custody without asking. | +| `--remove-pi-auth` | With `--yes`, consent to moving a stored Pi Anthropic login (see [Pi stored logins](#pi-stored-logins)). Plain `--yes` is not consent. | --- @@ -85,15 +102,15 @@ Add the OpenCode plugin to your OpenCode configuration: } ``` -Pinning is strongly recommended for any OpenCode plugin: +Pinning is strongly recommended for any OpenCode plugin. Replace `VERSION` with the release you install: ```json { - "plugin": ["@cortexkit/opencode-anthropic-auth@2.0.0"] + "plugin": ["@cortexkit/opencode-anthropic-auth@VERSION"] } ``` -After changing plugin config, restart OpenCode. +OpenCode 1 and OpenCode 2 load the same package. After changing plugin config, restart OpenCode. > [!TIP] > If OpenCode keeps using an old build, clear OpenCode's plugin cache with `rm -rf ~/.cache/opencode` and restart. @@ -102,87 +119,122 @@ After changing plugin config, restart OpenCode. Install the Pi package with Pi's package manager: -```bash -pi install npm:@cortexkit/pi-anthropic-auth@2.0.0 -``` - -For an unpinned install: - ```bash pi install npm:@cortexkit/pi-anthropic-auth ``` +To pin a release, replace `VERSION` with its version number and keep the quotes: `pi install "npm:@cortexkit/pi-anthropic-auth@VERSION"`. + To try it for one run without adding it to Pi settings: ```bash pi -e npm:@cortexkit/pi-anthropic-auth ``` -The Pi package registers a CortexKit Anthropic provider extension under Pi's built-in `anthropic` provider ID. After installation, start or restart Pi and authenticate with Pi's normal login command: +The Pi package replaces the implementation of Pi's built-in Anthropic provider; the `anthropic` provider ID and model IDs stay the same. It authenticates from the local account pool or the vault, not from Pi's `/login anthropic` credential. Run [setup](#quick-setup-recommended) before starting Pi. -```text -/login anthropic -``` +Pi's files live in `~/.pi/agent/` by default. `PI_CODING_AGENT_DIR` or `PI_AGENT_DIR` changes that directory, and `PI_ANTHROPIC_AUTH_FILE` changes the old sidecar path from which the pool's location is derived. -Pi package state lives separately from OpenCode in: +## Accounts and credential custody -```text -~/.pi/agent/anthropic-auth.json -``` +Each host takes Claude credentials from exactly one source: -Override the path with `PI_ANTHROPIC_AUTH_FILE`. The package also respects `PI_AGENT_DIR` when deriving the default sidecar path. +- **Local custody** (default): the host's local account pool. +- **Vault custody**: the Claustrum vault, after the host is enrolled through setup. -## Primary account authentication +The host's Claustrum enrollment record decides which credential source requests use; settings files do not. An approved enrollment keeps the host on the vault even if its token file is missing, so the host refuses rather than falling back to local accounts. If the enrollment record cannot be read, requests the plugin would serve refuse until it is repaired or disconnected; ordinary API-key authentication is unaffected. See [docs/custody-state-machine.md](docs/custody-state-machine.md) for the full contract. -OpenCode keeps its native `anthropic` entry as the main account. In `local` mode, sign in through OpenCode: +### Local account pool -```text -/connect anthropic -``` +The pool holds the host's Claude OAuth accounts and Anthropic-compatible API-key fallback routes, plus plugin settings. One OAuth account is the main account, tried first by default; the others are fallbacks, tried in order when the main account is unusable. The pool's location follows the host's old sidecar path: its files are created in the same directory, so `OPENCODE_ANTHROPIC_AUTH_FILE` and `PI_ANTHROPIC_AUTH_FILE` move the pool too. -Before the main account can enter Claustrum custody, the operator onboards it into the vault with Claustrum's tooling. Until the dedicated import tooling lands, use the interim path in the custody state machine's §8, “Adding a new account today.” The plugin never runs `ck`, never imports or migrates credentials, and never writes the host auth slot. +| Host | Pool | Old sidecar (migration source) | +| --- | --- | --- | +| OpenCode | `~/.config/opencode/anthropic-auth-pool.json` | `~/.config/opencode/anthropic-auth.json` | +| Pi | `~/.pi/agent/anthropic-auth-pool.json` | `~/.pi/agent/anthropic-auth.json` | -Verify the migration gate against the installed `ck-auth` binary, not an announcement or a claimed deployment revision. In an isolated scratch data directory, a production-shaped tombstone import must refuse with `refusing Claustrum tombstone material` and leave the audit chain unchanged; in the same run, a real-material import with `--replace` must succeed. The refusal alone is not enough because a broken import path also refuses. The exact tombstone write is: +Private credential state is kept beside it in `anthropic-auth-pool-state.json`. Every process of a host shares that host's pool. OpenCode and Pi keep separate pools. -```json -{ "type": "oauth", "access": "", "refresh": "claustrum-tombstone:v1:anthropic", "expires": 0 } +Setup migrates each host once, while OpenCode, LiteCode, and Pi are closed: + +- Fallback accounts, API-key routes, and settings from the old sidecar files are imported into the pool. The old sidecar files are not read afterwards. +- OpenCode's stored Claude OAuth login becomes the pool's main account. OpenCode keeps only an inert placeholder: a fixed, non-secret marker that is not a usable credential and only tells OpenCode to route Claude requests through the plugin. A stored OpenCode API key is left unchanged. +- Pi's stored Anthropic OAuth login is moved into the pool only with your consent. + +Requests refuse until migration has completed; they never fall back to credentials still stored in OpenCode's or Pi's own auth file. Before importing a stored main OAuth login, migration checks the Claude account UUID in the legacy main profile. That profile's recorded access-token fingerprint must match the host login; migration does not query Anthropic for identity. If it cannot, it changes nothing and tells you to run: + +```bash +bunx @cortexkit/opencode-anthropic-auth setup --login --no-claustrum ``` -`access` is empty so the sealer's shape gate rejects the material by construction. +`--login` opens a Claude sign-in during setup and stores the account as the main account. -OpenCode's upstream authentication options are still supported: +### Signing in -- Claude Pro/Max OAuth through `claude.ai`. -- Anthropic Console OAuth that creates an API key. -- Manually entered Anthropic API key. +In local custody, after running setup (see [Local account pool](#local-account-pool)), add the main account in any of these ways: -For Pi, install the Pi package, restart Pi, then use Pi's Anthropic login flow: +- **OpenCode 1**: run `/connect anthropic` and choose **Claude Pro/Max**. The account is saved in the local pool; OpenCode stores only the inert placeholder. +- **OpenCode 2**: connect Anthropic and choose **Claude Pro / Max (native account pool)**. +- **Pi**: after setup completes, open `/claude`, then use **Start OAuth sign-in** and **Finish OAuth sign-in**. The first account becomes the main account; later accounts are fallbacks, tried when the main account cannot serve. +- **Any host**: `setup --login`, while the host is stopped. -```text -/login anthropic -``` +OpenCode's stock API-key options (**Create an API Key** and **Manually enter API Key** in OpenCode 1) remain ordinary OpenCode API-key authentication. The plugin passes those requests through unchanged. -The Pi package registers under Pi's built-in `anthropic` provider ID and stores primary OAuth credentials through Pi's normal credential flow. CortexKit package state, fallback accounts, cache mode, dump mode, and relay config live in the Pi sidecar file. +Local sign-in refuses while the host is enrolled in the vault (see [Claustrum vault custody](#claustrum-vault-custody)), with `Disconnect from the vault first.` -## Sidecar config +### Claustrum vault custody -OpenCode package state lives in: +Vault custody is optional and chosen per host. Quit every OpenCode, LiteCode, and Pi process, make sure the Claustrum daemon is running and `ck` is installed, then run: -```text -~/.config/opencode/anthropic-auth.json +```bash +bunx @cortexkit/opencode-anthropic-auth setup --claustrum ``` -Override the OpenCode path with `OPENCODE_ANTHROPIC_AUTH_FILE`. +For each selected host, setup proposes the `anthropic-auth-opencode` or `anthropic-auth-pi` enrollment, approves it with `ck auth enroll approve`, and grants that enrollment read access to the `anthropic-native` credential category (`ck auth grant --principal enrolled:anthropic-auth- --selector-kind category --selector anthropic-native --operation read`). It then refreshes the vault inventory and requires its default Anthropic credential (`oauth:anthropic`) to have a vault-reported account identity; otherwise it stops with `The vault has no verified default Claude account. Sign in through the vault and rerun setup.` + +Vault setup writes the selected host's enrollment and, for Pi with your consent, Pi's auth file (see [Pi stored logins](#pi-stored-logins)). It does not read, import, change, or delete the local pool, migration journal, local runtime metadata, or routing assignments, it never imports local OAuth accounts into the vault, and it does not write OpenCode's auth file. If the vault reports that an earlier enrollment request was replaced by a newer one, already used, or no longer exists, setup clears that request and stops; run setup again. + +After setup, select the vault in the host: -Pi package state uses the same JSON shape but a separate file: +- **OpenCode 1**: run `/connect anthropic` and choose **Claude via credential vault**. This option appears only while OpenCode holds a vault enrollment, and it checks OpenCode's Anthropic login slot without changing it. If OpenCode already holds an Anthropic login, or the slot cannot be read, it refuses: remove that login with OpenCode's own tools, then choose the vault login, or disconnect from the vault. An OpenCode OAuth login other than the vault placeholder is never used while enrolled; requests refuse with `This host uses the credential vault, but OpenCode holds a separate Anthropic login. Remove it and choose the vault login, or disconnect from the vault.` +- **OpenCode 2**: connect Anthropic and choose **Claude vault accounts**. It refuses unless OpenCode is enrolled. +- **Pi**: no extra step; see [Pi stored logins](#pi-stored-logins). + +Plain OpenCode API keys are never changed and keep working as stock OpenCode authentication in every custody state, including an unreadable enrollment. OpenCode 2 serves a request only when it carries the plugin's placeholder; API keys from OpenCode's login, configuration, or environment, and any other connection, pass through unchanged. + +While a host is enrolled: + +- Claude accounts come only from the vault's scoped inventory. The vault's default account is the main account. Accounts added with `ck auth login --provider anthropic` are discovered without a restart and receive an initial quota check. +- Every model request, quota or profile query, quota-window priming request, and cache keepalive request obtains fresh vault authorization. A vault refusal never falls back to a local credential. +- Local pool accounts, including API-key routes, are not used, refreshed, quota-polled, kept warm, or shown. They stay unchanged and return after disconnect. +- If the vault's default account changes to a different Claude account, sessions pinned to the old default are assigned afresh; other session pins are kept. +- If the vault has no usable Claude account, requests refuse. Add accounts with `ck auth login --provider anthropic`, or disconnect from the vault. +- The `/claude` menu still enables and disables vault accounts and resets recorded retry waits, but hides local sign-in, API-key additions, account removal and reordering, and enrollment reset. + +### Pi stored logins + +Pi prefers its stored login over the extension's credentials. Setup asks before moving or stashing that login; with `--yes`, pass `--remove-pi-auth` to consent. + +- **Local custody**: setup moves Pi's stored Anthropic OAuth login into the local pool and removes Pi's copy. Until that happens, Pi refuses to serve with `Pi still has a host Anthropic OAuth credential. Run offline setup to migrate it and approve its removal before using the native pool.` +- **Vault custody**: setup moves Pi's stored Anthropic login (OAuth or API key) into a private stash and leaves a fixed, non-secret API-key marker in Pi's auth file so Pi does not refresh the stashed OAuth login. Disconnecting restores the original login byte-for-byte before the enrollment is removed. + +### Disconnecting from the vault + +Open `/claude` and choose **Disconnect from the vault** in the Accounts group. The action is offered while the host is enrolled, and also when the enrollment record cannot be read, so a damaged record can always be cleared. It asks for confirmation: ```text -~/.pi/agent/anthropic-auth.json +Disconnect this host from the vault and return to local accounts? The vault keeps this host's enrollment approved until an operator revokes it there. ``` -Override the Pi path with `PI_ANTHROPIC_AUTH_FILE`. +Disconnect never reads or changes OpenCode's stored sign-ins. For Pi, it restores the stashed login first. If Pi's auth file already holds a different login, disconnect stops, keeps both logins and the enrollment, and asks you to resolve them with setup. After a successful disconnect, the host returns to its local pool. If that pool has never been migrated, stop the hosts and run `setup --no-claustrum`; disconnect does not perform migration. -Example: +Only the setup command creates or polls enrollment requests. In local custody, **Reset host enrollment** clears a denied or blocked enrollment request without proposing a new one; run setup afterwards. It never resets a pending request or an approved enrollment. + +## Settings + +Plugin settings live in the host's local account pool (and, while the host is enrolled in the vault, in a separate `anthropic-auth-vault-settings.json`). Change the settings that have a `/claude` menu action from the menu; see [The /claude menu](#the-claude-menu). + +During migration, setup imports these settings from the old sidecar file (`~/.config/opencode/anthropic-auth.json` or `~/.pi/agent/anthropic-auth.json`, or the paths set by `OPENCODE_ANTHROPIC_AUTH_FILE` and `PI_ANTHROPIC_AUTH_FILE`). The old file is not read after migration. The imported shape is: ```json { @@ -248,28 +300,25 @@ Example: } ``` -The `routing` block controls `/claude-routing`, `claudeCache` controls `/claude-cache`, `cacheKeep` controls `/claude-cachekeep`, and `claudeFast` controls `/claude-fast`. Killswitch `scoped` thresholds apply to matching model-scoped quota windows such as Fable weekly quota. OpenCode zeroes Anthropic OAuth model costs by default because OAuth usage is quota-based; set `costZeroing.enabled` to `false` only if you want OpenCode to display the provider's model pricing instead. Set `quota.showToasts` to `true` to opt into OpenCode quota toast notifications after quota refreshes. The `main` field identifies OpenCode's primary auth entry; Pi keeps primary OAuth credentials in Pi's own credential store, but uses the same sidecar shape for CortexKit settings and fallback account labels. +The `routing` block holds the routing mode, `claudeCache` the cache mode, `cacheKeep` the cache keepalive schedule, and `claudeFast` the fast-mode switch; the `/claude` menu changes all of them. Killswitch `scoped` thresholds apply to model-specific quota windows, such as a separate weekly limit for Claude Fable models (`claude-fable-*`). A positive 5-hour or 7-day killswitch floor is a minimum remaining percentage. It blocks an account with no quota reading for that window, regardless of `quota.failClosedOnUnknownQuota`; with no positive floor, missing readings follow `quota.failClosedOnUnknownQuota`. A positive `scoped` floor (a minimum remaining percent for such a model-specific window) blocks when scoped-window information is missing; a reported empty list means the model has no separate window, so the floor does not apply. OpenCode zeroes Anthropic OAuth model costs by default because OAuth usage is quota-based; set `costZeroing.enabled` to `false` only if you want OpenCode to display the provider's model pricing instead. Set `quota.showToasts` to `true` to opt into OpenCode quota toast notifications after quota refreshes. `thinkingBinding.prefixMismatchBehavior` controls replayed signed/redacted thinking on OAuth Fable 5.1 requests. `account-default` (the default) sends no explicit prefix-mismatch override. Use `error` to make prefix changes fail deterministically during compaction testing, or `drop_block` to ask Anthropic to discard mismatched thinking blocks. The beta header is added only when an explicit behavior is configured and replayable thinking is present. `quotaHeaderFeed.enabled` is an OpenCode-only, restart-required opt-in. It publishes only allowlisted quota-window values, an opaque account reference, an observation timestamp, and the configured OAuth-account count; it never publishes tokens, raw headers, request bodies, model IDs, or refresh errors. Per-process lease files use owner-only permissions under `$TMPDIR/opencode-anthropic-auth/quota-header-feed`, expire after three minutes, and can be redirected with `OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR`. -Runtime data is stored separately in `anthropic-auth-state.json`: fallback OAuth tokens, API-route keys, token refresh backoff, quota snapshots, and quota API backoff. `sticky-balanced` session assignments use a separate `anthropic-auth-routing-state.json`; session IDs are SHA-256 hashed in that file. Background refresh and quota checks write only runtime state, so editing `anthropic-auth.json` does not get overwritten by another running plugin instance. - -## OpenCode lane-start setting - +Private runtime data (OAuth tokens, API-route keys, token refresh backoff, quota snapshots, and quota API backoff) is kept in `anthropic-auth-pool-state.json`, separate from settings. `sticky-balanced` session assignments use `anthropic-auth-pool-routing-state.json` in local custody and `anthropic-auth-vault-routing-state.json` in vault custody; session IDs are SHA-256 hashed in both. ## Fallback accounts -Fallback accounts are separate Claude OAuth accounts or Anthropic-compatible API-key routes managed by this plugin. By default, the main account is tried first unless quota policy says it is currently unusable. Fallbacks are then tried in sidecar order when the primary request returns a configured fallback status. +Fallback accounts are separate Claude OAuth accounts or Anthropic-compatible API-key routes in the local pool, or additional accounts in the vault while the host is enrolled. By default, the main account is tried first unless quota policy says it is currently unusable. Fallbacks are then tried in account order when the primary request returns an HTTP status listed in `fallbackOn` (defaults below). -Use `/claude-routing fallback-first` to prefer usable fallback accounts before the main account, `/claude-routing main-first` to restore the default, or `/claude-routing sticky-balanced` to allocate each new session to an OAuth account according to spendable 5-hour, 7-day, and matching model-scoped quota headroom. The command persists `routing.mode` and takes effect on the next request without restarting. `/claude-routing reset` clears only the current session's assignment so its next request is allocated again. +In the `/claude` menu, choose **Routing mode** → `fallback-first` to prefer usable fallback accounts before the main account, `main-first` to restore the default, or `sticky-balanced` to allocate each new session to an OAuth account according to its remaining quota above your configured minimums in the 5-hour, 7-day, and any model-specific window. The choice persists and takes effect on the next request without restarting. **Reset routing session** clears only the current session's assignment so its next request is allocated again. `sticky-balanced` uses a weighted deficit allocator: current quota headroom is normalized by time until reset, and the initial prompt size is counted until the next quota refresh. The selected account is then persisted by hashed session ID across plugin instances and restarts. Existing hybrid CacheKeep sessions seed their current OAuth route when the mode is enabled, avoiding an unnecessary account switch for an already-warm session. Changing the user-selected model discards the old assignment and its model-specific CacheKeep preference before quota-based reselection; transparent safety-recovery model changes retain the original account. -Affinity survives network failures, relay failures, 5xx responses, unconfirmed 429s, quota-probe failures, and changes in relative account weights. Confirmed 7-day or matching model-scoped exhaustion migrates the session. Confirmed 5-hour exhaustion migrates it only when more than 15 minutes remain before reset; at 15 minutes or less, the route is retained and returns `Retry-After` rather than moving a large prompt cache. Disabled/removed accounts, permanent re-login failures, and killswitch blocks are also eligible for migration. +Session affinity, keeping a session on the same account, survives network failures, relay failures, 5xx responses, unconfirmed 429s, quota-probe failures, and changes in relative account weights. Confirmed 7-day or matching model-scoped exhaustion migrates the session. Confirmed 5-hour exhaustion migrates it only when more than 15 minutes remain before reset; at 15 minutes or less, the route is retained and returns `Retry-After` rather than moving a large prompt cache. Disabled/removed accounts, permanent re-login failures, and killswitch blocks are also eligible for migration. -OpenCode Fable content-filter recovery uses the session's sticky assignment for both the original Fable request and transparent Opus 4.8 turns; its Fable prewarm already uses that same OAuth account. A session that starts directly on Opus first prefers an otherwise usable account whose Fable quota is exhausted, preserving Fable-capable quota. API-key routes are not included in quota-balanced first assignment and retain their stricter confirmed-exhaustion gate. +When a Fable request is refused and OpenCode temporarily switches the session to Opus 4.8 (see [Safety fallback](#safety-fallback-opencode)), the original Fable request, the Opus 4.8 turns, and the cache prewarm sent before returning to Fable all use the session's assigned account. A session that starts directly on Opus first prefers an otherwise usable account whose Fable quota is exhausted, preserving Fable-capable quota. API-key routes are not included in quota-balanced first assignment and retain their stricter confirmed-exhaustion gate. Default fallback statuses: @@ -277,7 +326,7 @@ Default fallback statuses: [401, 403, 429] ``` -Add and inspect OpenCode fallback accounts with the CLI: +Add and inspect OpenCode fallback accounts with the CLI (local custody only, after running setup for OpenCode): ```bash bunx @cortexkit/opencode-anthropic-auth login personal-alt @@ -285,34 +334,23 @@ bunx @cortexkit/opencode-anthropic-auth api add kie-opus bunx @cortexkit/opencode-anthropic-auth list ``` -Prefer npm? Use `npx -y @cortexkit/opencode-anthropic-auth ...` with the same subcommands. +Prefer npm? Use `npx -y @cortexkit/opencode-anthropic-auth ...` with the same subcommands. `login` and `api add` save to OpenCode's local pool and never change OpenCode's auth file; both refuse with `Disconnect from the vault first.` while OpenCode is enrolled in the vault, because vault accounts then replace the local pool. `api add` reads `OPENCODE_ANTHROPIC_AUTH_API_BASE_URL`, `OPENCODE_ANTHROPIC_AUTH_API_KEY`, and `OPENCODE_ANTHROPIC_AUTH_API_AUTH_HEADER` instead of prompting when they are set. -API fallback routes use `Authorization: Bearer ` by default and rewrite requests to the configured Anthropic-compatible base URL. For Kie, use `https://api.kie.ai/claude` as the base URL; the plugin appends `/v1/messages` automatically. API-key routes are only eligible after direct evidence that the main OAuth quota is exhausted: a fresh token-bound quota snapshot at 0% remaining, or an actual main OAuth model response with HTTP 429 / streaming rate-limit error followed by a live quota check that confirms 0% remaining. Low-but-nonzero quota, stale cached quota, unconfirmed 429s, 401, or 403 do not trigger API-key routes. API-route keys are stored in `anthropic-auth-state.json`, while `anthropic-auth.json` keeps the route label, type, enabled flag, base URL, and auth-header mode. +In both hosts, the `/claude` menu's Accounts group offers the same local changes: **Start OAuth sign-in** / **Finish OAuth sign-in**, **Add API-key fallback**, **Enable account**, **Disable account**, **Remove account**, **Move account up**, and **Move account down**. Account actions ask for the complete account ID shown in the account status. -OpenCode cost accounting stays simple: when the native Anthropic auth entry is OAuth, OpenCode sees zero-cost Claude OAuth models by default. If a request falls through to an API-key route, token accounting is still recorded, but OpenCode's built-in dollar cost is not route-aware. Advanced users can set `costZeroing.enabled` to `false` to show Anthropic model pricing for OAuth sessions too. +API fallback routes use `Authorization: Bearer ` by default and rewrite requests to the configured Anthropic-compatible base URL. For Kie, use `https://api.kie.ai/claude` as the base URL; the plugin appends `/v1/messages` automatically. API-key routes are only eligible after direct evidence that the main OAuth quota is exhausted: a fresh quota reading for the current Claude account showing 0% remaining, or an actual main OAuth model response with HTTP 429 / streaming rate-limit error followed by a live quota check that confirms 0% remaining. Low-but-nonzero quota, stale cached quota, unconfirmed 429s, 401, or 403 do not trigger API-key routes. API-route keys are stored with the pool's private state, separate from public route settings. **Add API-key fallback** selects either `authorization-bearer` or `x-api-key` for the authentication header. -For Pi fallback accounts, write the same account JSON shape to `~/.pi/agent/anthropic-auth.json`. The CLI helper currently lives in the OpenCode package, so you can also point it at Pi's sidecar path when logging in a fallback account: +OpenCode cost accounting stays simple: when the native Anthropic auth entry is OAuth, OpenCode sees zero-cost Claude OAuth models by default. If a request falls through to an API-key route, token accounting is still recorded, but OpenCode's built-in dollar cost is not route-aware. To retain Anthropic model pricing for OAuth sessions during migration, set `costZeroing.enabled` to `false` in the old `anthropic-auth.json` before the first local setup. Setup imports that setting; the menu does not expose it. -```bash -OPENCODE_ANTHROPIC_AUTH_FILE="$HOME/.pi/agent/anthropic-auth.json" \ - bunx @cortexkit/opencode-anthropic-auth login personal-alt -``` +The CLI manages only OpenCode's locally stored Claude accounts and API-key fallbacks. Add Pi fallback accounts from Pi's `/claude` menu. Fallback retries are only attempted when the request body is safely replayable. If the original body is non-replayable or already consumed, the plugin returns the primary response unchanged. ### Token refresh -Fallback OAuth tokens refresh in the background so idle accounts do not expire before they are needed. Refresh token rotation is persisted immediately. The plugin also re-reads the latest sidecar account before refreshing, which avoids using stale refresh-token snapshots when multiple background paths run close together. Persisted refresh backoffs are bound to the refresh token that produced them, so replacing a rejected credential immediately clears its stale latch. - -If Anthropic reports `invalid_grant`, that account must be logged in again. `/claude-account reset-backoff` manually clears the main account's refresh backoff and its matching quota backoff. - -### Claustrum scoped custody (OpenCode and Pi) - -Use `bunx @cortexkit/opencode-anthropic-auth setup` to enroll each host, grant `category:anthropic-native` read access, and activate custody while the hosts are stopped. OpenCode retains a non-secret OAuth tombstone so its custom fetch stays active; Pi uses native ambient authentication and a separate, independently revocable enrollment. The setup command asks for consent before removing a conflicting Pi OAuth credential. +Local OAuth tokens refresh in the background so idle accounts do not expire before they are needed. Refresh token rotation is persisted immediately. The plugin also re-reads the latest stored account before refreshing, which avoids using stale refresh-token snapshots when multiple background paths run close together. Persisted refresh backoffs are bound to the refresh token that produced them, so replacing a rejected credential immediately clears the backoff left by the old refresh token. In vault custody, the vault alone refreshes credentials. -Claustrum's scoped inventory is the authority for every OAuth account, including main. New accounts added via `ck auth login --provider anthropic` are discovered automatically without per-account binding or a restart. The plugin stores only secret-free routing preferences, fetches initial quota for new accounts, and authorizes each outbound request via `getScoped`. A vault refusal never falls back to local OAuth tokens. API-key routes remain separate. An older Claustrum configuration without a scoped roster fails closed until setup completes. - -`/claude-account` reads custody and enrollment status without creating or polling a request. Only the offline setup wizard enrolls a host; it can resume a pending request or renew a proven-expired one. `/claude-account claustrum` directs you to setup; `/claude-account local` explains the verified local re-login requirement rather than changing custody while OpenCode is running. `/claude-account enrollment-reset` only clears denied or blocked ceremony state under lock, never proposes a replacement or resets an approved token. Run setup afterward. +If Anthropic reports `invalid_grant`, that account must be signed in again. **Reset account backoff** in the `/claude` menu's Accounts group manually clears the main account's refresh backoff and its matching quota backoff. ## Quota-aware routing @@ -331,23 +369,17 @@ With this config, an account is skipped when it has less than 10% remaining in t Main-account quota is cached. If the main account is known to be exhausted, the plugin skips it until the relevant reset time. If the cached main quota is stale but usable, the request proceeds and quota refresh happens in the background. -Show current quota state: - -```text -/claude-quota -``` - -In OpenCode, this includes the main Anthropic account and sidecar fallback accounts. In Pi, the command reports sidecar fallback account quota state from `~/.pi/agent/anthropic-auth.json`. +Open `/claude` to see current quota state in the Quota group (**Quota summary**). It covers the main account and every fallback from the local pool, or from the vault while enrolled. In OpenCode, **Refresh quota** requests fresh quota; Pi shows the summary without that action. Reset times are rendered as relative durations, such as `resets in 10m` or `resets in 1h 15m`. ### Quota header feed -The optional quota header feed writes one lease file per process under `${TMPDIR:-/tmp}/opencode-anthropic-auth/quota-header-feed/`. Set `OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR` to override the directory. A file contains only accounts whose response headers THAT process harvested. Consumers MUST union entries from every file inside `lease_horizon_ms`, then deduplicate by account. "Newest file wins" drops accounts seen by other processes. +The optional quota header feed writes one lease file per process under `${TMPDIR:-/tmp}/opencode-anthropic-auth/quota-header-feed/`. Set `OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR` to override the directory. Each file contains only quota headers observed by the process that wrote it. Each file records `lease_horizon_ms` (default 180000, three minutes). Consumers must discard entries whose `observed_at_ms` is in the future or at least that old, then combine fresh entries from every file and deduplicate by account. "Newest file wins" drops accounts seen by other processes. -For each account, the newest entry wins for quota values. Resolve `anthropic_account_uuid` from any entry in that account's group that carries it. During a rollout, an older pre-restart publisher can write the newest entry without that field beside newer code that has it. +For each account, use the entry with the greatest `observed_at_ms` for quota values. Resolve `anthropic_account_uuid` from any entry in that account's group that carries it. During an upgrade, an older plugin process that is still running can write the newest entry without that field while an updated process writes entries that have it. -Each entry always includes `anthropic_account_uuid`; an absent key identifies an older producer. Its value is provider-derived or `null`, never a local substitute. A consumer that sees `null` must count a gap, not fall back to `account_ref`; that field is store-local and never a join key. +Each entry always includes `anthropic_account_uuid`; an absent key identifies an older producer. Its value is provider-derived or `null`, never a local substitute. A consumer that sees `null` must count a gap, not fall back to `account_ref`: that field is a row ID local to one account store, not a global account identifier, so it must never be used to match accounts across stores. ## Safety fallback (OpenCode) @@ -367,7 +399,7 @@ Legacy mode routes a refused Fable 5/5.1 or Opus 5 session through Opus 4.8 for ## Quota sidebar (OpenCode TUI) -OpenCode can render a live quota sidebar widget that surfaces plugin state without running a slash command. The widget is OpenCode-only; Pi has no TUI surface. +OpenCode can render a live quota sidebar widget that surfaces plugin state without opening the `/claude` menu. The widget is OpenCode-only; Pi has no TUI surface. OpenCode loads TUI plugins from a separate `tui.json` (or `tui.jsonc`) file in your OpenCode config directory, not from the main `plugin` array. Register the package there: @@ -381,7 +413,7 @@ Pinning is recommended, matching the main plugin entry: ```json { - "plugin": ["@cortexkit/opencode-anthropic-auth@2.0.0"] + "plugin": ["@cortexkit/opencode-anthropic-auth@VERSION"] } ``` @@ -396,21 +428,29 @@ The sidebar polls plugin state and refreshes on OpenCode session and message eve Click the `CLAUDE` header to collapse or expand the sidebar. Collapsed, it shows the active account's 5-hour quota usage and a fast-mode row when fast mode is on; the header shows the plugin version (or a `LIMITED` badge when degraded). Collapse state persists across restarts by default via `tui-preferences.jsonc` (`rememberCollapsed`); set `"rememberCollapsed": false` for the old per-session behavior. -### Command modals (OpenCode TUI) +## The /claude menu -In the OpenCode TUI, the `/claude-*` commands open interactive modal dialogs instead of posting a text reply: +Both hosts register one slash command, `/claude`. It opens a menu grouped by area; each action collects its own parameters, so typed arguments are not used (Pi rejects them and points you to the menu). Earlier separate `/claude-*` commands are no longer registered. -- `/claude-quota` — a read-only view rendering the same per-account quota bars and pacing as the sidebar. -- `/claude-routing` — select main-first, fallback-first, or sticky-balanced, or reset the current sticky assignment. -- `/claude-fast` — toggle fast mode on or off. -- `/claude-dump` — toggle request dump capture on or off. -- `/claude-cache` — select the 1-hour cache mode (off, explicit, automatic, or hybrid). -- `/claude-cachekeep` — select `always`, enter a cache keepalive window (`HH-HH`), or turn it `off`. -- `/claude-prime` — turn quota-window priming on or off, or view its status and usage. -- `/claude-killswitch` — enable or disable the killswitch, or edit per-account `5h,1w,scoped` thresholds. -- `/claude-account` — manage fallback routes, inspect sanitized Claustrum custody state, or use `reset-backoff` to clear main OAuth refresh and matching quota backoffs. +| Group | Status shown | Actions | Pi | +| --- | --- | --- | --- | +| Accounts | Account status and usage | Enable account, Disable account, Remove account, Move account up, Move account down, Reset account backoff, Reset host enrollment, Add API-key fallback, Start OAuth sign-in, Finish OAuth sign-in, Custody setup guidance, Disconnect from the vault | All | +| Quota | Quota summary | Refresh quota | Summary only | +| Routing | Routing status | Routing mode (`main-first`, `fallback-first`, `sticky-balanced`), Reset routing session | All | +| Limits | Killswitch status | Enable killswitch, Disable killswitch, Set killswitch thresholds | Not available | +| Cache | Cache status, Cache keep status | Enable cache, Disable cache, Cache mode (`explicit`, `automatic`, `hybrid`), Keep cache warm always, Disable cache keep, Cache keep window, Cache keep for subagents (`on`/`off`) | All | +| Diagnostics | Dump status, Logging status | Enable request dumps, Disable request dumps, Logging level (`error`, `warn`, `info`, `debug`, `trace`) | All | +| Extras | Fast mode status, Prime quota window status | Enable fast mode, Disable fast mode, Enable Prime, Disable Prime, Fire lane start | Fast mode actions; Prime status without on/off | -Applying a change in a modal persists it through the same configuration the slash arguments use, so the modal and the typed command (`/claude-routing fallback-first`, etc.) are equivalent. Outside the OpenCode TUI (OpenCode desktop or headless), the commands print their text summary as before; Pi is unaffected. +The Accounts actions shown depend on custody: + +- **Local custody**: every Accounts action except **Disconnect from the vault**. +- **Vault custody**: the Accounts group shows `Accounts are managed in the vault. Disconnect from the vault to use local accounts.` Enable, disable, **Reset account backoff**, and **Disconnect from the vault** remain. Local sign-in, API-key additions, removal, reordering, **Reset host enrollment**, and **Custody setup guidance** are hidden and refused. +- **Unreadable enrollment**: the Accounts group shows the same actions as vault custody, so **Disconnect from the vault** stays available. + +**Custody setup guidance** changes nothing; it explains that custody changes require stopping the hosts and running [setup](#quick-setup-recommended). **Remove account**, **Reset host enrollment**, and **Disconnect from the vault** ask for confirmation. Sign-in, API-key, guidance, and disconnect actions need an interactive session. + +In the OpenCode TUI, `/claude` opens an interactive dialog. Outside the TUI (OpenCode Desktop or headless), it posts a text summary of the menu. In Pi without a UI, it prints the status sections only. The modal bridge runs a loopback-only RPC server (bound to `127.0.0.1`, bearer-authenticated) so the OpenCode server process can signal the separate TUI process. Its runtime directory defaults to a per-project path under the OpenCode state directory; override it with `OPENCODE_ANTHROPIC_AUTH_RPC_DIR`. @@ -505,18 +545,7 @@ const order = computeEffectiveOrder(root, 'my-plugin', 200) ## Claude prompt cache control -Both OpenCode and Pi packages add a slash command for Anthropic's 1-hour ephemeral prompt-cache TTL: - -```text -/claude-cache -/claude-cache on -/claude-cache off -/claude-cache mode explicit -/claude-cache mode automatic -/claude-cache mode hybrid -``` - -Without arguments, `/claude-cache` shows the current setting. +Both OpenCode and Pi control Anthropic's 1-hour ephemeral prompt-cache lifetime from the `/claude` menu's Cache group: **Enable cache**, **Disable cache**, and **Cache mode** (`explicit`, `automatic`, or `hybrid`). **Cache status** shows the current setting. Modes: @@ -528,18 +557,11 @@ In OpenCode, subagent requests do not receive 1-hour TTL caching. The plugin det ### Cache keepalive -`/claude-cachekeep` keeps recently used hybrid-mode session caches warm while the agent process is running: +Cache keepalive keeps recently used hybrid-mode session caches warm while the agent process is running. Control it from the Cache group: **Keep cache warm always**, **Cache keep window** (start and end hour), **Disable cache keep**, and **Cache keep for subagents** (`on`/`off`). -```text -/claude-cachekeep -/claude-cachekeep always -/claude-cachekeep 09-23 -/claude-cachekeep off -``` +**Keep cache warm always** keeps tracked sessions warm continuously for as long as the agent process remains open. Windows use local 24-hour time and are start-inclusive/end-exclusive: start `9`, end `23` runs from 09:00 until 22:59, and overnight windows such as start `23`, end `9` are accepted. -`always` keeps tracked sessions warm continuously for as long as the agent process remains open. Hour ranges use local 24-hour time and are start-inclusive/end-exclusive: `09-23` runs from 09:00 until 22:59, and overnight windows such as `23-09` are accepted. - -Cache keepalive only tracks requests when `/claude-cache` is enabled in `hybrid` mode. For each active session seen in the configured schedule, the package keeps an in-memory clone of the latest rewritten Anthropic request and sends a non-streaming `max_tokens: 0` pre-warm request about five minutes before the 1-hour cache entry would expire. Running `/claude-cachekeep` without arguments lists every live tracked session across OpenCode projects and plugin processes; Pi reports its own separately scoped live sessions. +Cache keepalive only tracks requests when the cache is enabled in `hybrid` mode. For each active session seen in the configured schedule, the package keeps an in-memory clone of the latest rewritten Anthropic request and sends a non-streaming `max_tokens: 0` pre-warm request about five minutes before the 1-hour cache entry would expire. **Cache keep status** lists every live tracked session across OpenCode projects and plugin processes; Pi reports its own separately scoped live sessions. Request bodies, headers, and tokens remain in memory. A lease-backed file under the system temporary directory shares only session IDs and cache timing for cross-instance status; records from stopped processes disappear from status after about three minutes. @@ -547,46 +569,30 @@ Pre-warm requests preserve explicit cache anchors but remove response-only field ## Claude quota window priming -Quota-window priming is off by default. OpenCode controls it with: - -```text -/claude-prime -/claude-prime on -/claude-prime off -``` +Quota-window priming is off by default. In OpenCode, turn it on or off with **Enable Prime** and **Disable Prime** in the `/claude` menu's Extras group; **Prime quota window status** shows its state and usage. When enabled, the plugin watches each OAuth account's 5-hour quota reset. About one minute after a confirmed reset, it sends one minimal `claude-haiku-4-5` request so the next window starts without waiting for a normal prompt. Usage is measured from response accounting. -Scheduled fires happen only after the quota window has reset. If an idle account has no cached reset time, the plugin sends one bootstrap request to establish its first observed window. Atomic temporary-file claims ensure that multiple OpenCode processes sharing the same account config send only one request per account and reset. +Scheduled fires happen only after the quota window has reset. If an idle account has no cached reset time, the plugin sends one bootstrap request to establish its first observed window. When several processes share an account store, each tries to create a claim file for that account and reset; creating the file is atomic, so exactly one process succeeds and sends the request. -Prime marker identities live in `anthropic-auth-state.json`. Plugin-owned refresh rotations preserve the main account's lineage, while a host credential replacement creates a new lineage. An existing main lineage without a refresh-token binding attaches to the current credential on its first check without changing identity. On upgrade, an existing fallback account receives an identity during its first prime check; that one-time marker change can send one extra request in the current window. +Claim files live in a temporary directory, separated by account store and credential lineage. Cleanup removes files older than six hours. Ordinary token refresh keeps the lineage; a new sign-in starts a new one. Private runtime state stores cumulative priming usage counters, not a history of primed windows. Legacy claim files are not migrated. Without the old claim, the new scheduler can treat an already primed window as unclaimed and send one extra request. -Pi exposes `/claude-prime` as a status-only command. Its `on` and `off` arguments are ignored; enable or disable priming from OpenCode. +Pi shows **Prime quota window status** but has no Prime on/off actions; enable or disable priming from OpenCode. ## OpenCode lane start -`/claude-start` is an OpenCode-only command. It queues one synthetic turn for the current session: +**Fire lane start** in the `/claude` menu's Extras group is OpenCode-only. It queues one synthetic turn for the current session. -```text -/claude-start -``` - -The bare command fires immediately. The synthetic prompt uses the session's current model, agent, and variant, then travels through the ordinary quota, routing, cache, relay, signing, and response pipeline. OpenCode shapes that OAuth request to `max_tokens: 1` while keeping streaming enabled, and correlates the request by its synthetic message ID. A queued modal is a request to start the turn, not a provider-success claim. +The action fires immediately. The synthetic prompt, a turn generated by the plugin rather than typed by you, uses the session's current model, agent, and variant, then travels through the ordinary quota, routing, cache, relay, signing, and response pipeline. OpenCode shapes that OAuth request to `max_tokens: 1` while keeping streaming enabled, and correlates the request by its synthetic message ID. A queued turn means OpenCode accepted it into the session's queue; it does not mean Anthropic has completed the request. > [!IMPORTANT] > This is a real billed request. The one-token limit reduces generated output only; Anthropic still charges for prompt-cache reads and any new cache writes. Use it when you plan to resume the session before the refreshed cache expires. -Pi does not expose this command. +Pi does not offer this action to warm the session's prompt cache. ## Claude fast mode -Both OpenCode and Pi packages can persistently request Anthropic fast mode for supported Opus models: - -```text -/claude-fast -/claude-fast on -/claude-fast off -``` +Both OpenCode and Pi can save a fast-mode setting that applies to later requests and survives process restarts. It requests Anthropic fast mode for supported Opus models with **Enable fast mode** and **Disable fast mode** in the `/claude` menu's Extras group. **Fast mode status** shows the current setting. 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. @@ -625,21 +631,21 @@ The relay is opt-in and user-owned. CortexKit does not run shared relay infrastr When enabled, the package sends large Anthropic request bodies to a Cloudflare Worker that you own. The first request for a session sends a full body; later requests send compact patches keyed by session affinity (`x-session-affinity` in OpenCode, Pi's stream `sessionId` in Pi). The Worker reconstructs the full Anthropic `/v1/messages` request and streams Anthropic's SSE response back to the client. -New relay setups default to HTTP transport: +`relay setup` saves HTTP transport: ```json "transport": "http" ``` -HTTP is the safest release default and still sends compact full-sync/patch payloads through your Worker. WebSocket is available as an opt-in persistent session transport: +HTTP is the default because it also works with relay Workers deployed by older releases. It still sends compact payloads through your Worker: the full request body for a session's first request, then only the changes for later requests. WebSocket is an opt-in persistent session transport for OpenCode 1 and Pi: ```json "transport": "websocket" ``` -WebSocket mode uses protocol v2 on `/ws`, keeps one connection per OpenCode `x-session-affinity`, and serializes same-session requests until the previous stream finishes. Existing deployed Workers must be redeployed with this package's current Worker script before `"transport": "websocket"` will work; older relay Workers only understand the legacy protocol. +WebSocket mode uses protocol v2 on `/ws`, keeps one connection per session (OpenCode `x-session-affinity`, Pi stream `sessionId`), and serializes same-session requests until the previous stream finishes. Existing deployed Workers must be redeployed with this package's current Worker script before `"transport": "websocket"` will work; older relay Workers only understand the legacy protocol. OpenCode 2 supports direct and HTTP relay transport only; with WebSocket selected, it refuses requests before sending them with `request refused: WebSocket relay is not supported on OpenCode 2; select HTTP relay or direct transport`. -Set up a relay in your Cloudflare account with the OpenCode package CLI: +Set up a relay in your Cloudflare account with the OpenCode package CLI. The CLI saves relay settings in OpenCode's local account pool, so run [setup](#quick-setup-recommended) for OpenCode first to prepare the local account pool before the CLI saves settings: ```bash CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... bunx @cortexkit/opencode-anthropic-auth relay setup @@ -657,9 +663,9 @@ The setup command: 2. Uploads the relay Worker module. 3. Generates a relay shared secret. 4. Enables the Worker. -5. Writes the local `relay` block to `~/.config/opencode/anthropic-auth.json`. +5. Saves the relay URL, shared secret, and HTTP transport in OpenCode's local account pool. -For Pi, copy the generated `relay` block into `~/.pi/agent/anthropic-auth.json` if you want the Pi package to use the same user-owned Worker. +`relay setup` configures OpenCode's relay settings, not Pi's. During the first local setup, existing relay settings (including `transport`) are copied from `anthropic-auth.json` into that application's pool; the `/claude` menu has no relay actions. The Cloudflare API token is used only during setup and is not stored by the plugin. Re-running setup with the same Worker name uploads the current Worker script again; this is how existing relay Workers are upgraded for protocol changes such as WebSocket v2. @@ -688,13 +694,7 @@ Direct fallback logs `falling back direct`. ### Request dumps -For relay/cache debugging, enable exact request dumps from inside OpenCode or Pi: - -```text -/claude-dump on -/claude-dump off -/claude-dump -``` +For relay/cache debugging, enable exact request dumps from inside OpenCode or Pi with **Enable request dumps** and **Disable request dumps** in the `/claude` menu's Diagnostics group. **Dump status** shows the current setting. When enabled, the plugin writes artifacts under the OS temp directory: @@ -711,7 +711,7 @@ Each filename includes a sanitized session/affinity segment so dumps from differ Lane-start requests use the `-start-` dump marker; CacheKeep keeps `-prewarm-cachekeep-`. Their cache-diagnostics records use `source: "start"` with `synthetic: true`, alongside ordinary `turn` records. -Dump state is persisted in the active sidecar config as `dump.enabled` (`~/.config/opencode/anthropic-auth.json` for OpenCode, `~/.pi/agent/anthropic-auth.json` for Pi). Dumps may contain prompt content and should be treated as sensitive local debugging artifacts. +The dump setting is saved with the host's other plugin settings. Dumps may contain prompt content and should be treated as sensitive local debugging artifacts. ## Environment variables @@ -719,16 +719,21 @@ Dump state is persisted in the active sidecar config as `dump.enabled` (`~/.conf | --- | --- | | `ANTHROPIC_BASE_URL` | Override the Anthropic API endpoint. Must be HTTP(S). | | `ANTHROPIC_INSECURE` | Set to `1` or `true` to skip TLS verification when `ANTHROPIC_BASE_URL` is set. | -| `OPENCODE_ANTHROPIC_AUTH_FILE` | Override the OpenCode sidecar config path. | +| `OPENCODE_ANTHROPIC_AUTH_FILE` | Override OpenCode's old sidecar path. The local account pool and its related files are created next to it. | +| `OPENCODE_ANTHROPIC_AUTH_STATE_FILE` | Override the old sidecar state file that setup reads during migration. | | `OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE` | Override the owner-only OpenCode Claustrum enrollment-token path. | +| `OPENCODE_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE` | Override the Claustrum connection file OpenCode uses. | | `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE` | Set to `legacy` to bypass Anthropic's server policy and use deterministic 10-response client recovery exclusively. The default tries server-side safety fallback first and uses client recovery as a backstop. | | `OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE` | Override the persistent sticky-balanced session assignment file. | | `OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR` | Override the temporary OpenCode CacheKeep session lease directory. | -| `OPENCODE_ANTHROPIC_AUTH_RPC_DIR` | Override the directory for the OpenCode TUI command-modal RPC bridge (port file + token). Defaults to a per-project path under the OpenCode state directory. | -| `PI_ANTHROPIC_AUTH_FILE` | Override the Pi sidecar config path. | +| `OPENCODE_ANTHROPIC_AUTH_RPC_DIR` | Override the directory for the OpenCode TUI menu RPC bridge (port file + token). Defaults to a per-project path under the OpenCode state directory. | +| `PI_ANTHROPIC_AUTH_FILE` | Override Pi's old sidecar path. The local account pool and its related files are created next to it. | +| `PI_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE` | Override the owner-only Pi Claustrum enrollment-token path. | +| `PI_ANTHROPIC_AUTH_CLAUSTRUM_CONNECTION_FILE` | Override the Claustrum connection file Pi uses. | | `PI_ANTHROPIC_AUTH_ROUTING_STATE_FILE` | Override the persistent Pi sticky-balanced session assignment file. | | `PI_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR` | Override the temporary Pi CacheKeep session lease directory. | -| `PI_AGENT_DIR` | Override Pi's agent directory when deriving the default sidecar path. | +| `PI_CODING_AGENT_DIR`, `PI_AGENT_DIR` | Override Pi's agent directory when deriving default paths. | +| `XDG_STATE_HOME` | Base directory for the default enrollment files (`cortexkit/anthropic-auth/-enrollment.json`). Defaults to `~/.local/state`. | | `CLOUDFLARE_API_TOKEN` | Cloudflare token used by `bunx @cortexkit/opencode-anthropic-auth relay setup`. Not stored. | | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID used by relay setup. | @@ -740,7 +745,7 @@ For Claude Pro/Max OAuth requests, the plugin works at the final Anthropic wire- 2. Normalizes Claude-compatible OAuth headers and beta flags. 3. Removes OpenCode-specific identity text from system blocks. 4. Prepends Claude Code identity and billing-header blocks. -5. Rewrites cache controls according to `/claude-cache` mode. +5. Rewrites cache controls according to the configured cache mode. 6. Renames MCP tool names into Claude-compatible PascalCase form. 7. Opts eligible Fable 5/5.1 and Opus 5 OAuth requests into Anthropic's server-side safety fallback, restores stored fallback boundaries, and activates replay-safe client recovery if the response still refuses. 8. Computes final-body `cch` over the fully serialized request body. @@ -827,10 +832,12 @@ The release workflow runs checks, publishes the core, OpenCode, and Pi packages ## Troubleshooting - Clear OpenCode's plugin cache after plugin config changes: `rm -rf ~/.cache/opencode`. -- Restart OpenCode or Pi after changing sidecar config; some settings are loaded at startup. -- If an OpenCode fallback account shows `invalid_grant`, run `bunx @cortexkit/opencode-anthropic-auth login