Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 26 additions & 52 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
7 changes: 2 additions & 5 deletions packages/core/src/routing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type AccountStorage,
createEmptyStorage,
getAccountStoragePath,
loadAccounts,
type RoutingMode,
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 15 additions & 40 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
CLAUDE_LOGGING_COMMAND_NAME,
CLAUDE_QUOTAS_COMMAND_NAME,
CLAUDE_ROUTING_COMMAND_NAME,
createEmptyStorage,
dumpDirectRequest,
exchange,
executeAccountCommand,
Expand Down Expand Up @@ -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 &&
Expand All @@ -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 })
Expand Down Expand Up @@ -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 }
}
Expand All @@ -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.',
Expand Down Expand Up @@ -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(),
),
}
}
Expand All @@ -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.',
Expand All @@ -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.',
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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, {
Expand Down
64 changes: 64 additions & 0 deletions packages/opencode/src/tests/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/pi/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
CLAUDE_ACCOUNT_COMMAND_NAME,
CLAUDE_CACHE_KEEP_COMMAND_NAME,
CLAUDE_ROUTING_COMMAND_NAME,
createEmptyStorage,
executeAccountCommand,
executeCache1hCommand,
executeCacheKeepCommand,
Expand Down Expand Up @@ -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) {
Expand Down