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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 89 additions & 19 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,25 @@ function mergeConfigAccountAndState(
return { ...account, ...stateAccount }
}

function configAccountHasInvalidShape(account: Record<string, unknown>) {
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,
Expand Down Expand Up @@ -970,10 +989,13 @@ function mergeConfigAndState(
const accounts = Array.isArray(configValue.accounts)
? configValue.accounts.map((account) => {
if (!isRecord(account)) return account
const stateAccount: Record<string, unknown> =
typeof account.id === 'string' && isRecord(stateAccounts[account.id])
? (stateAccounts[account.id] as Record<string, unknown>)
: {}
const rawId = typeof account.id === 'string' ? account.id : undefined
const stateValue = rawId
? (stateAccounts[rawId] ?? stateAccounts[rawId.trim()])
: undefined
const stateAccount: Record<string, unknown> = isRecord(stateValue)
? (stateValue as Record<string, unknown>)
: {}
return mergeConfigAccountAndState(account, stateAccount)
})
: []
Expand Down Expand Up @@ -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<string>()
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<string, unknown> = isRecord(stateValue)
? (stateValue as Record<string, unknown>)
: {}
const incomingAccount = incomingAccounts[id] as
| Record<string, unknown>
| 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]
}
}
}

Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/quota-header-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
7 changes: 3 additions & 4 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,9 +1286,7 @@ const anthropicAuthPlugin = async (
? {
...entry,
quota: mergedQuota,
checkedAt: persistedQuotaBelongsToRequest
? (reloaded.quota?.mainQuotaCheckedAt ?? entry.checkedAt)
: entry.checkedAt,
checkedAt: entry.checkedAt,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This change drops preservation of the persisted mainQuotaCheckedAt and always reports entry.checkedAt. Under a concurrent writer harvesting quota for the same main account (multi-process is supported here), the reloaded mainQuotaCheckedAt — which mergeHeaderQuotaForPersistence merges from — can be newer than this request's entry.checkedAt, so publishQuotaHeaderFeed publishes observed_at_ms that is older than the newest quota window data it carries. It also contradicts the PR description's stated goal of "preserve quota timestamps." Please confirm this is intentional; in the single-process case the two are identical, so the change only rewrites cross-process behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/index.ts, line 1258:

<comment>This change drops preservation of the persisted `mainQuotaCheckedAt` and always reports `entry.checkedAt`. Under a concurrent writer harvesting quota for the same main account (multi-process is supported here), the reloaded `mainQuotaCheckedAt` — which `mergeHeaderQuotaForPersistence` merges from — can be newer than this request's `entry.checkedAt`, so `publishQuotaHeaderFeed` publishes `observed_at_ms` that is older than the newest quota window data it carries. It also contradicts the PR description's stated goal of "preserve quota timestamps." Please confirm this is intentional; in the single-process case the two are identical, so the change only rewrites cross-process behavior.</comment>

<file context>
@@ -1255,9 +1255,7 @@ const anthropicAuthPlugin = async (
-            checkedAt: persistedQuotaBelongsToRequest
-              ? (reloaded.quota?.mainQuotaCheckedAt ?? entry.checkedAt)
-              : entry.checkedAt,
+            checkedAt: entry.checkedAt,
           }
         : entry
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half right, and the half that's right is already fixed in a different PR — but not restoring the adoption, because that is a cross-account leak.

Why it was removed. The persisted top-level mainQuotaCheckedAt carries no account binding of its own, while the quota snapshot beside it does. The old code validated identity on the snapshot and then trusted the unbound timestamp, so a published feed entry for account A could carry account B's observation time. Reproduced deterministically: quota identity account-a with top-level timestamp 2_000_000 publishes observed_at_ms=2_000_000 for a request whose own observation was 1_000_000. It surfaced first as an intermittent CI failure, which is how I found it — removing ~300ms of network latency from the suite made the interleaving reachable where it previously wasn't. The same unbound pair exists at a second consumer (getPersistedMainQuotaseedMainFromStorage), filed as #177.

So restoring the adoption to fix a timestamp-ordering nit would reintroduce cross-account data in a published feed. Not a trade I'll make.

Your inconsistency is real, though, and I'd have told you it was already handled if I hadn't checked. My first instinct was that schema v3's per-field provenance map answers it — observed_at_ms covers header-derived fields, and merged poll-owned fields carry their own checkedAt. That is true, and it is not on this branch: 81200aa is schema v2 with zero fieldSources. The provenance work is #172, open and independent of this stack.

So on this branch as it stands, a published entry can carry merged poll fields newer than the timestamp it reports, with nothing telling a reader that. Narrow, but there is a live consumer of this feed, so it isn't theoretical.

Disposition: the structural fix belongs in #172, where the per-field provenance already exists — I'm not duplicating it here. What this PR gets is a doc comment on observed_at_ms stating what it covers, so the contract is written down rather than reverse-engineered from the merge logic.

On the PR description contradiction you flagged: fair, and it's fixed. The description was written before the leak was found and still described preserving persisted quota times. It now says what the code does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. Reporting entry.checkedAt (the request's own observation time) rather than the reloaded persisted mainQuotaCheckedAt is deliberate: the persisted top-level timestamp has no account binding, so under a concurrent cross-account writer, adopting it re-times this request's published observed_at_ms to a different account's observation — the cross-account leak filed as #177. So the single-process identity you note is the correct behaviour, and the cross-process case must NOT inherit the persisted timestamp.

The narrow real gap is on this branch's v2 feed: a poll-owned field merged in can be newer than the header-derived observed_at_ms, with no reader-visible provenance to distinguish them. The structural fix for that is per-field provenance (each field carries its own source + checkedAt), which is PR #172's schema v3 — not present on this branch. This branch documents the v2 contract (observed_at_ms is header-observation time; merged poll fields retain their own checkedAt) and leaves the mechanism to #172.

}
: entry
}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
}
Expand Down
100 changes: 91 additions & 9 deletions packages/opencode/src/tests/account-command.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<string>()
const originalFetch = globalThis.fetch
const timerTracking = createTimerTracking()
const {
activeIntervals,
disabledPluginTimerOverrides,
trackedClearInterval,
trackedSetInterval,
} = timerTracking

const baseStorage = (): AccountStorage => ({
version: 1,
Expand Down Expand Up @@ -68,15 +90,48 @@ const baseStorage = (): AccountStorage => ({
})

beforeEach(async () => {
installDefaultFetchMock()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The unconditional mock.restore() here defeats the leak detection the comment above it describes. mock.restore() restores every bun mock() including an untagged fetch mock, so it never reaches setup.ts's leak check (which only fires when fetch is neither guardedFetch nor tagged) as the comment claims it must. For these tests to preserve the intended leak signal, restore only the tagged mock and drop or guard the global mock.restore() so an untagged mock()-based fetch can't be silently cleaned up.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/account-command.test.ts, line 121:

<comment>The unconditional `mock.restore()` here defeats the leak detection the comment above it describes. `mock.restore()` restores every bun `mock()` including an untagged fetch mock, so it never reaches setup.ts's leak check (which only fires when fetch is neither `guardedFetch` nor tagged) as the comment claims it must. For these tests to preserve the intended leak signal, restore only the tagged mock and drop or guard the global `mock.restore()` so an untagged `mock()`-based fetch can't be silently cleaned up.</comment>

<file context>
@@ -68,15 +90,48 @@ const baseStorage = (): AccountStorage => ({
+      ),
+    )
+    tempDirs.clear()
+    mock.restore()
+  } finally {
+    // Assert last so a detected leak cannot abort the cleanup above.
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refuted by probe on this head: Bun 1.3.14's mock.restore() restores spyOn-created mocks only — a plain mock() ASSIGNED to globalThis.fetch survives it (observed: the assignment is still installed afterward), so an untagged assigned mock does reach the preload's detector; the comment's claim holds. The variant that WOULD be masked is spyOn(globalThis, 'fetch'), and no test in these files spyOns fetch (grepped). The unconditional mock.restore() stays: it is what resets call counts and non-fetch mocks between tests.

} finally {
// Assert last so a detected leak cannot abort the cleanup above.
expect(activeIntervals.size).toBe(0)
}
})

afterAll(async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: afterAll is a no-op: the per-test afterEach already removes tempDir (via the tempDirs set it clears) with each rm individually caught, so by the time afterAll runs both tempDirs (always empty) and tempDir (already deleted) contain nothing to clean. Drop the redundant afterAll block, or if it is meant as a safety net for a teardown that never runs, document why.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/account-command.test.ts, line 128:

<comment>`afterAll` is a no-op: the per-test `afterEach` already removes `tempDir` (via the `tempDirs` set it clears) with each rm individually caught, so by the time `afterAll` runs both `tempDirs` (always empty) and `tempDir` (already deleted) contain nothing to clean. Drop the redundant `afterAll` block, or if it is meant as a safety net for a teardown that never runs, document why.</comment>

<file context>
@@ -68,15 +90,48 @@ const baseStorage = (): AccountStorage => ({
+  }
+})
+
+afterAll(async () => {
+  await Promise.all(
+    [...tempDirs, tempDir].map((directory) =>
</file context>

await Promise.all(
[...tempDirs, tempDir].map((directory) =>
rm(directory, { recursive: true, force: true }).catch(() => {}),
),
)
tempDirs.clear()
})

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<any>
async function getPlugin(timerOverrides?: PluginTimerOverrides) {
const defaultTimerOverrides = disabledPluginTimerOverrides()
const plugin = (await (
AnthropicAuthPlugin as unknown as (
ctx: Parameters<typeof AnthropicAuthPlugin>[0],
timers?: PluginTimerOverrides,
) => ReturnType<typeof AnthropicAuthPlugin>
)(
{
// @ts-expect-error: minimal mock for testing
client: createMockClient(),
},
{ ...defaultTimerOverrides, ...timerOverrides },
)) as any
await plugin.__fallbackRefreshReady
return plugin
}

async function executeCommand(
Expand Down Expand Up @@ -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)
})
})
Loading