diff --git a/packages/core/src/commands.ts b/packages/core/src/commands.ts index e8013ab..c3a2411 100644 --- a/packages/core/src/commands.ts +++ b/packages/core/src/commands.ts @@ -355,6 +355,7 @@ async function executeAccountCommand( ctx: CommandContext, ): Promise { const tokens = args.trim().split(/\s+/).filter(Boolean) + log.info('account command parsed', { args, tokens }) const storage = (await ctx.loadAccounts(storePaths(ctx))) ?? { version: 1 as const, accounts: [], @@ -362,7 +363,12 @@ async function executeAccountCommand( const accounts = storage.accounts ?? [] if (tokens[0] === 'claustrum') { + log.info('claustrum mode requested', { + hasEnterFn: typeof ctx.enterClaustrumMode === 'function', + accounts: accounts.length, + }) if (!ctx.enterClaustrumMode) { + log.warn('claustrum refused: transition fn absent from command context') return { command: 'openai-account', text: '## Claustrum Unavailable\n\nThe custody runtime is not ready. Try again after OpenAI auth finishes initializing.', @@ -372,7 +378,21 @@ async function executeAccountCommand( }, } } - const result = await ctx.enterClaustrumMode() + log.info('claustrum transition starting', {}) + let result: Awaited>> + try { + result = await ctx.enterClaustrumMode() + } catch (error) { + log.error('claustrum transition threw', { + error: error instanceof Error ? error.message : String(error), + }) + throw error + } + log.info('claustrum transition finished', { + status: result.status, + reason: result.reason, + outcomes: result.outcomes, + }) const nextStorage = (await ctx.loadAccounts(storePaths(ctx))) ?? { version: 1 as const, accounts: [], @@ -398,7 +418,11 @@ async function executeAccountCommand( } if (tokens[0] === 'local') { + log.info('local mode requested', { + hasLeaveFn: typeof ctx.leaveClaustrumMode === 'function', + }) if (!ctx.leaveClaustrumMode) { + log.warn('local refused: transition fn absent from command context') return { command: 'openai-account', text: '## Local Mode Unavailable\n\nThe custody runtime is not ready. Try again after OpenAI auth finishes initializing.', diff --git a/packages/core/src/refresh-all-quota.ts b/packages/core/src/refresh-all-quota.ts index 89e6dba..d20f650 100644 --- a/packages/core/src/refresh-all-quota.ts +++ b/packages/core/src/refresh-all-quota.ts @@ -128,6 +128,11 @@ export interface RefreshAllQuotaDeps { ) => Promise< FallbackAccessResolution | typeof CUSTODY_REFUSE | typeof CUSTODY_EXCLUDED > + resolveMainAccess?: ( + storage: AccountStorage, + ) => Promise< + FallbackAccessResolution | typeof CUSTODY_REFUSE | typeof CUSTODY_EXCLUDED + > reportCustodyAuthFailure?: (params: { handle: string providerStatus: number @@ -230,8 +235,24 @@ export async function refreshAllQuota( recordOutcome({ account: 'main', ok: true }) } else { if (!auth.access || (auth.expires ?? 0) < deps.now()) { - const tokens = await deps.refreshMainWithLease() - auth = { ...auth, access: tokens.access, expires: tokens.expires } + const resolvedMain = deps.resolveMainAccess + ? await deps.resolveMainAccess( + storage ?? { version: 1, accounts: [] }, + ) + : CUSTODY_EXCLUDED + if (resolvedMain === CUSTODY_REFUSE) { + recordOutcome({ + account: 'main', + ok: false, + error: 'custody refused', + }) + auth = { ...auth, access: undefined } + } else if (resolvedMain !== CUSTODY_EXCLUDED) { + auth = { ...auth, access: resolvedMain.token } + } else { + const tokens = await deps.refreshMainWithLease() + auth = { ...auth, access: tokens.access, expires: tokens.expires } + } } if (auth.access) { @@ -258,7 +279,7 @@ export async function refreshAllQuota( quotaUpdated = true recordOutcome({ account: 'main', ok: true }) } - } else { + } else if (!results.some((result) => result.account === 'main')) { recordOutcome({ account: 'main', ok: false, diff --git a/packages/opencode/src/core/custody-runtime.ts b/packages/opencode/src/core/custody-runtime.ts index a336b85..ee48e3a 100644 --- a/packages/opencode/src/core/custody-runtime.ts +++ b/packages/opencode/src/core/custody-runtime.ts @@ -139,6 +139,8 @@ export type CustodyRuntime = { isEnabled(): boolean /** Cache handle (undefined when custody is disabled). */ getCache(): ClaustrumCredentialCache | undefined + /** Connect the cache on demand and return it. */ + ensureCache(): Promise /** Transport handle (undefined when custody is disabled). */ getTransport(): ClaustrumCacheTransportLike | undefined /** True if the detection step produced an `available` connection file. */ @@ -182,6 +184,11 @@ export function __createCustodyRuntimeForTest( isEnabled, wasDetected: () => detection?.status === 'available', getCache: () => cache, + async ensureCache() { + if (cache) return cache + await connectCache() + return cache + }, getTransport: () => transport, getCustodyProjection: (account, currentNow) => { const cached = projectionByAccountId.get(account.id) diff --git a/packages/opencode/src/core/custody-transition.ts b/packages/opencode/src/core/custody-transition.ts index 05f6f9d..f12eaac 100644 --- a/packages/opencode/src/core/custody-transition.ts +++ b/packages/opencode/src/core/custody-transition.ts @@ -211,7 +211,23 @@ export async function enterClaustrumMode( return { status: 'aborted', outcomes, reason: 'mode-lock-unavailable' } locks.push(modeLock) + deps.warn?.( + `transition deps: ${JSON.stringify({ + acquireLock: typeof deps.acquireLock, + withStoreTransaction: typeof deps.withStoreTransaction, + readManifest: typeof deps.readManifest, + preflight: typeof deps.preflight, + auth: typeof deps.auth, + authAll: typeof deps.auth?.all, + authGet: typeof deps.auth?.get, + authSet: typeof deps.auth?.set, + accountIds: deps.accountIds?.length, + })}`, + ) const participants = transitionParticipants(deps.accountIds) + deps.warn?.( + `transition participants: ${participants.map((participant) => participant.id).join(',')}`, + ) for (const participant of participants) { const accountLock = await deps.acquireLock({ name: lockName(participant), @@ -262,9 +278,13 @@ export async function enterClaustrumMode( const capturedGeneration = persisted?.storeGeneration ?? accountStoreGeneration(initial) if (!persisted) { + deps.warn?.( + `capturing main slot fingerprint; auth.get is ${typeof deps.auth?.get}`, + ) const mainSlot = asCompleteMainOauthSlot( await deps.auth.get({ path: { id: 'openai' } }), ) + deps.warn?.(`main slot captured: ${mainSlot ? 'complete' : 'absent'}`) if (mainSlot) { fingerprints.main = custodySlotFingerprint( mainSlot.access, @@ -278,8 +298,16 @@ export async function enterClaustrumMode( } await step('captured') + deps.warn?.( + `handles resolved: isMap=${handles instanceof Map} size=${ + handles instanceof Map ? handles.size : 'n/a' + }`, + ) for (const participant of currentParticipants) { const handle = handles.get(participant.id) + deps.warn?.( + `participant ${participant.id}: handle=${handle ? 'yes' : 'NO'}`, + ) if (!handle) { outcomes[participant.id] = 'no-handle' continue diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 408d3cd..1ca4dee 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1910,6 +1910,7 @@ export async function CodexAuthPlugin( whamFn: whamUsageFn, isFallbackRefreshInert: isFallbackAccountRefreshInert, resolveFallbackAccess: resolveAccountAccessForCustody, + resolveMainAccess: resolveMainAccessForCustody, reportCustodyAuthFailure: reportAuthFailureForCustody, ...(respectBackoff === undefined ? {} : { respectBackoff }), ...(skipFresherThanMs === undefined ? {} : { skipFresherThanMs }), @@ -2589,8 +2590,20 @@ export async function CodexAuthPlugin( ), readManifest: readCustodyManifest, preflight: async ({ accountId, handle }) => { - const cache = custodyRuntime.getCache() - if (!cache || cache.isBlocked(handle)) return 'vault-cold' + custodyLogger.info('preflight probing participant', { + accountId, + hasHandle: handle.length > 0, + }) + const cache = await custodyRuntime.ensureCache() + const blocked = cache?.isBlocked(handle) + if (!cache || blocked) { + custodyLogger.warn('preflight vault-cold', { + accountId, + hasCache: cache !== undefined, + blocked, + }) + return 'vault-cold' + } if ( cache.isReauth(handle, custodyOptions?.now?.() ?? Date.now()) ) { @@ -2621,8 +2634,34 @@ export async function CodexAuthPlugin( } }, auth: { - all: () => hostAuth.all(), - get: (value) => hostAuth.get(value), + all: async () => { + if (typeof hostAuth.all === 'function') return hostAuth.all() + const dataHome = + process.env.XDG_DATA_HOME ?? + join(os.homedir(), '.local', 'share') + const authPath = join(dataHome, 'opencode', 'auth.json') + try { + const parsed: unknown = JSON.parse( + readFileSync(authPath, 'utf8'), + ) + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } + }, + get: async (value) => { + if (typeof hostAuth.get === 'function') { + return hostAuth.get(value) + } + if (value.path.id !== 'openai' || !loaderGetAuth) { + custodyLogger.warn('auth.get unavailable for slot', { + id: value.path.id, + hasLoaderGetAuth: loaderGetAuth !== undefined, + }) + return undefined + } + return loaderGetAuth() + }, set: async (value) => { await hostAuth.set(value) }, @@ -4304,6 +4343,10 @@ export async function CodexAuthPlugin( output.maxOutputTokens = undefined }, config: async (config: { command?: Record }) => { + createLogger('commands').info('registering commands', { + existing: Object.keys(config.command ?? {}).length, + pid: process.pid, + }) config.command = { ...(config.command ?? {}), [OPENAI_QUOTA_COMMAND_NAME]: { @@ -4352,8 +4395,19 @@ export async function CodexAuthPlugin( arguments: string sessionID: string }) => { + createLogger('commands').info('command hook entered', { + command: input.command, + arguments: input.arguments, + modal: MODAL_COMMANDS.includes(input.command as CommandModalName), + hasCmdCtx: cmdCtx !== null, + pid: process.pid, + }) if (!MODAL_COMMANDS.includes(input.command as CommandModalName)) return if (!cmdCtx) { + createLogger('commands').warn('command rejected: context not loaded', { + command: input.command, + pid: process.pid, + }) await sendIgnoredMessage( input.sessionID, 'OpenAI auth plugin is still initializing. Send a request first, then try again.', diff --git a/packages/opencode/src/tests/custody-main.test.ts b/packages/opencode/src/tests/custody-main.test.ts index 2028117..7e8b885 100644 --- a/packages/opencode/src/tests/custody-main.test.ts +++ b/packages/opencode/src/tests/custody-main.test.ts @@ -7,7 +7,7 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { canonicalCustodyTombstone, custodyTombstoneKey, @@ -58,6 +58,8 @@ async function withMainLoader( storage: ReturnType transport: ClaustrumCacheTransportLike slotAbsent?: boolean + hostAuthOnlySet?: boolean + hostAuthWithoutGet?: boolean }, run: (input: { loader: ( @@ -66,6 +68,11 @@ async function withMainLoader( ) => Promise configPath: string authSetCalls: () => number + executeCommand: (input: { + command: string + arguments: string + sessionID: string + }) => Promise }) => Promise, ): Promise { const directory = mkdtempSync(join(tmpdir(), 'custody-main-')) @@ -95,10 +102,22 @@ async function withMainLoader( mkdirSync(directory, { recursive: true, mode: 0o700 }) writeFileSync(manifestPath, JSON.stringify(manifest.value), { mode: 0o600 }) chmodSync(manifestPath, 0o600) - hooks = await CodexAuthPlugin( - { - client: { - auth: { + const hostAuth = options.hostAuthOnlySet + ? { + _client: {}, + set: async () => { + authSetCalls += 1 + }, + } + : options.hostAuthWithoutGet + ? { + _client: {}, + all: async () => ({ openai: options.auth }), + set: async () => { + authSetCalls += 1 + }, + } + : { get: async () => (options.slotAbsent ? undefined : options.auth), all: async () => options.slotAbsent @@ -107,7 +126,12 @@ async function withMainLoader( set: async () => { authSetCalls += 1 }, - }, + } + hooks = await CodexAuthPlugin( + { + client: { + auth: hostAuth, + session: { promptAsync: async () => {} }, }, project: { id: 'test', name: 'test' }, directory: '', @@ -127,6 +151,11 @@ async function withMainLoader( ) => Promise, configPath, authSetCalls: () => authSetCalls, + executeCommand: hooks['command.execute.before'] as (input: { + command: string + arguments: string + sessionID: string + }) => Promise, }) } finally { await hooks?.dispose?.() @@ -357,6 +386,166 @@ describe('main host slot', () => { ) }) + test('enters claustrum when the host auth client lacks get', async () => { + const auth = { + type: 'oauth' as const, + access: mainJwt('stored-main'), + refresh: 'refresh-main', + expires: CUSTODY_FIXTURE_NOW + 60_000, + } + await withMainLoader( + { + auth, + hostAuthWithoutGet: true, + storage: liveStorage([], { + mainAccountId: 'stored-main', + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + transport: { + getCredential: async () => ({ + material: mainJwt('stored-main'), + recordVersion: 1, + expiresAtMs: CUSTODY_FIXTURE_NOW + 60_000, + }), + statusCredential: async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + }, + async ({ loader, executeCommand, configPath, authSetCalls }) => { + await loader(async () => auth, {}) + await expect( + executeCommand({ + command: 'openai-account', + arguments: 'claustrum', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_OPENAI_AUTH_COMMAND_HANDLED__') + expect( + (await loadAccounts(getAccountPaths(configPath)))?.claustrum?.mode, + ).toBe('claustrum') + expect(authSetCalls()).toBe(1) + }, + ) + }) + + test('enters claustrum when the host auth client exposes only set', async () => { + const auth = { + type: 'oauth' as const, + access: mainJwt('stored-main'), + refresh: 'refresh-main', + expires: CUSTODY_FIXTURE_NOW + 60_000, + } + const previousDataHome = process.env.XDG_DATA_HOME + try { + await withMainLoader( + { + auth, + hostAuthOnlySet: true, + storage: liveStorage([], { + mainAccountId: 'stored-main', + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + transport: { + getCredential: async () => ({ + material: mainJwt('stored-main'), + recordVersion: 1, + expiresAtMs: CUSTODY_FIXTURE_NOW + 60_000, + }), + statusCredential: async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + }, + async ({ loader, executeCommand, configPath, authSetCalls }) => { + const dataHome = join(dirname(configPath), 'data') + const authPath = join(dataHome, 'opencode', 'auth.json') + mkdirSync(dirname(authPath), { recursive: true }) + writeFileSync(authPath, JSON.stringify({ openai: auth })) + process.env.XDG_DATA_HOME = dataHome + await loader(async () => auth, {}) + await expect( + executeCommand({ + command: 'openai-account', + arguments: 'claustrum', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_OPENAI_AUTH_COMMAND_HANDLED__') + expect(authSetCalls()).toBe(1) + }, + ) + } finally { + if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME + else process.env.XDG_DATA_HOME = previousDataHome + } + }) + + test('defers the host tombstone when its auth file is malformed', async () => { + const auth = { + type: 'oauth' as const, + access: mainJwt('stored-main'), + refresh: 'refresh-main', + expires: CUSTODY_FIXTURE_NOW + 60_000, + } + const previousDataHome = process.env.XDG_DATA_HOME + try { + await withMainLoader( + { + auth, + hostAuthOnlySet: true, + storage: liveStorage([], { + mainAccountId: 'stored-main', + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + transport: { + getCredential: async () => ({ + material: mainJwt('stored-main'), + recordVersion: 1, + expiresAtMs: CUSTODY_FIXTURE_NOW + 60_000, + }), + statusCredential: async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + }, + async ({ loader, executeCommand, configPath, authSetCalls }) => { + const dataHome = join(dirname(configPath), 'data') + const authPath = join(dataHome, 'opencode', 'auth.json') + mkdirSync(dirname(authPath), { recursive: true }) + writeFileSync(authPath, '{not json') + process.env.XDG_DATA_HOME = dataHome + await loader(async () => auth, {}) + await expect( + executeCommand({ + command: 'openai-account', + arguments: 'claustrum', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_OPENAI_AUTH_COMMAND_HANDLED__') + expect(authSetCalls()).toBe(0) + }, + ) + } finally { + if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME + else process.env.XDG_DATA_HOME = previousDataHome + } + }) + test('writes the factory slot-absent verdict into the main sidebar row', async () => { await withMainLoader( { diff --git a/packages/opencode/src/tests/custody-runtime.test.ts b/packages/opencode/src/tests/custody-runtime.test.ts index e2b6b2b..a987531 100644 --- a/packages/opencode/src/tests/custody-runtime.test.ts +++ b/packages/opencode/src/tests/custody-runtime.test.ts @@ -268,6 +268,26 @@ function corruptAccount( // --------------------------------------------------------------------------- describe('custody detection', () => { + it('connects the cache on demand while the store is local', async () => { + const storage = liveStorage([], { + claustrum: claustrumConfig({ mode: 'local' }), + }) + const { transport } = makeTransport(() => { + throw new Error('credential reads are not part of cache connection') + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ storage, transport, detection: 'available' }), + ) + + await runtime.boot() + + expect(runtime.getCache()).toBeUndefined() + const cache = await runtime.ensureCache() + expect(cache).toBe(runtime.getCache()) + expect(runtime.getCache()).toBeDefined() + runtime.dispose() + }) + it('logs once at info and creates no client or timer when the connection file is absent', async () => { const transport: ClaustrumCacheTransportLike = { getCredential: mock(async () => { diff --git a/packages/opencode/src/tests/custody-transition.test.ts b/packages/opencode/src/tests/custody-transition.test.ts index 9d3bb2c..35efdc6 100644 --- a/packages/opencode/src/tests/custody-transition.test.ts +++ b/packages/opencode/src/tests/custody-transition.test.ts @@ -244,7 +244,7 @@ describe('enterClaustrumMode coordinator', () => { expect(index.getMainRefreshLockName()).toBe( transition.MAIN_REFRESH_LOCK_NAME, ) - expect(fixture.traces).toEqual([ + expect(fixture.traces.filter((step) => !step.startsWith('warn:'))).toEqual([ 'mutex-acquired', 'acquire:claustrum-mode:true', 'acquire:fallback-oauth-refresh-ztmmMIFaJkALBOTT:true', @@ -455,7 +455,11 @@ describe('enterClaustrumMode coordinator', () => { expect(result.status).toBe('incomplete') expect(result.outcomes.main).toBe('torn-read-deferred') expect(fixture.writes).toEqual(['mode', 'fallback', 'fallback']) - expect(fixture.traces.filter((step) => step.startsWith('warn:'))).toEqual([ + expect( + fixture.traces.filter((step) => + step.startsWith('warn:host auth store read empty'), + ), + ).toEqual([ 'warn:host auth store read empty; refusing to write — possible torn read', ]) }) diff --git a/packages/opencode/src/tests/refresh-all-quota.test.ts b/packages/opencode/src/tests/refresh-all-quota.test.ts index c959e4c..318a8d4 100644 --- a/packages/opencode/src/tests/refresh-all-quota.test.ts +++ b/packages/opencode/src/tests/refresh-all-quota.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { type AccountQuotaWindow, + CUSTODY_REFUSE, type FallbackAccount, hashRefreshToken, type OAuthQuotaSnapshot, @@ -11,6 +12,7 @@ import { type RefreshAllQuotaDeps, refreshAllQuota, } from '@cortexkit/openai-auth-core/internal' + import { getAccountStoragePath } from '../core/account-paths' import { DEFAULT_SIDEBAR_STATE, @@ -551,6 +553,32 @@ describe('refreshAllQuota', () => { expect(deps.quotaManager.getMain()?.quota?.primary?.usedPercent).toBe(30) }) + test('a tombstoned main uses the custody resolver without refreshing local auth', async () => { + const resolveMainAccess = mock( + async (): Promise => CUSTODY_REFUSE, + ) + const deps = makeDeps({ + getAuth: mock(async () => ({ + type: 'oauth' as const, + access: '', + refresh: 'claustrum-tombstone:v1:openai', + expires: 0, + })), + resolveMainAccess, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'main' }) + + expect(resolveMainAccess).toHaveBeenCalledWith( + expect.objectContaining({ mainAccountId: 'chatgpt-main' }), + ) + expect(deps.refreshMainWithLease).not.toHaveBeenCalled() + expect(deps.whamFn).not.toHaveBeenCalled() + expect(results).toEqual([ + { account: 'main', ok: false, error: 'custody refused' }, + ]) + }) + test('expired fallback token → refreshAccount invoked before wham', async () => { let refreshCalled = false const deps = makeDeps({