From 709cab62bf3c642c0d776a11883b1a2fe01e3779 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:00:10 +0200 Subject: [PATCH 1/2] fix(custody): make withheld fallback refresh observable The construction-time gate that withholds the fallback-account background refresh when vault residency is structurally unsafe emitted no signal, so a process that had silently stopped refreshing looked identical from outside to a healthy one. Emit one claustrum warn at the decision carrying the three dimensions that produced it (custody mode, provisional flag, fallbacks dimension), and add a process-wide fallbackRefreshStructuralDark flag to sidebar state so the condition is visible on the wire. The gate's boolean is unchanged. --- packages/opencode/src/index.ts | 12 ++ packages/opencode/src/sidebar-state.ts | 9 + .../fallback-refresh-observability.test.ts | 199 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 packages/opencode/src/tests/fallback-refresh-observability.test.ts diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 171a9b4d..5c9b9690 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,9 @@ const anthropicAuthPlugin = async ( : null })(), fastMode: isFastModeEnabled(), + ...(fallbackRefreshStructuralDark && { + fallbackRefreshStructuralDark: true, + }), cacheKeep: { enabled: isCacheKeepHybridActive(storage), window: isCacheKeepAlways(storage) 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..80f6544f --- /dev/null +++ b/packages/opencode/src/tests/fallback-refresh-observability.test.ts @@ -0,0 +1,199 @@ +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. +async function bootStructuralDark() { + 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) + + await saveAccounts( + { + version: 1, + claustrum: { mode: 'claustrum' }, + quota: { enabled: false, failClosedOnUnknownQuota: false }, + main: { + ...custodyTombstoneOAuth('anthropic'), + claustrumHandle: MAIN_HANDLE, + }, + accounts: [ + { + id: 'work-alt', + label: 'work', + type: 'oauth', + refresh: 'real-fallback-refresh', + access: 'real-fallback-access', + enabled: true, + claustrumHandle: FALLBACK_HANDLE, + }, + ], + } 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 } +} + +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 bootStructuralDark() + 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 bootStructuralDark() + 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?.() + } + }) +}) From 1bdb29db2b53cbac4ebe6c80980746f2b9da9e6f Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:43:07 +0200 Subject: [PATCH 2/2] fix(custody): close observability gaps in the structural-dark signal Add a non-dark control test: an unconditional gate (if (true) / ...(true &&)) previously passed the whole suite, so nothing proved the signal is absent when the gate does not withhold. The new test asserts no withheld warn and that the sidebar key is omitted entirely. Publish the boot decision to the sidebar at construction. The loader's own sidebar write is unreachable while structurally dark (the custody reconcile refuses first), so a warm-vault dark boot left the sidebar silent until a command ran. A poll-based test covers the fire-and-forget boot publish. Document at the projection site that the flag is a boot fact, never cleared. --- packages/opencode/src/index.ts | 10 ++ .../fallback-refresh-observability.test.ts | 106 +++++++++++++++--- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5c9b9690..d7a175c3 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -4063,6 +4063,9 @@ 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, }), @@ -5650,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/tests/fallback-refresh-observability.test.ts b/packages/opencode/src/tests/fallback-refresh-observability.test.ts index 80f6544f..441f9c0e 100644 --- a/packages/opencode/src/tests/fallback-refresh-observability.test.ts +++ b/packages/opencode/src/tests/fallback-refresh-observability.test.ts @@ -31,8 +31,9 @@ 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. -async function bootStructuralDark() { +// 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') @@ -68,6 +69,24 @@ async function bootStructuralDark() { ) 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, @@ -77,17 +96,7 @@ async function bootStructuralDark() { ...custodyTombstoneOAuth('anthropic'), claustrumHandle: MAIN_HANDLE, }, - accounts: [ - { - id: 'work-alt', - label: 'work', - type: 'oauth', - refresh: 'real-fallback-refresh', - access: 'real-fallback-access', - enabled: true, - claustrumHandle: FALLBACK_HANDLE, - }, - ], + accounts: [fallbackAccount], } as never, accountPath, ) @@ -135,6 +144,25 @@ async function bootStructuralDark() { 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') @@ -151,7 +179,7 @@ describe('fallback refresh structural-dark observability', () => { setLogLevel('debug') __setLogTestSink((record) => logs.push(record as Record)) try { - const { plugin } = await bootStructuralDark() + const { plugin } = await bootFixture({ dark: true }) try { const withheld = logs.find( (record) => @@ -174,7 +202,7 @@ describe('fallback refresh structural-dark observability', () => { }) test('the sidebar carries the structural-dark flag', async () => { - const { plugin, sidebarPath } = await bootStructuralDark() + 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 @@ -196,4 +224,52 @@ describe('fallback refresh structural-dark observability', () => { 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) + } + }) })