From fcbd9fc95f7d961fa64739442ed32f3531649ed4 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:32:16 +0200 Subject: [PATCH] test(opencode): stop the suite reaching live provider endpoints Fail-loud preload network guard for the test suite: any non-loopback HTTP(S) fetch throws, including across redirects (manual redirect following, 20-hop cap, unparseable URLs fail closed). Replaces 44 live vendor requests per run with deterministic local stubs, isolates background intervals and temp dirs, and asserts no test leaves globalThis.fetch or an interval behind. Production fixes found by the guard: account-state membership treats a missing or unparseable config as UNKNOWN (no pruning) rather than empty, loader/membership agree on whitespace-normalized ids, and rotated credentials persist under the canonical key. Loopback classification tests call assertLoopback() directly instead of opening sockets (127.0.0.2:1 hangs to timeout on Darwin), and the upstream Fable 5.1 effort test restores globalThis.fetch after itself. --- packages/core/src/accounts.ts | 108 ++- packages/core/src/quota-header-feed.ts | 1 + packages/opencode/src/index.ts | 7 +- .../src/tests/account-command.test.ts | 100 ++- packages/opencode/src/tests/accounts.test.ts | 795 ++++++++++++++++++ .../src/tests/add-account-flows.test.ts | 99 ++- packages/opencode/src/tests/index.test.ts | 526 +++++++++--- packages/opencode/src/tests/info-logs.test.ts | 71 +- .../opencode/src/tests/network-guard-utils.ts | 75 ++ .../opencode/src/tests/network-guard.test.ts | 596 +++++++++++++ packages/opencode/src/tests/setup.ts | 162 ++++ packages/opencode/src/tests/test-fetch.ts | 68 ++ .../opencode/src/tests/timer-tracking.test.ts | 23 + packages/opencode/src/tests/timer-tracking.ts | 66 ++ 14 files changed, 2543 insertions(+), 154 deletions(-) create mode 100644 packages/opencode/src/tests/network-guard-utils.ts create mode 100644 packages/opencode/src/tests/network-guard.test.ts create mode 100644 packages/opencode/src/tests/test-fetch.ts create mode 100644 packages/opencode/src/tests/timer-tracking.test.ts create mode 100644 packages/opencode/src/tests/timer-tracking.ts 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) + } + } + }, + } +}