diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index e8aada95..d65b131c 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -938,6 +938,25 @@ function mergeConfigAccountAndState( return { ...account, ...stateAccount } } +function configAccountHasInvalidShape(account: Record) { + if (account.type !== 'api' && account.type !== 'oauth') return true + if ( + account.type === 'api' && + 'baseURL' in account && + (typeof account.baseURL !== 'string' || !isValidApiBaseURL(account.baseURL)) + ) { + return true + } + if ( + account.type === 'oauth' && + 'refresh' in account && + (typeof account.refresh !== 'string' || !account.refresh.trim()) + ) { + return true + } + return false +} + function mergeConfigAndState( configValue: unknown, stateValue: unknown, @@ -970,10 +989,13 @@ function mergeConfigAndState( const accounts = Array.isArray(configValue.accounts) ? configValue.accounts.map((account) => { if (!isRecord(account)) return account - const stateAccount: Record = - typeof account.id === 'string' && isRecord(stateAccounts[account.id]) - ? (stateAccounts[account.id] as Record) - : {} + const rawId = typeof account.id === 'string' ? account.id : undefined + const stateValue = rawId + ? (stateAccounts[rawId] ?? stateAccounts[rawId.trim()]) + : undefined + const stateAccount: Record = isRecord(stateValue) + ? (stateValue as Record) + : {} return mergeConfigAccountAndState(account, stateAccount) }) : [] @@ -1943,31 +1965,78 @@ async function saveAccountStateUnlocked( if (scope.accounts) { const ids = scope.accounts === true ? null : new Set(scope.accounts) + const config = (await readJsonIfPresent(path)).value + const configuredIds = (() => { + if (!isRecord(config) || !Array.isArray(config.accounts)) return null + if (config.accounts.length === 0) return new Set() + const stateAccounts = isRecord(next.accounts) ? next.accounts : {} + const incomingAccounts = Object.fromEntries( + storage.accounts.map((account) => [ + account.id.trim(), + accountRuntimeState(account), + ]), + ) + // Keep both forms because legacy state may still use either key while + // scoped saves must preserve entries not superseded by an incoming account. + const memberships = config.accounts.map((account) => { + if (!isRecord(account) || typeof account.id !== 'string') return null + const id = account.id.trim() + if (!id) return null + if (configAccountHasInvalidShape(account)) return null + const stateValue = stateAccounts[account.id] ?? stateAccounts[id] + const stateAccount: Record = isRecord(stateValue) + ? (stateValue as Record) + : {} + const incomingAccount = incomingAccounts[id] as + | Record + | undefined + return normalizeAccount( + mergeConfigAccountAndState(account, incomingAccount ?? stateAccount), + ) + ? [account.id, id] + : null + }) + // A populated config with any unparseable entry cannot establish safe membership. + return memberships.every((membership) => membership !== null) + ? new Set(memberships.flatMap((membership) => membership ?? [])) + : null + })() next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) } for (const account of storage.accounts) { - if (ids && !ids.has(account.id)) continue - next.accounts[account.id] = mergeAccountRuntimeState( - next.accounts[account.id], + const accountId = account.id.trim() + if (ids && !ids.has(account.id) && !ids.has(accountId)) continue + if ( + configuredIds && + !configuredIds.has(account.id) && + !configuredIds.has(accountId) + ) + continue + const legacyKeys = Object.keys(next.accounts).filter( + (key) => key !== accountId && key.trim() === accountId, + ) + const legacyKey = legacyKeys[0] + const existingAccount = + next.accounts[accountId] ?? + (legacyKey ? next.accounts[legacyKey] : undefined) + for (const key of legacyKeys) delete next.accounts[key] + next.accounts[accountId] = mergeAccountRuntimeState( + existingAccount, accountRuntimeState(account), ) } + if (configuredIds) { + // Config membership is authoritative for scoped writes too; otherwise a + // stale writer can preserve state for an account removed out of band. + for (const id of Object.keys(next.accounts)) { + if (!configuredIds.has(id)) delete next.accounts[id] + } + } if (ids) { for (const id of ids) { if (!storage.accounts.some((account) => account.id === id)) { delete next.accounts[id] } } - } else { - // Full save: drop any per-account state whose id is no longer present in - // storage.accounts. The scoped path above only prunes ids it was asked to - // save; on a removal the storage is saved with scope.accounts === true - // (ids === null), so without this branch the removed account's runtime - // state (quota/lastRefreshError/access/refresh/expires) would be orphaned - // in the state file and later merged onto a re-added same-id account. - const present = new Set(storage.accounts.map((account) => account.id)) - for (const id of Object.keys(next.accounts)) { - if (!present.has(id)) delete next.accounts[id] - } } } @@ -3598,13 +3667,14 @@ export class FallbackAccountManager { await this.refreshDueAccounts() await this.refreshQuotaForDueAccounts() } - void run().catch(() => {}) + const initialRun = run().catch(() => {}) if (!this.refreshTimer) { this.refreshTimer = this.setIntervalImpl(() => { void run().catch(() => {}) }, BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS)) if ('unref' in this.refreshTimer) this.refreshTimer.unref() } + return initialRun } stopBackgroundRefresh() { diff --git a/packages/core/src/quota-header-feed.ts b/packages/core/src/quota-header-feed.ts index bde2eabf..065f4085 100644 --- a/packages/core/src/quota-header-feed.ts +++ b/packages/core/src/quota-header-feed.ts @@ -53,6 +53,7 @@ type QuotaHeaderFeedMetadata = { schema_version: typeof QUOTA_HEADER_FEED_SCHEMA_VERSION provider: 'anthropic' configured_account_count: number + /** Header observation time; merged poll-owned fields retain their own checkedAt. */ observed_at_ms: number } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1fa5054e..70f71f7a 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1286,9 +1286,7 @@ const anthropicAuthPlugin = async ( ? { ...entry, quota: mergedQuota, - checkedAt: persistedQuotaBelongsToRequest - ? (reloaded.quota?.mainQuotaCheckedAt ?? entry.checkedAt) - : entry.checkedAt, + checkedAt: entry.checkedAt, } : entry } @@ -1527,7 +1525,7 @@ const anthropicAuthPlugin = async ( void refreshSidebarQuota().catch(() => {}) }, }) - fallbackManager.startBackgroundRefresh() + const fallbackRefreshReady = fallbackManager.startBackgroundRefresh() const cacheDiagnosticsTracker = new CacheDiagnosticsTracker() const cacheDiagnosticsBetaTracker = new CacheDiagnosticsBetaTracker() type CacheDiagnosticsResponse = { @@ -6741,6 +6739,7 @@ const anthropicAuthPlugin = async ( }, __primeManager: primeManager, __quotaManager: quotaManager, + __fallbackRefreshReady: fallbackRefreshReady, // biome-ignore lint/suspicious/noExplicitAny: Plugin type doesn't include undocumented auth/hooks } as any } diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index d68843fd..aec2ac89 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from 'bun:test' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -16,9 +24,23 @@ import { setAccountEnabledPersistent, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin } from '../index' +import { DEFAULT_FETCH_MOCK, installDefaultFetchMock } from './test-fetch' +import { + createTimerTracking, + type PluginTimerOverrides, +} from './timer-tracking' let tempDir: string let accountPath: string +const tempDirs = new Set() +const originalFetch = globalThis.fetch +const timerTracking = createTimerTracking() +const { + activeIntervals, + disabledPluginTimerOverrides, + trackedClearInterval, + trackedSetInterval, +} = timerTracking const baseStorage = (): AccountStorage => ({ version: 1, @@ -68,15 +90,48 @@ const baseStorage = (): AccountStorage => ({ }) beforeEach(async () => { + installDefaultFetchMock() + timerTracking.reset() + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } tempDir = await mkdtemp(join(tmpdir(), 'anthropic-auth-acct-cmd-')) + tempDirs.add(tempDir) accountPath = join(tempDir, 'anthropic-auth.json') process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath }) afterEach(async () => { - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - await rm(tempDir, { recursive: true, force: true }) - mock.restore() + try { + // Restore only the fixture's tagged mock; an untagged custom mock left + // installed must reach the preload's leak detector, not be masked here. + const currentFetch = globalThis.fetch as + | (typeof fetch & { [DEFAULT_FETCH_MOCK]?: true }) + | undefined + if (currentFetch?.[DEFAULT_FETCH_MOCK]) { + globalThis.fetch = originalFetch + } + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + await Promise.all( + [...tempDirs].map((directory) => + rm(directory, { recursive: true, force: true }).catch(() => {}), + ), + ) + tempDirs.clear() + mock.restore() + } finally { + // Assert last so a detected leak cannot abort the cleanup above. + expect(activeIntervals.size).toBe(0) + } +}) + +afterAll(async () => { + await Promise.all( + [...tempDirs, tempDir].map((directory) => + rm(directory, { recursive: true, force: true }).catch(() => {}), + ), + ) + tempDirs.clear() }) // --------------------------------------------------------------------------- @@ -513,11 +568,22 @@ describe('account command INFO logs (via plugin)', () => { } } - async function getPlugin() { - return (await AnthropicAuthPlugin({ - // @ts-expect-error: minimal mock for testing - client: createMockClient(), - })) as Promise + async function getPlugin(timerOverrides?: PluginTimerOverrides) { + const defaultTimerOverrides = disabledPluginTimerOverrides() + const plugin = (await ( + AnthropicAuthPlugin as unknown as ( + ctx: Parameters[0], + timers?: PluginTimerOverrides, + ) => ReturnType + )( + { + // @ts-expect-error: minimal mock for testing + client: createMockClient(), + }, + { ...defaultTimerOverrides, ...timerOverrides }, + )) as any + await plugin.__fallbackRefreshReady + return plugin } async function executeCommand( @@ -636,4 +702,20 @@ describe('account command INFO logs (via plugin)', () => { capturedRecords.filter((r) => r.channel === 'commands'), ).toHaveLength(0) }) + + test('does not retain a background interval unless the helper opts in', async () => { + await saveAccounts(baseStorage(), accountPath) + await getPlugin() + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) + + await timerTracking.withTrackedInterval(async () => { + await getPlugin({ + setInterval: trackedSetInterval, + clearInterval: trackedClearInterval, + }) + expect(activeIntervals.size).toBe(1) + }) + expect(activeIntervals.size).toBe(0) + }) }) diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index c2ffe410..65e47141 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -216,6 +216,92 @@ describe('main account identity', () => { }) }) +describe('scoped account-state membership', () => { + test('preserves state for a whitespace-padded configured account id', async () => { + await writeFile( + accountPath, + JSON.stringify({ + ...baseStorage(), + accounts: [{ id: ' fb-1 ', type: 'oauth', enabled: true }], + }), + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + ' fb-1 ': { + access: 'access-token', + refresh: 'valid-refresh', + expires: Date.now() + 3_600_000, + }, + x: { refresh: 'removed-refresh' }, + }, + }), + ) + + const loaded = await loadAccounts(accountPath) + expect(loaded).not.toBeNull() + expect(loaded?.accounts).toHaveLength(1) + expect(loaded?.accounts[0]?.id).toBe('fb-1') + expect(loaded?.accounts[0]?.type).toBe('oauth') + expect((loaded!.accounts[0] as OAuthAccount).refresh).toBe('valid-refresh') + + const scopedStorage = { ...loaded!, accounts: [] } + await saveAccountState(scopedStorage, accountPath, { accounts: true }) + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts).toEqual({ + ' fb-1 ': { + access: 'access-token', + refresh: 'valid-refresh', + expires: expect.any(Number), + }, + }) + const loadedAfter = await loadAccounts(accountPath) + expect(loadedAfter?.accounts).toHaveLength(1) + expect(loadedAfter?.accounts[0]?.id).toBe('fb-1') + expect((loadedAfter!.accounts[0] as OAuthAccount).refresh).toBe( + 'valid-refresh', + ) + }) + + test('accepts a trimmed state key for a padded configured account', async () => { + await writeFile( + accountPath, + JSON.stringify({ + ...baseStorage(), + accounts: [{ id: ' fb-1 ', type: 'oauth', enabled: true }], + }), + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + 'fb-1': { refresh: 'trimmed-refresh' }, + x: { refresh: 'removed-refresh' }, + }, + }), + ) + + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(1) + expect((loaded!.accounts[0] as OAuthAccount).refresh).toBe( + 'trimmed-refresh', + ) + + await saveAccountState(loaded!, accountPath, { accounts: ['fb-1'] }) + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts).toEqual({ + 'fb-1': { refresh: 'trimmed-refresh' }, + }) + }) +}) + describe('main quota clear marker', () => { test('keeps the newer marker when an older marker arrives', () => { expect(mergeMainQuotaErrorClearedAt(2_000, 1_000)).toBe(2_000) @@ -1176,6 +1262,274 @@ describe('account storage', () => { expect(loaded?.accounts.map((account) => account.id)).toEqual(['good-api']) }) + test('preserves runtime state when config membership entry is shape-invalid', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'api', + apiKey: 'api-key', + baseURL: 'https://api.example.com/v1', + }, + { + id: 'b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + }, + ] + await saveAccounts(seeded, accountPath) + const staleStorage = await loadAccounts(accountPath) + expect(staleStorage?.accounts.map((account) => account.id)).toEqual([ + 'a', + 'b', + ]) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: 'a', type: 'api', baseURL: 'garbage' }], + }), + 'utf8', + ) + + await saveAccountState(staleStorage!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toHaveProperty('a') + expect(state.accounts).toHaveProperty('b') + expect(state.accounts.a.apiKey).toBe('api-key') + expect(state.accounts.b.refresh).toBe('refresh-b') + }) + + test('prunes runtime state for a fully valid config membership set', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'api', + apiKey: 'api-key', + baseURL: 'https://api.example.com/v1', + }, + { + id: 'b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + }, + ] + await saveAccounts(seeded, accountPath) + const staleStorage = await loadAccounts(accountPath) + expect(staleStorage?.accounts.map((account) => account.id)).toEqual([ + 'a', + 'b', + ]) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [ + { id: 'a', type: 'api', baseURL: 'https://api.example.com/v1' }, + ], + }), + 'utf8', + ) + + await saveAccountState(staleStorage!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toEqual({ a: expect.any(Object) }) + expect(state.accounts.b).toBeUndefined() + }) + + test('uses incoming account credentials to establish config membership', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'x', + type: 'oauth', + access: 'access-x', + refresh: 'refresh-x', + }, + ] + await saveAccounts(seeded, accountPath) + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts.map((account) => account.id)).toEqual(['x']) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: 'y', type: 'oauth' }], + }), + 'utf8', + ) + + const incoming = baseStorage() + incoming.accounts = [ + { + id: 'y', + type: 'oauth', + access: 'access-y', + refresh: 'refresh-y', + }, + ] + await saveAccountState(incoming, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toEqual({ y: expect.any(Object) }) + expect(state.accounts.x).toBeUndefined() + expect(state.accounts.y.refresh).toBe('refresh-y') + }) + + test('does not establish membership from config-owned fields on an incoming api account', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'api', + apiKey: 'api-key-a', + baseURL: 'https://api.example.com/v1', + }, + { + id: 'b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + }, + ] + await saveAccounts(seeded, accountPath) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: 'a', type: 'api' }], + }), + 'utf8', + ) + + const incoming = baseStorage() + incoming.accounts = [ + { + id: 'a', + type: 'api', + apiKey: 'api-key-a-new', + baseURL: 'https://api.example.com/v1', + }, + ] + await saveAccountState(incoming, accountPath, { accounts: true }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts).toHaveProperty('a') + expect(state.accounts).toHaveProperty('b') + }) + + test('keeps runtime state when incoming storage cannot validate config membership', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'x', + type: 'oauth', + access: 'access-x', + refresh: 'refresh-x', + }, + ] + await saveAccounts(seeded, accountPath) + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts.map((account) => account.id)).toEqual(['x']) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: 'y', type: 'oauth' }], + }), + 'utf8', + ) + + await saveAccountState(loaded!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts.x.refresh).toBe('refresh-x') + expect(state.accounts.y).toBeUndefined() + }) + + test('does not establish membership from an id-less shape-valid config entry', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + }, + { + id: 'b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + }, + ] + await saveAccounts(seeded, accountPath) + const staleStorage = await loadAccounts(accountPath) + expect(staleStorage?.accounts.map((account) => account.id)).toEqual([ + 'a', + 'b', + ]) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ type: 'oauth', refresh: 'new-refresh' }], + }), + 'utf8', + ) + + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(1) + await saveAccountState(staleStorage!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toHaveProperty('a') + expect(state.accounts).toHaveProperty('b') + }) + + test('prunes runtime state for an explicitly empty config account list', async () => { + const seeded = baseStorage() + seeded.accounts = [ + { + id: 'a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + }, + ] + await saveAccounts(seeded, accountPath) + const staleStorage = await loadAccounts(accountPath) + expect(staleStorage?.accounts).toHaveLength(1) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [], + }), + 'utf8', + ) + + await saveAccountState(staleStorage!, accountPath, { accounts: true }) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(state.accounts).toEqual({}) + }) + test('runtime state saves do not rewrite user-editable config', async () => { const storage = baseStorage() storage.quota = { @@ -1223,6 +1577,364 @@ describe('account storage', () => { expect(loaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(22) }) + test('runtime state saves preserve accounts when config is missing', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access-before', + refresh: 'refresh-before', + expires: 1_000, + lastRefreshedAt: 100, + }) + await saveAccounts(storage, accountPath) + await rm(accountPath) + storage.accounts[0] = { + ...storage.accounts[0], + access: 'access-after', + refresh: 'refresh-after', + expires: 2_000, + lastRefreshedAt: 200, + } as OAuthAccount + + await saveAccountState(storage, accountPath) + + const rawState = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(rawState.accounts['fallback-1']).toMatchObject({ + access: 'access-after', + refresh: 'refresh-after', + expires: 2_000, + lastRefreshedAt: 200, + }) + }) + + test('runtime state saves preserve whitespace-padded configured account ids', async () => { + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [ + { id: ' fb-1 ', type: 'oauth', refresh: 'refresh-padded' }, + { id: 'fb-control', type: 'oauth', refresh: 'refresh-control' }, + ], + }), + 'utf8', + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + ' fb-1 ': { + access: 'access-padded', + refresh: 'refresh-padded', + expires: 1_000, + }, + 'fb-control': { + access: 'access-control', + refresh: 'refresh-control', + expires: 1_000, + }, + }, + }), + 'utf8', + ) + const loaded = await loadAccounts(accountPath) + expect(loaded).not.toBeNull() + expect(loaded?.accounts.map((account) => account.id)).toEqual([ + 'fb-1', + 'fb-control', + ]) + expect((loaded!.accounts[0] as OAuthAccount).refresh).toBe('refresh-padded') + + await saveAccountState(loaded!, accountPath, { + accounts: ['fb-1', 'fb-control'], + }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(Object.keys(state.accounts ?? {}).sort()).toEqual([ + 'fb-1', + 'fb-control', + ]) + expect(state.accounts?.['fb-1']?.refresh).toBe('refresh-padded') + expect(state.accounts?.['fb-control']?.refresh).toBe('refresh-control') + }) + + test('loads a trimmed legacy state key for a padded configured account', async () => { + await writeFile( + accountPath, + JSON.stringify({ + ...baseStorage(), + accounts: [{ id: ' fb-1 ', type: 'oauth', enabled: true }], + }), + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + 'fb-1': { + access: 'access-trimmed', + refresh: 'refresh-trimmed', + expires: Date.now() + 3_600_000, + }, + }, + }), + ) + + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(1) + expect((loaded!.accounts[0] as OAuthAccount).refresh).toBe( + 'refresh-trimmed', + ) + + await saveAccountState({ ...loaded!, accounts: [] }, accountPath, { + accounts: true, + }) + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts).toEqual({ + 'fb-1': { + access: 'access-trimmed', + refresh: 'refresh-trimmed', + expires: expect.any(Number), + }, + }) + expect((await loadAccounts(accountPath))?.accounts).toHaveLength(1) + }) + + test('persists rotated credentials under the key the padded loader accepts', async () => { + await writeFile( + accountPath, + JSON.stringify({ + ...baseStorage(), + accounts: [{ id: ' fb-1 ', type: 'oauth' }], + }), + ) + await writeFile( + getAccountStatePath(accountPath), + JSON.stringify({ + version: 1, + accounts: { + ' fb-1 ': { + access: 'access-old', + refresh: 'refresh-old', + expires: Date.now() + 3_600_000, + lastRefreshedAt: 100, + }, + }, + }), + ) + + const loaded = await loadAccounts(accountPath) + expect((loaded!.accounts[0] as OAuthAccount).access).toBe('access-old') + const rotated = loaded!.accounts[0] as OAuthAccount + rotated.access = 'access-new' + rotated.refresh = 'refresh-new' + rotated.expires = Date.now() + 7_200_000 + rotated.lastRefreshedAt = 200 + await saveAccountState(loaded!, accountPath, { accounts: ['fb-1'] }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(Object.keys(state.accounts ?? {})).toEqual(['fb-1']) + expect((await loadAccounts(accountPath))?.accounts[0]).toMatchObject({ + access: 'access-new', + refresh: 'refresh-new', + }) + }) + + test('runtime state saves preserve all state when config has a whitespace-only account id', async () => { + const seeded = baseStorage() + seeded.accounts.push({ + id: 'fb-1', + type: 'oauth', + access: 'access-before', + refresh: 'refresh-before', + expires: 1_000, + }) + await saveAccounts(seeded, accountPath) + + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [{ id: ' ' }], + }), + 'utf8', + ) + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(0) + + await saveAccountState(loaded!, accountPath, { accounts: true }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(state.accounts?.['fb-1']?.refresh).toBe('refresh-before') + }) + + test('runtime state saves preserve accounts when config omits accounts', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access-before', + refresh: 'refresh-before', + expires: 1_000, + lastRefreshedAt: 100, + }) + await saveAccounts(storage, accountPath) + await writeFile( + accountPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + }), + 'utf8', + ) + storage.accounts[0] = { + ...storage.accounts[0], + access: 'access-after', + refresh: 'refresh-after', + expires: 2_000, + lastRefreshedAt: 200, + } as OAuthAccount + + await saveAccountState(storage, accountPath) + + const rawState = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(rawState.accounts['fallback-1']).toMatchObject({ + access: 'access-after', + refresh: 'refresh-after', + expires: 2_000, + lastRefreshedAt: 200, + }) + }) + + test('treats a partially parseable populated config as unknown membership', async () => { + const storage = baseStorage() + storage.accounts.push( + { + id: 'account-a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + expires: 1_000, + }, + { + id: 'account-b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + expires: 1_000, + }, + ) + await saveAccounts(storage) + await writeFile( + accountPath, + JSON.stringify({ version: 1, accounts: [{ id: 'account-a' }, {}] }), + 'utf8', + ) + + await saveAccountState(storage, accountPath, { accounts: true }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(Object.keys(state.accounts ?? {}).sort()).toEqual([ + 'account-a', + 'account-b', + ]) + expect(state.accounts?.['account-a']?.refresh).toBe('refresh-a') + expect(state.accounts?.['account-b']?.refresh).toBe('refresh-b') + }) + + test('scoped saves preserve accounts when populated config membership is unknown', async () => { + const storage = baseStorage() + storage.accounts.push( + { + id: 'account-a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + expires: 1_000, + }, + { + id: 'account-b', + type: 'oauth', + access: 'access-b', + refresh: 'refresh-b', + expires: 1_000, + }, + ) + await saveAccounts(storage) + await writeFile( + accountPath, + JSON.stringify({ version: 1, accounts: [{ id: 'account-a' }, {}] }), + 'utf8', + ) + + storage.accounts[0] = { + ...storage.accounts[0], + access: 'access-a-updated', + refresh: 'refresh-a-updated', + } as OAuthAccount + await saveAccountState(storage, accountPath, { accounts: ['account-a'] }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(state.accounts?.['account-a']).toMatchObject({ + access: 'access-a-updated', + refresh: 'refresh-a-updated', + }) + expect(state.accounts?.['account-b']).toMatchObject({ + access: 'access-b', + refresh: 'refresh-b', + }) + }) + + test('a wholly unparseable populated config prunes nothing when storage is loaded from it', async () => { + const seeded = baseStorage() + seeded.accounts.push({ + id: 'account-a', + type: 'oauth', + access: 'access-a', + refresh: 'refresh-a', + expires: 1_000, + }) + await saveAccounts(seeded) + + // Load drops the unparseable entries too, so the writer's own snapshot is + // empty for the same reason membership is unknown. Pruning against it would + // delete every credential. + await writeFile( + accountPath, + JSON.stringify({ version: 1, accounts: [{}] }), + 'utf8', + ) + const loaded = await loadAccounts(accountPath) + expect(loaded?.accounts).toHaveLength(0) + + await saveAccountState(loaded!, accountPath, { accounts: true }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) as { accounts?: Record } + expect(state.accounts?.['account-a']?.refresh).toBe('refresh-a') + }) + test('generic saves preserve a newer persisted main profile', async () => { const staleStorage = baseStorage() await saveAccounts(staleStorage) @@ -5264,6 +5976,89 @@ describe('multi-account persistence', () => { expect(Object.keys(state.accounts)).toEqual(['umut', 'yiyi']) }) + test('scoped saves prune state removed by an external config edit', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'keep', + type: 'oauth', + access: 'keep-access', + refresh: 'keep-refresh', + }) + storage.accounts.push({ + id: 'doomed', + type: 'oauth', + access: 'doomed-access', + refresh: 'doomed-refresh', + }) + await saveAccounts(storage, accountPath) + + const staleSnapshot = (await loadAccounts(accountPath))! + const statePath = getAccountStatePath(accountPath) + const before = JSON.parse(await readFile(statePath, 'utf8')) + expect(before.accounts?.keep).toBeDefined() + expect(before.accounts?.doomed).toBeDefined() + + const config = JSON.parse(await readFile(accountPath, 'utf8')) + config.accounts = config.accounts.filter( + (account: { id?: string }) => account.id !== 'doomed', + ) + await writeFile(accountPath, `${JSON.stringify(config)}\n`, 'utf8') + + await saveAccountState(staleSnapshot, accountPath, { + accounts: ['keep'], + }) + const afterStaleSave = JSON.parse(await readFile(statePath, 'utf8')) + expect(afterStaleSave.accounts?.doomed).toBeUndefined() + expect(afterStaleSave.accounts?.keep).toBeDefined() + }) + + test('scoped saves preserve state for configured accounts outside the scope', async () => { + const storage = baseStorage() + storage.accounts.push( + { + id: 'keep', + type: 'oauth', + access: 'keep-access', + refresh: 'keep-refresh', + }, + { + id: 'update', + type: 'oauth', + access: 'update-access', + refresh: 'update-refresh', + }, + ) + await saveAccounts(storage, accountPath) + + const scopedStorage = { + ...storage, + accounts: storage.accounts.map((account) => + account.id === 'update' + ? { + ...account, + access: 'update-access-new', + refresh: 'update-refresh-new', + } + : account, + ), + } + await saveAccountState(scopedStorage, accountPath, { + accounts: ['update'], + }) + + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.accounts?.keep).toMatchObject({ + access: 'keep-access', + refresh: 'keep-refresh', + }) + expect(state.accounts?.update).toMatchObject({ + access: 'update-access-new', + refresh: 'update-refresh-new', + }) + }) + test('concurrent account additions preserve both accounts', async () => { await Promise.all([ addAccountPersistent( diff --git a/packages/opencode/src/tests/add-account-flows.test.ts b/packages/opencode/src/tests/add-account-flows.test.ts index 0aeebbb2..626e3a9c 100644 --- a/packages/opencode/src/tests/add-account-flows.test.ts +++ b/packages/opencode/src/tests/add-account-flows.test.ts @@ -2,9 +2,22 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { DEFAULT_FETCH_MOCK } from './test-fetch' +import { + createTimerTracking, + type PluginTimerOverrides, +} from './timer-tracking' let tempDir: string let accountPath: string +const originalFetch = globalThis.fetch +const timerTracking = createTimerTracking() +const { + activeIntervals, + disabledPluginTimerOverrides, + trackedClearInterval, + trackedSetInterval, +} = timerTracking async function useTempAccountFile() { if (tempDir) { @@ -31,12 +44,21 @@ function createMockClient() { } } -async function getPlugin() { +async function getPlugin(timerOverrides?: PluginTimerOverrides) { const { AnthropicAuthPlugin } = await import('../index') - return (await AnthropicAuthPlugin({ - // @ts-expect-error: minimal mock for testing - client: createMockClient(), - })) as Promise + const defaultTimerOverrides = disabledPluginTimerOverrides() + return (await ( + AnthropicAuthPlugin as unknown as ( + ctx: Parameters[0], + timers?: PluginTimerOverrides, + ) => ReturnType + )( + { + // @ts-expect-error: minimal mock for testing + client: createMockClient(), + }, + { ...defaultTimerOverrides, ...timerOverrides }, + )) as Promise } async function executeCommand( @@ -98,6 +120,9 @@ let capturedRecords: Array<{ }> = [] beforeEach(async () => { + timerTracking.reset() + const { installDefaultFetchMock } = await import('./test-fetch') + installDefaultFetchMock() capturedRecords = [] const { __setLogTestSink } = await import('@cortexkit/anthropic-auth-core') __setLogTestSink((record) => { @@ -107,13 +132,54 @@ beforeEach(async () => { }) afterEach(async () => { - const { __setLogTestSink } = await import('@cortexkit/anthropic-auth-core') - __setLogTestSink(null) - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + try { + // Restore only the fixture's tagged mock; an untagged custom mock left + // installed must reach the preload's leak detector, not be masked here. + const currentFetch = globalThis.fetch as + | (typeof fetch & { [DEFAULT_FETCH_MOCK]?: true }) + | undefined + if (currentFetch?.[DEFAULT_FETCH_MOCK]) { + globalThis.fetch = originalFetch + } + const { __setLogTestSink } = await import('@cortexkit/anthropic-auth-core') + __setLogTestSink(null) + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } + mock.restore() + } finally { + // Assert last so a detected leak cannot abort the cleanup above. + expect(activeIntervals.size).toBe(0) + } +}) + +test('does not retain a background interval unless the helper opts in', async () => { + await getPlugin() + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) + + let thrown: unknown + try { + await timerTracking.withTrackedInterval(async () => { + await getPlugin({ + setInterval: trackedSetInterval, + clearInterval: trackedClearInterval, + }) + expect(activeIntervals.size).toBe(1) + throw new Error('forced timer assertion failure') + }) + } catch (error) { + thrown = error } - mock.restore() + expect((thrown as Error).message).toBe('forced timer assertion failure') + expect(activeIntervals.size).toBe(0) +}) + +test('partial timer overrides retain disabled interval defaults', async () => { + await getPlugin({ clearInterval: trackedClearInterval }) + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) }) // --------------------------------------------------------------------------- @@ -435,10 +501,13 @@ describe('add-oauth label threading', () => { async function getPluginWithClient() { const client = createMockClient() const { AnthropicAuthPlugin } = await import('../index') - const plugin = (await AnthropicAuthPlugin({ - // @ts-expect-error: minimal mock for testing - client, - })) as any + const plugin = (await AnthropicAuthPlugin( + { + // @ts-expect-error: minimal mock for testing + client, + }, + disabledPluginTimerOverrides(), + )) as any return { plugin, client } } diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index e336f177..39be449a 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from 'bun:test' import { readFileSync } from 'node:fs' import { chmod, @@ -60,13 +68,14 @@ import { setSidebarState, } from '../sidebar-state' import { rewriteRequestBody } from '../transform.ts' - -/** Extract the URL string from a fetch input (string, URL, or Request). */ -function extractUrl(input: string | URL | Request): string { - if (typeof input === 'string') return input - if (input instanceof URL) return input.toString() - return input.url -} +import { + extractUrl, + installDefaultFetchMock, + MESSAGES_URL, + PROFILE_URL, + QUOTA_URL, + TOKEN_URL, +} from './test-fetch' async function freshPrimeQuotaResponse( body: unknown, @@ -99,9 +108,163 @@ function createMockClient( } } -const MESSAGES_URL = 'https://api.anthropic.com/v1/messages' const EMPTY_POST = { method: 'POST', body: '{}' } as const let tempConfigDir: string | undefined +const tempConfigDirs = new Set() +const allTempConfigDirs = new Set() +const fallbackRefreshes = new Set>() + +beforeEach(() => { + installDefaultFetchMock() +}) + +afterEach(async () => { + await cleanupTempConfigDirs() +}) + +afterAll(async () => { + await cleanupTempConfigDirs() + await Bun.sleep(100) + await cleanupTempConfigDirs() + await Promise.all( + [...allTempConfigDirs].map((directory) => + rm(directory, { recursive: true, force: true }).catch(() => {}), + ), + ) + allTempConfigDirs.clear() +}) + +async function cleanupTempConfigDirs(drainTimeoutMs = 4_000) { + const refreshesSettled = await Promise.race([ + Promise.allSettled(fallbackRefreshes).then(() => true), + Bun.sleep(drainTimeoutMs).then(() => false), + ]) + if (refreshesSettled) fallbackRefreshes.clear() + await drainSidebarWrites() + const directories = [...tempConfigDirs, tempConfigDir].filter( + (directory): directory is string => Boolean(directory), + ) + tempConfigDirs.clear() + tempConfigDir = undefined + await Promise.all( + directories.map((directory) => + rm(directory, { recursive: true, force: true }).catch(() => {}), + ), + ) +} + +function deferred() { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +async function withDeadlockGuard( + promise: Promise, + ms: number, + message: string, +): Promise { + let timeout: ReturnType | undefined + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), ms) + }) + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } +} + +test('withDeadlockGuard clears its timeout after the primary settles', async () => { + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout + // Concurrent async work schedules unrelated timers during the override + // window; every counter is scoped to the guard's own timer (identified by + // this sentinel delay) so nothing else can flip the probe. + const sentinelDelayMs = 47 + const guardTimers = new Set() + let timeoutFired = 0 + let timeoutCleared = 0 + let unhandled = 0 + const guardMessage = 'late guard' + const onUnhandled = (reason: unknown) => { + if (reason instanceof Error && reason.message === guardMessage) { + unhandled += 1 + } + } + globalThis.setTimeout = (( + callback: (...args: any[]) => void, + delay?: number, + ) => { + const isGuardTimer = delay === sentinelDelayMs + const handle = originalSetTimeout(() => { + if (isGuardTimer) timeoutFired += 1 + callback() + }, delay) + if (isGuardTimer) guardTimers.add(handle) + return handle + }) as typeof globalThis.setTimeout + globalThis.clearTimeout = (( + timeout: ReturnType, + ) => { + if (guardTimers.delete(timeout)) timeoutCleared += 1 + originalClearTimeout(timeout) + }) as typeof globalThis.clearTimeout + process.on('unhandledRejection', onUnhandled) + try { + await withDeadlockGuard( + Promise.resolve('primary'), + sentinelDelayMs, + guardMessage, + ) + await Bun.sleep(100) + expect(timeoutFired).toBe(0) + expect(timeoutCleared).toBe(1) + expect(unhandled).toBe(0) + } finally { + process.off('unhandledRejection', onUnhandled) + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + } +}) + +test('withDeadlockGuard rejects with its message at the deadline', async () => { + await expect( + withDeadlockGuard(new Promise(() => {}), 20, 'deadline'), + ).rejects.toThrow('deadline') +}) + +test('cleanup retries a fallback refresh that outlives its bounded wait', async () => { + const slowRefresh = deferred() + let settled = false + const refresh = slowRefresh.promise.then(() => { + settled = true + }) + fallbackRefreshes.add(refresh) + + const firstStartedAt = Date.now() + await cleanupTempConfigDirs(50) + expect(Date.now() - firstStartedAt).toBeLessThan(500) + expect(settled).toBe(false) + expect(fallbackRefreshes.has(refresh)).toBe(true) + + const releaseTimer = setTimeout(() => slowRefresh.resolve(), 20) + try { + await cleanupTempConfigDirs(50) + } finally { + clearTimeout(releaseTimer) + } + expect(settled).toBe(true) + expect(fallbackRefreshes.size).toBe(0) +}) + +test('extractUrl uses canonical fetch URLs and preserves raw invalid strings', () => { + expect(extractUrl('https://example.com/a/../b')).toBe('https://example.com/b') + expect(extractUrl('/relative')).toBe('/relative') + expect(extractUrl('not a URL')).toBe('not a URL') +}) async function expectHandledCommandResponse(promise: Promise) { try { @@ -345,6 +508,8 @@ async function useTempAccountFile(storage: AccountStorage) { await rm(tempConfigDir, { recursive: true, force: true }) } tempConfigDir = await mkdtemp(join(tmpdir(), 'anthropic-plugin-test-')) + tempConfigDirs.add(tempConfigDir) + allTempConfigDirs.add(tempConfigDir) process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( tempConfigDir, 'anthropic-auth.json', @@ -472,6 +637,18 @@ type PluginTimerOverrides = Partial<{ clearInterval: typeof globalThis.clearInterval }> +function disabledPluginTimerOverrides(): PluginTimerOverrides { + return { + // Background intervals must not outlive the test-scoped fetch mock they captured. + setInterval: mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + } +} + +const originalSetInterval = globalThis.setInterval +const originalClearInterval = globalThis.clearInterval let pluginTimerOverrides: PluginTimerOverrides = {} beforeEach(() => { @@ -481,9 +658,14 @@ beforeEach(() => { async function getPlugin( client?: ReturnType, directory?: string, - timerOverrides: PluginTimerOverrides = pluginTimerOverrides, + timerOverrides: PluginTimerOverrides = {}, ) { - return (await ( + const defaultTimerOverrides = + globalThis.setInterval === originalSetInterval && + globalThis.clearInterval === originalClearInterval + ? disabledPluginTimerOverrides() + : {} + const plugin = (await ( AnthropicAuthPlugin as unknown as ( ctx: Parameters[0], timers?: PluginTimerOverrides, @@ -494,11 +676,21 @@ async function getPlugin( client: client ?? createMockClient(), ...(directory && { directory }), }, - timerOverrides, - )) as Promise + { ...defaultTimerOverrides, ...pluginTimerOverrides, ...timerOverrides }, + )) as any + if (plugin.__fallbackRefreshReady) { + fallbackRefreshes.add(plugin.__fallbackRefreshReady) + } + return plugin } describe('sidebar needsReauth (dead-fallback indicator)', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + function fallbackWithRefreshError(status: number) { const refresh = 'fallback-refresh' const now = Date.now() @@ -545,6 +737,28 @@ describe('sidebar needsReauth (dead-fallback indicator)', () => { test('transient (429 rate-limited) fallback → needsReauth false', async () => { await useTempAccountFile(fallbackWithRefreshError(429)) + const defaultFetch = globalThis.fetch + let tokenCalls = 0 + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url === TOKEN_URL) { + tokenCalls += 1 + return Promise.reject( + new Error('TOKEN_URL is outside this transient quota test'), + ) + } + if (url === QUOTA_URL) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + limits: [], + }), + ) + } + return defaultFetch(input, init) + }) as unknown as typeof fetch + const plugin = await getPlugin() await plugin.auth.loader( () => @@ -560,6 +774,7 @@ describe('sidebar needsReauth (dead-fallback indicator)', () => { (candidate) => candidate.fallbacks[0]?.needsReauth === false, ) expect(state.fallbacks[0]?.needsReauth).toBe(false) + expect(tokenCalls).toBe(0) }) }) @@ -643,6 +858,12 @@ async function loadMainAndFetch( } describe('quota header feed integration', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + test('publishes a direct harvested response with the observation timestamp', async () => { const originalNow = Date.now let clock = 1_000_000 @@ -1360,6 +1581,39 @@ describe('quota header feed integration', () => { accounts: [], }), ) + const otherCheckedAt = 2_000_000 + const statePath = getAccountStatePath( + process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, + ) + await writeFile( + statePath, + `${JSON.stringify({ + version: 1, + main: { + quota: { + source: 'headers', + accountIdentity, + five_hour: { + usedPercent: 4, + remainingPercent: 96, + checkedAt: requestCheckedAt, + }, + seven_day: { + usedPercent: 52, + remainingPercent: 48, + checkedAt: requestCheckedAt, + }, + }, + quotaCheckedAt: otherCheckedAt, + quotaToken: tokenFingerprint(accessToken), + }, + })}\n`, + ) + const mixedState = await loadAccounts() + expect(mixedState?.quota?.mainQuota?.accountIdentity).toBe( + accountIdentity, + ) + expect(mixedState?.quota?.mainQuotaCheckedAt).toBe(otherCheckedAt) globalThis.fetch = mock((input: any) => { const url = extractUrl(input) if (url.includes('/claude_cli/bootstrap')) { @@ -1380,14 +1634,6 @@ describe('quota header feed integration', () => { } return Promise.resolve(Response.json({})) }) as unknown as typeof fetch - const stateWriteLock = await acquireRefreshFileLock({ - name: 'state-write', - ttlMs: 10_000, - path: process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - renew: true, - }) - if (!stateWriteLock) throw new Error('failed to hold account state lock') - const plugin = await getPlugin() const result = await plugin.auth.loader( () => @@ -1402,34 +1648,6 @@ describe('quota header feed integration', () => { const response = await result.fetch(MESSAGES_URL, EMPTY_POST) await response.text() - const otherCheckedAt = 2_000_000 - const statePath = getAccountStatePath( - process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, - ) - const state = JSON.parse(await readFile(statePath, 'utf8')) as { - main?: Record - } - state.main = { - ...(state.main ?? {}), - quota: { - source: 'poll', - accountIdentity: 'account-b', - checkedAt: otherCheckedAt, - five_hour: { - usedPercent: 80, - remainingPercent: 20, - checkedAt: otherCheckedAt, - }, - }, - quotaCheckedAt: otherCheckedAt, - quotaToken: tokenFingerprint('sk-ant-oat-account-b'), - } - await writeFile(statePath, `${JSON.stringify(state)}\n`) - await stateWriteLock.release() - await waitForAccountStorage( - (loaded) => loaded?.quota?.mainQuotaCheckedAt === otherCheckedAt, - ) - const published = ( await waitForFeedEntries( (entries) => @@ -2202,6 +2420,12 @@ describe('experimental.chat.system.transform', () => { }) describe('quota header feed extended integration', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + test('disabled quota header feed publishes nothing by default or when explicitly false', async () => { for (const enabled of [undefined, false]) { const storage = createFallbackStorage({ @@ -2781,6 +3005,12 @@ test('test setup keeps sidebar state off the production default path', () => { }) describe('Fable 5.1 request-scoped effort history', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + test('carries message variants into the OAuth body and strips the internal header', async () => { await useTempAccountFile( createFallbackStorage({ @@ -3125,7 +3355,6 @@ describe('auth.loader', () => { const originalDateNow = Date.now beforeEach(async () => { - globalThis.fetch = originalFetch pluginTimerOverrides = {} Math.random = originalRandom Date.now = originalDateNow @@ -3468,6 +3697,7 @@ describe('auth.loader', () => { await useTempAccountFile( createFallbackStorage({ routing: { mode: 'fallback-first' }, + quota: { enabled: false }, accounts: [ { id: 'work-deleted', @@ -3476,49 +3706,132 @@ describe('auth.loader', () => { refresh: 'work-deleted-refresh', expires: Date.now() + 100000, }, - ], - }), - ) - const plugin = await getPlugin() - // v1.16.0's mergeAccountsForSave unions existing+incoming accounts, so a - // deletion must be declared explicitly via removedAccountIds — a plain - // save without the account no longer removes it. - await saveAccounts( - createFallbackStorage({ - routing: { mode: 'fallback-first' }, - accounts: [ { - id: 'work-current', + id: 'work-witness', type: 'oauth', - access: 'work-current-access', - refresh: 'work-current-refresh', + access: 'work-witness-stale-access', + refresh: 'work-witness-stale-refresh', expires: Date.now() + 100000, + lastRefreshedAt: 100, }, ], }), - undefined, - { removedAccountIds: ['work-deleted'] }, ) - await seedSidebarRouting('work-deleted', 'fallback-first', Date.now()) - - await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'main-access', - refresh: 'main-refresh', - expires: Date.now() + 100000, + const refreshStarted = deferred() + const releaseRefresh = deferred() + globalThis.fetch = mock((input: any) => { + const url = extractUrl(input) + if (url === TOKEN_URL) { + refreshStarted.resolve() + return releaseRefresh.promise.then( + () => + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ) + } + if (url === PROFILE_URL) { + return Promise.resolve(new Response('unauthorized', { status: 401 })) + } + if (url === QUOTA_URL) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.1 }, + seven_day: { utilization: 0.1 }, + limits: [], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + return Promise.reject(new Error(`Unexpected test fetch: ${url}`)) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const backgroundRefreshReady = ( + plugin as unknown as { __fallbackRefreshReady?: Promise } + ).__fallbackRefreshReady + expect(backgroundRefreshReady).toBeInstanceOf(Promise) + try { + await withDeadlockGuard( + refreshStarted.promise, + 4_000, + 'fallback refresh never reached the token stub; the eager refresh did not start', + ) + // v1.16.0's mergeAccountsForSave unions existing+incoming accounts, so a + // deletion must be declared explicitly via removedAccountIds — a plain + // save without the account no longer removes it. + await saveAccounts( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { enabled: false }, + accounts: [ + { + id: 'work-current', + type: 'oauth', + access: 'work-current-access', + refresh: 'work-current-refresh', + expires: Date.now() + 100000, + }, + { + id: 'work-witness', + type: 'oauth', + access: 'work-witness-current-access', + refresh: 'work-witness-current-refresh', + expires: Date.now() + 100000, + lastRefreshedAt: 200, + }, + ], }), - { models: {} }, - ) - await drainSidebarWrites() + undefined, + { removedAccountIds: ['work-deleted'] }, + ) + await seedSidebarRouting('work-deleted', 'fallback-first', Date.now()) - const state = await getSidebarState() - expect(state.activeId).toBe('work-current') - expect(state.route).toBe('fallback-first') - expect(state.fallbacks.map((account) => account.id)).toEqual([ - 'work-current', - ]) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + releaseRefresh.resolve() + await withDeadlockGuard( + backgroundRefreshReady!, + 4_000, + 'fallback background refresh did not complete after the token stub was released', + ) + const disk = await loadAccounts() + expect(disk?.accounts.map((account) => account.id)).toEqual([ + 'work-current', + 'work-witness', + ]) + expect( + disk?.accounts.find((account) => account.id === 'work-current'), + ).toMatchObject({ + access: 'work-current-access', + refresh: 'work-current-refresh', + }) + expect( + disk?.accounts.find((account) => account.id === 'work-witness'), + ).toMatchObject({ + lastRefreshError: { status: 400, permanent: true }, + }) + const state = await waitForSidebarState( + (candidate) => candidate.activeId === 'work-current', + ) + expect(state.route).toBe('fallback-first') + expect(state.fallbacks.map((account) => account.id)).toEqual([ + 'work-current', + 'work-witness', + ]) + } finally { + releaseRefresh.resolve() + } }) test('boot ignores stale sidebar routing and derives fallback-first routing', async () => { @@ -5494,7 +5807,7 @@ describe('auth.loader', () => { }), }) - expect(capturedUrl).toBe('https://relay.example.test') + expect(capturedUrl).toBe('https://relay.example.test/') expect(capturedHeaders?.get('x-relay-token')).toBe('relay-token') const payload = JSON.parse(capturedBody!) expect(payload).toMatchObject({ @@ -5566,7 +5879,7 @@ describe('auth.loader', () => { }), }) - expect(capturedUrl).toBe('https://relay.example.test') + expect(capturedUrl).toBe('https://relay.example.test/') }) test('sidebar relay transport reflects current sidecar storage', async () => { @@ -6743,7 +7056,6 @@ describe('auth.loader', () => { ) await profileStarted await drainSidebarWrites() - const initialSidebarUpdatedAt = (await getSidebarState()).lastUpdated const rotated = await loadAccounts() const fallback = rotated?.accounts[0] @@ -6763,11 +7075,16 @@ describe('auth.loader', () => { }, }), ) - await waitForSidebarState( - (state) => state.lastUpdated > initialSidebarUpdatedAt, - ) - - const reloaded = await loadAccounts() + const reloaded = await waitForAccountStorage((storage) => { + const account = storage?.accounts.find( + (candidate) => candidate.id === 'fb', + ) + return ( + account?.type === 'oauth' && + account.profile?.tier === 'default_claude_max_5x' + ) + }) + await drainSidebarWrites() const reloadedFallback = reloaded?.accounts[0] expect(reloadedFallback).toMatchObject({ access: 'new-access', @@ -12409,14 +12726,18 @@ describe('auth.loader', () => { }), ) const records: LogTestRecord[] = [] + let relay503Calls = 0 + let directCalls = 0 __setLogTestSink((record) => records.push(record)) globalThis.fetch = mock((input: string | URL | Request) => { const url = extractUrl(input) - if (url === 'https://relay.example.test') { + if (url === 'https://relay.example.test/') { + relay503Calls += 1 return Promise.resolve( new Response('relay unavailable', { status: 503 }), ) } + directCalls += 1 return Promise.resolve( new Response('direct', { headers: quotaHeaders }), ) @@ -12430,6 +12751,8 @@ describe('auth.loader', () => { headers: { 'x-session-affinity': 'quota-relay-direct-fallback' }, }) expect(await response.text()).toBe('direct') + expect(relay503Calls).toBe(1) + expect(directCalls).toBe(1) await waitForState((value) => value.main?.quota?.source === 'headers') expect( records.filter( @@ -14246,7 +14569,6 @@ describe('killswitch fetch gate', () => { const originalFetch = globalThis.fetch beforeEach(() => { - globalThis.fetch = originalFetch process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' // Prevent this plugin instance's background intervals from leaking into // later tests without mutating process-global timers used by other files. @@ -14651,7 +14973,6 @@ describe('claude-prime direct request', () => { const originalSetInterval = globalThis.setInterval beforeEach(async () => { - globalThis.fetch = originalFetch globalThis.setInterval = mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval @@ -14993,6 +15314,7 @@ describe('claude-prime direct request', () => { expires: Date.now() + 5 * 60 * 60_000, } const mockClient = createMockClient() + const mainRefreshPublished = deferred() let lineageObservedBeforePublish: string | undefined let lineageObservedDuringPublish: string | undefined ;(mockClient.auth as any).set = mock( @@ -15013,6 +15335,7 @@ describe('claude-prime direct request', () => { 'main', process.env.OPENCODE_ANTHROPIC_AUTH_FILE, ) + mainRefreshPublished.resolve() }, ) let sends = 0 @@ -15069,7 +15392,16 @@ describe('claude-prime direct request', () => { const mainRefreshHandler = intervalHandlers.at(-1) expect(mainRefreshHandler).toBeDefined() mainRefreshHandler!() - await waitForMockCall(mockClient.auth.set) + await withDeadlockGuard( + mainRefreshPublished.promise, + 4_000, + 'main background refresh did not publish rotated auth', + ) + await waitForAccountStorage( + (storage) => + hostAuth.access === 'main-access-b' && + storage?.refresh?.mainRefreshLeaseId === undefined, + ) await manager.tick() const after = JSON.parse( @@ -15762,7 +16094,6 @@ describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { const originalSetInterval = globalThis.setInterval beforeEach(async () => { - globalThis.fetch = originalFetch globalThis.setInterval = mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval @@ -16135,7 +16466,6 @@ describe('claude-prime — warn dedup (R3)', () => { const originalSetInterval = globalThis.setInterval beforeEach(async () => { - globalThis.fetch = originalFetch globalThis.setInterval = mock( () => ({ unref() {} }) as unknown as ReturnType, ) as unknown as typeof setInterval diff --git a/packages/opencode/src/tests/info-logs.test.ts b/packages/opencode/src/tests/info-logs.test.ts index a11b67db..15f7526f 100644 --- a/packages/opencode/src/tests/info-logs.test.ts +++ b/packages/opencode/src/tests/info-logs.test.ts @@ -9,6 +9,11 @@ import { saveAccounts, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin } from '../index' +import { DEFAULT_FETCH_MOCK, installDefaultFetchMock } from './test-fetch' +import { + createTimerTracking, + type PluginTimerOverrides, +} from './timer-tracking' function createFallbackStorage(): AccountStorage { return { @@ -31,6 +36,14 @@ function createFallbackStorage(): AccountStorage { } let tempConfigDir: string +const originalFetch = globalThis.fetch +const timerTracking = createTimerTracking() +const { + activeIntervals, + disabledPluginTimerOverrides, + trackedClearInterval, + trackedSetInterval, +} = timerTracking async function useTempAccountFile(storage: AccountStorage) { if (tempConfigDir) { @@ -53,16 +66,27 @@ function createMockClient() { } } -async function getPlugin() { - return (await AnthropicAuthPlugin({ - // @ts-expect-error: minimal mock for testing - client: createMockClient(), - })) as Promise +async function getPlugin(timerOverrides?: PluginTimerOverrides) { + const defaultTimerOverrides = disabledPluginTimerOverrides() + return (await ( + AnthropicAuthPlugin as unknown as ( + ctx: Parameters[0], + timers?: PluginTimerOverrides, + ) => ReturnType + )( + { + // @ts-expect-error: minimal mock for testing + client: createMockClient(), + }, + { ...defaultTimerOverrides, ...timerOverrides }, + )) as Promise } let capturedRecords: LogTestRecord[] = [] beforeEach(() => { + timerTracking.reset() + installDefaultFetchMock() capturedRecords = [] __setLogTestSink((record) => { capturedRecords.push(record) @@ -70,13 +94,42 @@ beforeEach(() => { }) afterEach(async () => { - __setLogTestSink(null) - delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE - if (tempConfigDir) { - await rm(tempConfigDir, { recursive: true, force: true }).catch(() => {}) + try { + // Restore only the fixture's tagged mock; an untagged custom mock left + // installed must reach the preload's leak detector, not be masked here. + const currentFetch = globalThis.fetch as + | (typeof fetch & { [DEFAULT_FETCH_MOCK]?: true }) + | undefined + if (currentFetch?.[DEFAULT_FETCH_MOCK]) { + globalThis.fetch = originalFetch + } + __setLogTestSink(null) + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + if (tempConfigDir) { + await rm(tempConfigDir, { recursive: true, force: true }).catch(() => {}) + } + } finally { + expect(activeIntervals.size).toBe(0) } }) +test('does not retain a background interval unless the helper opts in', async () => { + await useTempAccountFile(createFallbackStorage()) + timerTracking.reset() + await getPlugin() + expect(timerTracking.disabledIntervalCalls).toBe(1) + expect(activeIntervals.size).toBe(0) + + await timerTracking.withTrackedInterval(async () => { + await getPlugin({ + setInterval: trackedSetInterval, + clearInterval: trackedClearInterval, + }) + expect(activeIntervals.size).toBe(1) + }) + expect(activeIntervals.size).toBe(0) +}) + async function executeCommand( plugin: any, command: string, diff --git a/packages/opencode/src/tests/network-guard-utils.ts b/packages/opencode/src/tests/network-guard-utils.ts new file mode 100644 index 00000000..3a28f60f --- /dev/null +++ b/packages/opencode/src/tests/network-guard-utils.ts @@ -0,0 +1,75 @@ +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']) + +function isIpv4Loopback(hostname: string) { + const octets = hostname.split('.') + return ( + octets.length === 4 && + octets[0] === '127' && + octets + .slice(1) + .every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ) +} + +function mappedIpv4Hostname(hostname: string) { + const match = hostname.match(/^\[::ffff:([^\]]+)\]$/i) + if (!match) return undefined + + const tail = match[1] + if (!tail) return undefined + + // URL parsing canonicalizes mapped addresses to the 2-hextet form + // ([::ffff:127.0.0.1] -> [::ffff:7f00:1]), so only hextets arrive here. + const hextets = tail.split(':') + if ( + hextets.length !== 2 || + hextets.some((hextet) => !/^[\da-f]{1,4}$/i.test(hextet)) + ) { + return undefined + } + const [highHextet, lowHextet] = hextets + if (!highHextet || !lowHextet) return undefined + const high = Number.parseInt(highHextet, 16) + const low = Number.parseInt(lowHextet, 16) + return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.') +} + +export function fetchUrl(input: Parameters[0]) { + try { + if (typeof input === 'string') return new URL(input) + if (input instanceof URL) return input + return new URL(input.url) + } catch { + return undefined + } +} + +export function assertLoopback(url: URL) { + const hostname = url.hostname.endsWith('.') + ? url.hostname.slice(0, -1) + : url.hostname + const isLoopback = + LOOPBACK_HOSTS.has(hostname) || + isIpv4Loopback(hostname) || + isIpv4Loopback(mappedIpv4Hostname(hostname) ?? '') + if (!isLoopback) { + throw new Error( + `Blocked non-loopback fetch to ${url}; stub globalThis.fetch in the test`, + ) + } +} + +export function assertPreconnectUrl( + input: Parameters[0], +) { + const url = fetchUrl( + input as unknown as Parameters[0], + ) + if (!url) { + throw new Error( + 'Blocked fetch with an unparseable URL; stub globalThis.fetch in the test', + ) + } + assertLoopback(url) + return url +} diff --git a/packages/opencode/src/tests/network-guard.test.ts b/packages/opencode/src/tests/network-guard.test.ts new file mode 100644 index 00000000..990bd2c7 --- /dev/null +++ b/packages/opencode/src/tests/network-guard.test.ts @@ -0,0 +1,596 @@ +import { describe, expect, mock, test } from 'bun:test' +import { + QUOTA_URL as CORE_QUOTA_URL, + TOKEN_URL as CORE_TOKEN_URL, +} from '@cortexkit/anthropic-auth-core' +import { assertLoopback } from './network-guard-utils' +import { createGuardedFetch, MAX_REDIRECTS } from './setup' +import { + DEFAULT_FETCH_MOCK, + installDefaultFetchMock, + MESSAGES_URL, +} from './test-fetch' + +describe('test network guard', () => { + test('rejects non-loopback fetches with an actionable error', async () => { + await expect( + globalThis.fetch('https://example.invalid/provider'), + ).rejects.toThrow( + 'Blocked non-loopback fetch to https://example.invalid/provider; stub globalThis.fetch in the test', + ) + }) + + test('rejects fetches whose URL cannot be parsed', async () => { + await expect(globalThis.fetch({} as Request)).rejects.toThrow( + 'Blocked fetch with an unparseable URL; stub globalThis.fetch in the test', + ) + }) + + test('allows fetches to loopback servers', async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response('ok'), + }) + + try { + const response = await globalThis.fetch( + `http://127.0.0.1:${server.port}/health`, + ) + expect(response.status).toBe(200) + expect(await response.text()).toBe('ok') + } finally { + server.stop(true) + } + }) + + test('allows all IPv4 addresses in the loopback block', async () => { + for (const host of ['127.0.0.2', '127.1.2.3']) { + expect(() => + assertLoopback(new URL(`http://${host}/health`)), + ).not.toThrow() + } + }) + + test('rejects near-miss IPv4 addresses and loopback-looking hostnames', async () => { + for (const host of ['128.0.0.1', '27.0.0.1', '127.0.0.1.evil.com']) { + await expect( + globalThis.fetch(`http://${host}:8443/provider`), + ).rejects.toThrow( + `Blocked non-loopback fetch to http://${host}:8443/provider; stub globalThis.fetch in the test`, + ) + } + }) + + test('rejects a loopback redirect to a non-loopback URL', async () => { + const server = Bun.serve({ + port: 0, + fetch: () => Response.redirect('https://example.invalid/pwned', 302), + }) + + try { + await expect( + globalThis.fetch(`http://127.0.0.1:${server.port}/redirect`), + ).rejects.toThrow( + 'Blocked non-loopback fetch to https://example.invalid/pwned; stub globalThis.fetch in the test', + ) + } finally { + server.stop(true) + } + }) + + test('follows redirects that stay on loopback', async () => { + let requests = 0 + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + requests += 1 + const path = new URL(request.url).pathname + if (path === '/redirect') { + return Response.redirect( + new URL('/middle', request.url).toString(), + 302, + ) + } + if (path === '/middle') { + return Response.redirect( + new URL('/final', request.url).toString(), + 302, + ) + } + return new Response( + new ReadableStream({ + start(controller) { + setTimeout(() => { + controller.enqueue( + new TextEncoder().encode('final body intact'), + ) + controller.close() + }, 25) + }, + }), + ) + }, + }) + + try { + const response = await globalThis.fetch( + `http://127.0.0.1:${server.port}/redirect`, + ) + expect(response.status).toBe(200) + expect(await response.text()).toBe('final body intact') + expect(requests).toBe(3) + } finally { + server.stop(true) + } + }) + + test('propagates abort signals through a non-switching redirect', async () => { + let requests = 0 + let secondHopStarted!: () => void + const secondHopReady = new Promise((resolve) => { + secondHopStarted = resolve + }) + let responseTimer: ReturnType | undefined + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + requests += 1 + if (requests === 1) { + return Response.redirect( + new URL('/second', request.url).toString(), + 307, + ) + } + return new Response( + new ReadableStream({ + start(controller) { + secondHopStarted() + responseTimer = setTimeout(() => { + controller.enqueue(new TextEncoder().encode('done')) + controller.close() + }, 500) + }, + cancel() { + if (responseTimer) clearTimeout(responseTimer) + }, + }), + ) + }, + }) + + try { + const controller = new AbortController() + const pending = globalThis.fetch( + `http://127.0.0.1:${server.port}/first`, + { method: 'POST', body: 'payload', signal: controller.signal }, + ) + await secondHopReady + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(requests).toBe(2) + } finally { + if (responseTimer) clearTimeout(responseTimer) + server.stop(true) + } + }) + + test('rejects after exceeding the loopback redirect cap', async () => { + const redirectHops = 21 + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + const hop = Number(new URL(request.url).pathname.slice('/hop/'.length)) + if (hop >= redirectHops) return new Response('ok') + return Response.redirect( + new URL(`/hop/${hop + 1}`, request.url).toString(), + 302, + ) + }, + }) + + try { + await expect( + globalThis.fetch(`http://127.0.0.1:${server.port}/hop/0`), + ).rejects.toThrow('Too many redirects while fetching') + } finally { + server.stop(true) + } + }) + + test('allows a redirect chain at the loopback redirect cap boundary', async () => { + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + const hop = Number(new URL(request.url).pathname.slice('/hop/'.length)) + if (hop < MAX_REDIRECTS) { + return Response.redirect( + new URL(`/hop/${hop + 1}`, request.url).toString(), + 302, + ) + } + return new Response('ok') + }, + }) + + try { + const response = await globalThis.fetch( + `http://127.0.0.1:${server.port}/hop/0`, + ) + expect(response.status).toBe(200) + expect(await response.text()).toBe('ok') + } finally { + server.stop(true) + } + }) + + test('rewrites 301 and 302 non-GET requests but preserves 307 and 308 requests', async () => { + for (const status of [301, 302, 307, 308]) { + for (const method of ['PUT', 'POST']) { + const seen: Array<{ method: string; body: string }> = [] + const nativeFetch = Object.assign( + async (request: Request) => { + seen.push({ + method: request.method, + body: await request.clone().text(), + }) + if (seen.length === 1) { + return new Response(null, { + status, + headers: { location: 'http://127.0.0.1/final' }, + }) + } + return new Response('ok') + }, + { preconnect: () => {} }, + ) as unknown as typeof globalThis.fetch + + const response = await createGuardedFetch(nativeFetch)( + 'http://127.0.0.1/first', + { method, body: 'payload' }, + ) + + expect(await response.text()).toBe('ok') + const rewrites = status === 301 || status === 302 + expect(seen).toEqual([ + { method, body: 'payload' }, + { + method: rewrites ? 'GET' : method, + body: rewrites ? '' : 'payload', + }, + ]) + } + } + }) + + test('cancels intermediate redirect bodies but preserves the final body', async () => { + let requests = 0 + let intermediateCancels = 0 + let finalCancels = 0 + const nativeFetch = Object.assign( + async () => { + requests += 1 + if (requests === 1) { + return new Response( + new ReadableStream({ + cancel() { + intermediateCancels += 1 + }, + }), + { + status: 302, + headers: { location: 'http://127.0.0.1/second' }, + }, + ) + } + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('terminal body')) + controller.close() + }, + cancel() { + finalCancels += 1 + }, + }), + ) + }, + { preconnect: () => {} }, + ) as unknown as typeof globalThis.fetch + + const guardedFetch = createGuardedFetch(nativeFetch) + const response = await guardedFetch('http://127.0.0.1/first') + + expect(await response.text()).toBe('terminal body') + expect(requests).toBe(2) + expect(intermediateCancels).toBe(1) + expect(finalCancels).toBe(0) + }) + + test('rejects a non-loopback target after multiple loopback hops', async () => { + let requests = 0 + const server = Bun.serve({ + port: 0, + fetch: (request): Response => { + requests += 1 + const path = new URL(request.url).pathname + if (path === '/first') { + return Response.redirect( + new URL('/second', request.url).toString(), + 302, + ) + } + return Response.redirect('https://example.invalid/pwned', 302) + }, + }) + + try { + await expect( + globalThis.fetch(`http://127.0.0.1:${server.port}/first`), + ).rejects.toThrow( + 'Blocked non-loopback fetch to https://example.invalid/pwned; stub globalThis.fetch in the test', + ) + expect(requests).toBe(2) + } finally { + server.stop(true) + } + }) + + // URL.hostname keeps the brackets on an IPv6 literal, so the allow-list entry + // has to carry them too or every [::1] request reads as non-loopback. + test('allows the IPv6 loopback literal', () => { + expect(() => assertLoopback(new URL('http://[::1]/health'))).not.toThrow() + }) + + test('allows IPv4-mapped IPv6 loopback and blocks non-loopback variants', () => { + for (const host of [ + '[::ffff:127.0.0.1]', + '[::ffff:7f00:1]', + '127.0.0.2', + '127.1.2.3', + ]) { + expect(() => + assertLoopback(new URL(`http://${host}/health`)), + ).not.toThrow() + } + for (const host of ['[::ffff:8.8.8.8]', '127.0.0.1.evil.com']) { + expect(() => assertLoopback(new URL(`http://${host}/health`))).toThrow( + 'Blocked non-loopback fetch to', + ) + } + }) + + test('allows one trailing dot on localhost but not ambiguous names', () => { + expect(() => + assertLoopback(new URL('http://localhost./health')), + ).not.toThrow() + expect(() => assertLoopback(new URL('http://localhost../health'))).toThrow( + 'Blocked non-loopback fetch to', + ) + expect(() => + assertLoopback(new URL('http://evil.localhost./health')), + ).toThrow('Blocked non-loopback fetch to') + }) + + test('strips credentials across loopback origins but preserves same-origin redirects', async () => { + const crossOriginHeaders: Record[] = [] + const sameOriginHeaders: Record[] = [] + const serverB = Bun.serve({ + port: 0, + fetch: (request) => { + const headers = new Headers(request.headers) + crossOriginHeaders.push({ + authorization: headers.get('authorization'), + 'proxy-authorization': headers.get('proxy-authorization'), + cookie: headers.get('cookie'), + 'x-api-key': headers.get('x-api-key'), + }) + return new Response('cross-origin') + }, + }) + const serverA = Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url) + if (url.pathname === '/same-target') { + const headers = new Headers(request.headers) + sameOriginHeaders.push({ + authorization: headers.get('authorization'), + 'proxy-authorization': headers.get('proxy-authorization'), + cookie: headers.get('cookie'), + 'x-api-key': headers.get('x-api-key'), + }) + return new Response('same-origin') + } + if (url.pathname === '/same-origin') { + return Response.redirect( + new URL('/same-target', request.url).toString(), + 307, + ) + } + return Response.redirect( + new URL( + `${url.pathname}-target`, + `http://127.0.0.1:${serverB.port}`, + ).toString(), + url.pathname === '/cross-switch' ? 302 : 307, + ) + }, + }) + + try { + const credentials = { + Authorization: 'Bearer secret', + 'Proxy-Authorization': 'Basic secret', + Cookie: 'session=secret', + 'x-api-key': 'secret', + } + for (const path of ['/cross-switch', '/cross-preserve']) { + const response = await globalThis.fetch( + `http://127.0.0.1:${serverA.port}${path}`, + { method: 'POST', headers: credentials, body: 'payload' }, + ) + expect(await response.text()).toBe('cross-origin') + } + const sameOriginFetch = await globalThis.fetch( + `http://127.0.0.1:${serverA.port}/same-origin`, + { headers: credentials }, + ) + expect(await sameOriginFetch.text()).toBe('same-origin') + } finally { + serverA.stop(true) + serverB.stop(true) + } + + expect(crossOriginHeaders).toHaveLength(2) + expect(crossOriginHeaders).toEqual([ + { + authorization: null, + 'proxy-authorization': null, + cookie: null, + 'x-api-key': null, + }, + { + authorization: null, + 'proxy-authorization': null, + cookie: null, + 'x-api-key': null, + }, + ]) + expect(sameOriginHeaders).toEqual([ + { + authorization: 'Bearer secret', + 'proxy-authorization': 'Basic secret', + cookie: 'session=secret', + 'x-api-key': 'secret', + }, + ]) + }) + + test('does not interfere with a test stub assigned to globalThis.fetch', async () => { + const realFetch = globalThis.fetch + const stub = mock(() => Promise.resolve(new Response('stubbed'))) + globalThis.fetch = stub as unknown as typeof globalThis.fetch + + try { + const response = await globalThis.fetch( + 'https://api.anthropic.com/v1/messages', + ) + expect(response.status).toBe(200) + expect(await response.text()).toBe('stubbed') + expect(stub).toHaveBeenCalledTimes(1) + } finally { + globalThis.fetch = realFetch + } + }) + + test('shared provider fixture handles OAuth token exchange locally', async () => { + const realFetch = globalThis.fetch + installDefaultFetchMock() + + try { + const response = await globalThis.fetch(CORE_TOKEN_URL) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'invalid_grant' }) + const quotaResponse = await globalThis.fetch(CORE_QUOTA_URL) + expect(quotaResponse.status).toBe(401) + } finally { + globalThis.fetch = realFetch + } + }) + + test('shared provider fixture rejects URLs that only share the messages prefix', async () => { + const realFetch = globalThis.fetch + installDefaultFetchMock() + + try { + await expect(globalThis.fetch(`${MESSAGES_URL}/count`)).rejects.toThrow( + `Unexpected test fetch: ${MESSAGES_URL}/count`, + ) + } finally { + globalThis.fetch = realFetch + } + }) + + test('shared provider fixture recognises messages query parameters', async () => { + const realFetch = globalThis.fetch + installDefaultFetchMock() + + try { + const response = await globalThis.fetch(`${MESSAGES_URL}?beta=true`) + expect(response.status).toBe(401) + } finally { + globalThis.fetch = realFetch + } + }) + + test('default provider fixture guards preconnect in both directions', () => { + const realFetch = globalThis.fetch + installDefaultFetchMock() + + try { + expect(() => + globalThis.fetch.preconnect('http://127.0.0.1:8080/provider'), + ).not.toThrow() + expect(() => + globalThis.fetch.preconnect('https://example.invalid:8443/provider'), + ).toThrow( + 'Blocked non-loopback fetch to https://example.invalid:8443/provider; stub globalThis.fetch in the test', + ) + } finally { + globalThis.fetch = realFetch + } + }) + + test('preconnect rejects non-loopback hosts', () => { + expect(() => + globalThis.fetch.preconnect('https://example.invalid:8443/provider'), + ).toThrow( + 'Blocked non-loopback fetch to https://example.invalid:8443/provider; stub globalThis.fetch in the test', + ) + }) + + test('preconnect allows loopback hosts', async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response('ok'), + }) + + try { + expect(() => + globalThis.fetch.preconnect(`http://127.0.0.1:${server.port}`), + ).not.toThrow() + const response = await globalThis.fetch( + `http://127.0.0.1:${server.port}/health`, + ) + expect(response.status).toBe(200) + } finally { + server.stop(true) + } + }) + + test('preconnect no-ops on an impl without preconnect after the loopback check', () => { + const bare = (async () => + new Response('ok')) as unknown as typeof globalThis.fetch + const guarded = createGuardedFetch(bare) + expect(() => guarded.preconnect('http://127.0.0.1:9')).not.toThrow() + expect(() => + guarded.preconnect('https://example.invalid/provider'), + ).toThrow( + 'Blocked non-loopback fetch to https://example.invalid/provider; stub globalThis.fetch in the test', + ) + }) + + test('restores the guard after a test leaves the default mock installed', () => { + installDefaultFetchMock() + expect( + (globalThis.fetch as { [DEFAULT_FETCH_MOCK]?: true })[DEFAULT_FETCH_MOCK], + ).toBe(true) + }) + + // Deliberately runs before "restores the network guard after a default fetch mock is left behind". + test('restores the network guard after a default fetch mock is left behind', async () => { + await expect(globalThis.fetch(MESSAGES_URL)).rejects.toThrow( + `Blocked non-loopback fetch to ${MESSAGES_URL}; stub globalThis.fetch in the test`, + ) + }) +}) diff --git a/packages/opencode/src/tests/setup.ts b/packages/opencode/src/tests/setup.ts index bb679b2f..278c91f2 100644 --- a/packages/opencode/src/tests/setup.ts +++ b/packages/opencode/src/tests/setup.ts @@ -1,9 +1,171 @@ +import { afterAll, afterEach } from 'bun:test' import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + assertLoopback, + assertPreconnectUrl, + fetchUrl, +} from './network-guard-utils' +import { DEFAULT_FETCH_MOCK } from './test-fetch' + +const nativeFetch = globalThis.fetch +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) +const CROSS_ORIGIN_CREDENTIAL_HEADERS = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'x-api-key', +] +export const MAX_REDIRECTS = 20 +type GuardRequest = InstanceType +type GuardRequestInit = NonNullable< + ConstructorParameters[1] +> + +function buildRedirectRequest( + request: GuardRequest, + url: URL, + status: number, +): GuardRequest { + const switchesToGet = + (status === 301 || status === 302) && + !['GET', 'HEAD'].includes(request.method) + ? true + : status === 303 && !['GET', 'HEAD'].includes(request.method) + const headers = new Headers(request.headers) + if (new URL(request.url).origin !== url.origin) { + for (const name of CROSS_ORIGIN_CREDENTIAL_HEADERS) headers.delete(name) + } + if (!switchesToGet) { + return new globalThis.Request(url.toString(), { + method: request.method, + headers, + body: request.body, + redirect: 'manual', + credentials: request.credentials, + cache: request.cache, + integrity: request.integrity, + keepalive: request.keepalive, + mode: request.mode, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + signal: request.signal, + }) + } + + for (const name of [ + 'content-encoding', + 'content-language', + 'content-location', + 'content-type', + 'content-length', + ]) { + headers.delete(name) + } + return new globalThis.Request(url.toString(), { + method: 'GET', + headers, + redirect: 'manual', + credentials: request.credentials, + cache: request.cache, + integrity: request.integrity, + keepalive: request.keepalive, + mode: request.mode, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + signal: request.signal, + }) +} + +export function createGuardedFetch( + nativeFetchImpl: typeof nativeFetch = nativeFetch, +) { + return Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + if (!fetchUrl(input)) { + throw new Error( + 'Blocked fetch with an unparseable URL; stub globalThis.fetch in the test', + ) + } + + let request = new globalThis.Request( + input as unknown as ConstructorParameters[0], + { ...init, redirect: 'manual' } as GuardRequestInit, + ) + for (let redirects = 0; ; redirects++) { + assertLoopback(new URL(request.url)) + const replayableRequest = request.clone() + const response = await Reflect.apply(nativeFetchImpl, globalThis, [ + request, + ]) + if (!REDIRECT_STATUSES.has(response.status)) return response + const location = response.headers.get('location') + if (!location) return response + await response.body?.cancel() + if (redirects >= MAX_REDIRECTS) { + throw new Error(`Too many redirects while fetching ${request.url}`) + } + + let nextURL: URL + try { + nextURL = new URL(location, request.url) + } catch { + throw new Error( + 'Blocked redirect with an unparseable URL; stub globalThis.fetch in the test', + ) + } + request = buildRedirectRequest( + replayableRequest as unknown as GuardRequest, + nextURL, + response.status, + ) + } + }, + { + preconnect: (input: Parameters[0]) => { + assertPreconnectUrl(input) + // Preconnect is an advisory hint; an impl without it has nothing to do. + // The loopback assertion above already enforced the safety property. + if (typeof nativeFetchImpl.preconnect !== 'function') return + return nativeFetchImpl.preconnect(input) + }, + }, + ) +} + +const guardedFetch = createGuardedFetch() + +afterEach(() => { + // A destroyed global is itself a leak; read the tag only off a present value + // so the report stays the named leak error rather than a raw TypeError. + const currentFetch = globalThis.fetch as + | (typeof fetch & { [DEFAULT_FETCH_MOCK]?: true }) + | undefined + const isDefaultFetchMock = Boolean(currentFetch?.[DEFAULT_FETCH_MOCK]) + if (currentFetch !== guardedFetch && !isDefaultFetchMock) { + globalThis.fetch = guardedFetch + throw new Error( + 'fetch mock leaked past its owning test; Bun reports the offending test name above', + ) + } + if (isDefaultFetchMock) globalThis.fetch = guardedFetch +}) + +// Keep a data property so Bun's spyOn can replace fetch and tests can restore +// the captured guarded implementation afterward. +globalThis.fetch = guardedFetch const testDir = mkdtempSync(join(tmpdir(), 'anthropic-auth-opencode-test-')) +afterAll(async () => { + await rm(testDir, { recursive: true, force: true }).catch(() => {}) +}) + process.env.OPENCODE_ANTHROPIC_AUTH_TEST_DIR = testDir process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join(testDir, 'anthropic-auth.json') process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( diff --git a/packages/opencode/src/tests/test-fetch.ts b/packages/opencode/src/tests/test-fetch.ts new file mode 100644 index 00000000..7b4c9f8a --- /dev/null +++ b/packages/opencode/src/tests/test-fetch.ts @@ -0,0 +1,68 @@ +import { mock } from 'bun:test' +import { QUOTA_URL, TOKEN_URL } from '@cortexkit/anthropic-auth-core' +import { assertPreconnectUrl, fetchUrl } from './network-guard-utils' + +export const MESSAGES_URL = 'https://api.anthropic.com/v1/messages' +export const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile' +export const DEFAULT_FETCH_MOCK = Symbol('anthropic-auth.default-fetch-mock') +export { QUOTA_URL, TOKEN_URL } + +const messagesURL = new URL(MESSAGES_URL) + +export type TestFetchInput = Parameters[0] + +export function extractUrl(input: TestFetchInput): string { + const parsed = fetchUrl(input) + if (parsed) return parsed.href + if (typeof input === 'string') return input + return input instanceof URL ? input.toString() : input.url +} + +function isMessagesURL(url: string) { + try { + const parsed = new URL(url) + return ( + parsed.origin === messagesURL.origin && + parsed.pathname === messagesURL.pathname + ) + } catch { + return false + } +} + +export function installDefaultFetchMock() { + const fetchMock = Object.assign( + mock((input: TestFetchInput) => { + const url = extractUrl(input) + if (url === TOKEN_URL) { + return Promise.resolve( + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ) + } + if (url === PROFILE_URL) { + return Promise.resolve(new Response('unauthorized', { status: 401 })) + } + if (url === QUOTA_URL) { + return Promise.resolve(new Response('unauthorized', { status: 401 })) + } + if (isMessagesURL(url)) { + return Promise.resolve(new Response('unauthorized', { status: 401 })) + } + return Promise.reject(new Error(`Unexpected test fetch: ${url}`)) + }), + { + preconnect: ( + input: Parameters[0], + ) => { + assertPreconnectUrl(input) + }, + }, + ) as unknown as typeof fetch & { + [DEFAULT_FETCH_MOCK]?: true + } + Object.defineProperty(fetchMock, DEFAULT_FETCH_MOCK, { value: true }) + globalThis.fetch = fetchMock +} diff --git a/packages/opencode/src/tests/timer-tracking.test.ts b/packages/opencode/src/tests/timer-tracking.test.ts new file mode 100644 index 00000000..26a6f76c --- /dev/null +++ b/packages/opencode/src/tests/timer-tracking.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'bun:test' +import { createTimerTracking } from './timer-tracking' + +test('reset disarms leaked tracked intervals before dropping their handles', async () => { + const tracking = createTimerTracking() + let ticks = 0 + const timer = tracking.trackedSetInterval(() => { + ticks += 1 + }, 10) + + try { + expect(tracking.activeIntervals.size).toBe(1) + + tracking.reset() + expect(tracking.activeIntervals.size).toBe(0) + const ticksAfterReset = ticks + await Bun.sleep(35) + + expect(ticks).toBe(ticksAfterReset) + } finally { + tracking.originalClearInterval(timer) + } +}) diff --git a/packages/opencode/src/tests/timer-tracking.ts b/packages/opencode/src/tests/timer-tracking.ts new file mode 100644 index 00000000..1fa0953a --- /dev/null +++ b/packages/opencode/src/tests/timer-tracking.ts @@ -0,0 +1,66 @@ +import { mock } from 'bun:test' + +export type PluginTimerOverrides = Partial<{ + setInterval: typeof globalThis.setInterval + clearInterval: typeof globalThis.clearInterval +}> + +export function createTimerTracking() { + const originalSetInterval = globalThis.setInterval + const originalClearInterval = globalThis.clearInterval + const activeIntervals = new Set>() + let disabledIntervalCalls = 0 + + function disabledPluginTimerOverrides(): PluginTimerOverrides { + return { + // Background intervals must not outlive the test-scoped fetch mock they captured. + setInterval: mock(() => { + disabledIntervalCalls += 1 + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof setInterval, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + } + } + + const trackedSetInterval = mock((...args: Parameters) => { + const timer = originalSetInterval(...args) + activeIntervals.add(timer) + return timer + }) as unknown as typeof setInterval + const trackedClearInterval = mock( + (timer: Parameters[0]) => { + // Forward only handles this helper created; the disabled mock's fake + // handle object must not reach the real clearInterval. + if (activeIntervals.delete(timer as ReturnType)) { + originalClearInterval(timer) + } + }, + ) as unknown as typeof clearInterval + + return { + originalSetInterval, + originalClearInterval, + activeIntervals, + disabledPluginTimerOverrides, + trackedSetInterval, + trackedClearInterval, + get disabledIntervalCalls() { + return disabledIntervalCalls + }, + reset() { + disabledIntervalCalls = 0 + for (const timer of activeIntervals) originalClearInterval(timer) + activeIntervals.clear() + }, + async withTrackedInterval(callback: () => T | Promise): Promise { + const existingIntervals = new Set(activeIntervals) + try { + return await callback() + } finally { + for (const timer of activeIntervals) { + if (!existingIntervals.has(timer)) trackedClearInterval(timer) + } + } + }, + } +}