From 6d8d69f4c97d6263a6247a39a61ea629638fa689 Mon Sep 17 00:00:00 2001 From: Jonathan Mast Date: Wed, 24 Jun 2026 13:21:52 -0400 Subject: [PATCH] fix: read runtime state when config file is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadAccounts treated the config file (anthropic-auth.json) as a gatekeeper: if it did not exist, it returned null without ever reading the runtime state file (anthropic-auth-state.json). But the two files are written independently, and runtime-only flows — notably main-OAuth refresh with no fallback accounts — write the state file while never creating the config file. As a result, for any user without fallback accounts the config file never exists, so every reader got null and the state file's refresh-lease, quota, and error data was invisible. This broke the post-write lease verification as well as the backoff check and lease-skip check in the same refresh path, all of which silently received null storage. Fix loadAccounts to read both files and return null only when neither exists; when config is absent but state is present, synthesize an empty config shell and merge state in as usual. This matches the documented two-file design where the state file holds independent runtime data. Also extract the repeated empty-storage literal into a shared createEmptyStorage() factory in core and replace the 18 inline duplicates across accounts.ts, routing.ts, index.ts, and pi/commands.ts. The CLI's richer defaultStorage() (config bootstrap with seeded defaults) is intentionally left separate. Adds regression tests covering: null when neither file exists, runtime state visible when config is absent, and a lease written via saveAccountState being readable by loadAccounts without a config file. --- packages/core/src/accounts.ts | 78 +++++++------------- packages/core/src/routing.ts | 7 +- packages/opencode/src/index.ts | 55 ++++---------- packages/opencode/src/tests/accounts.test.ts | 64 ++++++++++++++++ packages/pi/src/commands.ts | 3 +- 5 files changed, 109 insertions(+), 98 deletions(-) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 4ab389e7..28a38572 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -402,6 +402,16 @@ function normalizeQuota(value: unknown): OAuthAccount['quota'] { return Object.keys(quota).length ? quota : undefined } +// Fresh empty storage shell — main OpenCode OAuth account, no fallback +// accounts. Returns a new object each call so mutating callers don't alias. +export function createEmptyStorage(): AccountStorage { + return { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [], + } +} + function normalizeStorage(value: unknown): AccountStorage | null { if (!isRecord(value) || !Array.isArray(value.accounts)) return null return { @@ -497,9 +507,13 @@ function mergeConfigAndState( export async function loadAccounts(path = getAccountStoragePath()) { const config = await readJsonIfPresent(path) - if (!config.exists) return null const state = await readJsonIfPresent(getAccountStatePath(path)) - return normalizeStorage(mergeConfigAndState(config.value, state.value)) + // Runtime-only flows (main-OAuth refresh with no fallback accounts) write the + // state file but never the config file, so the store is absent only when + // neither exists. Synthesize an empty config to merge state into otherwise. + if (!config.exists && !state.exists) return null + const configValue = config.exists ? config.value : createEmptyStorage() + return normalizeStorage(mergeConfigAndState(configValue, state.value)) } async function loadExistingTopLevelFields(path: string) { @@ -1027,11 +1041,7 @@ export async function setCache1hPersistentEnabled( mode?: Cache1hMode, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.claudeCache = { ...(storage.claudeCache ?? {}), enabled, @@ -1045,11 +1055,7 @@ export async function setCache1hPersistentMode( mode: Cache1hMode, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.claudeCache = { ...(storage.claudeCache ?? {}), enabled: storage.claudeCache?.enabled === true, @@ -1067,11 +1073,7 @@ export async function setDumpPersistentEnabled( enabled: boolean, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.dump = { ...(storage.dump ?? {}), enabled, @@ -1088,11 +1090,7 @@ export async function setFastModePersistentEnabled( enabled: boolean, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.claudeFast = { ...(storage.claudeFast ?? {}), enabled, @@ -1106,11 +1104,7 @@ export async function setCacheKeepPersistentWindow( endHour: number, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.cacheKeep = { enabled: true, startHour, @@ -1124,11 +1118,7 @@ export async function setCacheKeepPersistentEnabled( enabled: boolean, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.cacheKeep = { ...(storage.cacheKeep ?? {}), enabled, @@ -1145,11 +1135,7 @@ export async function setCacheKeepSubagentsEnabled( enabled: boolean, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.cacheKeep = { ...(storage.cacheKeep ?? {}), subagents: enabled, @@ -1340,11 +1326,7 @@ export async function setLogLevelPersistent( path = getAccountStoragePath(), ) { const { setLogLevel } = await import('./logger.ts') - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.logging = { ...(storage.logging ?? {}), level, @@ -1512,11 +1494,7 @@ export async function setKillswitchPersistent( config: KillswitchConfig, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.killswitch = config await saveAccounts(storage, path) return storage @@ -1559,11 +1537,7 @@ export async function addAccountPersistent( account: FallbackAccount, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() upsertAccount(storage, account) await saveAccounts(storage, path) } diff --git a/packages/core/src/routing.ts b/packages/core/src/routing.ts index b72d561b..f85b4e06 100644 --- a/packages/core/src/routing.ts +++ b/packages/core/src/routing.ts @@ -1,5 +1,6 @@ import { type AccountStorage, + createEmptyStorage, getAccountStoragePath, loadAccounts, type RoutingMode, @@ -33,11 +34,7 @@ export async function setRoutingMode( mode: RoutingMode, path = getAccountStoragePath(), ) { - const storage = (await loadAccounts(path)) ?? { - version: 1, - main: { type: 'opencode' as const, provider: 'anthropic' as const }, - accounts: [], - } + const storage = (await loadAccounts(path)) ?? createEmptyStorage() storage.routing = { ...(storage.routing ?? {}), mode, diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ab9b6416..78896de3 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -24,6 +24,7 @@ import { CLAUDE_LOGGING_COMMAND_NAME, CLAUDE_QUOTAS_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, + createEmptyStorage, dumpDirectRequest, exchange, executeAccountCommand, @@ -485,10 +486,8 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { fetchStartedAt, ) => { try { - const storage = (await loadAccounts(accountStoragePath)) ?? { - version: 1 as const, - accounts: [], - } + const storage = + (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() const persisted = getPersistedMainQuota(storage) if ( persisted && @@ -513,10 +512,8 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }, onApiError: async (error) => { try { - const storage = (await loadAccounts(accountStoragePath)) ?? { - version: 1 as const, - accounts: [], - } + const storage = + (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() storage.quota = storage.quota ?? {} storage.quota.mainLastQuotaApiError = error await saveAccountState(storage, accountStoragePath, { mainQuota: true }) @@ -1001,10 +998,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (action.type === 'add-apikey') { if (!action.apiKey) { const accounts = buildAccountList( - (await loadAccounts(accountStoragePath)) ?? { - version: 1, - accounts: [], - }, + (await loadAccounts(accountStoragePath)) ?? createEmptyStorage(), ) return { text: 'API key is required', accounts } } @@ -1014,10 +1008,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { action.baseURL?.trim() || 'https://api.kie.ai/claude' if (!isValidApiBaseURL(resolvedBaseURL)) { const accounts = buildAccountList( - (await loadAccounts(accountStoragePath)) ?? { - version: 1, - accounts: [], - }, + (await loadAccounts(accountStoragePath)) ?? createEmptyStorage(), ) return { text: 'Invalid base URL. Must be an http(s) URL without embedded credentials.', @@ -1070,10 +1061,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { text: `Open this URL in your browser:\n${authResult.url}`, knobs: { oauthUrl: authResult.url }, accounts: buildAccountList( - (await loadAccounts(accountStoragePath)) ?? { - version: 1, - accounts: [], - }, + (await loadAccounts(accountStoragePath)) ?? createEmptyStorage(), ), } } @@ -1084,10 +1072,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const pending = takeOAuthPending(key) if (!pending) { const accounts = buildAccountList( - (await loadAccounts(accountStoragePath)) ?? { - version: 1, - accounts: [], - }, + (await loadAccounts(accountStoragePath)) ?? createEmptyStorage(), ) return { text: 'OAuth session expired. Please start again.', @@ -1105,10 +1090,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (result.type === 'failed') { const accounts = buildAccountList( - (await loadAccounts(accountStoragePath)) ?? { - version: 1, - accounts: [], - }, + (await loadAccounts(accountStoragePath)) ?? createEmptyStorage(), ) return { text: 'OAuth authentication failed. Please check the code and try again.', @@ -1137,15 +1119,12 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const updatedStorage = await loadAccounts(accountStoragePath) await refreshSidebarAfterMutation(updatedStorage) const accounts = buildAccountList( - updatedStorage ?? { version: 1, accounts: [] }, + updatedStorage ?? createEmptyStorage(), ) return { text: `OAuth account added.`, accounts } } catch { const accounts = buildAccountList( - (await loadAccounts(accountStoragePath)) ?? { - version: 1, - accounts: [], - }, + (await loadAccounts(accountStoragePath)) ?? createEmptyStorage(), ) return { text: 'OAuth exchange failed due to a network error. Please try again.', @@ -1595,13 +1574,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function updateMainRefreshState( update: (storage: AccountStorage) => void, ) { - const storage: AccountStorage = (await loadAccounts( - accountStoragePath, - )) ?? { - version: 1, - main: { type: 'opencode', provider: 'anthropic' }, - accounts: [], - } + const storage: AccountStorage = + (await loadAccounts(accountStoragePath)) ?? + createEmptyStorage() storage.refresh = storage.refresh ?? {} update(storage) await saveAccountState(storage, accountStoragePath, { diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 2389a0fb..8ff2f897 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -800,6 +800,70 @@ describe('account storage', () => { expect(saved?.claudeFast).toEqual({ enabled: false }) expect(isFastModePersistentlyEnabled(saved)).toBe(false) }) + + test('returns null when neither config nor state file exists', async () => { + await expect(loadAccounts()).resolves.toBeNull() + }) + + test('reads runtime state when config file is absent (no fallback accounts)', async () => { + const statePath = getAccountStatePath(accountPath) + await writeFile( + statePath, + JSON.stringify({ + version: 1, + main: { + refreshLeaseId: 'lease-abc', + refreshLeaseUntil: 9_999_999_999_999, + refreshLeaseTokenHash: 'hash-xyz', + quota: { + five_hour: { + usedPercent: 33, + remainingPercent: 67, + checkedAt: 777, + }, + }, + quotaCheckedAt: 777, + quotaToken: 'token-state-only', + }, + }), + 'utf8', + ) + + // Config file must NOT exist for this scenario. + await expect(stat(accountPath)).rejects.toThrow() + + const loaded = await loadAccounts() + expect(loaded).not.toBeNull() + expect(loaded?.accounts).toEqual([]) + expect(loaded?.refresh?.mainRefreshLeaseId).toBe('lease-abc') + expect(loaded?.refresh?.mainRefreshLeaseUntil).toBe(9_999_999_999_999) + expect(loaded?.refresh?.mainRefreshLeaseTokenHash).toBe('hash-xyz') + expect(loaded?.quota?.mainQuotaToken).toBe('token-state-only') + expect(loaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(33) + }) + + test('lease written via saveAccountState is visible to loadAccounts without a config file', async () => { + const storage: AccountStorage = { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [], + refresh: { + mainRefreshLeaseId: 'lease-from-save', + mainRefreshLeaseUntil: 9_999_999_999_999, + mainRefreshLeaseTokenHash: 'token-hash-from-save', + }, + } + await saveAccountState(storage, accountPath, { mainRefresh: true }) + + // saveAccountState must not have created the config file. + await expect(stat(accountPath)).rejects.toThrow() + + const loaded = await loadAccounts() + expect(loaded?.refresh?.mainRefreshLeaseId).toBe('lease-from-save') + expect(loaded?.refresh?.mainRefreshLeaseTokenHash).toBe( + 'token-hash-from-save', + ) + }) }) describe('FallbackAccountManager', () => { diff --git a/packages/pi/src/commands.ts b/packages/pi/src/commands.ts index 5af9b1ce..965e7384 100644 --- a/packages/pi/src/commands.ts +++ b/packages/pi/src/commands.ts @@ -4,6 +4,7 @@ import { CLAUDE_ACCOUNT_COMMAND_NAME, CLAUDE_CACHE_KEEP_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, + createEmptyStorage, executeAccountCommand, executeCache1hCommand, executeCacheKeepCommand, @@ -237,7 +238,7 @@ export function registerCommands(pi: ExtensionAPI) { const storage = await loadAccounts(path) const result = executeAccountCommand({ argumentsText: args ?? '', - storage: storage ?? { version: 1, accounts: [] }, + storage: storage ?? createEmptyStorage(), }) if (!result.updated) {