diff --git a/packages/opencode/src/cli.ts b/packages/opencode/src/cli.ts index 7f92436..7b9103b 100644 --- a/packages/opencode/src/cli.ts +++ b/packages/opencode/src/cli.ts @@ -3,8 +3,8 @@ import { getAccountStoragePath, loadAccounts, + mutateAccounts, type OAuthAccount, - saveAccounts, } from './core/accounts' import { beginAccountLogin, upsertAccount } from './core/oauth' import { openUrl } from './util/open-url' @@ -70,30 +70,31 @@ async function main() { const account = await completion - const storage = (await loadAccounts()) ?? { - version: 1 as const, - accounts: [], - } + let rejected = false + let rejectedMsg = '' + + await mutateAccounts((fresh) => { + if ( + account.accountId && + fresh.mainAccountId && + account.accountId === fresh.mainAccountId + ) { + rejected = true + rejectedMsg = + '\nError: that account is already your main (same ChatGPT account).' + return + } + upsertAccount(fresh.accounts, account as unknown as OAuthAccount) + }) - // Reject self-fallback: adding main's ChatGPT account as a fallback - // would let routing retry on the account that just returned 429. - if ( - account.accountId && - storage.mainAccountId && - account.accountId === storage.mainAccountId - ) { - console.error( - '\nError: that account is already your main (same ChatGPT account).', - ) + if (rejected) { + console.error(rejectedMsg) console.error( 'A self-fallback would retry on the account that just returned 429.', ) process.exit(1) } - upsertAccount(storage.accounts, account as unknown as OAuthAccount) - await saveAccounts(storage) - console.log(`\n✓ Added account ${account.id}`) if (account.label) console.log(` Label: ${account.label}`) break @@ -123,20 +124,21 @@ async function main() { process.exit(1) } - const storage = await loadAccounts() - if (!storage) { - console.error('No account store found.') - process.exit(1) - } + let notFoundRemove = false + await mutateAccounts((fresh) => { + const idx = fresh.accounts.findIndex((a) => a.id === targetId) + if (idx === -1) { + notFoundRemove = true + return + } + fresh.accounts.splice(idx, 1) + }) - const idx = storage.accounts.findIndex((a) => a.id === targetId) - if (idx === -1) { + if (notFoundRemove) { console.error(`No account with id "${targetId}".`) process.exit(1) } - storage.accounts.splice(idx, 1) - await saveAccounts(storage) console.log(`Removed account ${targetId}.`) break } diff --git a/packages/opencode/src/commands.ts b/packages/opencode/src/commands.ts index f10a6da..688e4ef 100644 --- a/packages/opencode/src/commands.ts +++ b/packages/opencode/src/commands.ts @@ -77,6 +77,13 @@ export interface CommandContext { setCacheKeepEnabled?: (enabled: boolean) => void /** Updates the live loader's persisted-subagent cachekeep gate. */ setCacheKeepSubagents?: (enabled: boolean) => void + /** Lock-held read-modify-write; preserves concurrent additions. */ + mutateAccounts: ( + mutator: ( + storage: import('./core/accounts').AccountStorage, + ) => void | Promise, + path: string, + ) => Promise } const log = createLogger('commands') @@ -196,20 +203,21 @@ async function executeAccountCommand( if (tokens[0] === 'switch' && tokens[1]) { const targetId = tokens[1] - if (targetId === 'main') { - storage.routing = { ...(storage.routing ?? {}), activeId: 'main' } - await defaultSaveAccounts(storage, ctx.accountStoragePath) - log.info('account switched', { activeId: 'main' }) - void ctx.refreshSidebar?.().catch(() => {}) - return { - command: 'openai-account', - text: '## Account Switched\n\nActive account is now main.', - knobs: { accounts, activeId: 'main' }, + let notFoundSwitch = false + const fresh = await ctx.mutateAccounts((fresh) => { + if (targetId === 'main') { + fresh.routing = { ...(fresh.routing ?? {}), activeId: 'main' } + return } - } + const account = fresh.accounts.find((a) => a.id === targetId) + if (!account) { + notFoundSwitch = true + return + } + fresh.routing = { ...(fresh.routing ?? {}), activeId: targetId } + }, ctx.accountStoragePath) - const account = accounts.find((a) => a.id === targetId) - if (!account) { + if (notFoundSwitch) { return { command: 'openai-account', text: `## Account Not Found\n\nNo account with id \`${targetId}\` exists.`, @@ -217,23 +225,43 @@ async function executeAccountCommand( } } - // Persist the active account id - storage.routing = { ...(storage.routing ?? {}), activeId: targetId } - await defaultSaveAccounts(storage, ctx.accountStoragePath) log.info('account switched', { activeId: targetId }) void ctx.refreshSidebar?.().catch(() => {}) return { command: 'openai-account', - text: `## Account Switched\n\nActive account is now \`${targetId}\`.`, - knobs: { accounts, activeId: targetId }, + text: + targetId === 'main' + ? '## Account Switched\n\nActive account is now main.' + : `## Account Switched\n\nActive account is now \`${targetId}\`.`, + knobs: { accounts: fresh.accounts, activeId: targetId }, } } if (tokens[0] === 'remove' && tokens[1]) { const targetId = tokens[1] - const idx = accounts.findIndex((a) => a.id === targetId) - if (idx === -1) { + let notFoundRemove = false + + const fresh = await ctx.mutateAccounts((fresh) => { + const idx = fresh.accounts.findIndex((a) => a.id === targetId) + if (idx === -1) { + notFoundRemove = true + return + } + + const wasActive = fresh.routing?.activeId === targetId + fresh.accounts.splice(idx, 1) + + if (wasActive) { + const next = fresh.accounts.find(isOAuthAccount) + fresh.routing = { + ...(fresh.routing ?? {}), + activeId: next?.id ?? 'main', + } + } + }, ctx.accountStoragePath) + + if (notFoundRemove) { return { command: 'openai-account', text: `## Account Not Found\n\nNo account with id \`${targetId}\` exists.`, @@ -241,52 +269,48 @@ async function executeAccountCommand( } } - const wasActive = storage.routing?.activeId === targetId - accounts.splice(idx, 1) - - // If removing the active account, repoint to the next OAuth fallback or main. - if (wasActive) { - const next = accounts.find(isOAuthAccount) - storage.routing = { - ...(storage.routing ?? {}), - activeId: next?.id ?? 'main', - } - } - - await defaultSaveAccounts(storage, ctx.accountStoragePath) log.info('account removed', { id: targetId }) void ctx.refreshSidebar?.().catch(() => {}) return { command: 'openai-account', text: `## Account Removed\n\nRemoved account \`${targetId}\`.`, - knobs: { accounts }, + knobs: { accounts: fresh.accounts }, } } if (tokens[0] === 'order' && tokens.length >= 3) { - // Reorder: swap positions of two accounts - const a = accounts.findIndex((ac) => ac.id === tokens[1]) - const b = accounts.findIndex((ac) => ac.id === tokens[2]) - if (a === -1 || b === -1) { + let notFoundOrder = false + + const fresh = await ctx.mutateAccounts((fresh) => { + const a = fresh.accounts.findIndex((ac) => ac.id === tokens[1]) + const b = fresh.accounts.findIndex((ac) => ac.id === tokens[2]) + if (a === -1 || b === -1) { + notFoundOrder = true + return + } + // biome-ignore lint/style/noNonNullAssertion: a,b validated in-bounds by findIndex above + const tmp = fresh.accounts[a]! + // biome-ignore lint/style/noNonNullAssertion: a,b validated in-bounds by findIndex above + fresh.accounts[a] = fresh.accounts[b]! + fresh.accounts[b] = tmp + }, ctx.accountStoragePath) + + if (notFoundOrder) { return { command: 'openai-account', text: '## Invalid Order\n\nBoth account IDs must exist.', knobs: { accounts }, } } - // biome-ignore lint/style/noNonNullAssertion: a,b validated in-bounds by findIndex above - const tmp = accounts[a]! - // biome-ignore lint/style/noNonNullAssertion: a,b validated in-bounds by findIndex above - accounts[a] = accounts[b]! - accounts[b] = tmp - await defaultSaveAccounts(storage, ctx.accountStoragePath) + log.info('accounts reordered', { a: tokens[1], b: tokens[2] }) void ctx.refreshSidebar?.().catch(() => {}) + return { command: 'openai-account', text: `## Accounts Reordered\n\nSwapped positions of \`${tokens[1]}\` and \`${tokens[2]}\`.`, - knobs: { accounts }, + knobs: { accounts: fresh.accounts }, } } @@ -307,32 +331,36 @@ async function executeAccountCommand( // never reach the user. completion .then(async (account) => { - const store = (await ctx.loadAccounts(ctx.accountStoragePath)) ?? { - version: 1 as const, - accounts: [], - } - - if ( - account.accountId && - store.mainAccountId && - account.accountId === store.mainAccountId - ) { - const msg = - 'That account is already your main account — not added as a fallback.' + let rejected = false + let rejectedMsg = '' + + await ctx.mutateAccounts((fresh) => { + if ( + account.accountId && + fresh.mainAccountId && + account.accountId === fresh.mainAccountId + ) { + rejected = true + rejectedMsg = + 'That account is already your main account — not added as a fallback.' + return + } + upsertAccount(fresh.accounts, account as OAuthAccount) + }, ctx.accountStoragePath) + + if (rejected) { log.warn('account add rejected (main identity)', { accountId: account.accountId, sessionId, }) notify?.({ command: 'openai-account', - text: `## Add Failed\n\n${msg}`, + text: `## Add Failed\n\n${rejectedMsg}`, knobs: {}, }) return } - upsertAccount(store.accounts, account as OAuthAccount) - await defaultSaveAccounts(store, ctx.accountStoragePath) log.info('account added', { id: account.id, label: account.label, diff --git a/packages/opencode/src/core/accounts.ts b/packages/opencode/src/core/accounts.ts index 8cb81f0..91f92f5 100644 --- a/packages/opencode/src/core/accounts.ts +++ b/packages/opencode/src/core/accounts.ts @@ -642,14 +642,21 @@ function mergeStorageForSave( ): AccountStorage { if (!latest) return incoming - const accounts = new Map() - for (const account of latest.accounts) accounts.set(account.id, account) - for (const account of incoming.accounts) accounts.set(account.id, account) - + // Incoming storage is authoritative for the account set and for every + // top-level field it carries. Fields absent from incoming fall back to + // the latest on-disk snapshot. Because the spread is { ...latest, + // ...incoming }, incoming can overwrite any field that was concurrently + // written to disk — callers that need to preserve concurrent writes must + // use mutateAccounts (which re-reads under the lock) instead of a + // load→mutate→saveAccounts sequence. + // + // Per-account runtime state (quota snapshots, refresh errors) is written + // by background timers through the scoped saveAccountState path and is + // unaffected by this merge. return { ...latest, ...incoming, - accounts: [...accounts.values()], + accounts: incoming.accounts, } } @@ -745,6 +752,45 @@ export async function saveAccounts( } } +export async function mutateAccounts( + mutator: (storage: AccountStorage) => void | Promise, + path = getAccountStoragePath(), +): Promise { + const statePath = getAccountStatePath(path) + const lock = await acquireSaveAccountsLock(path) + try { + const stateLock = await acquireSaveAccountsLock(statePath) + try { + const configJson = await readJsonIfPresent(path) + const stateJson = await readJsonIfPresent(statePath) + const fresh: AccountStorage = configJson.exists + ? (normalizeStorage( + mergeConfigAndState(configJson.value, stateJson.value), + ) ?? + ({ + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [], + } as AccountStorage)) + : { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [], + } + await mutator(fresh) + const existing = isRecord(configJson.value) ? configJson.value : {} + const nextConfig = { ...existing, ...configFromStorage(fresh) } + await writeJsonAtomic(path, nextConfig) + await writeJsonAtomic(statePath, stateFromStorage(fresh)) + return fresh + } finally { + await stateLock.release() + } + } finally { + await lock.release() + } +} + function applyMainQuotaStatePatch( state: AccountRuntimeState, storage: AccountStorage, @@ -820,6 +866,10 @@ export async function saveAccountState( ) } if (ids) { + // Scoped save: only prune ids that are in the requested scope and + // absent from storage.accounts. Do NOT touch ids outside the scope + // so that a partial save never prunes state for accounts it was not + // asked to manage. for (const id of ids) { if (!storage.accounts.some((account) => account.id === id)) { delete next.accounts[id] diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ca0fbc4..27832bf 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -29,8 +29,8 @@ import { isOAuthAccount, loadAccounts, migrateIfNeeded, + mutateAccounts, type OAuthAccount, - saveAccounts, shouldFallbackStatus, } from './core/accounts' import { @@ -601,8 +601,9 @@ export async function CodexAuthPlugin( refresh_token: auth.refresh ?? '', }) if (liveAccountId && liveAccountId !== storage.mainAccountId) { - storage.mainAccountId = liveAccountId - await saveAccounts(storage, getConfigPath()) + await mutateAccounts((fresh) => { + fresh.mainAccountId = liveAccountId + }, getConfigPath()) invalidateRequestStorageCache() } } @@ -664,14 +665,10 @@ export async function CodexAuthPlugin( async function updateMainRefreshState( update: (storage: AccountStorage) => void, ) { - const nextStorage = (await loadAccounts(getConfigPath())) ?? { - version: 1 as const, - main: { type: 'opencode' as const, provider: 'openai' as const }, - accounts: [], - } - nextStorage.refresh = nextStorage.refresh ?? {} - update(nextStorage) - await saveAccounts(nextStorage, getConfigPath()) + await mutateAccounts((fresh) => { + fresh.refresh = fresh.refresh ?? {} + update(fresh) + }, getConfigPath()) invalidateRequestStorageCache() } @@ -975,6 +972,7 @@ export async function CodexAuthPlugin( accountStoragePath: getConfigPath(), quotaManager, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: input.client as CommandContext['client'], cacheKeepManager, setCacheKeepEnabled: (enabled) => { diff --git a/packages/opencode/src/tests/account-removal.test.ts b/packages/opencode/src/tests/account-removal.test.ts new file mode 100644 index 0000000..d6d8014 --- /dev/null +++ b/packages/opencode/src/tests/account-removal.test.ts @@ -0,0 +1,322 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { OAuthAccount } from '../core/accounts.ts' +import { FLOOR_AUTH_FILE, FLOOR_STATE_FILE } from './setup-env.ts' + +let dir: string +let cfgPath: string +let statePath: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'oai-acct-rm-')) + cfgPath = join(dir, 'openai-auth.json') + statePath = join(dir, 'openai-auth-state.json') + process.env.OPENCODE_OPENAI_AUTH_FILE = cfgPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = statePath +}) + +afterEach(() => { + process.env.OPENCODE_OPENAI_AUTH_FILE = FLOOR_AUTH_FILE + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = FLOOR_STATE_FILE + try { + rmSync(dir, { recursive: true, force: true }) + } catch {} +}) + +function makeAccount(id: string): OAuthAccount { + return { + id, + type: 'oauth', + access: `acc-${id}`, + refresh: `ref-${id}`, + expires: Date.now() + 3600_000, + } +} + +describe('account removal', () => { + it('saveAccounts removal round-trip: splice out an account, save, reload — only the kept account remains', async () => { + const { loadAccounts, saveAccounts } = await import('../core/accounts.ts') + + const acctA = makeAccount('acct-a') + const acctB = makeAccount('acct-b') + + // Seed both accounts + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA, acctB], + }, + cfgPath, + ) + + // Reload to confirm both are present + let loaded = await loadAccounts(cfgPath) + expect(loaded?.accounts.map((a) => a.id).sort()).toEqual([ + 'acct-a', + 'acct-b', + ]) + + // Simulate a removal: splice out acctB + const storage = loaded! + const idx = storage.accounts.findIndex((a) => a.id === 'acct-b') + expect(idx).not.toBe(-1) + storage.accounts.splice(idx, 1) + + await saveAccounts(storage, cfgPath) + + // Reload — acctB must be gone + loaded = await loadAccounts(cfgPath) + expect(loaded?.accounts.map((a) => a.id).sort()).toEqual(['acct-a']) + }) + + it('saveAccounts: incoming account set is authoritative over disk state (removal must stick)', async () => { + const { loadAccounts, saveAccounts } = await import('../core/accounts.ts') + + const acctA = makeAccount('acct-a') + const acctB = makeAccount('acct-b') + + // Write disk state with two accounts directly (simulating a prior save) + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA, acctB], + }, + cfgPath, + ) + + // Now save with only acctA — acctB should be gone afterwards + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA], + }, + cfgPath, + ) + + const loaded = await loadAccounts(cfgPath) + expect(loaded?.accounts.map((a) => a.id).sort()).toEqual(['acct-a']) + }) + + it('saveAccounts: non-account top-level fields from disk still merge with incoming', async () => { + const { loadAccounts, saveAccounts } = await import('../core/accounts.ts') + + // First save establishes routing on disk + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [makeAccount('acct-a')], + routing: { activeId: 'acct-a', mode: 'fallback-first' as const }, + }, + cfgPath, + ) + + // Now save with a different set of top-level fields (e.g., quota set but no routing) + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [makeAccount('acct-a')], + quota: { enabled: false }, + }, + cfgPath, + ) + + const loaded = await loadAccounts(cfgPath) + // The incoming spread ({...latest, ...incoming}) means incoming wins for + // fields it sets, but fields it omits survive from latest. Here incoming + // sets quota.enabled=false but omits routing — so routing should persist. + expect(loaded?.quota?.enabled).toBe(false) + expect(loaded?.routing?.activeId).toBe('acct-a') + expect(loaded?.routing?.mode).toBe('fallback-first') + }) + + it('production removal path cleans state file (stateFromStorage rebuild drops removed account)', async () => { + const { loadAccounts, saveAccounts } = await import('../core/accounts.ts') + + const acctA = makeAccount('acct-a') + const acctB = makeAccount('acct-b') + + // Seed both accounts via saveAccounts (which writes config + state files) + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA, acctB], + }, + cfgPath, + ) + + // Confirm both have state entries + const stateBefore = JSON.parse(readFileSync(statePath, 'utf8')) + expect(stateBefore.accounts).toHaveProperty('acct-a') + expect(stateBefore.accounts).toHaveProperty('acct-b') + + // Reload, splice out acctB, save — simulating the production removal path + const loaded = await loadAccounts(cfgPath) + const idx = loaded!.accounts.findIndex((a) => a.id === 'acct-b') + expect(idx).not.toBe(-1) + loaded!.accounts.splice(idx, 1) + await saveAccounts(loaded!, cfgPath) + + // State file must not contain acctB — stateFromStorage rebuilds + // state.accounts from the merged storage's accounts array, so a removed + // account is dropped without any explicit prune in saveAccountState. + const stateAfter = JSON.parse(readFileSync(statePath, 'utf8')) + expect(stateAfter.accounts).toHaveProperty('acct-a') + expect(stateAfter.accounts).not.toHaveProperty('acct-b') + }) + + it('saveAccountState scoped save does not prune accounts outside the scope', async () => { + const { saveAccounts, saveAccountState } = await import( + '../core/accounts.ts' + ) + + const acctA = makeAccount('acct-a') + const acctB = makeAccount('acct-b') + + // Seed both accounts + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA, acctB], + }, + cfgPath, + ) + + // Save state with storage containing only acctA, but scope limited to acctA + const updatedAcctA: OAuthAccount = { + ...acctA, + access: 'acc-a-updated', + lastUsed: Date.now(), + } + await saveAccountState( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [updatedAcctA], + }, + cfgPath, + { accounts: ['acct-a'] }, + ) + + const state = JSON.parse(readFileSync(statePath, 'utf8')) + // acctA should be updated + expect(state.accounts['acct-a'].access).toBe('acc-a-updated') + // acctB must remain untouched — scoped saves are partial + expect(state.accounts).toHaveProperty('acct-b') + expect(state.accounts['acct-b'].access).toBe('acc-acct-b') + }) +}) + +describe('mutateAccounts', () => { + it('removal: splice out an account, reload — only kept accounts remain', async () => { + const { loadAccounts, mutateAccounts, saveAccounts } = await import( + '../core/accounts.ts' + ) + + const acctA = makeAccount('acct-a') + const acctB = makeAccount('acct-b') + + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA, acctB], + }, + cfgPath, + ) + + await mutateAccounts((fresh) => { + const i = fresh.accounts.findIndex((a) => a.id === 'acct-b') + expect(i).not.toBe(-1) + fresh.accounts.splice(i, 1) + }, cfgPath) + + const loaded = await loadAccounts(cfgPath) + expect(loaded?.accounts.map((a) => a.id).sort()).toEqual(['acct-a']) + }) + + it('concurrency: preserves a concurrent addition that would be wiped by naive load→mutate→saveAccounts', async () => { + const { loadAccounts, mutateAccounts, saveAccounts } = await import( + '../core/accounts.ts' + ) + + const acctA = makeAccount('acct-a') + const acctB = makeAccount('acct-b') + const acctC = makeAccount('acct-c') + + // 1. Write {a} to disk + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA], + }, + cfgPath, + ) + + // 2. Capture a stale in-memory snapshot {a} + const stale = await loadAccounts(cfgPath) + expect(stale!.accounts.map((a) => a.id)).toEqual(['acct-a']) + + // 3. Concurrently write {a,b} to disk (simulating another process adding b) + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA, acctB], + }, + cfgPath, + ) + + // 4. Apply mutation through mutateAccounts: add c + await mutateAccounts((fresh) => { + fresh.accounts.push(acctC) + }, cfgPath) + + // 5. Reload — accounts should be [a, b, c] (b preserved, c added) + const loaded = await loadAccounts(cfgPath) + expect(loaded?.accounts.map((a) => a.id).sort()).toEqual([ + 'acct-a', + 'acct-b', + 'acct-c', + ]) + + // 6. Contrast: a naive saveAccounts with stale snapshot + c would wipe b + // Reset disk to {a} + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA], + }, + cfgPath, + ) + // Concurrently add b again + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [acctA, acctB], + }, + cfgPath, + ) + // Now use the stale snapshot + c via saveAccounts (the naive approach) + const staleWithC = { ...stale!, accounts: [...stale!.accounts, acctC] } + await saveAccounts(staleWithC, cfgPath) + + const afterNaive = await loadAccounts(cfgPath) + // b is wiped — only a and c remain + expect(afterNaive?.accounts.map((a) => a.id).sort()).toEqual([ + 'acct-a', + 'acct-c', + ]) + }) +}) diff --git a/packages/opencode/src/tests/accounts-store.test.ts b/packages/opencode/src/tests/accounts-store.test.ts index 480b8c2..40056a2 100644 --- a/packages/opencode/src/tests/accounts-store.test.ts +++ b/packages/opencode/src/tests/accounts-store.test.ts @@ -181,21 +181,27 @@ describe('accounts store', () => { expect(loadedAcct1.access).toBe('acc-1') }) - it('saveAccounts waits for the file lock and merges with the latest on-disk accounts', async () => { + it('saveAccounts waits for the file lock, then the incoming account list is authoritative', async () => { const { loadAccounts, saveAccounts } = await import('../core/accounts.ts') - const staleAccount: OAuthAccount = { - id: 'stale-writer', + // The account list is written only by lock-serialized callers (user + // add/remove/switch/order and the loader's main-state writes). incoming is + // authoritative for the set, so a removal sticks; an account absent from + // incoming is not resurrected from the on-disk snapshot. (Per-account + // runtime state — quota / refresh-error from background timers — is written + // separately via saveAccountState and is covered by its own tests.) + const incomingAccount: OAuthAccount = { + id: 'incoming-writer', type: 'oauth', - access: 'acc-stale', - refresh: 'ref-stale', + access: 'acc-incoming', + refresh: 'ref-incoming', expires: Date.now() + 3600_000, } - const latestAccount: OAuthAccount = { - id: 'latest-writer', + const onDiskAccount: OAuthAccount = { + id: 'on-disk-writer', type: 'oauth', - access: 'acc-latest', - refresh: 'ref-latest', + access: 'acc-on-disk', + refresh: 'ref-on-disk', expires: Date.now() + 3600_000, } @@ -206,9 +212,10 @@ describe('accounts store', () => { }) expect(lock).not.toBeNull() + // saveAccounts must block until the lock is released (serialization). let settled = false - const staleSave = saveAccounts( - { version: 1, accounts: [staleAccount] }, + const blockedSave = saveAccounts( + { version: 1, accounts: [incomingAccount] }, cfgPath, ).finally(() => { settled = true @@ -217,22 +224,32 @@ describe('accounts store', () => { await new Promise((resolve) => setTimeout(resolve, 50)) expect(settled).toBe(false) + // A different account lands on disk while the save is blocked. await writeFile( cfgPath, - `${JSON.stringify({ version: 1, accounts: [{ id: latestAccount.id, type: 'oauth', enabled: true }] })}\n`, + `${JSON.stringify({ version: 1, accounts: [{ id: onDiskAccount.id, type: 'oauth', enabled: true }] })}\n`, ) await writeFile( statePath, - `${JSON.stringify({ version: 1, accounts: { [latestAccount.id]: latestAccount } })}\n`, + `${JSON.stringify({ version: 1, accounts: { [onDiskAccount.id]: onDiskAccount } })}\n`, ) await lock?.release() - await staleSave + await blockedSave + // The resumed save's incoming list wins — only incoming-writer remains. const loaded = await loadAccounts(cfgPath) expect(loaded?.accounts.map((account) => account.id).sort()).toEqual([ - 'latest-writer', - 'stale-writer', + 'incoming-writer', ]) + + // The state file is rebuilt from the authoritative account set, so the + // overwritten account's per-account secrets are pruned — no stale + // access/refresh tokens for a dropped account linger at rest. + const stateRaw = readFileSync(statePath, 'utf8') + const state = JSON.parse(stateRaw) + expect(Object.keys(state.accounts ?? {})).toEqual(['incoming-writer']) + expect(stateRaw).not.toContain('acc-on-disk') + expect(stateRaw).not.toContain('ref-on-disk') }) }) diff --git a/packages/opencode/src/tests/commands.test.ts b/packages/opencode/src/tests/commands.test.ts index d2e1bf0..89a1976 100644 --- a/packages/opencode/src/tests/commands.test.ts +++ b/packages/opencode/src/tests/commands.test.ts @@ -14,7 +14,12 @@ import type { CommandContext } from '../commands' // Static import for tests that don't need mocking. import { buildDialogPayload } from '../commands' import type { AccountQuotaWindow, OAuthQuotaSnapshot } from '../core/accounts' -import { loadAccounts, type OAuthAccount, saveAccounts } from '../core/accounts' +import { + loadAccounts, + mutateAccounts, + type OAuthAccount, + saveAccounts, +} from '../core/accounts' import { QuotaManager } from '../core/quota-manager' import { createLogger, flushForTest, setLogLevel } from '../logger' import { resetNotificationsForTest } from '../rpc/notifications' @@ -84,6 +89,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -101,6 +107,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -131,6 +138,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), cacheKeepManager: { status: () => ({ @@ -173,6 +181,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), setCacheKeepEnabled, cacheKeepManager: { @@ -220,6 +229,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), setCacheKeepSubagents, cacheKeepManager: { @@ -259,6 +269,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), cacheKeepManager: { start, @@ -303,6 +314,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client, } @@ -347,6 +359,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client, } @@ -390,6 +403,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client, } @@ -422,6 +436,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client, } @@ -457,6 +472,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } await saveAccounts( @@ -483,6 +499,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } await saveAccounts( @@ -516,6 +533,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client, refreshSidebar: async () => { refreshCalls.push(1) @@ -545,6 +563,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client, refreshSidebar: async () => { refreshCalls.push(1) @@ -574,6 +593,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client, refreshSidebar: async () => { refreshCalls.push(1) @@ -603,6 +623,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client, refreshSidebar: async () => { refreshCalls.push(1) @@ -639,6 +660,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -695,6 +717,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -720,6 +743,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -740,6 +764,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -770,6 +795,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), refreshAllQuota: async () => { qm.setMain('access-main', { @@ -820,6 +846,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), refreshAllQuota: async () => { qm.setMain('access-main', { @@ -867,6 +894,7 @@ describe('commands', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), // refreshAllQuota intentionally omitted } @@ -943,6 +971,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -983,6 +1012,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client, } @@ -1027,6 +1057,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -1065,6 +1096,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -1111,6 +1143,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -1156,6 +1189,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), notify: (payload) => { notifyCalls.push({ text: payload.text }) @@ -1192,6 +1226,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), notify: (payload) => { notifyCalls.push({ text: payload.text }) @@ -1227,6 +1262,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -1264,6 +1300,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -1301,6 +1338,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), refreshSidebar: async () => { refreshCalls.push(1) @@ -1337,6 +1375,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -1371,6 +1410,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), } @@ -1412,6 +1452,7 @@ describe('commands (add)', () => { accountStoragePath: configPath, quotaManager: qm, loadAccounts, + mutateAccounts: (m, p) => mutateAccounts(m, p), client: makeClient(), sessionId: 'session-one', notify: (payload) => {