diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 171a9b4d..d7a175c3 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -2991,6 +2991,15 @@ const anthropicAuthPlugin = async ( provisionalCustody.provisional === true && (fallbackDimensions.fallbacks === 'M' || fallbackDimensions.fallbacks === 'R') + if (fallbackRefreshStructuralDark) { + // Withholding the refresh is invisible from outside; record the three + // dimensions that produced the decision so a stalled process is diagnosable. + logger.warn('claustrum', 'fallback refresh withheld at construction', { + custodyMode: getClaustrumMode(initialStorage), + provisional: provisionalCustody.provisional, + fallbacks: fallbackDimensions.fallbacks, + }) + } const fallbackRefreshReady = fallbackRefreshStructuralDark ? Promise.resolve('not-started') : fallbackManager.startBackgroundRefresh() @@ -4054,6 +4063,12 @@ const anthropicAuthPlugin = async ( : null })(), fastMode: isFastModeEnabled(), + // Set once at construction and never cleared: this stays true after the + // process recovers, so it records that the boot gate withheld the refresh, + // not that the refresh is currently dark. + ...(fallbackRefreshStructuralDark && { + fallbackRefreshStructuralDark: true, + }), cacheKeep: { enabled: isCacheKeepHybridActive(storage), window: isCacheKeepAlways(storage) @@ -5638,6 +5653,13 @@ const anthropicAuthPlugin = async ( }, ) + // The loader's own sidebar write is unreachable while structurally dark — the + // custody reconcile refuses before it — so publish the boot decision here. The + // cold-vault path already republishes via refreshVaultBackedOAuthAccounts. + if (fallbackRefreshStructuralDark) { + void refreshSidebarQuota().catch(() => {}) + } + return { 'experimental.chat.messages.transform': async ( _input: Record, diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index 625b6030..669fda6f 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -88,6 +88,12 @@ export interface SidebarState { route: string relay: { enabled: boolean; transport: string } | null fastMode: boolean + /** + * True when the boot-time fallback-account background refresh was withheld + * because vault residency was structurally unsafe before a main slot existed. + * Process-wide (one boot decision), not per-account. + */ + fallbackRefreshStructuralDark?: boolean cacheKeep?: { enabled: boolean window?: string @@ -434,6 +440,9 @@ export function normalizeSidebarState(raw: unknown): SidebarState { typeof raw.fastMode === 'boolean' ? raw.fastMode : DEFAULT_SIDEBAR_STATE.fastMode, + ...(raw.fallbackRefreshStructuralDark === true && { + fallbackRefreshStructuralDark: true, + }), cacheKeep, prime: normalizePrimeSection(raw.prime), fableRecoveries: fableRecoveries.length > 0 ? fableRecoveries : undefined, diff --git a/packages/opencode/src/tests/fallback-refresh-observability.test.ts b/packages/opencode/src/tests/fallback-refresh-observability.test.ts new file mode 100644 index 00000000..441f9c0e --- /dev/null +++ b/packages/opencode/src/tests/fallback-refresh-observability.test.ts @@ -0,0 +1,275 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + __setLogTestSink, + custodyTombstoneOAuth, + getLogLevel, + saveAccounts, + setLogLevel, +} from '@cortexkit/anthropic-auth-core' + +import { AnthropicAuthPlugin } from '../index' +import { drainSidebarWrites } from '../sidebar-state' + +const roots: string[] = [] +const originalEnv = { + account: process.env.OPENCODE_ANTHROPIC_AUTH_FILE, + sidebar: process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE, + manifest: process.env.CLAUSTRUM_OPENCODE_HANDLES, +} + +function restoreEnv(name: keyof typeof originalEnv, variable: string) { + const value = originalEnv[name] + if (value === undefined) delete process.env[variable] + else process.env[variable] = value +} + +const MAIN_HANDLE = `ckh_${'Z'.repeat(43)}` +const FALLBACK_HANDLE = `ckh_${'F'.repeat(43)}` + +// Structural-dark requires claustrum mode + provisional custody + a fallback +// dimension of M or R. A fallback with real refresh material (not a tombstone) +// and a resolved manifest binding classifies as R; a tombstone fallback +// classifies as T, which is the non-dark control. +async function bootFixture({ dark }: { dark: boolean }) { + const root = await mkdtemp(join(tmpdir(), 'fallback-refresh-observability-')) + roots.push(root) + const accountPath = join(root, 'anthropic-auth.json') + const manifestPath = join(root, 'handles.json') + const sidebarPath = join(root, 'sidebar.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = sidebarPath + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + + await writeFile( + manifestPath, + JSON.stringify({ + version: 1, + providers: [ + { + provider: 'anthropic', + serve: 'anthropic-auth', + accounts: [ + { + label: 'main', + handle: MAIN_HANDLE, + credential_id: 'oauth:anthropic:main', + }, + { + label: 'work', + handle: FALLBACK_HANDLE, + credential_id: 'oauth:anthropic:work', + }, + ], + }, + ], + }), + ) + await chmod(manifestPath, 0o600) + + const fallbackAccount = dark + ? { + id: 'work-alt', + label: 'work', + type: 'oauth', + refresh: 'real-fallback-refresh', + access: 'real-fallback-access', + enabled: true, + claustrumHandle: FALLBACK_HANDLE, + } + : { + id: 'work-alt', + label: 'work', + ...custodyTombstoneOAuth('anthropic'), + enabled: true, + claustrumHandle: FALLBACK_HANDLE, + } + + await saveAccounts( + { + version: 1, + claustrum: { mode: 'claustrum' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + main: { + ...custodyTombstoneOAuth('anthropic'), + claustrumHandle: MAIN_HANDLE, + }, + accounts: [fallbackAccount], + } as never, + accountPath, + ) + + const connector = async () => + ({ + call: async (_moduleId: string, method: string, params?: unknown) => { + if (method !== 'credential.get') return { result: {} } + const handle = (params as { handle?: string } | undefined)?.handle + const isMain = handle === MAIN_HANDLE + return { + result: { + payload: Array.from( + new TextEncoder().encode( + JSON.stringify({ + access_token: isMain + ? 'vault-main-access' + : 'vault-fallback-access', + }), + ), + ), + expires_at_ms: Date.now() + 60 * 60 * 1000, + record_version: 1, + }, + } + }, + close: () => {}, + }) as never + + const plugin = await ( + AnthropicAuthPlugin as unknown as ( + ctx: unknown, + runtime: unknown, + ) => Promise + )( + { + client: { + auth: { set: mock(() => Promise.resolve()) }, + session: { promptAsync: mock(() => Promise.resolve()) }, + }, + }, + { claustrumConnector: connector }, + ) + + return { plugin, sidebarPath } +} + +// The boot-time publish is fire-and-forget, so poll rather than assume the +// write has landed by the time the plugin factory resolves. +async function readSidebarWhen( + path: string, + predicate: (sidebar: Record) => boolean, + timeoutMs = 2_000, +): Promise> { + const deadline = Date.now() + timeoutMs + let last: Record = {} + while (Date.now() < deadline) { + try { + last = JSON.parse(await readFile(path, 'utf8')) + if (predicate(last)) return last + } catch {} + await new Promise((resolve) => setTimeout(resolve, 10)) + } + return last +} + +afterEach(async () => { + restoreEnv('account', 'OPENCODE_ANTHROPIC_AUTH_FILE') + restoreEnv('sidebar', 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE') + restoreEnv('manifest', 'CLAUSTRUM_OPENCODE_HANDLES') + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +describe('fallback refresh structural-dark observability', () => { + test('withholding the fallback refresh logs the three dimensions that produced it', async () => { + const previousLogLevel = getLogLevel() + const logs: Array> = [] + setLogLevel('debug') + __setLogTestSink((record) => logs.push(record as Record)) + try { + const { plugin } = await bootFixture({ dark: true }) + try { + const withheld = logs.find( + (record) => + record.channel === 'claustrum' && + record.message === 'fallback refresh withheld at construction', + ) + expect(withheld).toBeDefined() + expect(withheld?.payload).toEqual({ + custodyMode: 'claustrum', + provisional: true, + fallbacks: 'R', + }) + } finally { + await plugin.dispose?.() + } + } finally { + __setLogTestSink(null) + setLogLevel(previousLogLevel) + } + }) + + test('the sidebar carries the structural-dark flag', async () => { + const { plugin, sidebarPath } = await bootFixture({ dark: true }) + try { + // The loader sets latestGetAuth before its custody reconcile refuses; the + // add-apikey command then routes through refreshSidebarAfterMutation, which + // is the write path reachable while the process is structurally dark. + await plugin.auth.loader( + () => Promise.resolve(custodyTombstoneOAuth('anthropic') as never), + { models: {} }, + ) + await plugin['command.execute.before']({ + command: 'claude-account', + arguments: 'add-apikey sk-ant-observability-test', + sessionID: 'fallback-refresh-observability', + }).catch(() => {}) + await drainSidebarWrites() + + const sidebar = JSON.parse(await readFile(sidebarPath, 'utf8')) + expect(sidebar.fallbackRefreshStructuralDark).toBe(true) + } finally { + await plugin.dispose?.() + } + }) + + test('the boot decision reaches the sidebar without a command', async () => { + const { plugin, sidebarPath } = await bootFixture({ dark: true }) + try { + const sidebar = await readSidebarWhen( + sidebarPath, + (state) => state.fallbackRefreshStructuralDark === true, + ) + expect(sidebar.fallbackRefreshStructuralDark).toBe(true) + } finally { + await plugin.dispose?.() + } + }) + + test('a non-dark boot emits no withheld warn and omits the sidebar flag', async () => { + const previousLogLevel = getLogLevel() + const logs: Array> = [] + setLogLevel('debug') + __setLogTestSink((record) => logs.push(record as Record)) + try { + const { plugin, sidebarPath } = await bootFixture({ dark: false }) + try { + expect( + logs.some( + (record) => + record.channel === 'claustrum' && + record.message === 'fallback refresh withheld at construction', + ), + ).toBe(false) + + // The loader reaches its own sidebar write here (claustrum + tombstone + // main + tombstone fallback reconciles to CLAUSTRUM_SERVE, not a refusal). + await plugin.auth.loader( + () => Promise.resolve(custodyTombstoneOAuth('anthropic') as never), + { models: {} }, + ) + await drainSidebarWrites() + + const sidebar = JSON.parse(await readFile(sidebarPath, 'utf8')) + expect('fallbackRefreshStructuralDark' in sidebar).toBe(false) + } finally { + await plugin.dispose?.() + } + } finally { + __setLogTestSink(null) + setLogLevel(previousLogLevel) + } + }) +})