diff --git a/biome.json b/biome.json index c794ccc..edd8098 100644 --- a/biome.json +++ b/biome.json @@ -20,6 +20,8 @@ "includes": [ "packages/*/src/**", "!packages/opencode/src/tui-compiled", + "!packages/opencode/src/vendor", + "!packages/opencode/src/tests/fixtures/claustrum-golden", "packages/*/*.json", "scripts/**", "*.json", diff --git a/bun.lock b/bun.lock index 52a1103..6f42add 100644 --- a/bun.lock +++ b/bun.lock @@ -19,11 +19,12 @@ }, "packages/opencode": { "name": "@cortexkit/opencode-openai-auth", - "version": "0.6.4", + "version": "0.6.5", "bin": { "openai-auth": "./dist/cli.js", }, "dependencies": { + "@cortexkit/subc-client": "^0.8.1", "@opentui/core": ">=0.5.1", "@opentui/solid": ">=0.5.1", "ai": "^7.0.90", @@ -36,7 +37,7 @@ }, "packages/pi": { "name": "@cortexkit/pi-openai-auth", - "version": "0.6.4", + "version": "0.6.5", "peerDependencies": { "@earendil-works/pi-ai": ">=0.80.10", "@earendil-works/pi-coding-agent": ">=0.80.10", @@ -183,6 +184,8 @@ "@cortexkit/pi-openai-auth": ["@cortexkit/pi-openai-auth@workspace:packages/pi"], + "@cortexkit/subc-client": ["@cortexkit/subc-client@0.8.1", "", {}, "sha512-8U9w3AnSff0QYlLVzcKOuTULakRtVpcGJrquGHxOQQlWZGzXZdCs8nroLMeoM2xhFGwxIh8xFk832w1KG6akAA=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.84.4", "", { "dependencies": { "@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", "yaml": "2.9.0" } }, "sha512-HyUnjaOXj6oN/6SNcr8A1J/ElRQA50FtIE0XUTSKAQVqmdlb9qdojOyUQwF/jULE5+yOEtGuVgi/N1RnBiNG+g=="], "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.84.4", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.4", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-AClAZxf5+c4RRu44NJPS6wyQy+Nmq+Mzyyrdvm4ZVMNuixelO02RZX4G4Aq1F145Yzp43wnM5S+hLlSI7ypfVw=="], diff --git a/packages/opencode/docs/custody-state-machine.md b/packages/opencode/docs/custody-state-machine.md new file mode 100644 index 0000000..a102c36 --- /dev/null +++ b/packages/opencode/docs/custody-state-machine.md @@ -0,0 +1,249 @@ +# Custody state machine + +## Purpose & scope + +This is the reviewer-facing Phase A custody state machine for OpenAI accounts. It names the startup verdict for every implemented coordinate, the barrier that changes global mode, and the recovery rules after an interrupted transition. Its governing rule is: a row that cannot prove a single refresher never serves local under `mode=claustrum`. + +Phase A reads custody bindings and can write only the local custody tombstone under the guarded transition; it does not write bindings, enroll accounts, or export local tokens. Phase B remains a boundary, not an available surface: `/openai-account export-local --output ` is the deferred 0600 local-token export verb for future coordination only. + +## Coordinates + +| axis | values | source | +|---|---|---| +| `mode` | `local` · `claustrum` | `openai-auth.json` → `claustrum.mode`; `claustrumMode()` defaults any absent/other value to `local` | +| `manifest` | `absent` · `present` · `unreadable` | custody-manifest reader result | +| `local` | `real` · `tombstone` · `empty` · `gone` · `slot-absent` | fallback row or main host slot classification | +| `vault` | `serves` · `cold` · `needs_reauth` · `identity_mismatch` · `no_handle` | custody cache outcome and served-JWT identity check | + +`real` is parseable non-sentinel material. `tombstone` is the canonical local sentinel. `empty` has sentinel `refresh` but a non-canonical access/expiry shape; it remains custody evidence. `gone` is a retained corrupt OAuth fallback row, not a missing row. `slot-absent` is main-only and is confirmed through the host SDK. A missing fallback row is not a `local` value; it is an orphan binding. + +Config absent is equivalent to `mode=local`. Config-unparseable never enters this table: `loadAccounts()` surfaces the actionable JSON, access, or permission failure rather than normalizing it into custody state. + +## Persisted schema + fingerprint contract + +The persisted custody portion of the account store is: + +```ts +claustrum?: { + mode?: 'local' | 'claustrum' + transition?: { + manifestRevision: string + storeGeneration: string + fingerprints: { + main?: string + fallbacks: Record + } + } + rowHistory?: string[] +} +``` + +`rowHistory` distinguishes an orphan whose row was removed from one awaiting first discovery. `transition` survives a committed mode write until all fingerprint-gated tombstones complete. + +The canonical custody tombstone is `{ type: 'oauth', access: '', refresh: custodyTombstoneKey(provider), expires: 0 }`. Recognition is exact on `refresh`; the empty `access` prevents the tombstone from being usable as bearer material. + +`custodySlotFingerprint(access, refresh)` is lowercase hexadecimal SHA-256 over: + +```text +u32be(byteLength(access)) || utf8(access) || u32be(byteLength(refresh)) || utf8(refresh) +``` + +The length prefixes make distinct token pairs unambiguous. A later local family with a different fingerprint is operator-owned and is never tombstoned by resume logic. + +## Predicates + +The prefix is `claustrum-tombstone:v1:` and the per-provider sentinel is `claustrum-tombstone:v1:`. + +**RECOGNISE** is exact: `refresh === custodyTombstoneKey(provider)`. Access and expiry are deliberately ignored, so a partial write (`empty`) remains custody evidence. + +**REFUSE** is prefix-based: any refresh value whose `startsWith(CUSTODY_TOMBSTONE_PREFIX)` is rejected as bearer material. Refusal is therefore a strict superset of recognition: exact sentinels are recognised, while malformed or foreign values sharing the prefix are still refused. + +## Verdict-reason vocabulary + +- `INERT:needs-login` +- `INERT:mode-mismatch` +- `INERT:corrupt-under-binding` +- `INERT:manifest-unreadable` +- `INERT:unbound-under-claustrum` +- `INERT:orphan-tombstone` +- `INERT:vault-cold` +- `INERT:vault-reauth` +- `INERT:identity-mismatch` +- `INERT:no-handle` +- `INERT:takeover-incomplete` +- `INERT:new-local-family-under-claustrum` +- `INERT:takeover-incomplete/vault-unavailable` +- `INERT:takeover-incomplete/slot-absent` + +`LOCAL` permits the local family; `VAULT` serves only the vault record; `INERT` excludes the account from candidates and local refresh; `NEEDS_LOGIN` has an optional `corrupt` presentation reason. The `canonicalize` flag on an `empty` result permits the sweep to repair only its write shape. + +## Local-mode table + +The vault axis is not read in local mode. These are the 15 implemented `manifest × local` coordinates; the verified-login fact refines only `present × real`. + +| manifest | local | verified in-process login | verdict | implementation consequence | +|---|---|---|---|---| +| absent | real | n/a | `LOCAL` | ordinary local family | +| absent | tombstone | n/a | `NEEDS_LOGIN` | local mode does not make a tombstone usable | +| absent | empty | n/a | `NEEDS_LOGIN` | partial tombstone is still custody evidence | +| absent | gone | n/a | `NEEDS_LOGIN` (`corrupt`) | retained corrupt fallback row | +| absent | slot-absent | n/a | `NEEDS_LOGIN` | ordinary missing main slot | +| present | real | yes → `LOCAL`; no → `INERT:needs-login` | conditional | verified login in this process may complete the local exit; otherwise the binding remains and is never auto-cleared | +| present | tombstone | n/a | `INERT:mode-mismatch` | local intent and custody material disagree | +| present | empty | n/a | `INERT:mode-mismatch` | same verdict as tombstone | +| present | gone | n/a | `INERT:corrupt-under-binding` | bound corruption is not a local family | +| present | slot-absent | n/a | `INERT:mode-mismatch` | no main-slot install in local mode | +| unreadable | real | n/a | `INERT:manifest-unreadable` | manifest precedence wins | +| unreadable | tombstone | n/a | `INERT:manifest-unreadable` | manifest precedence wins | +| unreadable | empty | n/a | `INERT:manifest-unreadable` | manifest precedence wins | +| unreadable | gone | n/a | `INERT:manifest-unreadable` | manifest precedence wins | +| unreadable | slot-absent | n/a | `INERT:manifest-unreadable` | manifest precedence wins | + +The `present × real` pair is one coordinate with an explicit verified-login boundary, not two different stored states. + +## Claustrum-mode table + +These are the 31 implemented rows: five `manifest=absent` rows, 25 `manifest=present` rows, and the unreadable collapse. `fingerprint match` matters only for `present × real × serves`. + +| manifest | local | vault | verdict | write / recovery | +|---|---|---|---|---| +| absent | real | not consulted | `INERT:unbound-under-claustrum` | refuse; do not serve local | +| absent | tombstone | not consulted | `INERT:orphan-tombstone` | refuse; do not manufacture a binding | +| absent | empty | not consulted | `INERT:orphan-tombstone` | same as tombstone | +| absent | gone | not consulted | `INERT:unbound-under-claustrum` | no install | +| absent | slot-absent | not consulted | `INERT:unbound-under-claustrum` | no main-slot install | +| present | tombstone | serves | `VAULT` | steady state | +| present | tombstone | cold | `INERT:vault-cold` | retry on a later tick | +| present | tombstone | needs_reauth | `INERT:vault-reauth` | remain excluded | +| present | tombstone | identity_mismatch | `INERT:identity-mismatch` | remain excluded | +| present | tombstone | no_handle | `INERT:no-handle` | remain excluded | +| present | empty | serves | `VAULT` | canonicalize to the write shape | +| present | empty | cold | `INERT:vault-cold` | canonicalize when recovered | +| present | empty | needs_reauth | `INERT:vault-reauth` | canonicalize when recovered | +| present | empty | identity_mismatch | `INERT:identity-mismatch` | canonicalize when recovered | +| present | empty | no_handle | `INERT:no-handle` | canonicalize when recovered | +| present | real | serves | match or omitted → `INERT:takeover-incomplete`; differs → `INERT:new-local-family-under-claustrum` | fingerprint-gated resume may tombstone only a match; never write the new family | +| present | real | cold | `INERT:takeover-incomplete/vault-unavailable` | no rollback | +| present | real | needs_reauth | `INERT:takeover-incomplete/vault-unavailable` | no rollback | +| present | real | identity_mismatch | `INERT:identity-mismatch` | local material is not consulted | +| present | real | no_handle | `INERT:no-handle` | retain material; no rollback | +| present | gone | serves | `VAULT` | install the exact fallback tombstone | +| present | gone | cold | `INERT:vault-cold` | install the exact fallback tombstone | +| present | gone | needs_reauth | `INERT:vault-reauth` | install the exact fallback tombstone | +| present | gone | identity_mismatch | `INERT:identity-mismatch` | no install | +| present | gone | no_handle | `INERT:no-handle` | no install | +| present | slot-absent | serves | `INERT:takeover-incomplete/slot-absent` | main INSTALL withdrawn; do not write | +| present | slot-absent | cold | `INERT:takeover-incomplete/slot-absent` | main INSTALL withdrawn; do not write | +| present | slot-absent | needs_reauth | `INERT:takeover-incomplete/slot-absent` | main INSTALL withdrawn; do not write | +| present | slot-absent | identity_mismatch | `INERT:identity-mismatch` | no install | +| present | slot-absent | no_handle | `INERT:no-handle` | no install | +| unreadable | any | not consulted | `INERT:manifest-unreadable` | no local or vault inspection | + +## Invariants A/B/C/E + +**Invariant A.** Under `mode=local`, the vault is never consulted for serving or refresh decisions. A manifest binding can still make a local refresh inert. + +**Invariant B.** An unreadable manifest refuses before examining the local coordinate or invoking the vault. Unreadable refusal is decided; only a distinct typed presentation remains open. + +**Invariant C.** Under `manifest=absent`, no handle exists for that account, so the vault is never queried. + +**Invariant E.** `empty` is verdict-equivalent to `tombstone`. The sweep alone distinguishes it by canonicalizing the write shape. The tables list both coordinates so their equality is reviewable. + +## Identity + +Every provider identity fall-through is minted by the same OpenAI JWT issuer; manifest labels and locally minted ids are never substitutes. + +For the host main slot, `main: gone ≡ slot-absent via SDK`. The SDK drops unparseable entries, so only fallback rows retain a distinct `gone` coordinate through `CorruptOAuthAccount`. + +OpenAI and Anthropic deliberately diverge on `identity_mismatch`. OpenAI treats a served JWT claim mismatch as provider-asserted evidence that the binding is disputed, so it refuses and does not install a tombstone. Anthropic may install for a typed mismatch because its identity evidence is operator-labelled and weaker. + +## Main host slot + +`classifyMainAuthSlot()` recognises canonical tombstones, partial tombstones, real OAuth material, and an indeterminate value. A main slot is `slot-absent` only after two `undefined` `auth.get()` reads at least 250 ms apart, with a non-empty `auth.all()` map on both observations. A short sleep or an empty map produces `indeterminate`, not absence. + +Under `mode=claustrum`, a confirmed absent main slot yields `INERT:takeover-incomplete/slot-absent` for vault `serves`, `cold`, and `needs_reauth`; main INSTALL is withdrawn. The pre-write guard reads `auth.all()` and refuses a write when it is empty: + +`host auth store read empty; refusing to write — possible torn read` + +This is deliberately separate from fallback `gone` installation, which is allowed only for a present usable binding. + +## Barrier + +Entering claustrum uses the process-local custody mutex, then the renewable `claustrum-mode` lock, then renewable account locks in sorted identity order (main before fallback ids). Main custody work uses the `main-refresh` lock. A main login retains a process-local exclusion lease until host `auth.get()` readback observes the written access/refresh pair, or the 5-second readback lease expires and logs a warning before release. + +The barrier is: + +1. Acquire the mutex and mode/account locks; capture manifest revision, store generation, and each local-slot fingerprint. +2. Preflight every participant against a usable manifest handle and vault identity while fenced. +3. Re-read manifest revision and store generation; write `claustrum.mode = "claustrum"` with the persisted transition fingerprints. +4. For each fallback, re-read material, tombstone only on fingerprint match, then read it back; mismatches become a new local family. +5. For main, apply the same fingerprint check, require non-empty `auth.all()`, write the tombstone, and require post-tombstone host readback. + +Crash rows are monotone: before step 3 the disk mode is local and no destructive write occurred; re-running the transition is safe. After step 3, boot/tick resumes only fingerprint-matching tombstones. Mid-step 4 or 5 produces mixed per-account rows that the claustrum table resolves independently. There is no rollback after the mode write. + +The residual is cross-process host login: a host `Auth.set` can land outside this process's locks and become `INERT:new-local-family-under-claustrum`, never a target for deletion. The transition confirmation is exactly: `do not run a login in another OpenCode window during this transition`. + +## Local exit rows + +Leaving custody takes `claustrum-mode` and writes `mode=local`; it does not clear bindings or restore tokens. A crash immediately after that write yields `present × tombstone/empty → INERT:mode-mismatch` until each account has fresh local material and its binding is cleared outside Phase A. + +The verified-login boundary is strict. For main real material with a surviving binding and no in-process verified-login record, the implemented verdict is `INERT:needs-login`, never `LOCAL`, and the binding is never auto-cleared. A verified login in this process may return `LOCAL`; a restored backup, another process, or a raced host login cannot prove that boundary. + +## Operation table + +| operation | `mode=local` | `mode=claustrum` | +|---|---|---| +| `/openai-account claustrum` | runs the guarded barrier | idempotent resume or a new guarded barrier | +| `/openai-account local` | remains local | writes local mode only; re-login and external binding removal complete exit | +| enable | ordinary toggle | under the account lock, require a usable binding and identity; otherwise refuse | +| disable | ordinary toggle | ordinary row toggle; does not remove binding or vault material | +| remove | removes the row | removes only the row; a manifest entry becomes an orphan binding | +| add | local OAuth flow may persist a fallback | the completion path re-checks mode under the account lock and refuses before credential write | +| `/login openai` | ordinary host login | host write is outside plugin lock; a raced login becomes a fingerprint mismatch on reconciliation | +| CLI login | ordinary local login | refused by the custody-aware path before credential write; an external host write follows the same mismatch rule | + +Mode is checked under the relevant account lock immediately before any plugin-owned credential write. + +## Binding-pending + orphan causes + +A new manifest entry discovered under `mode=claustrum` creates a fallback row with the canonical tombstone, `enabled: true`, and no `accountId`. That is `binding-pending`: the first served OpenAI JWT binds `accountId`; it cannot be an identity mismatch before then. + +An entry with no fallback row is an `orphan-binding`, not a local coordinate. Runtime logs either `orphan-binding: row removed` when `rowHistory` contains the label or `orphan-binding: awaiting discovery` otherwise. Claustrum mode discovers it under that fallback's refresh lock; local mode logs it and does not create a custodied row. + +## Required-injection rule + +`FallbackAccountManager` is constructed as `new FallbackAccountManager(options: AccountManagerOptions)`, where `AccountManagerOptions` requires `custody: { readManifest: () => Promise; provider?: string }`. Every production call site must supply that `custody` object; an omitted policy reader would silently re-enable local refresh. + +The requirement exists because anthropic-auth incident 1 demonstrated that optional custody wiring fails open. `CorruptOAuthAccount` preserves a malformed OAuth row as typed custody-relevant state rather than silently dropping it as an ordinary missing account. + +## Test list + +| file | assertions pinned | +|---|---| +| `src/tests/custody-state.test.ts` | `localCases` enumerates all 15 local coordinates; `claustrumCases` enumerates all 31 claustrum rows, including empty/tombstone equivalence, fingerprint mismatch, fallback INSTALL, and withdrawn main slot INSTALL. The present×tombstone/local case defensively pins `INERT:mode-mismatch` so the non-custody serving branch cannot serve it. | +| `src/tests/custody-transition.test.ts` | mode/account lock order, preflight and revalidation aborts, fingerprint-gated writes, post-write readback, torn-read deferral, and transition outcomes. | +| `src/tests/custody-main.test.ts` | main slot classification, two-read absence confirmation, host slot reconciliation, and main factory behavior. | +| `src/tests/custody-runtime.test.ts` | boot/tick mode gating, discovery, row history orphan causes, corrupt fallback installation, resume completion, and unavailable vault projection. | +| `src/tests/custody.test.ts` | exact tombstone recognition, prefix refusal, identity binding, enrollment completion, and refresh-lock serialization. | +| `src/tests/custody-authorize.test.ts` | custody-aware authorization and verified in-process main login records. | +| `src/tests/custody-refresh.test.ts` | refresh exclusion for tombstoned or bound fallback accounts and lock behavior. | +| `src/tests/custody-request.test.ts` | request-path custody loading, served-vault provenance, and identity checks. | +| `src/tests/commands.test.ts` | enter/leave command behavior, account enable refusal, add refusal under claustrum, and the transition confirmation text. | + +## §17 debts folded + +| debt | landing | +|---|---| +| S-DI | Required-injection rule gives the constructor shape, mandatory property, call-site rule, and incident rationale. | +| S-orphan | Binding-pending + orphan causes names both `row removed` and `awaiting discovery`. | +| S-mm-pin | Test list names the defensive present×tombstone/local pin. | +| S-intro | Purpose & scope restricts the no-local-serving claim to `mode=claustrum`. | +| S-order | Invariant B states that manifest unreadability takes precedence over local/tombstone inspection. | +| S-15.8 | Invariant B records that unreadable refusal is decided; only distinct typed presentation remains open. | +| S-mode-axis | Coordinates records config absent as local and config-unparseable as the actionable `loadAccounts()` error path. | + +## Divergences from spec prose + +- The local-exit `present × real × unverified` row is implemented as `INERT:needs-login`, not `INERT:enrolled-under-local`. The latter remains a listed but unused reason. The maintainer-ruled §15.6 wording says this row is `needs-login`; the §16 table and earlier plan prose use the older name. The evaluator and sidebar follow `needs-login`. +- The `present × slot-absent × serves/cold/needs_reauth` rows do not install main tombstones. The implementation returns `INERT:takeover-incomplete/slot-absent`; the prose that prescribed INSTALL is superseded by the implemented main-host carve-out. +- Unreadable refusal is implemented and unconditional before other predicate work. The remaining open question is presentation granularity, not whether refusal happens. diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4cfae32..6a3093b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -40,6 +40,7 @@ "src/tui", "src/tui-compiled", "src/sidebar-state.ts", + "src/core/custody-state.ts", "src/tui-preferences.ts", "src/logger.ts", "src/core/account-paths.ts", @@ -56,12 +57,14 @@ "test": "bun test src/tests", "typecheck": "tsc", "types": "tsc", + "check:claustrum-golden": "bun scripts/check-claustrum-golden.ts", "prepublishOnly": "bun run build" }, "peerDependencies": { "@opencode-ai/plugin": "*" }, "dependencies": { + "@cortexkit/subc-client": "^0.8.1", "@opentui/core": ">=0.5.1", "@opentui/solid": ">=0.5.1", "ai": "^7.0.90", diff --git a/packages/opencode/scripts/build-tui.ts b/packages/opencode/scripts/build-tui.ts index aae98e2..1def1c7 100644 --- a/packages/opencode/scripts/build-tui.ts +++ b/packages/opencode/scripts/build-tui.ts @@ -11,6 +11,7 @@ const shippedSourceFiles = [ 'tui/command-dialogs.tsx', 'sidebar-state.ts', 'core/account-paths.ts', + 'core/custody-state.ts', 'core/refresh-file-lock.ts', 'tui-preferences.ts', 'logger.ts', diff --git a/packages/opencode/scripts/check-claustrum-golden.ts b/packages/opencode/scripts/check-claustrum-golden.ts new file mode 100644 index 0000000..e1562f6 --- /dev/null +++ b/packages/opencode/scripts/check-claustrum-golden.ts @@ -0,0 +1,72 @@ +/** + * Golden check for the Claustrum handle/tombstone fixtures. + * + * The script compares the local fixtures under + * `packages/opencode/src/tests/fixtures/claustrum-golden/` against the bytes + * published at the upstream commit recorded in `SOURCE.json`. Drift is + * reported on stderr and exits non-zero so CI can fail the gate. + * + * Run via `bun run check:claustrum-golden` (see package.json scripts). + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +const fixtureDir = join( + import.meta.dir, + '..', + 'src', + 'tests', + 'fixtures', + 'claustrum-golden', +) +const source = JSON.parse( + await readFile(join(fixtureDir, 'SOURCE.json'), 'utf8'), +) as { + repo: string + ref: string + paths: Record +} + +function rejectSource(reason: string): never { + console.error(`INVALID SOURCE.json: ${reason}`) + process.exit(1) +} + +if (!source.repo) rejectSource('repo is missing') +if (!source.ref) rejectSource('ref is missing') +if (!/^[0-9a-f]{40}$/.test(source.ref)) { + rejectSource('ref must be a 40-hex SHA') +} +const paths = Object.entries(source.paths ?? {}) +if (paths.length === 0) rejectSource('paths must contain at least one entry') + +for (const [name] of paths) { + try { + await readFile(join(fixtureDir, `${name}.json`)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + rejectSource(`vendored file is missing: ${name}.json`) + } + throw error + } +} + +let drifted = false +for (const [name, sourcePath] of paths) { + const url = `https://raw.githubusercontent.com/${source.repo}/${source.ref}/${sourcePath}` + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch ${name} golden: ${response.status} ${url}`) + } + const remote = Buffer.from(await response.arrayBuffer()) + const local = await readFile(join(fixtureDir, `${name}.json`)) + if (Buffer.compare(remote, local) !== 0) { + console.error(`DRIFT: ${name}.json differs from ${url}`) + drifted = true + continue + } + console.log(`${name}.json: IDENTICAL (${source.ref})`) +} + +if (drifted) process.exitCode = 1 diff --git a/packages/opencode/src/cli.ts b/packages/opencode/src/cli.ts index 983e443..8230d96 100644 --- a/packages/opencode/src/cli.ts +++ b/packages/opencode/src/cli.ts @@ -1,6 +1,9 @@ #!/usr/bin/env node import { + claustrumMode, + FALLBACK_REFRESH_LOCK_TTL_MS, + fallbackRefreshLockName, getAccountStoragePath, loadAccounts, mutateAccounts, @@ -12,12 +15,24 @@ import { beginAccountLogin, upsertAccount, } from './core/oauth' +import { acquireRefreshFileLock } from './core/refresh-file-lock' import { openUrl } from './util/open-url' export { openUrl as openBrowserForLogin } from './util/open-url' -function usage() { - console.log(`Usage: +type CliDeps = Partial<{ + beginAccountLogin: typeof beginAccountLogin + openUrl: typeof openUrl + getAccountStoragePath: typeof getAccountStoragePath + loadAccounts: typeof loadAccounts + mutateAccounts: typeof mutateAccounts + acquireRefreshFileLock: typeof acquireRefreshFileLock + log: typeof console.log + error: typeof console.error +}> + +function usage(log: typeof console.log = console.log) { + log(`Usage: npx @cortexkit/opencode-openai-auth login [--label ] [--headless] npx @cortexkit/opencode-openai-auth list npx @cortexkit/opencode-openai-auth remove @@ -48,13 +63,24 @@ function parseArgs(argv: string[]) { return { positional, flags } } -async function main() { - const { positional, flags } = parseArgs(process.argv.slice(2)) +export async function runCli( + argv = process.argv.slice(2), + deps: CliDeps = {}, +): Promise { + const login = deps.beginAccountLogin ?? beginAccountLogin + const open = deps.openUrl ?? openUrl + const storagePath = deps.getAccountStoragePath ?? getAccountStoragePath + const load = deps.loadAccounts ?? loadAccounts + const mutate = deps.mutateAccounts ?? mutateAccounts + const acquireLock = deps.acquireRefreshFileLock ?? acquireRefreshFileLock + const log = deps.log ?? console.log + const writeError = deps.error ?? console.error + const { positional, flags } = parseArgs(argv) const [command, ...rest] = positional if (!command || command === 'help') { - usage() - process.exit(0) + usage(log) + return 0 } switch (command) { @@ -65,81 +91,103 @@ async function main() { try { assertFallbackAccountIdAllowed(label) } catch (error) { - console.error( + writeError( `\nError: ${error instanceof Error ? error.message : String(error)}`, ) - process.exit(1) + return 1 } - const { url, instructions, completion } = await beginAccountLogin({ + const { url, instructions, completion } = await login({ label, headless, }) - console.log('\nOpen this URL in your browser and complete sign-in:\n') - console.log(`${url}\n`) - if (instructions) console.log(`${instructions}\n`) + log('\nOpen this URL in your browser and complete sign-in:\n') + log(`${url}\n`) + if (instructions) log(`${instructions}\n`) - openUrl(url) + open(url) const account = await completion // Read-modify-write under the store lock so a concurrent add/remove // (another CLI invocation or a TUI command) cannot clobber this insertion, // and the self-fallback check sees the freshest mainAccountId. + const configPath = storagePath() + const lock = await acquireLock({ + name: fallbackRefreshLockName(account.id), + ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS, + path: configPath, + renew: true, + }) + if (!lock) throw new Error('Fallback account lock unavailable') let selfFallback = false - await mutateAccounts((current) => { - // Reject self-fallback: adding main's ChatGPT account as a fallback - // would let routing retry on the account that just returned 429. - if ( - account.accountId && - current.mainAccountId && - account.accountId === current.mainAccountId - ) { - selfFallback = true - return current + let blockedByClaustrum = false + try { + if (claustrumMode((await load(configPath)) ?? {}) === 'claustrum') { + blockedByClaustrum = true + } else { + await mutate((current) => { + if ( + account.accountId && + current.mainAccountId && + account.accountId === current.mainAccountId + ) { + selfFallback = true + return current + } + upsertAccount(current.accounts, account as unknown as OAuthAccount) + return current + }, configPath) } - upsertAccount(current.accounts, account as unknown as OAuthAccount) - return current - }) + } finally { + await lock.release() + } + + if (blockedByClaustrum) { + writeError( + '\nError: Claustrum mode is active. Run /openai-account local before adding a fallback account.', + ) + return 1 + } if (selfFallback) { - console.error( + writeError( '\nError: that account is already your main (same ChatGPT account).', ) - console.error( + writeError( 'A self-fallback would retry on the account that just returned 429.', ) - process.exit(1) + return 1 } - console.log(`\n✓ Added account ${account.id}`) - if (account.label) console.log(` Label: ${account.label}`) - break + log(`\n✓ Added account ${account.id}`) + if (account.label) log(` Label: ${account.label}`) + return 0 } case 'list': { - const storage = await loadAccounts() + const storage = await load() if (!storage || storage.accounts.length === 0) { - console.log('No fallback accounts configured.') + log('No fallback accounts configured.') } else { for (const a of storage.accounts) { const label = (a as { label?: string }).label const parts = [` ${a.id}`] if (label) parts.push(`(${label})`) parts.push(a.enabled !== false ? '[enabled]' : '[disabled]') - console.log(parts.join(' ')) + log(parts.join(' ')) } } - break + return 0 } case 'remove': { const targetId = rest[0] if (!targetId) { - console.error('Error: remove requires an account ID.') - usage() - process.exit(1) + writeError('Error: remove requires an account ID.') + usage(log) + return 1 } // `allowDrop` is unconditional — for a healthy entry it is a no-op, @@ -150,12 +198,12 @@ async function main() { // cannot see. The pre-read is purely diagnostic — a stale read can // only change the message when another writer races us, and the // mutator signal covers exactly that case. - const configPath = getAccountStoragePath() + const configPath = storagePath() const rawRoster = await readConfigRosterIds(configPath) const preReadSawIt = rawRoster ? rawRoster.has(targetId) : false let mutatorSplicedIt = false - await mutateAccounts( + await mutate( (current) => { const idx = current.accounts.findIndex((a) => a.id === targetId) if (idx === -1) return current @@ -169,20 +217,24 @@ async function main() { const removed = mutatorSplicedIt || preReadSawIt if (!removed) { - console.error(`No account with id "${targetId}".`) - process.exit(1) + writeError(`No account with id "${targetId}".`) + return 1 } - console.log(`Removed account ${targetId}.`) - break + log(`Removed account ${targetId}.`) + return 0 } default: - console.error(`Unknown command: ${command}`) - usage() - process.exit(1) + writeError(`Unknown command: ${command}`) + usage(log) + return 1 } } +async function main() { + process.exit(await runCli()) +} + if (import.meta.main) { main().catch((err) => { console.error(err instanceof Error ? err.message : String(err)) diff --git a/packages/opencode/src/commands.ts b/packages/opencode/src/commands.ts index 0609f15..55ce418 100644 --- a/packages/opencode/src/commands.ts +++ b/packages/opencode/src/commands.ts @@ -1,5 +1,6 @@ import { getSettings } from './config' import { + claustrumMode, DEFAULT_KILLSWITCH_THRESHOLDS, type loadAccounts as defaultLoadAccounts, isSafeResetAccountKey, @@ -11,6 +12,8 @@ import { } from './core/accounts' import type { FallbackAccount } from './core/accounts.ts' import type { CacheKeepManager, CacheKeepWindow } from './core/cachekeep' +import type { CustodyInertReason } from './core/custody-state.ts' +import type { TransitionResult } from './core/custody-transition.ts' import { beginAccountLogin, upsertAccount } from './core/oauth' import { whamUsageFn } from './core/provider' import type { QuotaManager } from './core/quota-manager' @@ -106,9 +109,23 @@ export interface CommandContext { fetchImpl?: typeof fetch now?: () => number randomUUID?: () => string + /** Starts an OAuth account-add flow; injected by the runtime boundary. */ + beginAccountLogin?: typeof beginAccountLogin refreshResetTargetQuota?: ( accountKey: string, ) => Promise + enterClaustrumMode?: () => Promise + leaveClaustrumMode?: () => Promise + withFallbackAccountLock: ( + accountId: string, + action: () => Promise, + ) => Promise + checkUsableCustodyBinding?: ( + account: OAuthAccount, + ) => Promise< + | { ready: true; accountId: string } + | { ready: false; reason: CustodyInertReason } + > } export interface ResetTargetIdentity { @@ -116,6 +133,7 @@ export interface ResetTargetIdentity { label: string accessToken: string chatgptAccountId?: string + onAuthFailure?: (status: number) => Promise } const log = createLogger('commands') @@ -278,6 +296,145 @@ async function executeAccountCommand( } const accounts = storage.accounts ?? [] + if (tokens[0] === 'claustrum') { + if (!ctx.enterClaustrumMode) { + return { + command: 'openai-account', + text: '## Claustrum Unavailable\n\nThe custody runtime is not ready. Try again after OpenAI auth finishes initializing.', + knobs: { + accounts: accounts.map(accountKnob), + claustrumMode: claustrumMode(storage), + }, + } + } + const result = await ctx.enterClaustrumMode() + const nextStorage = (await ctx.loadAccounts(ctx.accountStoragePath)) ?? { + version: 1 as const, + accounts: [], + } + const rows = Object.entries(result.outcomes).map( + ([id, outcome]) => `- \`${id}\`: ${outcome}`, + ) + return { + command: 'openai-account', + text: [ + `## Claustrum ${result.status}`, + '', + 'do not run a login in another OpenCode window during this transition', + '', + ...(rows.length > 0 ? rows : ['- No enabled OAuth accounts.']), + ...(result.reason ? ['', `Reason: ${result.reason}`] : []), + ].join('\n'), + knobs: { + accounts: nextStorage.accounts.map(accountKnob), + claustrumMode: claustrumMode(nextStorage), + }, + } + } + + if (tokens[0] === 'local') { + if (!ctx.leaveClaustrumMode) { + return { + command: 'openai-account', + text: '## Local Mode Unavailable\n\nThe custody runtime is not ready. Try again after OpenAI auth finishes initializing.', + knobs: { + accounts: accounts.map(accountKnob), + claustrumMode: claustrumMode(storage), + }, + } + } + await ctx.leaveClaustrumMode() + const nextStorage = (await ctx.loadAccounts(ctx.accountStoragePath)) ?? { + version: 1 as const, + accounts: [], + } + return { + command: 'openai-account', + text: '## Local Mode\n\nClaustrum mode is now local. Run a fresh `/login openai` for each account, then remove its binding with `ck auth` before it can refresh locally.', + knobs: { + accounts: nextStorage.accounts.map(accountKnob), + claustrumMode: claustrumMode(nextStorage), + }, + } + } + + if ((tokens[0] === 'enable' || tokens[0] === 'disable') && tokens[1]) { + const targetId = tokens[1] + const enabled = tokens[0] === 'enable' + let refusal: CustodyInertReason | undefined + let found = false + const next = await ctx.withFallbackAccountLock(targetId, async () => { + const current = await ctx.loadAccounts(ctx.accountStoragePath) + const currentAccount = current?.accounts.find( + (account): account is OAuthAccount => + account.id === targetId && account.type === 'oauth', + ) + if (!currentAccount) + return current ?? { version: 1 as const, accounts: [] } + if (enabled && claustrumMode(current) === 'claustrum') { + const binding = ctx.checkUsableCustodyBinding + ? await ctx.checkUsableCustodyBinding(currentAccount) + : { + ready: false as const, + reason: 'unbound-under-claustrum' as const, + } + if (!binding.ready) { + refusal = binding.reason + return current + } + return mutateAccounts((latest) => { + const account = latest.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === targetId && candidate.type === 'oauth', + ) + if (!account) return latest + found = true + account.accountId = binding.accountId + account.enabled = true + return latest + }, ctx.accountStoragePath) + } + return mutateAccounts((latest) => { + const account = latest.accounts.find( + (candidate) => candidate.id === targetId, + ) + if (!account) return latest + found = true + account.enabled = enabled + return latest + }, ctx.accountStoragePath) + }) + const resolvedNext = next ?? { version: 1 as const, accounts: [] } + if (refusal) { + return { + command: 'openai-account', + text: `## Cannot Enable Account\n\n\`${targetId}\` remains disabled: ${refusal}. Resolve the custody binding, then try again.`, + knobs: { + accounts: resolvedNext.accounts.map(accountKnob), + claustrumMode: claustrumMode(resolvedNext), + }, + } + } + if (!found) { + return { + command: 'openai-account', + text: `## Account Not Found\n\nNo account with id \`${targetId}\` exists.`, + knobs: { + accounts: resolvedNext.accounts.map(accountKnob), + claustrumMode: claustrumMode(resolvedNext), + }, + } + } + return { + command: 'openai-account', + text: `## Account ${enabled ? 'Enabled' : 'Disabled'}\n\n\`${targetId}\` is ${enabled ? 'enabled' : 'disabled'}.`, + knobs: { + accounts: resolvedNext.accounts.map(accountKnob), + claustrumMode: claustrumMode(resolvedNext), + }, + } + } + if (tokens.length === 0) { // Show status const lines = ['## OpenAI Accounts', ''] @@ -298,12 +455,15 @@ async function executeAccountCommand( } lines.push('') lines.push( - 'Commands: `/openai-account add [label]` | `/openai-account remove `', + `Claustrum mode: \`${claustrumMode(storage)}\`\n\nCommands: \`/openai-account claustrum\` | \`/openai-account local\` | \`/openai-account add [label]\` | \`/openai-account enable \` | \`/openai-account disable \` | \`/openai-account remove \``, ) return { command: 'openai-account', text: lines.join('\n'), - knobs: { accounts: accounts.map(accountKnob) }, + knobs: { + accounts: accounts.map(accountKnob), + claustrumMode: claustrumMode(storage), + }, } } @@ -401,7 +561,9 @@ async function executeAccountCommand( const headless = tokens.includes('--headless') const labelTokens = tokens.filter((t) => t !== 'add' && t !== '--headless') const label = labelTokens.length > 0 ? labelTokens.join(' ') : undefined - const { url, instructions, completion } = await beginAccountLogin({ + const { url, instructions, completion } = await ( + ctx.beginAccountLogin ?? beginAccountLogin + )({ label, headless, }) @@ -414,29 +576,35 @@ async function executeAccountCommand( // never reach the user. completion .then(async (account) => { - let rejectedAsMain = false - // Route the add through mutateAccounts: read-modify-write under the lock - // so a concurrent add/remove cannot clobber this insertion, and so the - // main-identity check reads the freshest mainAccountId. - await mutateAccounts((current) => { - if ( - account.accountId && - current.mainAccountId && - account.accountId === current.mainAccountId - ) { - rejectedAsMain = true - return current + let rejection: 'claustrum mode' | 'main identity' | undefined + await ctx.withFallbackAccountLock(account.id, async () => { + const currentStorage = await ctx.loadAccounts(ctx.accountStoragePath) + if (claustrumMode(currentStorage ?? {}) === 'claustrum') { + rejection = 'claustrum mode' + return } - upsertAccount(current.accounts, account as OAuthAccount) - return current - }, ctx.accountStoragePath) + await mutateAccounts((current) => { + if ( + account.accountId && + current.mainAccountId && + account.accountId === current.mainAccountId + ) { + rejection = 'main identity' + return current + } + upsertAccount(current.accounts, account as OAuthAccount) + return current + }, ctx.accountStoragePath) + }) - if (rejectedAsMain) { + if (rejection) { const msg = - 'That account is already your main account — not added as a fallback.' + rejection === 'claustrum mode' + ? 'That account cannot be added while Claustrum mode is active. Run `/openai-account local` first.' + : 'That account is already your main account — not added as a fallback.' // Log the internal account id, never the ChatGPT stable id (a sensitive // identity from the OAuth claims). - log.warn('account add rejected (main identity)', { + log.warn(`account add rejected (${rejection})`, { id: account.id, sessionId, }) @@ -490,8 +658,11 @@ async function executeAccountCommand( return { command: 'openai-account', - text: '## Account Commands\n\n- `/openai-account` — show accounts\n- `/openai-account add [label]` — add a new account\n- `/openai-account remove ` — remove\n- `/openai-account order ` — swap fallback positions\n\nRouting modes are `main-first`, `fallback-first`, and `sticky-balanced`. `/openai-routing reset` clears the current session pin.', - knobs: { accounts: accounts.map(accountKnob) }, + text: '## Account Commands\n\n- `/openai-account claustrum` — enter Claustrum mode\n- `/openai-account local` — leave Claustrum mode\n- `/openai-account add [label]` — add a new account\n- `/openai-account enable ` — enable a fallback\n- `/openai-account disable ` — disable a fallback\n- `/openai-account remove ` — remove\n- `/openai-account order ` — swap fallback positions\n\nRouting modes are `main-first`, `fallback-first`, and `sticky-balanced`. `/openai-routing reset` clears the current session pin.', + knobs: { + accounts: accounts.map(accountKnob), + claustrumMode: claustrumMode(storage), + }, } } @@ -1222,8 +1393,9 @@ async function buildResetPreviewRow( accountKey: string, ctx: ResetCommandContext, ): Promise { + let target: ResetTargetIdentity | undefined try { - const target = await ctx.resolveResetTarget(accountKey) + target = await ctx.resolveResetTarget(accountKey) const wireAccountId = target.accountKey === 'main' ? undefined : target.chatgptAccountId const [quota, credits] = await Promise.all([ @@ -1269,6 +1441,8 @@ async function buildResetPreviewRow( selectedCreditExpiresAt: selectedCredit?.expiresAt, } } catch (error) { + if ((error as { status?: unknown })?.status === 401) + await target?.onAuthFailure?.(401) log.warn('reset preview row failed', { accountKey, error: (error as Error)?.message ?? String(error), diff --git a/packages/opencode/src/core/account-paths.ts b/packages/opencode/src/core/account-paths.ts index a1704a6..cc5da0b 100644 --- a/packages/opencode/src/core/account-paths.ts +++ b/packages/opencode/src/core/account-paths.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { realpathSync } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' @@ -19,6 +20,13 @@ import { basename, dirname, join, resolve } from 'node:path' export const ACCOUNT_FILE_NAME = 'openai-auth.json' export const ACCOUNT_STATE_FILE_NAME = 'openai-auth-state.json' +export function fallbackRefreshLockName(accountId: string) { + return `fallback-oauth-refresh-${createHash('sha256') + .update(accountId) + .digest('base64url') + .slice(0, 16)}` +} + function getConfigDir() { if (process.env.OPENCODE_CONFIG_DIR?.trim()) { return process.env.OPENCODE_CONFIG_DIR.trim() diff --git a/packages/opencode/src/core/accounts.ts b/packages/opencode/src/core/accounts.ts index d373327..c4cf759 100644 --- a/packages/opencode/src/core/accounts.ts +++ b/packages/opencode/src/core/accounts.ts @@ -1,10 +1,11 @@ -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import { setTimeout as sleep } from 'node:timers/promises' import { createLogger } from '../logger.ts' import { ACCOUNT_FILE_NAME, ACCOUNT_STATE_FILE_NAME, deriveStatePath, + fallbackRefreshLockName, getAccountStatePath, getAccountStoragePath, } from './account-paths' @@ -17,6 +18,18 @@ import { quotaBackoffActive, refreshBackoffActive, } from './backoff.ts' +import { + assertNotCustodyTombstone, + CustodyTombstoneRefreshError, + enrolling, + refreshInert, + tombstoned, +} from './custody.ts' +import type { CustodyManifestReadResult } from './custody-manifest.ts' +import type { + ClaustrumMode, + CustodyTransitionState, +} from './custody-transition.ts' import { extractAccountId } from './oauth' import type { ProviderQuotaFn, @@ -49,6 +62,7 @@ export { ACCOUNT_FILE_NAME, ACCOUNT_STATE_FILE_NAME, deriveStatePath, + fallbackRefreshLockName, getAccountStatePath, getAccountStoragePath, } @@ -57,6 +71,12 @@ export { // Re-export the widened QuotaWindowName + consts from the injection seam // --------------------------------------------------------------------------- +export { + accountStoreGeneration, + type ClaustrumMode, + type CustodyTransitionState, + custodySlotFingerprint, +} from './custody-transition.ts' export type { QuotaWindowName } export { PRIMARY, SECONDARY } @@ -103,6 +123,7 @@ export type AccountOperationError = { export type OAuthAccount = AccountBase & { type: 'oauth' + corrupt?: false access?: string refresh: string expires?: number @@ -112,6 +133,18 @@ export type OAuthAccount = AccountBase & { quota?: OAuthQuotaSnapshot } +export type CorruptOAuthAccount = AccountBase & { + type: 'oauth' + corrupt: true + access?: undefined + refresh?: undefined + expires?: undefined + lastRefreshedAt?: undefined + lastRefreshError?: undefined + lastQuotaRefreshError?: undefined + quota?: undefined +} + export type ApiKeyAccount = AccountBase & { type: 'api' apiKey?: string @@ -119,12 +152,12 @@ export type ApiKeyAccount = AccountBase & { authHeader?: 'authorization-bearer' | 'x-api-key' } -export type FallbackAccount = OAuthAccount | ApiKeyAccount +export type FallbackAccount = OAuthAccount | CorruptOAuthAccount | ApiKeyAccount export function isOAuthAccount( account: FallbackAccount, ): account is OAuthAccount { - return account.type === 'oauth' + return account.type === 'oauth' && account.corrupt !== true } export function isApiKeyAccount( @@ -244,6 +277,11 @@ export type AccountStorage = { } /** Stable ChatGPT account identifier of the main account (extracted from OAuth token). */ mainAccountId?: string + claustrum?: { + mode?: ClaustrumMode + transition?: CustodyTransitionState + rowHistory?: string[] + } accounts: FallbackAccount[] } @@ -289,6 +327,11 @@ export type AccountStateSaveScope = { accounts?: true | string[] } +export type AccountManagerCustodyOptions = { + readManifest: () => Promise + provider?: string +} + export type AccountManagerOptions = { now?: () => number fetchImpl?: typeof fetch @@ -300,6 +343,9 @@ export type AccountManagerOptions = { fetchQuotaFn?: ProviderQuotaFn /** QuotaManager instance for unified cache (constructor-injected). */ quotaManager?: import('./quota-manager.ts').QuotaManager + // Required because an omitted policy reader silently re-enables local refresh; + // anthropic-auth incident 1 demonstrated that optional custody wiring fails open. + custody: AccountManagerCustodyOptions } export type AccountRefreshError = { @@ -440,7 +486,7 @@ function normalizeQuota(value: unknown): OAuthAccount['quota'] { return Object.keys(quota).length ? quota : undefined } -function normalizeAccount(value: unknown): FallbackAccount | null { +export function normalizeAccount(value: unknown): FallbackAccount | null { if (!isRecord(value)) return null if (value.type === 'api') { const baseURL = @@ -459,7 +505,27 @@ function normalizeAccount(value: unknown): FallbackAccount | null { } if (value.type !== 'oauth') return null - if (typeof value.refresh !== 'string' || !value.refresh.trim()) return null + if (value.corrupt === true) { + if (typeof value.id !== 'string' || !value.id.trim()) return null + return { + ...normalizeAccountBase(value), + type: 'oauth', + corrupt: true, + } + } + if (typeof value.refresh !== 'string' || !value.refresh.trim()) { + if ( + !Object.hasOwn(value, 'refresh') || + typeof value.id !== 'string' || + !value.id.trim() + ) + return null + return { + ...normalizeAccountBase(value), + type: 'oauth', + corrupt: true, + } + } return { ...normalizeAccountBase(value), @@ -605,10 +671,57 @@ function normalizeStorage(value: unknown): AccountStorage | null { cachekeep: isRecord(value.cachekeep) ? value.cachekeep : undefined, mainAccountId: typeof value.mainAccountId === 'string' ? value.mainAccountId : undefined, + claustrum: normalizeClaustrum(value.claustrum), accounts: normalizedAccounts, } } +function normalizeClaustrum(value: unknown): AccountStorage['claustrum'] { + if (!isRecord(value)) return undefined + const mode = value.mode === 'claustrum' ? 'claustrum' : 'local' + const transition = normalizeCustodyTransition(value.transition) + const rowHistory = Array.isArray(value.rowHistory) + ? value.rowHistory.filter( + (entry): entry is string => typeof entry === 'string', + ) + : undefined + + return { + mode, + transition, + rowHistory, + } +} + +function normalizeCustodyTransition( + value: unknown, +): CustodyTransitionState | undefined { + if (!isRecord(value) || !isRecord(value.fingerprints)) return undefined + if ( + typeof value.manifestRevision !== 'string' || + typeof value.storeGeneration !== 'string' || + !isRecord(value.fingerprints.fallbacks) + ) { + return undefined + } + const fallbacks = Object.fromEntries( + Object.entries(value.fingerprints.fallbacks).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) + return { + manifestRevision: value.manifestRevision, + storeGeneration: value.storeGeneration, + fingerprints: { + main: + typeof value.fingerprints.main === 'string' + ? value.fingerprints.main + : undefined, + fallbacks, + }, + } +} + // --------------------------------------------------------------------------- // I/O helpers // --------------------------------------------------------------------------- @@ -736,6 +849,8 @@ function accountConfig(account: FallbackAccount) { id: account.id, label: account.label, type: account.type, + corrupt: + account.type === 'oauth' && account.corrupt === true ? true : undefined, enabled: account.enabled, addedAt: account.addedAt, accountId: account.accountId, @@ -751,6 +866,7 @@ function accountRuntimeState(account: FallbackAccount) { lastUsed: account.lastUsed, }) } + if (account.corrupt) return {} return objectWithDefinedEntries({ access: account.access, refresh: account.refresh, @@ -903,6 +1019,19 @@ function configFromStorage(storage: AccountStorage): Record { logging: storage.logging, cachekeep: storage.cachekeep, mainAccountId: storage.mainAccountId, + ...(storage.claustrum !== undefined + ? { + claustrum: { + mode: storage.claustrum.mode ?? 'local', + ...(storage.claustrum.transition + ? { transition: storage.claustrum.transition } + : {}), + ...(storage.claustrum.rowHistory + ? { rowHistory: storage.claustrum.rowHistory } + : {}), + }, + } + : {}), accounts: storage.accounts.map(accountConfig), }) } @@ -924,7 +1053,7 @@ function mergeStorageForSave( } } -async function acquireSaveAccountsLock(path: string) { +async function acquireSaveAccountsLock(path: string, renew = false) { const startedAt = Date.now() const deadline = startedAt + SAVE_ACCOUNTS_LOCK_WAIT_MS let attempts = 0 @@ -934,6 +1063,7 @@ async function acquireSaveAccountsLock(path: string) { name: 'save', ttlMs: SAVE_ACCOUNTS_LOCK_TTL_MS, path, + renew, }) if (lock) return lock @@ -974,6 +1104,136 @@ async function acquireSaveAccountsLock(path: string) { ) } +export function claustrumMode( + storage: Pick | null | undefined, +): ClaustrumMode { + return storage?.claustrum?.mode === 'claustrum' ? 'claustrum' : 'local' +} + +export type AccountStoreTransaction = { + read(): Promise + write(storage: AccountStorage): Promise + writeMode( + mode: ClaustrumMode, + transition?: CustodyTransitionState, + ): Promise +} + +export async function withAccountStoreTransaction( + action: (transaction: AccountStoreTransaction) => Promise, + path = getAccountStoragePath(), +): Promise { + const statePath = getAccountStatePath(path) + const lock = await acquireSaveAccountsLock(path, true) + try { + const stateLock = await acquireSaveAccountsLock(statePath, true) + try { + const configJson = await readJsonIfPresent(path) + const stateJson = await readJsonIfPresent(statePath) + let current = + (configJson.exists + ? normalizeStorage( + mergeConfigAndState(configJson.value, stateJson.value), + ) + : null) ?? emptyAccountStorage() + const currentAccountIds = new Set( + current.accounts.map((account) => account.id), + ) + + const write = async (next: AccountStorage) => { + const baseConfig = configFromStorage(next) + const preserved = buildPreservedAdditions( + configJson.value, + currentAccountIds, + new Set(), + ) + const writtenIds = new Set( + (Array.isArray(baseConfig.accounts) ? baseConfig.accounts : []) + .map((entry) => + isRecord(entry) && typeof entry.id === 'string' + ? entry.id.trim() + : '', + ) + .filter(Boolean), + ) + const additions = preserved.filter( + (entry) => + isRecord(entry) && + typeof entry.id === 'string' && + !writtenIds.has(entry.id.trim()), + ) + const existing = isRecord(configJson.value) ? configJson.value : {} + const nextConfig = { + ...existing, + ...baseConfig, + accounts: [ + ...(Array.isArray(baseConfig.accounts) ? baseConfig.accounts : []), + ...additions, + ], + } + await writeJsonAtomic(path, nextConfig) + await writeJsonAtomic(statePath, stateFromStorage(next)) + configJson.value = nextConfig + current = structuredClone(next) + } + + const writeMode = async ( + mode: ClaustrumMode, + transition?: CustodyTransitionState, + ) => { + const existing = isRecord(configJson.value) + ? configJson.value + : { version: 1, accounts: [] } + const existingClaustrum = isRecord(existing.claustrum) + ? existing.claustrum + : {} + const rowHistory = Array.isArray(existingClaustrum.rowHistory) + ? existingClaustrum.rowHistory.filter( + (entry): entry is string => typeof entry === 'string', + ) + : undefined + const claustrum = { + mode, + ...(mode === 'claustrum' && transition ? { transition } : {}), + ...(rowHistory ? { rowHistory } : {}), + } + const nextConfig = { ...existing, claustrum } + await writeJsonAtomic(path, nextConfig) + configJson.value = nextConfig + current = { + ...current, + claustrum: { + mode, + ...(mode === 'claustrum' && transition ? { transition } : {}), + ...(rowHistory ? { rowHistory } : {}), + }, + } + } + + return await action({ + read: async () => structuredClone(current), + write, + writeMode, + }) + } finally { + await stateLock.release() + } + } finally { + await lock.release() + } +} + +export async function writeClaustrumModeAndTransition( + path: string, + mode: ClaustrumMode, + transition?: CustodyTransitionState, +): Promise { + await withAccountStoreTransaction( + (transaction) => transaction.writeMode(mode, transition), + path, + ) +} + function stateFromStorage(storage: AccountStorage): AccountRuntimeState { const accounts = Object.fromEntries( storage.accounts.map((account) => [ @@ -1288,6 +1548,19 @@ export async function mutateAccounts( // identical to a load-time drop. const currentAccountIds = new Set(current.accounts.map((a) => a.id)) const next = mutate(current) ?? current + const nextAccountIds = new Set(next.accounts.map((account) => account.id)) + const removedIds = [...currentAccountIds].filter( + (accountId) => !nextAccountIds.has(accountId), + ) + if (removedIds.length > 0) { + next.claustrum = { + ...next.claustrum, + mode: next.claustrum?.mode ?? 'local', + rowHistory: [ + ...new Set([...(next.claustrum?.rowHistory ?? []), ...removedIds]), + ], + } + } // Preserve load-dropped raw entries via the shared pipeline. The // comparison is against `currentAccountIds` (pre-mutator) so a @@ -1653,7 +1926,7 @@ function isAccountStore(value: Record): boolean { * Tolerates expired/revoked tokens (migrates them; refresh handles validity). * * Guards against first-run races with the same save-lock order used by - * structural account mutations. + * structural account writes. */ export async function migrateIfNeeded( existingToken: @@ -1766,6 +2039,12 @@ function recordRefreshError( error: unknown, now: number, ) { + // The tombstone class is the wired-in short-circuit: stamping a permanent + // backoff onto an account the vault owns would re-arm refresh against an + // inert target. Defence in depth — the choke point already throws before + // this is reached in the gate paths, but a direct caller still has to + // observe the same contract. + if (error instanceof CustodyTombstoneRefreshError) return account.lastRefreshError = buildRefreshOperationError({ error, now, @@ -1779,6 +2058,7 @@ function recordQuotaRefreshError( error: unknown, now: number, ) { + if (error instanceof CustodyTombstoneRefreshError) return account.lastQuotaRefreshError = buildQuotaOperationError({ error, now, @@ -1797,13 +2077,6 @@ function recordQuotaRefreshError( } } -function fallbackRefreshLockName(accountId: string) { - return `fallback-oauth-refresh-${createHash('sha256') - .update(accountId) - .digest('base64url') - .slice(0, 16)}` -} - // eslint-disable-next-line @typescript-eslint/no-unused-vars function _clampPercent(value: number) { if (!Number.isFinite(value)) return 0 @@ -1878,7 +2151,7 @@ export function getQuotaCheckIntervalMs(storage: AccountStorage | null) { const BACKGROUND_TICK_MS = 60_000 const BACKGROUND_TICK_JITTER_MS = 60_000 -const FALLBACK_REFRESH_LOCK_TTL_MS = 10 * 60_000 +export const FALLBACK_REFRESH_LOCK_TTL_MS = 10 * 60_000 const FALLBACK_REFRESH_JOIN_WAIT_MS = 10_000 const FALLBACK_REFRESH_JOIN_POLL_MS = 100 const DEFAULT_REFRESH_INTERVAL_MINUTES = 10 @@ -1907,14 +2180,57 @@ export class FallbackAccountManager { readonly quotaManager: import('./quota-manager.ts').QuotaManager | null private readonly onFallbackStorageChanged: (() => void) | undefined private readonly options: AccountManagerOptions + private readonly custodyReadManifest: () => Promise + private readonly custodyProvider: string - constructor(options: AccountManagerOptions = {}) { + constructor(options: AccountManagerOptions) { this.options = options this.now = options.now ?? Date.now this.fetchImpl = options.fetchImpl ?? fetch this.configPath = options.configPath ?? getAccountStoragePath() this.quotaManager = options.quotaManager ?? null this.onFallbackStorageChanged = options.onFallbackStorageChanged + this.custodyReadManifest = options.custody.readManifest + this.custodyProvider = options.custody.provider ?? 'openai' + } + + // Throws CustodyTombstoneRefreshError when the account is refresh-inert + // (manifest entry OR tombstone sentinel). The storage toggle does not + // participate: enabling or disabling it must not resurrect a local + // refresher over a vault-held family. + private async assertNotCustodyInert( + account: OAuthAccount | undefined, + ): Promise { + if (!account) return + assertNotCustodyTombstone(account, this.custodyProvider) + const manifest = await this.custodyReadManifest() + if (refreshInert(account, manifest, this.custodyProvider)) { + throw new CustodyTombstoneRefreshError(this.custodyProvider) + } + } + + // Boolean form: the entry gates use this to skip the account without + // throwing — throwing inside a loop body would force a catch that loses + // the surrounding selection bookkeeping. Derived from the granular + // custodyAccountState so there is one source of truth for the gate. + private async isCustodyRefreshInert(account: OAuthAccount): Promise { + return (await this.custodyAccountState(account)) !== null + } + + // Granular form for `getUsableFallbackAccounts`: tombstoned accounts are + // never usable candidates until the vault resolver serves them; enrolling + // accounts (manifest entry, not tombstoned) remain + // usable while their local token is valid but must never be refreshed + // locally — the refresh gate is the source of truth, the selection path + // is a separate concern. + private async custodyAccountState( + account: OAuthAccount, + ): Promise<'enrolling' | 'tombstoned' | null> { + if (tombstoned(account, this.custodyProvider)) return 'tombstoned' + const manifest = await this.custodyReadManifest() + return enrolling(account, manifest, this.custodyProvider) + ? 'enrolling' + : null } /** @@ -1991,6 +2307,10 @@ export class FallbackAccountManager { for (const account of storage.accounts) { if (account.enabled === false || !isOAuthAccount(account)) continue if (isMainAccountFallback(storage, account)) continue + const state = await this.custodyAccountState(account) + // Custody owns these families; the request resolver decides whether a + // vault or still-valid local bearer exists before send. + const skipRefresh = state === 'enrolling' || state === 'tombstoned' let refreshFailed = false let candidate = account try { @@ -2005,29 +2325,31 @@ export class FallbackAccountManager { formatRefreshBackoffMessage(refreshError, this.now()), ) } - try { - candidate = await this.refreshAccount(candidate, storage) - changed = true - } catch (error) { - if (isAccountRemovedDuringRefreshError(error)) continue - refreshFailed = true - const stored = storage.accounts.find( - (candidate): candidate is OAuthAccount => - candidate.id === account.id && isOAuthAccount(candidate), - ) - if ( - stored && - !refreshBackoffActive( - stored.lastRefreshError, - stored.refresh, - this.now(), - ) - ) { - recordRefreshError(stored, error, this.now()) - updateStoredAccount(storage, stored) + if (!skipRefresh) { + try { + candidate = await this.refreshAccount(candidate, storage) changed = true + } catch (error) { + if (isAccountRemovedDuringRefreshError(error)) continue + refreshFailed = true + const stored = storage.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === account.id && isOAuthAccount(candidate), + ) + if ( + stored && + !refreshBackoffActive( + stored.lastRefreshError, + stored.refresh, + this.now(), + ) + ) { + recordRefreshError(stored, error, this.now()) + updateStoredAccount(storage, stored) + changed = true + } + throw error } - throw error } } this.seedFallbackQuota(candidate, storage) @@ -2151,6 +2473,7 @@ export class FallbackAccountManager { let changed = false for (const account of storage.accounts) { if (account.enabled === false || !isOAuthAccount(account)) continue + if (await this.isCustodyRefreshInert(account)) continue if (!tokenNeedsRefresh(account, storage, this.now())) continue if ( refreshBackoffActive( @@ -2193,6 +2516,7 @@ export class FallbackAccountManager { let changed = false for (const account of storage.accounts) { if (account.enabled === false || !isOAuthAccount(account)) continue + if (await this.isCustodyRefreshInert(account)) continue let next = account try { if (tokenNeedsRefresh(next, storage, this.now())) { @@ -2240,6 +2564,7 @@ export class FallbackAccountManager { let changed = false for (const account of storage.accounts) { if (account.enabled === false || !isOAuthAccount(account)) continue + if (await this.isCustodyRefreshInert(account)) continue let next = account try { if (tokenNeedsRefresh(next, storage, this.now())) { @@ -2320,6 +2645,12 @@ export class FallbackAccountManager { ) if (!latestAccount) continue + // Per-poll custody gate: a manifest write is NOT a storage change, so + // the `changed` test below cannot see it. The per-poll gate reads the + // manifest fresh on every iteration and throws before any return — + // `force:true` must NEVER receive an enrolled or tombstoned account. + await this.assertNotCustodyInert(latestAccount) + const changed = latestAccount.access !== previous.access || latestAccount.refresh !== previous.refresh || @@ -2362,6 +2693,9 @@ export class FallbackAccountManager { (candidate): candidate is OAuthAccount => candidate.id === account.id && isOAuthAccount(candidate), ) + // Choke point (initial load): refuse any provider call when the + // reloaded account is enrolled or tombstoned. The toggle is ignored. + await this.assertNotCustodyInert(latestAccount) if ( latestAccount && !options.force && @@ -2400,6 +2734,10 @@ export class FallbackAccountManager { (candidate): candidate is OAuthAccount => candidate.id === account.id && isOAuthAccount(candidate), ) + // Choke point (under-lock load): a tombstone landing while the lock + // was contended, or a manifest entry appearing on disk, both abort + // the refresh before the provider call. + await this.assertNotCustodyInert(latestAccount) if ( latestAccount && !options.force && @@ -2441,6 +2779,15 @@ export class FallbackAccountManager { updateStoredAccount(storage, sourceAccount) await this.save(storage) const refreshedStorage = await this.load() + // Choke point (post-save load): a concurrent custody write landing + // between save and the verification load must invalidate the result + // even though the refreshFn succeeded. + await this.assertNotCustodyInert( + refreshedStorage?.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === account.id && isOAuthAccount(candidate), + ), + ) if ( !refreshedStorage?.accounts.some( (candidate) => candidate.id === account.id, @@ -2463,6 +2810,7 @@ export class FallbackAccountManager { async refreshAccountQuota(account: OAuthAccount, storage: AccountStorage) { const target = account + assertNotCustodyTombstone(target, 'openai') if (!target.access) { throw new Error(`Fallback account ${account.id} has no access token`) } diff --git a/packages/opencode/src/core/cachekeep.ts b/packages/opencode/src/core/cachekeep.ts index 09e1da2..a924264 100644 --- a/packages/opencode/src/core/cachekeep.ts +++ b/packages/opencode/src/core/cachekeep.ts @@ -33,7 +33,13 @@ export interface Target { export interface CacheKeepManagerOptions { fetchImpl: typeof fetch getMainToken: () => Promise - refreshFallback: (accountId: string) => Promise + refreshFallback: (accountId: string) => Promise< + | string + | { + token: string + onAuthFailure?: (status: number) => Promise + } + > codexResponsesUrl: string logger: { info: (msg: string, data?: unknown) => void @@ -56,6 +62,13 @@ export interface CacheKeepManagerOptions { getSustain?: () => boolean } +type CacheKeepFallbackAccess = + | string + | { + token: string + onAuthFailure?: (status: number) => Promise + } + export interface CacheKeepStatus { running: boolean tracked: number @@ -308,7 +321,9 @@ export class CacheKeepManager { private readonly targets = new Map() private readonly fetchImpl: typeof fetch private readonly getMainToken: () => Promise - private readonly refreshFallback: (accountId: string) => Promise + private readonly refreshFallback: ( + accountId: string, + ) => Promise private readonly codexResponsesUrl: string private readonly log: CacheKeepManagerOptions['logger'] private readonly now: () => number @@ -629,9 +644,16 @@ export class CacheKeepManager { private async prewarm(sessionKey: string, target: Target): Promise { // Resolve token let accessToken: string + let onAuthFailure: ((status: number) => Promise) | undefined try { if (target.accountId && target.accountId !== 'main') { - accessToken = await this.refreshFallback(target.accountId) + const resolved = await this.refreshFallback(target.accountId) + if (typeof resolved === 'string') { + accessToken = resolved + } else { + accessToken = resolved.token + onAuthFailure = resolved.onAuthFailure + } } else { accessToken = await this.getMainToken() } @@ -711,6 +733,7 @@ export class CacheKeepManager { if (!response.ok) { target.backoffUntil = this.now() + BACKOFF_MS + if (response.status === 401) await onAuthFailure?.(response.status) this.log.warn( 'cachekeep failed', this.logPayload({ diff --git a/packages/opencode/src/core/custody-host-slot.ts b/packages/opencode/src/core/custody-host-slot.ts new file mode 100644 index 0000000..8b6842e --- /dev/null +++ b/packages/opencode/src/core/custody-host-slot.ts @@ -0,0 +1,261 @@ +import { createHash } from 'node:crypto' +import { canonicalCustodyTombstone, tombstoned } from './custody.ts' +import { + type CustodyManifestReadResult, + custodyManifestHandles, +} from './custody-manifest.ts' +import { type CustodyVerdict, evaluateCustodyStartup } from './custody-state.ts' +import { extractAccountIdFromClaims, parseJwtClaims } from './oauth.ts' + +const MAIN_PROVIDER = 'openai' +const SLOT_ABSENT_CONFIRMATION_MS = 250 +const verifiedInProcessMainLoginFingerprints = new Set() + +export type MainOauthSlot = { + type: 'oauth' + access?: string + refresh?: string + expires?: number +} + +export function mainSlotFamilyFingerprint( + slot: MainOauthSlot, +): string | undefined { + if (typeof slot.access !== 'string' || typeof slot.refresh !== 'string') { + return undefined + } + const access = Buffer.from(slot.access) + const refresh = Buffer.from(slot.refresh) + const length = (value: Buffer) => { + const encoded = Buffer.alloc(4) + encoded.writeUInt32BE(value.length) + return encoded + } + return createHash('sha256') + .update(length(access)) + .update(access) + .update(length(refresh)) + .update(refresh) + .digest('hex') +} + +export function recordVerifiedInProcessMainLogin(slot: MainOauthSlot): void { + const fingerprint = mainSlotFamilyFingerprint(slot) + if (fingerprint) verifiedInProcessMainLoginFingerprints.add(fingerprint) +} + +export function hasVerifiedInProcessMainLogin(slot: MainOauthSlot): boolean { + const fingerprint = mainSlotFamilyFingerprint(slot) + return ( + fingerprint !== undefined && + verifiedInProcessMainLoginFingerprints.has(fingerprint) + ) +} + +export type MainAuthSlot = + | { kind: 'real'; oauth: MainOauthSlot } + | { kind: 'tombstone'; oauth: MainOauthSlot } + | { kind: 'empty'; oauth: MainOauthSlot } + | { kind: 'slot-absent' } + | { kind: 'indeterminate' } + +type HostAuthClient = { + auth: { + get?: (input: { path: { id: string } }) => Promise + all?: () => Promise> + set?: unknown + } +} + +type MainSlotConfirmationDeps = { + client: HostAuthClient + now: () => number + sleep: (ms: number) => Promise +} + +type MainSlotReconciliationDeps = MainSlotConfirmationDeps & { + mode: 'local' | 'claustrum' + manifest: CustodyManifestReadResult + mainAccountId?: string + getCredential?: (handle: string) => Promise<{ access: string }> + isReauth?: (handle: string) => boolean +} + +export type CustodyBootstrap = { + mainVerdict?: CustodyVerdict + mainAccountId?: string + cache?: { + get( + handle: string, + minTtlMs?: number, + ): Promise<{ payload: { access: string } }> + isReauth(handle: string): boolean + } +} + +function asOauthSlot(value: unknown): MainOauthSlot | undefined { + if (!value || typeof value !== 'object') return undefined + const candidate = value as Record + if (candidate.type !== 'oauth') return undefined + return { + type: 'oauth', + ...(typeof candidate.access === 'string' + ? { access: candidate.access } + : {}), + ...(typeof candidate.refresh === 'string' + ? { refresh: candidate.refresh } + : {}), + ...(typeof candidate.expires === 'number' + ? { expires: candidate.expires } + : {}), + } +} + +export function asCompleteMainOauthSlot( + value: unknown, +): { access: string; refresh: string; expires?: number } | undefined { + const oauth = asOauthSlot(value) + if (typeof oauth?.access !== 'string' || typeof oauth.refresh !== 'string') { + return undefined + } + return { + access: oauth.access, + refresh: oauth.refresh, + ...(typeof oauth.expires === 'number' ? { expires: oauth.expires } : {}), + } +} + +export function classifyMainAuthSlot(value: unknown): MainAuthSlot { + const oauth = asOauthSlot(value) + if (!oauth) return { kind: 'indeterminate' } + if ( + !tombstoned( + { + id: 'main', + type: 'oauth', + access: oauth.access ?? '', + refresh: oauth.refresh ?? '', + expires: oauth.expires ?? 0, + addedAt: 0, + }, + MAIN_PROVIDER, + ) + ) { + return { kind: 'real', oauth } + } + const tombstone = canonicalCustodyTombstone(MAIN_PROVIDER) + if ( + oauth.access === tombstone.access && + oauth.expires === tombstone.expires + ) { + return { kind: 'tombstone', oauth } + } + return { kind: 'empty', oauth } +} + +async function getMainSlot(client: HostAuthClient): Promise { + if (!client.auth.get) return undefined + return client.auth.get({ path: { id: MAIN_PROVIDER } }) +} + +async function nonEmptyAuthMap(client: HostAuthClient): Promise { + if (!client.auth.all) return false + return Object.keys(await client.auth.all()).length > 0 +} + +export async function confirmMainAuthSlot( + deps: MainSlotConfirmationDeps, +): Promise { + const first = await getMainSlot(deps.client) + if (first !== undefined) return classifyMainAuthSlot(first) + + const firstMapNonEmpty = await nonEmptyAuthMap(deps.client) + const beforeSleep = deps.now() + await deps.sleep(SLOT_ABSENT_CONFIRMATION_MS) + if (deps.now() - beforeSleep < SLOT_ABSENT_CONFIRMATION_MS) { + return { kind: 'indeterminate' } + } + + const second = await getMainSlot(deps.client) + if (second !== undefined) return classifyMainAuthSlot(second) + const secondMapNonEmpty = await nonEmptyAuthMap(deps.client) + return firstMapNonEmpty && secondMapNonEmpty + ? { kind: 'slot-absent' } + : { kind: 'indeterminate' } +} + +function manifestState( + manifest: CustodyManifestReadResult, +): 'absent' | 'present' | 'unreadable' { + if (manifest.ok) return 'present' + return manifest.reason === 'absent' ? 'absent' : 'unreadable' +} + +export function mainAccountIdFromServedCredential( + access: string, +): string | undefined { + const claims = parseJwtClaims(access) + return claims ? extractAccountIdFromClaims(claims) : undefined +} + +async function mainVaultState( + deps: MainSlotReconciliationDeps, +): Promise< + 'serves' | 'cold' | 'needs_reauth' | 'identity_mismatch' | 'no_handle' +> { + const handle = custodyManifestHandles(deps.manifest).get('main') + if (!handle) return 'no_handle' + if (!deps.getCredential) return 'cold' + try { + const credential = await deps.getCredential(handle) + const servedAccountId = mainAccountIdFromServedCredential(credential.access) + if ( + deps.mainAccountId && + servedAccountId && + servedAccountId !== deps.mainAccountId + ) { + return 'identity_mismatch' + } + return 'serves' + } catch { + return deps.isReauth?.(handle) ? 'needs_reauth' : 'cold' + } +} + +function verifiedInProcessLogin(slot: MainAuthSlot): boolean { + return slot.kind === 'real' && hasVerifiedInProcessMainLogin(slot.oauth) +} + +export async function reconcileMainSlotBeforeHooks( + deps: MainSlotReconciliationDeps, +): Promise { + const slot = await confirmMainAuthSlot(deps) + if (slot.kind === 'indeterminate') return undefined + + const manifest = manifestState(deps.manifest) + if (slot.kind === 'slot-absent') { + const vault = await mainVaultState(deps) + return evaluateCustodyStartup({ + mode: deps.mode, + manifest, + local: slot.kind, + verifiedInProcessLogin: verifiedInProcessLogin(slot), + // The absent-slot recovery row remains takeover-incomplete while a bound + // vault is merely cold or latched; only a missing/disputed binding changes + // the typed factory verdict. + vault: () => + vault === 'no_handle' || vault === 'identity_mismatch' + ? vault + : 'serves', + }) + } + + const vault = await mainVaultState(deps) + return evaluateCustodyStartup({ + mode: deps.mode, + manifest, + local: slot.kind, + verifiedInProcessLogin: verifiedInProcessLogin(slot), + vault: () => vault, + }) +} diff --git a/packages/opencode/src/core/custody-manifest.ts b/packages/opencode/src/core/custody-manifest.ts new file mode 100644 index 0000000..7145902 --- /dev/null +++ b/packages/opencode/src/core/custody-manifest.ts @@ -0,0 +1,332 @@ +/** + * Custody manifest reader — secure, narrow path parser for the opencode + * Claude-vault handle file. The owning filter is provider:'openai' / + * shape:'oauth' / serve:'openai-auth'; consumers (enrollment, predicates, + * the resolver) consult the parsed file but NEVER receive raw strings + * (handles, errors) that could leak through logs or thrown-error messages. + * + * The transport's `ManifestHandleFile` type is the source of truth for the + * structural rules (version, providers, identifier regexes, handle regex, + * no prototype keys). This file owns only the I/O path: resolver + parser + + * lstat/O_NOFOLLOW/fstat + 0600/uid + parent + 256 KiB cap + bounded read. + */ + +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { + lstat as nodeLstat, + open as nodeOpen, + stat as nodeStat, + readFile, +} from 'node:fs/promises' +import { userInfo } from 'node:os' +import { dirname, join } from 'node:path' + +import type { + ManifestHandleAccount, + ManifestHandleFile, + ManifestHandleProvider, +} from '../vendor/claustrum-client/manifest-lock.ts' + +const HANDLE_FILE_MAX_BYTES = 256 * 1024 +const IDENTIFIER_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/ +const HANDLE_RE = /^ckh_[A-Za-z0-9_-]{43}$/ +const FORBIDDEN_IDENTIFIERS = new Set(['__proto__', 'constructor', 'prototype']) +const TENANT = 'openai-auth' +const OWNING_PROVIDER = 'openai' +const OWNING_SHAPE = 'oauth' +const OWNING_SERVE = 'openai-auth' + +// Default location: $CLAUSTRUM_OPENCODE_HANDLES, then XDG_CONFIG_HOME or +// ~/.config/cortexkit/opencode-handles.json. The floor pre-set in +// tests/setup-env.ts makes this safe under the test runner. +export function defaultCustodyManifestPath( + env: NodeJS.ProcessEnv = process.env, +): string { + if (env.CLAUSTRUM_OPENCODE_HANDLES) return env.CLAUSTRUM_OPENCODE_HANDLES + const configHome = + env.XDG_CONFIG_HOME || (env.HOME ? join(env.HOME, '.config') : '.config') + return join(configHome, 'cortexkit', 'opencode-handles.json') +} + +export type CustodyManifestReadResult = + | { ok: true; value: ManifestHandleFile; revision: string } + | { ok: false; reason: 'absent' } + | { ok: false; reason: 'tooLarge'; message: string } + | { + ok: false + reason: 'permissions' | 'unsafeParent' | 'notRegular' | 'symlink' + message: string + } + | { ok: false; reason: 'invalid'; message: string } + | { ok: false; reason: 'unreadable'; message: string } + +export type CustodyManifestIo = { + lstat?: (path: string) => Promise + stat?: (path: string) => Promise + readFile?: (path: string, encoding: 'utf8') => Promise + open?: (path: string) => Promise + currentUid?: () => number | undefined +} + +type CustodyStat = { + isFile(): boolean + isDirectory?(): boolean + isSymbolicLink?(): boolean + mode: number + size?: number + uid?: number + mtimeMs?: number +} + +type CustodyDescriptor = { + stat(): Promise + read(options: { + buffer: Buffer + offset: number + length: number + position: number + }): Promise<{ bytesRead: number }> + close(): Promise +} + +function currentUid(): number | undefined { + return process.getuid?.() ?? userInfo().uid +} + +export async function readCustodyManifest( + path = defaultCustodyManifestPath(), + io: CustodyManifestIo = {}, +): Promise { + const lstat = io.lstat ?? nodeLstat + const stat = io.stat ?? nodeStat + const readFileImpl = io.readFile ?? readFile + const openImpl = + io.open ?? + ((candidate: string) => + nodeOpen( + candidate, + constants.O_RDONLY | constants.O_NOFOLLOW, + ) as unknown as Promise) + + let descriptor: CustodyDescriptor | undefined + try { + let metadata: CustodyStat + try { + if (io.lstat || io.readFile) { + // Callers that injected their own lstat/readFile opt out of the bounded + // open() path — bounded read needs a real FD to size-check before read. + metadata = await lstat(path) + } else { + descriptor = await openImpl(path) + metadata = await descriptor.stat() + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { ok: false, reason: 'absent' } + } + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + return { ok: false, reason: 'symlink', message: 'symlink' } + } + return { + ok: false, + reason: 'unreadable', + message: `unreadable (${errno(error)})`, + } + } + if (metadata.isSymbolicLink?.()) { + return { ok: false, reason: 'symlink', message: 'symlink' } + } + if (!metadata.isFile()) { + return { ok: false, reason: 'notRegular', message: 'not regular' } + } + if ((metadata.size ?? 0) > HANDLE_FILE_MAX_BYTES) { + return { ok: false, reason: 'tooLarge', message: 'too large' } + } + if ((metadata.mode & 0o777) !== 0o600) { + return { ok: false, reason: 'permissions', message: 'mode' } + } + const uid = io.currentUid ?? currentUid + const expectedUid = uid() + if ( + expectedUid !== undefined && + metadata.uid !== undefined && + metadata.uid !== expectedUid + ) { + return { ok: false, reason: 'permissions', message: 'uid' } + } + + let parent: CustodyStat + try { + parent = await stat(dirname(path)) + } catch { + return { ok: false, reason: 'unsafeParent', message: 'parent' } + } + if (!parent.isDirectory?.()) { + return { ok: false, reason: 'unsafeParent', message: 'parent not dir' } + } + if ( + expectedUid !== undefined && + parent.uid !== undefined && + parent.uid !== expectedUid + ) { + return { ok: false, reason: 'unsafeParent', message: 'parent uid' } + } + // A permissive parent (group- or world-writable, OR group/other readable) + // is the TOCTOU surface the manifest-lock writer guards against. Refuse + // the read so a racing writer cannot race-replace the manifest we are + // about to parse, and so a co-tenant with read access to the parent + // cannot inspect the owning tenant's handle file. Mode 0o700 (owner only) + // is the only safe default. + if ((parent.mode & 0o077) !== 0) { + return { ok: false, reason: 'unsafeParent', message: 'parent mode' } + } + + let source: string + try { + if (descriptor) { + // Bounded read: read up to cap+1 bytes from the already-fstat'd FD so a + // TOCTOU write that grows the file between fstat and read cannot drive + // the read past the cap. + const cap = HANDLE_FILE_MAX_BYTES + const buffer = Buffer.alloc(cap + 1) + const { bytesRead } = await descriptor.read({ + buffer, + offset: 0, + length: cap + 1, + position: 0, + }) + if (bytesRead > cap) { + return { ok: false, reason: 'tooLarge', message: 'too large' } + } + source = buffer.subarray(0, bytesRead).toString('utf8') + } else { + source = await readFileImpl(path, 'utf8') + } + } catch { + return { + ok: false, + reason: 'unreadable', + message: `unreadable (${errno(descriptor ? 'fd' : 'io')})`, + } + } + if (Buffer.byteLength(source, 'utf8') > HANDLE_FILE_MAX_BYTES) { + return { ok: false, reason: 'tooLarge', message: 'too large' } + } + + let value: unknown + try { + value = JSON.parse(source) + } catch { + // Stable message + no cause: the caller path is log/throw surfaces, + // both of which would otherwise leak the partial source (which may + // contain a ckh_… handle) when wrapped into a higher-level error. + return { ok: false, reason: 'invalid', message: 'invalid JSON' } + } + const parsed = parseOwningProvider(value) + if (!parsed) { + return { ok: false, reason: 'invalid', message: 'invalid manifest' } + } + return { ok: true, value: parsed, revision: manifestRevision(source) } + } finally { + await descriptor?.close() + } +} + +export function manifestRevision(source: string): string { + return createHash('sha256').update(source, 'utf8').digest('hex') +} + +function errno(error: unknown): string { + if (typeof error === 'string') return error + const code = (error as NodeJS.ErrnoException | undefined)?.code + return code ?? 'EIO' +} + +/** + * Extract the opencode-claustrum owning provider block from a parsed manifest. + * Other tenants (anthropic-auth, deepseek, …) are ignored — this consumer + * owns the openai/oauth/openai-auth block and nothing else. Validation here + * mirrors the manifest-lock primitive's rules so the read path enforces the + * same identifier and handle shape on disk. + */ +function parseOwningProvider(value: unknown): ManifestHandleFile | null { + if (!value || typeof value !== 'object') return null + const file = value as Record + if (file.version !== 1 || !Array.isArray(file.providers)) return null + if (!isSafeIdentifier(OWNING_PROVIDER)) return null + // An empty providers list means no ownership has been declared yet. The + // read path treats that as 'absent' rather than 'invalid' so callers can + // distinguish a never-onboarded manifest from a corrupted one — but for + // the resolver path an empty list has no effect either way (no enrollment + // is possible). + const providers: ManifestHandleProvider[] = [] + for (const provider of file.providers) { + if (!provider || typeof provider !== 'object') return null + const item = provider as Record + if (item.provider !== OWNING_PROVIDER) continue + if (item.shape !== OWNING_SHAPE) continue + if (item.serve !== OWNING_SERVE) continue + if (!Array.isArray(item.accounts) || item.accounts.length === 0) return null + const labels = new Set() + const accounts: ManifestHandleAccount[] = [] + for (const account of item.accounts) { + if (!account || typeof account !== 'object') return null + const entry = account as Record + const label = entry.label + const handle = entry.handle + const credential_id = entry.credential_id + if (typeof label !== 'string' || !isSafeIdentifier(label)) return null + if (labels.has(label)) return null + labels.add(label) + if (typeof handle !== 'string' || !HANDLE_RE.test(handle)) return null + if (typeof credential_id !== 'string' || !credential_id) return null + accounts.push({ label, handle, credential_id }) + } + providers.push({ + provider: OWNING_PROVIDER, + shape: OWNING_SHAPE, + serve: OWNING_SERVE, + accounts, + }) + } + return { version: 1, providers } +} + +function isSafeIdentifier(value: string): boolean { + return IDENTIFIER_RE.test(value) && !FORBIDDEN_IDENTIFIERS.has(value) +} + +/** + * Tenant-stable identifier used by the manifest-lock writer when the + * consumer needs to add or replace an account on disk. Tests + the + * resolver both depend on this constant being stable. + */ +export const CUSTODY_MANIFEST_TENANT = TENANT + +// Sentinel exposed for callers that want to assert the owning filter +// without re-hardcoding it (e.g. fixtures). +export const CUSTODY_OWNING_PROVIDER = OWNING_PROVIDER +export const CUSTODY_OWNING_SHAPE = OWNING_SHAPE +export const CUSTODY_OWNING_SERVE = OWNING_SERVE + +/** Returns the owning tenant's case-exact account-label to vault-handle map. */ +export function custodyManifestHandles( + manifest: CustodyManifestReadResult, +): ReadonlyMap { + if (!manifest.ok) return new Map() + const handles = new Map() + for (const provider of manifest.value.providers) { + if ( + provider.provider !== OWNING_PROVIDER || + provider.shape !== OWNING_SHAPE || + provider.serve !== OWNING_SERVE + ) { + continue + } + for (const account of provider.accounts) { + handles.set(account.label, account.handle) + } + } + return handles +} diff --git a/packages/opencode/src/core/custody-runtime.ts b/packages/opencode/src/core/custody-runtime.ts new file mode 100644 index 0000000..703b819 --- /dev/null +++ b/packages/opencode/src/core/custody-runtime.ts @@ -0,0 +1,1020 @@ +import { + projectCustodyForSidebar, + type SidebarAccountCustody, +} from '../sidebar-state.ts' +import { + ClaustrumClient, + detectClaustrumConnection, + getDefaultClaustrumConnectionPath, +} from '../vendor/claustrum-client/index.ts' +import { fallbackRefreshLockName } from './account-paths.ts' +import { + type AccountStorage, + FALLBACK_REFRESH_LOCK_TTL_MS, + isOAuthAccount, + type loadAccounts, + type mutateAccounts, + type OAuthAccount, + type withAccountStoreTransaction, +} from './accounts.ts' +import { + ClaustrumCredentialCache, + type CompleteEnrollmentDeps, + type CompleteEnrollmentOutcome, + canonicalCustodyTombstone, + custodied, + enrolling, + reconcileFallbackCustody, + refreshInert, + tombstoned, +} from './custody.ts' +import { + CUSTODY_OWNING_PROVIDER, + custodyManifestHandles, + defaultCustodyManifestPath, + type readCustodyManifest, +} from './custody-manifest.ts' +import { + evaluateCustodyStartup, + type VaultCustodyState, +} from './custody-state.ts' +import { + type CustodyHostAuth, + custodySlotFingerprint, + MAIN_REFRESH_LOCK_NAME, + writeMainCustodyTombstone, +} from './custody-transition.ts' +import { extractAccountIdFromClaims, parseJwtClaims } from './oauth.ts' +import type { acquireRefreshFileLock } from './refresh-file-lock.ts' + +// --------------------------------------------------------------------------- +// Custody runtime — owns the vendored client, the credential cache, the boot / +// tick loop, and the live custody projection handed to the sidebar writer. +// +// Construction is dependency-injected so the loader and the runtime tests +// share one entry point. Production passes the real vendored client detector / +// connector; tests pass a fake. The runtime never reaches outside the injected +// surface, so a unit test can drive every observable without going through the +// loader. +// --------------------------------------------------------------------------- + +type RuntimeLogger = { + info: (message: string, meta?: Record) => void + warn: (message: string, meta?: Record) => void + debug: (message: string, meta?: Record) => void + error: (message: string, meta?: Record) => void +} + +export type CustodyRuntimeOptions = { + /** Storage snapshot at loader time — used for the boot sweep pass. */ + storage: AccountStorage | null + configPath: string + manifestPath?: string + /** Test seam: the vendored detector. Production passes the real function. */ + detectClaustrumConnection?: typeof detectClaustrumConnection + /** Test seam: transport factory passed to `ClaustrumCredentialCache`. */ + cacheConnector?: (options: { + connectionFile: string + handshakeTimeoutMs?: number + }) => Promise + loadAccounts: typeof loadAccounts + mutateAccounts: typeof mutateAccounts + withAccountStoreTransaction: typeof withAccountStoreTransaction + readCustodyManifest: typeof readCustodyManifest + acquireRefreshFileLock: typeof acquireRefreshFileLock + auth?: CustodyHostAuth + resolveFallbackVaultState?: (input: { + accountId: string + handle: string + }) => Promise + now?: () => number + setIntervalFn?: ( + callback: () => void, + intervalMs: number, + ) => ReturnType + clearIntervalFn?: (handle: ReturnType) => void + onReconcileStep?: ( + step: 'discovery-lock-acquired' | 'enabled-manifest-join', + accountId?: string, + ) => void | Promise + logger: RuntimeLogger +} + +export type ClaustrumCacheTransportLike = { + getCredential( + handle: string, + minTtlMs?: number, + ): Promise<{ + material: string + recordVersion: number + expiresAtMs: number | null + }> + statusCredential(handle: string): Promise<{ + ready: boolean + lastErrorCode: string | null + leaseHeld: boolean + recordVersion: number + }> + reportAuthFailure(params: { + handle: string + providerStatus: number + recordVersion: number + reporterSource: 'direct' | 'relay_status_field' | 'relay_message_parse' + }): Promise + close(): void +} + +export type CustodyRuntime = { + /** Run the initial completion sweep. Resolves BEFORE the background refresh is armed. */ + boot(): Promise + /** Clear the timer and close the cache + transport. Idempotent. */ + dispose(): void + /** Force one tick pass (used by the timer and tests). */ + runTick(): Promise + /** Sync projection read for the sidebar writer. */ + getCustodyProjection( + account: OAuthAccount, + now: number, + ): SidebarAccountCustody | undefined + /** Whether the custody runtime is enabled for this process. */ + isEnabled(): boolean + /** Cache handle (undefined when custody is disabled). */ + getCache(): ClaustrumCredentialCache | undefined + /** Transport handle (undefined when custody is disabled). */ + getTransport(): ClaustrumCacheTransportLike | undefined + /** True if the detection step produced an `available` connection file. */ + wasDetected(): boolean +} + +const CUSTODY_TICK_INTERVAL_MS = 5 * 60_000 +const CUSTODY_TICK_JITTER_MS = 30_000 +const CUSTODY_HANDSHAKE_TIMEOUT_MS = 5_000 +// Aggregate-bound warm: the loader races its await against this cap and +// proceeds; the warm itself keeps populating the cache detached. +const CUSTODY_WARM_AWAIT_MS = 100 + +export function __createCustodyRuntimeForTest( + options: CustodyRuntimeOptions, +): CustodyRuntime { + const log = options.logger + const now = options.now ?? Date.now + const manifestPath = options.manifestPath ?? defaultCustodyManifestPath() + const cacheConnector = options.cacheConnector ?? defaultCacheConnector(log) + const detect = options.detectClaustrumConnection ?? detectClaustrumConnection + + let cache: ClaustrumCredentialCache | undefined + let transport: ClaustrumCacheTransportLike | undefined + let reconnecting: Promise | undefined + let detection: Awaited> | undefined + let timer: ReturnType | undefined + let closed = false + // Live custody projection per account id. Updated after every sweep pass + // (boot or tick). A test that probes projection before the first sweep sees + // no entry — the sidebar omits `custody` for that account. + const projectionByAccountId = new Map() + const orphanLogKeys = new Set() + let latestManifest: + | Awaited> + | undefined + + const isEnabled = () => detection?.status === 'available' + + const runtime: CustodyRuntime = { + isEnabled, + wasDetected: () => detection?.status === 'available', + getCache: () => cache, + getTransport: () => transport, + getCustodyProjection: (account, currentNow) => { + const cached = projectionByAccountId.get(account.id) + if (cached) return cached + // Refresh-inert accounts without a stored projection are projected from + // the live predicates. The toggle is irrelevant here (refreshInert binds + // on manifest entry OR tombstone), but the projection still reports the + // ownership shape. + return projectFromPredicates( + account, + options.storage, + currentNow, + cache, + latestManifest, + ) + }, + async boot() { + if (closed) return + try { + detection = await detect() + } catch (error) { + log.warn('custody detection failed', { + error: error instanceof Error ? error.message : String(error), + }) + return + } + if (detection.status !== 'available') { + if (detection.status === 'malformed') { + log.warn('custody connection malformed; disabled for this process', { + reason: detection.reason, + }) + } else { + log.info('custody not configured; no client/timer created', {}) + } + return + } + const bootManifest = await options.readCustodyManifest(manifestPath) + latestManifest = bootManifest + await runDiscoveryPass(bootManifest) + if (options.storage?.claustrum?.mode !== 'claustrum') { + log.info( + 'custody connection available but mode is local; manifest read for the refresh gate, no client/timer', + {}, + ) + return + } + if (!(await connectCache())) { + scheduleNextTick() + return + } + const connectedCache = cache + if (!connectedCache) return + if (!bootManifest.ok && bootManifest.reason !== 'absent') { + projectManifestUnreadable( + await options.loadAccounts(options.configPath), + ) + scheduleNextTick() + return + } + await runFingerprintResumePass() + await runFallbackInstallPass(bootManifest) + const currentStorage = await options.loadAccounts(options.configPath) + await options.onReconcileStep?.('enabled-manifest-join') + const enabledHandles = enabledManifestHandles( + bootManifest, + currentStorage, + ) + const sweepPromises: Promise[] = [] + for (const account of oauthAccounts(currentStorage)) { + if ( + currentStorage?.claustrum?.transition?.fingerprints.fallbacks[ + account.id + ] + ) + continue + if ( + !enrolling(account, bootManifest, CUSTODY_OWNING_PROVIDER) && + !(tombstoned(account, CUSTODY_OWNING_PROVIDER) && !account.accountId) + ) + continue + const handle = enabledHandles.get(account.id) + if (!handle) continue + const sweepDeps = buildSweepDeps(connectedCache) + sweepPromises.push( + reconcileFallbackCustody(account, sweepDeps) + .then((outcome) => + applyOutcomeToProjection(account, outcome, bootManifest), + ) + .catch((error) => + log.warn('custody boot sweep failed', { + error: error instanceof Error ? error.message : String(error), + }), + ), + ) + } + // The sweep must finish before the loader can arm background refresh; + // otherwise both paths can contend for an enrolling account's lock. + await Promise.all(sweepPromises) + // A cap miss leaves the warm in flight detached; the next tick picks up + // any cache entry that completes after the bound. + await raceAggregateWarm( + enabledHandles, + connectedCache, + currentStorage, + CUSTODY_WARM_AWAIT_MS, + ) + scheduleNextTick() + }, + async runTick() { + if (closed) return + if (options.storage?.claustrum?.mode !== 'claustrum') return + // Re-read manifest (hot-reload on mtime) so an operator edit lands at + // the next tick without a restart. + const previousRevision = latestManifest?.ok + ? latestManifest.revision + : undefined + const manifestPromise = options.readCustodyManifest(manifestPath) + const reconnect = cache ? undefined : connectCache() + const manifest = await manifestPromise + latestManifest = manifest + if (!manifest.ok && manifest.reason !== 'absent') { + projectManifestUnreadable( + await options.loadAccounts(options.configPath), + ) + return + } + const currentStorage = + manifest.ok && manifest.revision !== previousRevision + ? await runDiscoveryPass(manifest) + : await options.loadAccounts(options.configPath) + if (!cache && !(await reconnect)) return + if (manifest.ok && manifest.revision === previousRevision) { + await runFingerprintResumePass() + await options.onReconcileStep?.('enabled-manifest-join') + const enabledHandles = enabledManifestHandles(manifest, currentStorage) + await runCompletionSweep(manifest, enabledHandles) + await runWarmPass(manifest, enabledHandles) + return + } + await runFingerprintResumePass() + await runFallbackInstallPass(manifest) + await options.onReconcileStep?.('enabled-manifest-join') + const enabledHandles = enabledManifestHandles(manifest, currentStorage) + // Step 1: completion sweep — every enrolling account under its refresh + // lock, identity-verified, tombstoned on success. The sweep is the only + // place a get happens for an enrolling account. + await runCompletionSweep(manifest, enabledHandles) + // Step 2: warm / outcome pass — one get per enabled custodied account. + await runWarmPass(manifest, enabledHandles) + }, + dispose() { + if (closed) return + closed = true + if (timer) { + const clear = options.clearIntervalFn ?? clearInterval + clear(timer) + timer = undefined + } + try { + cache?.close() + } catch {} + if (!cache) { + try { + transport?.close() + } catch {} + } + cache = undefined + transport = undefined + }, + } + + async function connectCache(): Promise { + if (closed) return false + if (cache) return true + if (reconnecting) return reconnecting + if (!detection) return false + const attempt = (async () => { + let candidate: ClaustrumCredentialCache | undefined + try { + const connection = cacheConnector({ + connectionFile: resolveConnectionPath(detection), + handshakeTimeoutMs: CUSTODY_HANDSHAKE_TIMEOUT_MS, + }) + candidate = new ClaustrumCredentialCache({ + connector: () => connection, + now, + }) + const client = await connection + if (closed) { + candidate.close() + return false + } + cache = candidate + transport = client + return true + } catch (error) { + candidate?.close() + log.warn('custody connect failed; disabled until tick reconnect', { + error: error instanceof Error ? error.message : String(error), + }) + return false + } + })() + reconnecting = attempt + try { + return await attempt + } finally { + if (reconnecting === attempt) reconnecting = undefined + } + } + + function projectManifestUnreadable(storage: AccountStorage | null): void { + for (const account of oauthAccounts(storage)) { + projectionByAccountId.set(account.id, { + state: 'inert', + reason: 'manifest-unreadable', + }) + } + } + + async function runDiscoveryPass( + manifest: Awaited>, + ): Promise { + let storage = await options.loadAccounts(options.configPath) + if (!storage || !manifest.ok) return storage + for (const [accountId, expectedHandle] of custodyManifestHandles( + manifest, + )) { + if (!storage) break + if (accountId === 'main') continue + if (storage.accounts.some((account) => account.id === accountId)) continue + const cause = storage.claustrum?.rowHistory?.includes(accountId) + ? 'row removed' + : 'awaiting discovery' + const logKey = `${accountId}:${cause}` + if (!orphanLogKeys.has(logKey)) { + orphanLogKeys.add(logKey) + log.info(`orphan-binding: ${cause}`, { accountId }) + } + if (storage.claustrum?.mode !== 'claustrum') continue + let lock = null + while (!lock && !closed) { + lock = await options.acquireRefreshFileLock({ + name: fallbackRefreshLockName(accountId), + ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS, + path: options.configPath, + renew: true, + }) + if (!lock) await new Promise((resolve) => setTimeout(resolve, 5)) + } + if (!lock) continue + try { + await options.onReconcileStep?.('discovery-lock-acquired', accountId) + const recheckManifest = await options.readCustodyManifest(manifestPath) + const recheckStorage = await options.loadAccounts(options.configPath) + if ( + recheckStorage?.claustrum?.mode !== 'claustrum' || + !recheckManifest.ok || + custodyManifestHandles(recheckManifest).get(accountId) !== + expectedHandle || + recheckStorage.accounts.some((account) => account.id === accountId) + ) { + continue + } + await options.mutateAccounts((current) => { + if ( + current.claustrum?.mode !== 'claustrum' || + current.accounts.some((account) => account.id === accountId) + ) { + return current + } + return { + ...current, + claustrum: { + ...current.claustrum, + mode: 'claustrum', + rowHistory: [ + ...new Set([ + ...(current.claustrum?.rowHistory ?? []), + accountId, + ]), + ], + }, + accounts: [ + ...current.accounts, + { + id: accountId, + ...canonicalCustodyTombstone(CUSTODY_OWNING_PROVIDER), + enabled: true, + }, + ], + } + }, options.configPath) + } finally { + await lock.release().catch(() => {}) + } + storage = await options.loadAccounts(options.configPath) + } + return storage + } + + async function runCompletionSweep( + manifest: ReturnType extends Promise< + infer R + > + ? R + : never, + enabledHandles: Map, + ): Promise { + if (!cache) return + const storage = await options.loadAccounts(options.configPath) + const sweepDeps = buildSweepDeps(cache) + for (const account of oauthAccounts(storage)) { + if (storage?.claustrum?.transition?.fingerprints.fallbacks[account.id]) + continue + if ( + !enrolling(account, manifest, CUSTODY_OWNING_PROVIDER) && + !(tombstoned(account, CUSTODY_OWNING_PROVIDER) && !account.accountId) + ) + continue + if (!enabledHandles.has(account.id)) continue + const outcome = await reconcileFallbackCustody(account, sweepDeps) + applyOutcomeToProjection(account, outcome, manifest) + } + } + + async function runFingerprintResumePass(): Promise { + const initial = await options.loadAccounts(options.configPath) + const transition = initial?.claustrum?.transition + if (initial?.claustrum?.mode !== 'claustrum' || !transition) return + let incomplete = false + + for (const [accountId, expected] of Object.entries( + transition.fingerprints.fallbacks, + )) { + const lock = await options.acquireRefreshFileLock({ + name: fallbackRefreshLockName(accountId), + ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS, + path: options.configPath, + renew: true, + }) + if (!lock) { + incomplete = true + continue + } + try { + const completed = await options.withAccountStoreTransaction( + async (transaction) => { + const current = await transaction.read() + const account = current.accounts.find( + (candidate) => candidate.id === accountId, + ) + if (account?.type === 'oauth' && tombstoned(account, 'openai')) { + return true + } + if ( + !account || + !isOAuthAccount(account) || + !account.access || + custodySlotFingerprint(account.access, account.refresh) !== + expected + ) { + projectionByAccountId.set(accountId, { + state: 'inert', + reason: 'new-local-family-under-claustrum', + }) + return false + } + const next = structuredClone(current) + const target = next.accounts.find( + (candidate) => candidate.id === accountId, + ) + if (!target || !isOAuthAccount(target)) return false + Object.assign( + target, + canonicalCustodyTombstone(CUSTODY_OWNING_PROVIDER), + ) + await transaction.write(next) + const written = (await transaction.read()).accounts.find( + (candidate) => candidate.id === accountId, + ) + return !!( + written?.type === 'oauth' && tombstoned(written, 'openai') + ) + }, + options.configPath, + ) + if (!completed) incomplete = true + } catch { + incomplete = true + } finally { + await lock.release().catch(() => {}) + } + } + + if (transition.fingerprints.main) { + if (!options.auth) { + incomplete = true + } else { + const lock = await options.acquireRefreshFileLock({ + name: MAIN_REFRESH_LOCK_NAME, + ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS, + path: options.configPath, + renew: true, + }) + if (!lock) { + incomplete = true + } else { + try { + const outcome = await writeMainCustodyTombstone( + transition.fingerprints.main, + { + auth: options.auth, + warn: (message) => log.warn(message, {}), + }, + ) + if (outcome !== 'tombstoned') incomplete = true + } finally { + await lock.release().catch(() => {}) + } + } + } + } + + if (incomplete) return + await options.withAccountStoreTransaction(async (transaction) => { + const current = await transaction.read() + if ( + current.claustrum?.mode !== 'claustrum' || + current.claustrum.transition?.manifestRevision !== + transition.manifestRevision || + current.claustrum.transition?.storeGeneration !== + transition.storeGeneration + ) { + return + } + await transaction.writeMode('claustrum') + }, options.configPath) + } + + async function runFallbackInstallPass( + manifest: Awaited>, + ): Promise { + if (!cache || !manifest.ok) return + const storage = await options.loadAccounts(options.configPath) + if (storage?.claustrum?.mode !== 'claustrum') return + const handles = custodyManifestHandles(manifest) + for (const account of storage.accounts) { + if (account.type !== 'oauth' || account.corrupt !== true) continue + const handle = handles.get(account.id) + const vault = handle + ? await resolveFallbackVaultState(account.id, account.accountId, handle) + : 'no_handle' + const verdict = evaluateCustodyStartup({ + mode: 'claustrum', + manifest: handle ? 'present' : 'absent', + local: 'gone', + vault: () => vault, + verifiedInProcessLogin: false, + }) + if ( + !('installTombstone' in verdict) || + !verdict.installTombstone || + !handle + ) + continue + await installFallbackTombstone(account.id, handle) + } + } + + async function resolveFallbackVaultState( + accountId: string, + expectedAccountId: string | undefined, + handle: string, + ): Promise { + if (options.resolveFallbackVaultState) { + return options.resolveFallbackVaultState({ accountId, handle }) + } + if (!cache) return 'cold' + if (cache.isReauth(handle, now())) return 'needs_reauth' + try { + const served = await cache.get(handle, custodyMinTtlMs(options.storage), { + force: true, + }) + const claims = parseJwtClaims(served.payload.access) + const servedAccountId = claims + ? extractAccountIdFromClaims(claims) + : undefined + return expectedAccountId && servedAccountId !== expectedAccountId + ? 'identity_mismatch' + : servedAccountId + ? 'serves' + : 'identity_mismatch' + } catch { + return cache.isReauth(handle, now()) ? 'needs_reauth' : 'cold' + } + } + + async function installFallbackTombstone( + accountId: string, + expectedHandle: string, + ): Promise { + const lock = await options.acquireRefreshFileLock({ + name: fallbackRefreshLockName(accountId), + ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS, + path: options.configPath, + renew: true, + }) + if (!lock) return + try { + const manifest = await options.readCustodyManifest(manifestPath) + const storage = await options.loadAccounts(options.configPath) + const target = storage?.accounts.find( + (account) => account.id === accountId, + ) + if ( + storage?.claustrum?.mode !== 'claustrum' || + !manifest.ok || + custodyManifestHandles(manifest).get(accountId) !== expectedHandle || + target?.type !== 'oauth' || + target.corrupt !== true + ) { + return + } + await options.mutateAccounts((current) => { + const live = current.accounts.find( + (account) => account.id === accountId, + ) + if (live?.type !== 'oauth' || live.corrupt !== true) return current + const { corrupt: _corrupt, ...metadata } = live + void _corrupt + return { + ...current, + accounts: current.accounts.map((account) => + account.id === accountId + ? { + ...metadata, + ...canonicalCustodyTombstone(CUSTODY_OWNING_PROVIDER), + } + : account, + ), + } + }, options.configPath) + } finally { + await lock.release().catch(() => {}) + } + } + + async function runWarmPass( + manifest: ReturnType extends Promise< + infer R + > + ? R + : never, + enabledHandles: Map, + ): Promise { + if (!cache) return + for (const [accountId, handle] of enabledHandles) { + if (cache.isReauth(handle, now())) continue + const storage = await options.loadAccounts(options.configPath) + const account = storage?.accounts.find((a) => a.id === accountId) + if (!account || !isOAuthAccount(account)) continue + // The completion sweep above handled `enrolling` accounts. The warm + // pass targets `custodied` ones only — `enrolling` is not the warm's + // job, and overwriting the latch with a successful get would render the + // sidebar as `vault` for an account the operator has not completed. + if ( + !custodied(account, manifest, options.storage ?? ({} as AccountStorage)) + ) + continue + try { + const record = await cache.get(handle) + // Surface the served recordVersion to the projection so the sidebar + // carries it through `getCustodyProjection`. A failed `get` leaves + // the existing projection untouched. + projectionByAccountId.set(accountId, { + state: 'vault', + recordVersion: record.recordVersion, + }) + } catch { + // Per-tick error handling is the cache's job (retry / reauth / gone + // / reduce_and_retry). Nothing to do at this layer. + } + } + } + + function scheduleNextTick(): void { + if (closed) return + const setI = options.setIntervalFn ?? setInterval + const jitter = Math.floor((Math.random() * 2 - 1) * CUSTODY_TICK_JITTER_MS) + const intervalMs = CUSTODY_TICK_INTERVAL_MS + jitter + const handle = setI(() => { + if (closed) return + void runtime.runTick().catch((error) => + log.warn('custody tick failed', { + error: error instanceof Error ? error.message : String(error), + }), + ) + }, intervalMs) + timer = handle as ReturnType + if (timer && typeof timer === 'object' && 'unref' in timer) { + ;(timer as { unref?: () => void }).unref?.() + } + } + + function buildSweepDeps( + cacheInstance: ClaustrumCredentialCache, + ): CompleteEnrollmentDeps { + return { + loadAccounts: options.loadAccounts, + readCustodyManifest: options.readCustodyManifest, + acquireRefreshFileLock: options.acquireRefreshFileLock, + configPath: options.configPath, + manifestPath, + cache: cacheInstance, + minTtlMs: custodyMinTtlMs(options.storage), + mutateAccounts: options.mutateAccounts, + provider: CUSTODY_OWNING_PROVIDER, + now, + } + } + + function applyOutcomeToProjection( + account: OAuthAccount, + outcome: CompleteEnrollmentOutcome, + manifest: ReturnType extends Promise< + infer R + > + ? R + : never, + ): void { + if (outcome.kind === 'succeeded') { + projectionByAccountId.set(account.id, { + state: 'vault', + recordVersion: outcome.recordVersion, + }) + return + } + if (outcome.kind === 'failed') { + const map: Record< + typeof outcome.reason, + 'gone' | 'unavailable' | 'identityMismatch' | 'nullClaim' + > = { + gone: 'gone', + identityMismatch: 'identityMismatch', + nullClaim: 'nullClaim', + unavailable: 'unavailable', + } + const reason = map[outcome.reason] + projectionByAccountId.set(account.id, { + state: 'inert', + reason: + reason === 'unavailable' + ? 'takeover-incomplete/vault-unavailable' + : 'identity-mismatch', + }) + // Per-process, per-(account,reason) dedupe at one hour (spec §7.3). + // First failure emits a warn; subsequent failures within the hour are + // silent. The next failure past the hour emits again. Never logs the + // handle or any credential material. + logSweepFailureOnce(log, account.id, reason, outcome.recordVersion, now()) + return + } + // A completed tombstone write changes the row's startup coordinate. + if (outcome.reason === 'notEnrolling') { + // A successful tombstone wrote disk; re-read state. + const refreshNow = now() + const projection = projectFromPredicates( + account, + options.storage, + refreshNow, + cache, + manifest, + ) + if (projection) projectionByAccountId.set(account.id, projection) + } + } + + return runtime +} + +const SWEEP_FAILURE_LOG_DEDUPE_WINDOW_MS = 60 * 60_000 +// Per-process dedupe map for sweep-failure log lines. Keyed by +// `${accountId}:${reason}`; an entry is created on first failure and +// refreshed when the hour window elapses. +const sweepFailureLogDedupe = new Map() + +function logSweepFailureOnce( + logger: { warn: (message: string, meta?: Record) => void }, + accountId: string, + reason: 'gone' | 'nullClaim' | 'identityMismatch' | 'unavailable', + recordVersion: number | undefined, + nowMs: number, +): void { + const key = `${accountId}:${reason}` + const last = sweepFailureLogDedupe.get(key) + if (last !== undefined && nowMs - last < SWEEP_FAILURE_LOG_DEDUPE_WINDOW_MS) { + return + } + sweepFailureLogDedupe.set(key, nowMs) + logger.warn('custody enroll-completion sweep failed', { + accountId, + reason, + ...(typeof recordVersion === 'number' ? { recordVersion } : {}), + }) +} + +// Test-only dedupe reset; not part of the production surface. +export function __resetSweepFailureLogDedupeForTest(): void { + sweepFailureLogDedupe.clear() +} + +export function custodyMinTtlMs(storage: AccountStorage | null): number { + const minutes = storage?.refresh?.refreshBeforeExpiryMinutes ?? 240 + // refreshBeforeExpiryMinutes + 30 min — keeps the cache ahead of the + // refresh gate's pre-expiry window without over-fetching. + return (minutes + 30) * 60_000 +} + +function enabledManifestHandles( + manifest: ReturnType extends Promise + ? R + : never, + storage: AccountStorage | null, +): Map { + const result = new Map() + for (const [label, handle] of custodyManifestHandles(manifest)) { + const account = storage?.accounts.find((a) => a.id === label) + if (account?.type !== 'oauth') continue + // enabled here = `refreshInert` (toggle-independent). The resolver and + // quota path consult this same predicate; the tick's reach matches. + if (!refreshInert(account, manifest, CUSTODY_OWNING_PROVIDER)) continue + result.set(label, handle) + } + return result +} + +function projectFromPredicates( + account: OAuthAccount, + storage: AccountStorage | null, + currentNow: number, + cacheInstance: ClaustrumCredentialCache | undefined, + manifest: Awaited> | undefined, +): SidebarAccountCustody | undefined { + const safeStorage = storage ?? ({} as AccountStorage) + const handle = manifest + ? custodyManifestHandles(manifest).get(account.id) + : undefined + if (!tombstoned(account, CUSTODY_OWNING_PROVIDER) && !handle) { + return undefined + } + if (!tombstoned(account, CUSTODY_OWNING_PROVIDER)) return undefined + if (safeStorage.claustrum?.mode !== 'claustrum' || !handle) { + return projectCustodyForSidebar({ kind: 'NEEDS_LOGIN' }) + } + return projectCustodyForSidebar({ + kind: 'INERT', + reason: cacheInstance?.isReauth(handle, currentNow) + ? 'vault-reauth' + : 'vault-cold', + }) +} + +async function raceAggregateWarm( + handles: Map, + cacheInstance: ClaustrumCredentialCache, + storage: AccountStorage | null, + capMs: number, + extraPromises: Promise[] = [], +): Promise { + void storage + if (handles.size === 0 && extraPromises.length === 0) return + const promises: Promise[] = [...extraPromises] + for (const [, handle] of handles) { + promises.push( + cacheInstance + .get(handle) + .then(() => undefined) + .catch(() => { + // Slow warm does not delay the loader beyond the bound. The promise + // stays in flight detached; the next tick picks up the populated cache. + }), + ) + } + await Promise.race([ + Promise.all(promises), + new Promise((resolve) => setTimeout(resolve, capMs)), + ]) +} + +function oauthAccounts(storage: AccountStorage | null): OAuthAccount[] { + if (!storage) return [] + return storage.accounts.filter(isOAuthAccount) +} + +function defaultCacheConnector(log: RuntimeLogger) { + return async (options: { + connectionFile: string + handshakeTimeoutMs?: number + }): Promise => { + const client = await ClaustrumClient.connect({ + connectionFile: options.connectionFile, + handshakeTimeoutMs: options.handshakeTimeoutMs, + }) + return clientToTransport(client, log) + } +} + +function clientToTransport( + client: ClaustrumClient, + _log: RuntimeLogger, +): ClaustrumCacheTransportLike { + return { + getCredential: (handle, minTtlMs) => client.getCredential(handle, minTtlMs), + statusCredential: (handle) => client.statusCredential(handle), + reportAuthFailure: (params) => + client.reportAuthFailure({ + handle: params.handle, + providerStatus: params.providerStatus, + recordVersion: params.recordVersion, + reporterSource: params.reporterSource, + }), + close: () => client.close(), + } +} + +function resolveConnectionPath( + detection: Awaited>, +): string { + if (detection.status !== 'available') return detection.path ?? '' + // The detection step resolves the path internally; reach for the default + // helper to hand the same path back to the cache. `available` does not + // carry the resolved path on its surface (only `absent` / `malformed` do). + return getDefaultClaustrumConnectionPath() +} diff --git a/packages/opencode/src/core/custody-state.ts b/packages/opencode/src/core/custody-state.ts new file mode 100644 index 0000000..49df9b1 --- /dev/null +++ b/packages/opencode/src/core/custody-state.ts @@ -0,0 +1,243 @@ +export type CustodyMode = 'local' | 'claustrum' + +export type ManifestCustodyState = 'absent' | 'present' | 'unreadable' + +export type LocalCustodyState = + | 'real' + | 'tombstone' + | 'empty' + | 'gone' + | 'slot-absent' + +export type VaultCustodyState = + | 'serves' + | 'cold' + | 'needs_reauth' + | 'identity_mismatch' + | 'no_handle' + +export const CUSTODY_INERT_REASONS = [ + 'needs-login', + 'mode-mismatch', + 'corrupt-under-binding', + 'manifest-unreadable', + 'unbound-under-claustrum', + 'orphan-tombstone', + 'vault-cold', + 'vault-reauth', + 'identity-mismatch', + 'no-handle', + 'takeover-incomplete', + 'new-local-family-under-claustrum', + 'takeover-incomplete/vault-unavailable', + 'takeover-incomplete/slot-absent', +] as const + +export type CustodyInertReason = (typeof CUSTODY_INERT_REASONS)[number] + +export type CustodyVerdict = + | { kind: 'LOCAL' } + | { + kind: 'VAULT' + canonicalize?: boolean + installTombstone?: boolean + } + | { + kind: 'INERT' + reason: CustodyInertReason + installTombstone?: boolean + canonicalize?: boolean + } + | { kind: 'NEEDS_LOGIN'; reason?: 'corrupt' } + +export interface EvaluateCustodyStartupInput { + mode: CustodyMode + manifest: ManifestCustodyState + local: LocalCustodyState + vault: () => VaultCustodyState + manifestFailureReason?: string + fingerprintMatch?: boolean + verifiedInProcessLogin: boolean + isMain?: boolean +} + +function unreachable(value: never): never { + return value +} + +function withCanonicalization< + T extends Extract, +>(local: LocalCustodyState, verdict: T): T | (T & { canonicalize: true }) { + return local === 'empty' ? { ...verdict, canonicalize: true } : verdict +} + +function evaluateLocal(local: LocalCustodyState): CustodyVerdict { + switch (local) { + case 'real': + return { kind: 'LOCAL' } + case 'tombstone': + case 'empty': + case 'slot-absent': + return { kind: 'NEEDS_LOGIN' } + case 'gone': + return { kind: 'NEEDS_LOGIN', reason: 'corrupt' } + } + return unreachable(local) +} + +function evaluateLocalWithBinding( + local: LocalCustodyState, + verifiedInProcessLogin: boolean, +): CustodyVerdict { + switch (local) { + case 'real': + return verifiedInProcessLogin + ? { kind: 'LOCAL' } + : { kind: 'INERT', reason: 'needs-login' } + case 'tombstone': + case 'empty': + case 'slot-absent': + return { kind: 'INERT', reason: 'mode-mismatch' } + case 'gone': + return { kind: 'INERT', reason: 'corrupt-under-binding' } + } + return unreachable(local) +} + +function evaluateUnboundClaustrum(local: LocalCustodyState): CustodyVerdict { + switch (local) { + case 'tombstone': + case 'empty': + return { kind: 'INERT', reason: 'orphan-tombstone' } + case 'real': + case 'gone': + case 'slot-absent': + return { kind: 'INERT', reason: 'unbound-under-claustrum' } + } + return unreachable(local) +} + +function evaluateTombstone( + local: Extract, + vault: VaultCustodyState, +): CustodyVerdict { + switch (vault) { + case 'serves': + return withCanonicalization(local, { kind: 'VAULT' }) + case 'cold': + return withCanonicalization(local, { + kind: 'INERT', + reason: 'vault-cold', + }) + case 'needs_reauth': + return withCanonicalization(local, { + kind: 'INERT', + reason: 'vault-reauth', + }) + case 'identity_mismatch': + return withCanonicalization(local, { + kind: 'INERT', + reason: 'identity-mismatch', + }) + case 'no_handle': + return withCanonicalization(local, { kind: 'INERT', reason: 'no-handle' }) + } + return unreachable(vault) +} + +function evaluateReal( + vault: VaultCustodyState, + fingerprintMatch: boolean | undefined, +): CustodyVerdict { + switch (vault) { + case 'serves': + return fingerprintMatch === false + ? { kind: 'INERT', reason: 'new-local-family-under-claustrum' } + : { kind: 'INERT', reason: 'takeover-incomplete' } + case 'cold': + case 'needs_reauth': + return { kind: 'INERT', reason: 'takeover-incomplete/vault-unavailable' } + case 'identity_mismatch': + return { kind: 'INERT', reason: 'identity-mismatch' } + case 'no_handle': + return { kind: 'INERT', reason: 'no-handle' } + } + return unreachable(vault) +} + +function evaluateGone(vault: VaultCustodyState): CustodyVerdict { + switch (vault) { + case 'serves': + return { kind: 'VAULT', installTombstone: true } + case 'cold': + return { kind: 'INERT', reason: 'vault-cold', installTombstone: true } + case 'needs_reauth': + return { kind: 'INERT', reason: 'vault-reauth', installTombstone: true } + case 'identity_mismatch': + return { kind: 'INERT', reason: 'identity-mismatch' } + case 'no_handle': + return { kind: 'INERT', reason: 'no-handle' } + } + return unreachable(vault) +} + +function evaluateSlotAbsent(vault: VaultCustodyState): CustodyVerdict { + switch (vault) { + case 'serves': + case 'cold': + case 'needs_reauth': + return { kind: 'INERT', reason: 'takeover-incomplete/slot-absent' } + case 'identity_mismatch': + return { kind: 'INERT', reason: 'identity-mismatch' } + case 'no_handle': + return { kind: 'INERT', reason: 'no-handle' } + } + return unreachable(vault) +} + +function evaluatePresentClaustrum( + local: LocalCustodyState, + vault: VaultCustodyState, + fingerprintMatch: boolean | undefined, +): CustodyVerdict { + switch (local) { + case 'tombstone': + case 'empty': + return evaluateTombstone(local, vault) + case 'real': + return evaluateReal(vault, fingerprintMatch) + case 'gone': + return evaluateGone(vault) + case 'slot-absent': + return evaluateSlotAbsent(vault) + } + return unreachable(local) +} + +export function evaluateCustodyStartup( + input: EvaluateCustodyStartupInput, +): CustodyVerdict { + if (input.manifest === 'unreadable') { + return { kind: 'INERT', reason: 'manifest-unreadable' } + } + + switch (input.mode) { + case 'local': + return input.manifest === 'present' + ? evaluateLocalWithBinding(input.local, input.verifiedInProcessLogin) + : evaluateLocal(input.local) + case 'claustrum': + if (input.manifest === 'absent') { + return evaluateUnboundClaustrum(input.local) + } + if (input.manifest === 'present') { + return evaluatePresentClaustrum( + input.local, + input.vault(), + input.fingerprintMatch, + ) + } + return unreachable(input.manifest) + } + return unreachable(input.mode) +} diff --git a/packages/opencode/src/core/custody-transition.ts b/packages/opencode/src/core/custody-transition.ts new file mode 100644 index 0000000..9e7f85c --- /dev/null +++ b/packages/opencode/src/core/custody-transition.ts @@ -0,0 +1,528 @@ +import { createHash } from 'node:crypto' +import { fallbackRefreshLockName } from './account-paths.ts' +import type { + AccountStorage, + AccountStoreTransaction, + OAuthAccount, +} from './accounts.ts' +import { isOAuthAccount } from './accounts.ts' +import { canonicalCustodyTombstone, tombstoned } from './custody.ts' +import { asCompleteMainOauthSlot } from './custody-host-slot.ts' +import { + type CustodyManifestReadResult, + custodyManifestHandles, +} from './custody-manifest.ts' +import type { CustodyInertReason } from './custody-state.ts' + +export type ClaustrumMode = 'local' | 'claustrum' + +export type CustodyTransitionState = { + manifestRevision: string + storeGeneration: string + fingerprints: { + main?: string + fallbacks: Record + } +} + +export const MODE_LOCK_NAME = 'claustrum-mode' +export const MAIN_REFRESH_LOCK_NAME = 'main-refresh' + +export type TransitionOutcome = + | 'ready' + | 'tombstoned' + | Extract< + CustodyInertReason, + | 'new-local-family-under-claustrum' + | 'vault-cold' + | 'vault-reauth' + | 'identity-mismatch' + | 'no-handle' + > + | 'torn-read-deferred' + | `aborted:${string}` + +export type TransitionResult = { + status: 'completed' | 'incomplete' | 'aborted' + outcomes: Record + reason?: string +} + +export type CustodyHostAuth = EnterClaustrumModeDeps['auth'] + +type Release = { release(): Promise } + +export type EnterClaustrumModeDeps = { + accountIds: readonly string[] + acquireLock(options: { + name: string + renew: boolean + }): Promise + withStoreTransaction( + action: (transaction: AccountStoreTransaction) => Promise, + ): Promise + readManifest(): Promise + preflight(input: { + id: string + accountId?: string + handle: string + }): Promise< + Exclude< + TransitionOutcome, + | `aborted:${string}` + | 'tombstoned' + | 'new-local-family-under-claustrum' + | 'torn-read-deferred' + > + > + auth: { + all(): Promise> + get(input: { path: { id: string } }): Promise + set(input: { + path: { id: string } + body: { type: 'oauth'; access: string; refresh: string; expires: number } + }): Promise + } + onStep?(step: string): void | Promise + warn?(message: string): void +} + +let custodyMutexTail = Promise.resolve() + +export async function acquireCustodyTransitionMutex(): Promise { + let release!: () => void + const next = new Promise((resolve) => { + release = resolve + }) + const previous = custodyMutexTail + custodyMutexTail = previous.then(() => next) + await previous + let released = false + return { + async release() { + if (released) return + released = true + release() + }, + } +} + +export async function releaseCustodyLoginLeaseAfterHostWrite(input: { + accessToken: string + refreshToken: string + getAuth(): Promise<{ access?: string; refresh?: string } | undefined> + onObserved?(auth: { access: string; refresh: string }): void | Promise + release(): Promise + warn(message: string): void + now(): number + sleep(ms: number): Promise +}): Promise { + const deadline = input.now() + 5_000 + while (input.now() < deadline) { + try { + const auth = await input.getAuth() + if ( + auth?.access === input.accessToken && + auth.refresh === input.refreshToken + ) { + await input.onObserved?.({ access: auth.access, refresh: auth.refresh }) + await input.release() + return + } + } catch { + // A transient host read must not strand the process-local exclusion lease. + } + await input.sleep(Math.max(0, Math.min(100, deadline - input.now()))) + } + input.warn('host write not observed within 5s; lease released') + await input.release() +} + +type Participant = { + id: string + accountId?: string + kind: 'main' | 'fallback' +} + +function enabledOauthAccounts(storage: AccountStorage): OAuthAccount[] { + return storage.accounts.filter( + (account): account is OAuthAccount => + isOAuthAccount(account) && account.enabled !== false, + ) +} + +function compareAccountIds(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function transitionParticipants( + accountIds: readonly string[], + storage?: AccountStorage, +): Participant[] { + return [ + { id: 'main', accountId: storage?.mainAccountId, kind: 'main' as const }, + ...accountIds.map((id) => ({ + id, + accountId: storage?.accounts.find((account) => account.id === id) + ?.accountId, + kind: 'fallback' as const, + })), + ].sort((left, right) => compareAccountIds(left.id, right.id)) +} + +function lockName(participant: Participant): string { + return participant.kind === 'main' + ? MAIN_REFRESH_LOCK_NAME + : fallbackRefreshLockName(participant.id) +} + +function fallbackFingerprint(account: OAuthAccount): string | undefined { + return account.access + ? custodySlotFingerprint(account.access, account.refresh) + : undefined +} + +function incomplete(outcomes: Record): boolean { + return Object.values(outcomes).some( + (outcome) => + outcome === 'new-local-family-under-claustrum' || + outcome === 'torn-read-deferred' || + outcome.startsWith('aborted:'), + ) +} + +export async function enterClaustrumMode( + deps: EnterClaustrumModeDeps, +): Promise { + const mutex = await acquireCustodyTransitionMutex() + const locks: Release[] = [] + const outcomes: Record = {} + let warnedTornRead = false + + const step = async (name: string) => { + await deps.onStep?.(name) + } + + try { + await step('mutex-acquired') + if ( + deps.accountIds.includes('main') || + new Set(deps.accountIds).size !== deps.accountIds.length + ) { + return { status: 'aborted', outcomes, reason: 'duplicate-account-lock' } + } + const modeLock = await deps.acquireLock({ + name: MODE_LOCK_NAME, + renew: true, + }) + if (!modeLock) + return { status: 'aborted', outcomes, reason: 'mode-lock-unavailable' } + locks.push(modeLock) + + const participants = transitionParticipants(deps.accountIds) + for (const participant of participants) { + const accountLock = await deps.acquireLock({ + name: lockName(participant), + renew: true, + }) + if (!accountLock) { + return { + status: 'aborted', + outcomes, + reason: `account-lock-unavailable:${participant.id}`, + } + } + locks.push(accountLock) + } + + return await deps.withStoreTransaction(async (transaction) => { + const initial = await transaction.read() + const currentParticipants = transitionParticipants( + deps.accountIds, + initial, + ) + const currentAccountIds = enabledOauthAccounts(initial) + .map((account) => account.id) + .sort(compareAccountIds) + const lockedAccountIds = [...deps.accountIds].sort(compareAccountIds) + if ( + currentAccountIds.length !== lockedAccountIds.length || + currentAccountIds.some((id, index) => id !== lockedAccountIds[index]) + ) { + return { status: 'aborted', outcomes, reason: 'account-roster-changed' } + } + const manifest = await deps.readManifest() + if (!manifest.ok) { + return { + status: 'aborted', + outcomes, + reason: `manifest-${manifest.reason}`, + } + } + const handles = custodyManifestHandles(manifest) + const persisted = + initial.claustrum?.mode === 'claustrum' + ? initial.claustrum?.transition + : undefined + const fingerprints: CustodyTransitionState['fingerprints'] = persisted + ? persisted.fingerprints + : { fallbacks: {} } + const capturedGeneration = + persisted?.storeGeneration ?? accountStoreGeneration(initial) + if (!persisted) { + const mainSlot = asCompleteMainOauthSlot( + await deps.auth.get({ path: { id: 'openai' } }), + ) + if (mainSlot) { + fingerprints.main = custodySlotFingerprint( + mainSlot.access, + mainSlot.refresh, + ) + } + for (const account of enabledOauthAccounts(initial)) { + const fingerprint = fallbackFingerprint(account) + if (fingerprint) fingerprints.fallbacks[account.id] = fingerprint + } + } + await step('captured') + + for (const participant of currentParticipants) { + const handle = handles.get(participant.id) + if (!handle) { + outcomes[participant.id] = 'no-handle' + continue + } + const outcome = await deps.preflight({ + id: participant.id, + accountId: participant.accountId, + handle, + }) + outcomes[participant.id] = outcome + } + await step('preflight') + if (Object.values(outcomes).some((outcome) => outcome !== 'ready')) { + return { status: 'aborted', outcomes, reason: 'preflight-failed' } + } + + if (!persisted) { + const revalidatedManifest = await deps.readManifest() + const revalidated = await transaction.read() + if ( + !revalidatedManifest.ok || + revalidatedManifest.revision !== manifest.revision + ) { + return { + status: 'aborted', + outcomes, + reason: 'manifest-revision-changed', + } + } + if (accountStoreGeneration(revalidated) !== capturedGeneration) { + return { + status: 'aborted', + outcomes, + reason: 'store-generation-changed', + } + } + await step('revalidated') + await transaction.writeMode('claustrum', { + manifestRevision: manifest.revision, + storeGeneration: capturedGeneration, + fingerprints, + }) + await step('mode-written') + } + + let materialWriteFailed = false + for (const participant of participants) { + if (participant.kind === 'main') continue + const current = await transaction.read() + const account = current.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === participant.id && candidate.type === 'oauth', + ) + if (account && tombstoned(account, 'openai')) { + outcomes[participant.id] = 'tombstoned' + continue + } + const expected = fingerprints.fallbacks[participant.id] + if ( + !account || + !expected || + fallbackFingerprint(account) !== expected + ) { + outcomes[participant.id] = 'new-local-family-under-claustrum' + continue + } + const next = structuredClone(current) + const nextAccount = next.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === participant.id && candidate.type === 'oauth', + ) + if (!nextAccount) { + outcomes[participant.id] = 'new-local-family-under-claustrum' + continue + } + Object.assign(nextAccount, canonicalCustodyTombstone('openai')) + try { + await transaction.write(next) + const written = (await transaction.read()).accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === participant.id && candidate.type === 'oauth', + ) + outcomes[participant.id] = + written && tombstoned(written, 'openai') + ? 'tombstoned' + : 'aborted:post-write-readback' + } catch { + outcomes[participant.id] = 'aborted:write-failed' + materialWriteFailed = true + break + } + } + + if (materialWriteFailed) { + await step('material-written') + return { status: 'incomplete', outcomes } + } + + outcomes.main = fingerprints.main + ? await writeMainCustodyTombstone(fingerprints.main, { + auth: deps.auth, + warn: (message) => { + if (warnedTornRead) return + warnedTornRead = true + deps.warn?.(message) + }, + }) + : 'new-local-family-under-claustrum' + await step('material-written') + if (!incomplete(outcomes)) { + await transaction.writeMode('claustrum') + } + return { + status: incomplete(outcomes) ? 'incomplete' : 'completed', + outcomes, + } + }) + } finally { + for (const lock of locks.reverse()) { + await lock.release().catch(() => {}) + } + await mutex.release() + await step('mutex-released').catch(() => {}) + } +} + +export async function writeMainCustodyTombstone( + expectedFingerprint: string, + deps: { + auth: CustodyHostAuth + warn?(message: string): void + }, +): Promise { + const current = asCompleteMainOauthSlot( + await deps.auth.get({ path: { id: 'openai' } }), + ) + if ( + current && + tombstoned( + { + id: 'main', + type: 'oauth', + access: current.access, + refresh: current.refresh, + expires: current.expires ?? 0, + }, + 'openai', + ) + ) { + return 'tombstoned' + } + if ( + !current || + custodySlotFingerprint(current.access, current.refresh) !== + expectedFingerprint + ) { + return 'new-local-family-under-claustrum' + } + const all = await deps.auth.all() + if (Object.keys(all).length === 0) { + deps.warn?.( + 'host auth store read empty; refusing to write — possible torn read', + ) + return 'torn-read-deferred' + } + try { + const tombstone = canonicalCustodyTombstone('openai') + const writeAuth = deps.auth.set.bind(deps.auth) + await writeAuth({ + path: { id: 'openai' }, + body: tombstone, + }) + const after = asCompleteMainOauthSlot( + await deps.auth.get({ path: { id: 'openai' } }), + ) + return after && + after.access === tombstone.access && + after.refresh === tombstone.refresh && + after.expires === tombstone.expires + ? 'tombstoned' + : 'new-local-family-under-claustrum' + } catch { + return 'aborted:write-failed' + } +} + +export async function leaveClaustrumMode( + deps: Pick, +): Promise { + const modeLock = await deps.acquireLock({ name: MODE_LOCK_NAME, renew: true }) + if (!modeLock) throw new Error('Claustrum mode lock unavailable') + try { + await deps.withStoreTransaction(async (transaction) => { + await transaction.writeMode('local') + return { status: 'completed', outcomes: {} } + }) + } finally { + await modeLock.release() + } +} + +export function custodySlotFingerprint( + access: string, + refresh: string, +): string { + const accessBytes = Buffer.from(access, 'utf8') + const refreshBytes = Buffer.from(refresh, 'utf8') + const accessLength = Buffer.allocUnsafe(4) + const refreshLength = Buffer.allocUnsafe(4) + accessLength.writeUInt32BE(accessBytes.length) + refreshLength.writeUInt32BE(refreshBytes.length) + return createHash('sha256') + .update(accessLength) + .update(accessBytes) + .update(refreshLength) + .update(refreshBytes) + .digest('hex') +} + +export function accountStoreGeneration( + storage: Pick, +): string { + const rows = storage.accounts + .filter((account) => account.type === 'oauth') + .map((account) => ({ + id: account.id, + enabled: account.enabled !== false, + accountId: account.accountId ?? '', + access: account.access ?? '', + refresh: account.refresh, + expires: account.expires ?? null, + })) + .sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0, + ) + + return createHash('sha256').update(JSON.stringify(rows)).digest('hex') +} diff --git a/packages/opencode/src/core/custody.ts b/packages/opencode/src/core/custody.ts new file mode 100644 index 0000000..16d00b8 --- /dev/null +++ b/packages/opencode/src/core/custody.ts @@ -0,0 +1,878 @@ +/** + * Custody policy core — vault-aware OAuth fallback resolution. + * + * Three layers, in order of trust: + * + * 1. **Tombstone sentinel** — when an account's refresh equals + * `claustrum-tombstone:v1:`, the account is tombstoned. Predicates + * alone observe this; the storage mode is irrelevant. + * 2. **Manifest enrollment** — case-exact `manifest.label === account.id` under + * the opencode-claustrum owning filter. Other tenants are ignored. + * 3. **Storage mode** — `storage.claustrum?.mode === 'claustrum'` arms the + * custody path. Without it the predicates evaluate as if custody were off: + * enrolling accounts still serve local access, tombstoned ones report + * `excluded`. + * + * `resolveFallbackAccess` is async from day one so an inline completion + * seam can be slotted in without touching call sites. + * + * Handle values and credential payloads NEVER appear in logs, thrown-error + * messages/causes, or any surface that could be dumped or sidetabled. + */ + +import { setTimeout as sleep } from 'node:timers/promises' +import { createLogger } from '../logger.ts' +import { + type AccountStorage, + type CorruptOAuthAccount, + claustrumMode, + FALLBACK_REFRESH_LOCK_TTL_MS, + fallbackRefreshLockName, + isOAuthAccount, + type OAuthAccount, +} from './accounts.ts' +import { + CUSTODY_OWNING_PROVIDER, + type CustodyManifestReadResult, + custodyManifestHandles, + manifestRevision, +} from './custody-manifest.ts' +import { extractAccountIdFromClaims, parseJwtClaims } from './oauth.ts' +import type { acquireRefreshFileLock } from './refresh-file-lock.ts' + +const log = createLogger('custody') + +export const CUSTODY_TOMBSTONE_PREFIX = 'claustrum-tombstone:v1:' +export const CUSTODY_REFUSE = Symbol('custody-refuse') +export const CUSTODY_EXCLUDED = Symbol('custody-excluded') + +export type VaultProvenance = { handle: string; recordVersion: number } + +export type FallbackAccessResolution = + | { token: string; provenance: 'local' } + | { token: string; provenance: VaultProvenance } + +export function stampVaultProvenance( + response: Response, + provenance: VaultProvenance | 'local' | undefined, + responseProvenance: WeakMap, +): Response { + if (provenance && provenance !== 'local') { + responseProvenance.set(response, provenance) + } + return response +} + +export function custodyTombstoneKey(provider: string): string { + return `${CUSTODY_TOMBSTONE_PREFIX}${provider}` +} + +export function canonicalCustodyTombstone(provider: string) { + return { + type: 'oauth' as const, + access: '', + refresh: custodyTombstoneKey(provider), + expires: 0, + } +} + +export function assertNoCustodyTombstoneMaterial(refreshToken: string): void { + if (refreshToken.startsWith(CUSTODY_TOMBSTONE_PREFIX)) { + throw new CustodyTombstoneRefreshError('unknown') + } +} + +// --------------------------------------------------------------------------- +// Served credential (normalized) +// --------------------------------------------------------------------------- + +export type ServedFallbackCredential = { + payload: { access: string } + recordVersion: number + expiresAtMs: number + servedAccountId?: string +} + +// --------------------------------------------------------------------------- +// Tombstone sentinel — survives `normalizeAccount` +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Predicates +// --------------------------------------------------------------------------- + +type OAuthCustodyAccount = OAuthAccount | CorruptOAuthAccount + +function owningAccount( + account: OAuthCustodyAccount, + manifest: CustodyManifestReadResult, +): boolean { + // Case-exact label === account.id. Lower-casing either side would let a + // manifest with label "Main" claim local id "main" — wrong: the local + // id is the source of truth, so the join must be case-exact both ways. + return custodyManifestHandles(manifest).has(account.id) +} + +/** + * `enrolled` = the manifest contains a case-exact `label === account.id` + * entry under the opencode-claustrum owning filter. The storage mode is + * intentionally ignored: enrollment is a manifest fact, not a policy choice. + */ +export function enrolled( + account: OAuthCustodyAccount, + manifest: CustodyManifestReadResult, +): boolean { + return owningAccount(account, manifest) +} + +/** + * `tombstoned` = oauth refresh equals the exact per-provider sentinel. Access + * and expiry are ignored so partial writes remain custody evidence. + */ +export function tombstoned( + account: OAuthCustodyAccount, + provider: string, +): boolean { + if (account.corrupt) return false + return account.refresh === custodyTombstoneKey(provider) +} + +/** + * `custodied` = mode=claustrum AND enrolled AND tombstoned. The storage mode + * is the only knob that arms the vault path; without it + * a tombstoned account is `excluded`, not `custodied`. + */ +export function custodied( + account: OAuthAccount, + manifest: CustodyManifestReadResult, + storage: Pick, + provider: string = CUSTODY_OWNING_PROVIDER, +): boolean { + if (claustrumMode(storage) !== 'claustrum') return false + if (!enrolled(account, manifest)) return false + return tombstoned(account, provider) +} + +/** + * `enrolling` = enrolled AND NOT tombstoned. The account has a manifest + * entry but is still serving local access (e.g. migration in progress). + */ +export function enrolling( + account: OAuthAccount, + manifest: CustodyManifestReadResult, + provider: string = CUSTODY_OWNING_PROVIDER, +): boolean { + return enrolled(account, manifest) && !tombstoned(account, provider) +} + +/** + * `refreshInert` = enrolled OR tombstoned. Used by the refresh gate to + * decide whether a refresh attempt would actually fetch a new token vs + * fall back to the local/vault read. + */ +export function refreshInert( + account: OAuthCustodyAccount, + manifest: CustodyManifestReadResult, + provider: string, +): boolean { + return enrolled(account, manifest) || tombstoned(account, provider) +} + +/** + * `excluded` = tombstoned AND NOT custodied. The account is dead in the + * vault's view but the operator has not selected claustrum mode — + * refuse to serve any token for it. + */ +export function excluded( + account: OAuthAccount, + manifest: CustodyManifestReadResult, + storage: Pick, + provider: string, +): boolean { + return tombstoned(account, provider) && !custodied(account, manifest, storage) +} + +// --------------------------------------------------------------------------- +// Identity verifier +// --------------------------------------------------------------------------- + +export type ServedIdentityCheck = + | { reason: 'ok' } + | { reason: 'nullClaim' } + | { + reason: 'identityMismatch' + detail: 'claimDiffersFromLocal' | 'labelDisagreesWithClaim' + } + +/** + * Parse the served access token, compare its `chatgpt_account_id` claim + * against the local account's `accountId`, and (when present) check the + * served-account-id label field agrees. The vendored `ServedCredential` has + * no served id today, so the labelDisagreesWithClaim branch is conditional — + * the test asserting it is `test.skip` until the wire contract adds the + * field. + */ +export function verifyServedFallbackIdentity( + served: ServedFallbackCredential, + account: OAuthAccount, +): ServedIdentityCheck { + const claims = parseJwtClaims(served.payload.access) + if (!claims) return { reason: 'nullClaim' } + const claimId = extractAccountIdFromClaims(claims) + if (!claimId) return { reason: 'nullClaim' } + if (account.accountId && claimId !== account.accountId) { + return { reason: 'identityMismatch', detail: 'claimDiffersFromLocal' } + } + if (served.servedAccountId && served.servedAccountId !== claimId) { + return { reason: 'identityMismatch', detail: 'labelDisagreesWithClaim' } + } + return { reason: 'ok' } +} + +// --------------------------------------------------------------------------- +// Resolver +// --------------------------------------------------------------------------- + +export type ResolveFallbackAccessOptions = { + cache?: ClaustrumCredentialCache + manifestHandle?: string + completeEnrollmentDeps?: CompleteEnrollmentDeps + refreshBeforeExpiryMs?: number + now?: () => number + requestPath?: boolean +} + +export async function resolveFallbackAccess( + account: OAuthAccount, + storage: Pick, + manifest?: CustodyManifestReadResult, + options: ResolveFallbackAccessOptions = {}, +): Promise< + FallbackAccessResolution | typeof CUSTODY_REFUSE | typeof CUSTODY_EXCLUDED +> { + const manifestState: CustodyManifestReadResult = manifest ?? { + ok: true, + value: { version: 1, providers: [] }, + revision: manifestRevision('{"version":1,"providers":[]}'), + } + + if (tombstoned(account, CUSTODY_OWNING_PROVIDER)) { + if (claustrumMode(storage) !== 'claustrum') return CUSTODY_EXCLUDED + if (!enrolled(account, manifestState)) return CUSTODY_REFUSE + if (!account.accountId && options.completeEnrollmentDeps) { + const outcome = await reconcileFallbackCustody( + account, + options.completeEnrollmentDeps, + ) + if ( + outcome.kind === 'failed' || + (outcome.kind === 'skipped' && outcome.reason === 'lockBusy') + ) { + return CUSTODY_REFUSE + } + const reboundStorage = await options.completeEnrollmentDeps.loadAccounts( + options.completeEnrollmentDeps.configPath, + ) + const rebound = reboundStorage?.accounts.find( + (candidate) => candidate.id === account.id, + ) + if ( + !reboundStorage || + !rebound || + !isOAuthAccount(rebound) || + !rebound.accountId + ) { + return CUSTODY_REFUSE + } + return resolveFallbackAccess(rebound, reboundStorage, manifestState, { + ...options, + completeEnrollmentDeps: undefined, + }) + } + // Custodied path: must have a manifest handle and a live cache hit. + const handle = options.manifestHandle + const cache = options.cache + if (!handle || !cache) return CUSTODY_REFUSE + const now = (options.now ?? Date.now)() + // Reauth and blocked are vault verdicts about the credential; serving a + // peeked record would ignore them. + if (cache.isBlocked(handle) || cache.isReauth(handle, now)) { + return CUSTODY_REFUSE + } + let served = await cache.peek(handle) + if (!served && !options.requestPath) { + try { + served = await cache.get(handle, 30_000) + } catch { + return CUSTODY_REFUSE + } + } + if (!served || served.expiresAtMs <= now) { + return CUSTODY_REFUSE + } + const check = verifyServedFallbackIdentity(served, account) + if (check.reason !== 'ok') { + log.warn('custody identity check refused', { reason: check.reason }) + return CUSTODY_REFUSE + } + return { + token: served.payload.access, + provenance: { handle, recordVersion: served.recordVersion }, + } + } + + if (enrolling(account, manifestState, CUSTODY_OWNING_PROVIDER)) { + const now = (options.now ?? Date.now)() + const refreshBeforeExpiryMs = options.refreshBeforeExpiryMs ?? 0 + if (claustrumMode(storage) === 'claustrum') { + if (!options.completeEnrollmentDeps) return CUSTODY_REFUSE + const outcome = await reconcileFallbackCustody( + account, + options.completeEnrollmentDeps, + ) + if ( + outcome.kind === 'failed' || + (outcome.kind === 'skipped' && outcome.reason === 'lockBusy') + ) { + return CUSTODY_REFUSE + } + const completedStorage = + await options.completeEnrollmentDeps.loadAccounts( + options.completeEnrollmentDeps.configPath, + ) + const completedAccount = completedStorage?.accounts.find( + (candidate) => candidate.id === account.id, + ) + if ( + !completedStorage || + !completedAccount || + !isOAuthAccount(completedAccount) + ) { + return CUSTODY_REFUSE + } + const completedManifest = + await options.completeEnrollmentDeps.readCustodyManifest( + options.completeEnrollmentDeps.manifestPath, + ) + return resolveFallbackAccess( + completedAccount, + completedStorage, + completedManifest, + options, + ) + } + if ( + account.access && + account.expires && + account.expires - now > refreshBeforeExpiryMs + ) { + return { token: account.access, provenance: 'local' } + } + return CUSTODY_REFUSE + } + + if (!account.access) return CUSTODY_REFUSE + return { token: account.access, provenance: 'local' } +} + +// --------------------------------------------------------------------------- +// Credential cache +// --------------------------------------------------------------------------- + +export type ClaustrumCredentialCacheOptions = { + connector: (options: { + connectionFile: string + handshakeTimeoutMs?: number + }) => Promise + now?: () => number +} + +export type ClaustrumCacheTransport = { + getCredential( + handle: string, + minTtlMs?: number, + ): Promise<{ + material: string + recordVersion: number + expiresAtMs: number | null + }> + statusCredential(handle: string): Promise<{ + ready: boolean + lastErrorCode: string | null + leaseHeld: boolean + recordVersion: number + }> + reportAuthFailure(params: { + handle: string + providerStatus: number + recordVersion: number + reporterSource: 'direct' | 'relay_status_field' | 'relay_message_parse' + }): Promise + close(): void +} + +type ResidentRecord = { + payload: { access: string } + recordVersion: number + expiresAtMs: number +} + +type InflightSlot = { + force: boolean + promise: Promise +} + +type ReportBound = { + count: number + firstReportedAt: number + reauthUntil?: number +} + +const REAUTH_HOUR_MS = 60 * 60 * 1000 + +export class ClaustrumCredentialCache { + readonly #transport: Promise + readonly #resident = new Map() + readonly #inflight = new Map() + readonly #reported = new Map() // handle -> last reported version + readonly #rejectedVersions = new Map>() // handle -> versions the daemon rejected + readonly #blocked = new Set() + readonly #reauth = new Map() // handle -> reauthUntilMs + readonly #reportBound = new Map() // handle -> bound + readonly #now: () => number + #closed = false + + constructor(options: ClaustrumCredentialCacheOptions) { + this.#transport = options.connector({ + connectionFile: '', + handshakeTimeoutMs: 5_000, + }) + this.#now = options.now ?? Date.now + } + + /** + * Peek the resident record without I/O. Returns `undefined` if absent, + * including the case where the record was invalidated by `reportAuthFailure`. + */ + async peek(handle: string): Promise { + return this.#resident.get(handle) + } + + /** + * Public read-only view of a peek result. Exposes only the version and + * expiry — never the credential material — so a projection layer can + * surface a vault `recordVersion` without ever seeing a token. + */ + async peekMetadata( + handle: string, + ): Promise<{ recordVersion: number; expiresAtMs: number } | undefined> { + const record = this.#resident.get(handle) + if (!record) return undefined + return { + recordVersion: record.recordVersion, + expiresAtMs: record.expiresAtMs, + } + } + + /** + * True iff a recent auth failure on this handle has been reported to the + * daemon. The blocked set is informational; the resolver still attempts a + * fetch on `get`, which clears the flag on success. + */ + isBlocked(handle: string): boolean { + return this.#blocked.has(handle) + } + + /** + * True iff the bound-and-reauth fence has fired for this handle and the + * reauth window has not yet elapsed. Callers that project "needs reauth" + * UI should gate on this. The fence clears after a proven 2xx vault request + * or when the reauth window expires. + */ + isReauth(handle: string, now: number = this.#now()): boolean { + const until = this.#reauth.get(handle) + if (until === undefined) return false + return until > now + } + + markVaultSuccess(handle: string): void { + this.#reauth.delete(handle) + this.#reportBound.delete(handle) + this.#reported.delete(handle) + } + + /** + * Get the live resident record. If a live record exists and the caller + * did not pass `force:true`, it returns immediately (no daemon I/O). + * Otherwise it issues a single-flight `getCredential` call. + * + * `force:true` ALWAYS issues a new daemon call (it bypasses both the + * resident record and any pending in-flight that originated from a + * non-force caller). Two concurrent force:true calls share the same + * new in-flight promise, so the second and subsequent force:true verifies + * collapse onto the first one. A non-force caller issued while a + * force:true fetch is pending still joins that force-fetch in-flight — + * the cache never races two parallel daemon calls for the same handle. + */ + async get( + handle: string, + minTtlMs?: number, + options: { force?: boolean } = {}, + ): Promise { + if (this.#closed) throw new Error('Claustrum cache is closed') + if (!options.force) { + const existing = this.#resident.get(handle) + if (existing) return existing + } + // Track whether THIS caller created the in-flight. If two concurrent + // force:true callers race the map insert, only one wins; the loser + // joins the winner's promise. The loser must NOT issue a second fetch, + // or the bounded single-flight guarantee collapses to N. + const force = !!options.force + const existingInflight = this.#inflight.get(handle) + if (existingInflight && existingInflight.force === force) { + return existingInflight.promise + } + const promise = this.#fetch(handle, minTtlMs) + const slot: InflightSlot = { + force, + promise: promise.finally(() => { + this.#inflight.delete(handle) + }), + } + this.#inflight.set(handle, slot) + return slot.promise + } + + async #fetch(handle: string, minTtlMs?: number): Promise { + const client = await this.#transport + // Retry the daemon call while it keeps returning a rejected version. + // A bounded retry count guards against a wedged daemon returning the + // poisoned version forever — after the bound the cache surfaces the + // poison so the caller can fail closed instead of spinning. + const maxAttempts = 8 + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const response = await client.getCredential(handle, minTtlMs) + const expiresAtMs = response.expiresAtMs ?? Number.MAX_SAFE_INTEGER + const rejected = this.#rejectedVersions.get(handle) + if (!rejected?.has(response.recordVersion)) { + const record: ResidentRecord = { + payload: { access: response.material }, + recordVersion: response.recordVersion, + expiresAtMs, + } + this.#resident.set(handle, record) + // A served version is not evidence it works; only a 2xx request resets + // the bound. A fresh record can still immediately fail upstream. + this.#blocked.delete(handle) + this.#rejectedVersions.delete(handle) + return record + } + // Same poisoned version — back off and retry. + await sleep(5 * (attempt + 1)) + } + throw new Error('custody credential rejected version after retries') + } + + /** + * Report an upstream auth failure on a served record. The report is + * version-fenced (one report per handle per recordVersion), and after + * two reports on the same version a one-hour bound fires — the third + * and subsequent reports on the same version are suppressed, and the + * handle is moved into the `reauth` set for an hour. A proven 2xx vault + * request or expiry of that window clears the bound and reauth entry. + * + * The reported version is invalidated from the resident record in + * `finally`, so a follow-up `get` cannot accidentally serve the same + * version the daemon already rejected. + */ + async reportAuthFailure(params: { + handle: string + providerStatus: number + recordVersion: number + }): Promise { + if (this.#closed) return + const { handle, recordVersion, providerStatus } = params + const now = this.#now() + // Version fence (monotonic per handle): once a version has been reported + // for this handle, subsequent reports for the same version are dropped + // without round-tripping to the daemon. A cleared resident (after a + // successful get re-fetching a higher version) lets a report at a + // higher version bypass the fence again. + const lastReported = this.#reported.get(handle) + if (lastReported !== undefined && lastReported >= recordVersion) { + return + } + // Two-cycle bound: after 2 distinct versions have been reported for this + // handle, the next report (any version) is suppressed for one hour. A + // a proven 2xx vault request or expiry lifts the suppression. + let bound = this.#reportBound.get(handle) + if (bound && bound.count >= 2) { + if (bound.reauthUntil !== undefined && now >= bound.reauthUntil) { + this.#reportBound.delete(handle) + this.#reauth.delete(handle) + this.#reported.delete(handle) + bound = undefined + } else { + return + } + } + const client = await this.#transport + try { + await client.reportAuthFailure({ + handle, + providerStatus, + recordVersion, + reporterSource: 'direct', + }) + this.#reported.set(handle, recordVersion) + this.#blocked.add(handle) + const prior = this.#reportBound.get(handle) + this.#reportBound.set(handle, { + count: (prior?.count ?? 0) + 1, + firstReportedAt: prior?.firstReportedAt ?? now, + reauthUntil: + (prior?.count ?? 0) + 1 >= 2 ? now + REAUTH_HOUR_MS : undefined, + }) + if ((prior?.count ?? 0) + 1 >= 2) { + this.#reauth.set(handle, now + REAUTH_HOUR_MS) + log.warn('custody report bound reached; entering reauth', { + reauthMs: REAUTH_HOUR_MS, + }) + } + } finally { + // Invalidate exactly the reported version so a follow-up get does + // not serve the version the daemon rejected. The fetch path will + // repopulate the resident record with whatever the daemon returns + // next, which is the contract the test pins. + const resident = this.#resident.get(handle) + if (resident && resident.recordVersion === recordVersion) { + this.#resident.delete(handle) + } + let rejected = this.#rejectedVersions.get(handle) + if (!rejected) { + rejected = new Set() + this.#rejectedVersions.set(handle, rejected) + } + rejected.add(recordVersion) + } + } + + close(): void { + if (this.#closed) return + this.#closed = true + this.#resident.clear() + this.#inflight.clear() + this.#reported.clear() + this.#rejectedVersions.clear() + this.#blocked.clear() + this.#reauth.clear() + this.#reportBound.clear() + void this.#transport.then((client) => client.close()).catch(() => {}) + } +} + +// --------------------------------------------------------------------------- +// Refresh error (defence in depth) +// --------------------------------------------------------------------------- + +/** + * Defence-in-depth error class. The predicates above are the policy source + * of truth; this error is the wired-in escape hatch for a caller that wants + * to short-circuit on a tombstone (status 503). Construction is intentionally + * cheap; no payload leakage through `message`/`cause`. + */ +export class CustodyTombstoneRefreshError extends Error { + readonly code = 'CUSTODY_TOMBSTONED' + readonly status = 503 + readonly isRefreshError = true + constructor(provider: string) { + // No handle, no payload — the sentinel value itself carries the + // provider identifier; surfacing that here keeps the message stable + // for caller pattern-matching without leaking per-account fields. + super(`custody tombstoned: ${custodyTombstoneKey(provider)}`) + this.name = 'CustodyTombstoneRefreshError' + } +} + +export function assertNotCustodyTombstone( + account: OAuthAccount, + provider: string, +): void { + if (tombstoned(account, provider)) { + throw new CustodyTombstoneRefreshError(provider) + } +} + +// --------------------------------------------------------------------------- +// Enroll-completion sweep (§7.3) +// --------------------------------------------------------------------------- + +export type CompleteEnrollmentDeps = { + loadAccounts: (path: string) => Promise + readCustodyManifest: (path?: string) => Promise + acquireRefreshFileLock: typeof acquireRefreshFileLock + configPath: string + manifestPath?: string + cache: ClaustrumCredentialCache + /** Minimum TTL (ms) handed to the vault for the verify-get. */ + minTtlMs: number + /** + * Read-modify-write the account store. The sweep calls this once on a + * successful verify, setting empty `access`, sentinel `refresh`, and + * `expires = 0` — that single write is the only durable effect. + */ + mutateAccounts: ( + mutate: (current: AccountStorage) => AccountStorage | undefined, + path?: string, + ) => Promise + /** Provider name; defaults to the owning provider (openai). */ + provider?: string + /** Caller-supplied clock so tests can drive the boot/hour log dedupe. */ + now?: () => number +} + +/** Outcome of one locked fallback reconciliation attempt. */ +export type CompleteEnrollmentOutcome = + | { kind: 'skipped'; reason: 'notEnrolling' } + | { kind: 'skipped'; reason: 'lockBusy' } + | { kind: 'succeeded'; recordVersion: number } + | { + kind: 'failed' + reason: 'gone' | 'nullClaim' | 'identityMismatch' | 'unavailable' + recordVersion?: number + } + +/** + * Re-reads the row under its refresh lock, binds the first served identity, and + * tombstones real material only after the served claim matches an existing bind. + */ +export async function reconcileFallbackCustody( + account: OAuthAccount, + deps: CompleteEnrollmentDeps, +): Promise { + const provider = deps.provider ?? CUSTODY_OWNING_PROVIDER + const manifest = await deps.readCustodyManifest(deps.manifestPath) + const storage = await deps.loadAccounts(deps.configPath) + if (!storage) return { kind: 'skipped', reason: 'notEnrolling' } + const liveAccount = storage.accounts.find( + (candidate) => candidate.id === account.id, + ) + if (!liveAccount || !isOAuthAccount(liveAccount)) { + return { kind: 'skipped', reason: 'notEnrolling' } + } + if ( + !enrolling(liveAccount, manifest, provider) && + !(tombstoned(liveAccount, provider) && !liveAccount.accountId) + ) { + return { kind: 'skipped', reason: 'notEnrolling' } + } + const manifestHandle = custodyManifestHandles(manifest).get(liveAccount.id) + if (!manifestHandle) return { kind: 'skipped', reason: 'notEnrolling' } + + const lockName = fallbackRefreshLockName(liveAccount.id) + // Skip this pass if another holder is mid-flight; never join-wait. The + // refresh choke point already serialises refreshers and the next tick will + // pick up any residue. + const lock = await deps.acquireRefreshFileLock({ + name: lockName, + ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS, + path: deps.configPath, + }) + if (!lock) return { kind: 'skipped', reason: 'lockBusy' } + try { + // Re-check under the lock: a concurrent tombstone write that completed + // between the outer read and the lock acquisition must turn this pass + // into a no-op. The manifest is read again to honour a hot-reloaded entry + // removal too — but the loader does not own the entry-removal path; the + // outer guard is the contract. + const recheckManifest = await deps.readCustodyManifest(deps.manifestPath) + const recheckStorage = await deps.loadAccounts(deps.configPath) + const recheckAccount = recheckStorage?.accounts.find( + (candidate) => candidate.id === account.id, + ) + if ( + !recheckStorage || + !recheckAccount || + !isOAuthAccount(recheckAccount) || + (!enrolling(recheckAccount, recheckManifest, provider) && + !(tombstoned(recheckAccount, provider) && !recheckAccount.accountId)) + ) { + return { kind: 'skipped', reason: 'notEnrolling' } + } + let served: Awaited> + try { + served = await deps.cache.get(manifestHandle, deps.minTtlMs, { + force: true, + }) + } catch (error) { + const reason = classifyGetError(error) + return { kind: 'failed', reason } + } + const identity = verifyServedFallbackIdentity( + { + payload: { access: served.payload.access }, + recordVersion: served.recordVersion, + expiresAtMs: served.expiresAtMs, + }, + recheckAccount, + ) + if (identity.reason !== 'ok') { + const reason: CompleteEnrollmentOutcome & { kind: 'failed' } = + identity.reason === 'nullClaim' + ? { + kind: 'failed', + reason: 'nullClaim', + recordVersion: served.recordVersion, + } + : { + kind: 'failed', + reason: 'identityMismatch', + recordVersion: served.recordVersion, + } + return reason + } + const claims = parseJwtClaims(served.payload.access) + const servedAccountId = claims + ? extractAccountIdFromClaims(claims) + : undefined + if (!servedAccountId) { + return { + kind: 'failed', + reason: 'nullClaim', + recordVersion: served.recordVersion, + } + } + await deps.mutateAccounts((current) => { + const target = current.accounts.find((a) => a.id === account.id) + if (!target || !isOAuthAccount(target)) return current + const next: OAuthAccount = { + ...target, + accountId: target.accountId ?? servedAccountId, + ...canonicalCustodyTombstone(provider), + } + return { + ...current, + accounts: current.accounts.map((a) => (a.id === account.id ? next : a)), + } + }, deps.configPath) + return { kind: 'succeeded', recordVersion: served.recordVersion } + } finally { + await lock.release().catch(() => {}) + } +} + +function classifyGetError(error: unknown): 'gone' | 'unavailable' { + // `ClaustrumCredentialError.action === 'gone'` is the vault's verdict for + // not_found / permanent; everything else folds into `unavailable` so the + // boot/hour log surfaces the difference only when the vault says so. + if ( + error && + typeof error === 'object' && + 'action' in error && + (error as { action?: unknown }).action === 'gone' + ) { + return 'gone' + } + return 'unavailable' +} diff --git a/packages/opencode/src/core/provider.ts b/packages/opencode/src/core/provider.ts index d13e106..00309ef 100644 --- a/packages/opencode/src/core/provider.ts +++ b/packages/opencode/src/core/provider.ts @@ -10,6 +10,7 @@ import { createLogger } from '../logger.ts' import { errorMessage } from '../util/error.ts' import type { OAuthQuotaSnapshot } from './accounts.ts' import { parseRetryAfter } from './backoff.ts' +import { assertNoCustodyTombstoneMaterial } from './custody.ts' const log = createLogger('quota') type QuotaLogger = Pick @@ -98,6 +99,7 @@ export async function codexRefreshFn(input: { expires: number expiresIn: number }> { + assertNoCustodyTombstoneMaterial(input.refreshToken) const response = await input.fetchImpl(`${CODEX_ISSUER}/oauth/token`, { method: 'POST', signal: AbortSignal.timeout(15_000), diff --git a/packages/opencode/src/core/quota-manager.ts b/packages/opencode/src/core/quota-manager.ts index ebab42b..4b00290 100644 --- a/packages/opencode/src/core/quota-manager.ts +++ b/packages/opencode/src/core/quota-manager.ts @@ -19,6 +19,7 @@ import type { OAuthQuotaSnapshot, } from './accounts.ts' import { buildQuotaOperationError, quotaBackoffActive } from './backoff.ts' +import { assertNotCustodyTombstone } from './custody.ts' import { PRIMARY, type ProviderQuotaFn, SECONDARY } from './provider.ts' import { acquireRefreshFileLock } from './refresh-file-lock' @@ -496,6 +497,7 @@ export class QuotaManager { for (const account of accounts) { if (account.enabled === false) continue + assertNotCustodyTombstone(account, 'openai') if (!account.access) continue const cached = this.getFallback(account.id, account.access) diff --git a/packages/opencode/src/core/refresh-all-quota.ts b/packages/opencode/src/core/refresh-all-quota.ts index 31b66cd..6ec2af1 100644 --- a/packages/opencode/src/core/refresh-all-quota.ts +++ b/packages/opencode/src/core/refresh-all-quota.ts @@ -2,17 +2,25 @@ import { createLogger } from '../logger' import { getSidebarState, type SidebarState } from '../sidebar-state' import { errorMessage } from '../util/error' import type { + AccountStorage, FallbackAccountManager, isOAuthAccount, loadAccounts, OAuthAccount, } from './accounts' import { formatRefreshBackoffMessage, refreshBackoffActive } from './backoff.ts' +import { + CUSTODY_EXCLUDED, + CUSTODY_REFUSE, + type FallbackAccessResolution, +} from './custody.ts' import type { whamUsageFn } from './provider' import type { QuotaManager } from './quota-manager' const log = createLogger('quota') +export const CUSTODY_DEPS_INCOMPLETE = 'custody-deps-incomplete' + /** * True when a thrown provider error carries HTTP 401. * @@ -75,6 +83,27 @@ export interface RefreshAllQuotaDeps { logger?: QuotaLogger skipFresherThanMs?: number readSidebarState?: () => Promise + /** + * Custody deps. Absent → pre-custody behaviour (the existing local-refresh + * block runs as before, no tombstone handling). When present, every + * refresh-inert account is short-circuited into the resolver arm and the + * local-refresh block is skipped entirely. + */ + isFallbackRefreshInert?: ( + account: OAuthAccount, + storage: AccountStorage, + ) => Promise | boolean + resolveFallbackAccess?: ( + account: OAuthAccount, + storage: AccountStorage, + ) => Promise< + FallbackAccessResolution | typeof CUSTODY_REFUSE | typeof CUSTODY_EXCLUDED + > + reportCustodyAuthFailure?: (params: { + handle: string + providerStatus: number + recordVersion: number + }) => Promise } export interface RefreshAllQuotaOptions { @@ -110,6 +139,10 @@ export async function refreshAllQuota( const results: RefreshAllQuotaResult[] = [] const logger = deps.logger ?? log + // Partial-custody-deps log dedupe: at most one warn per poll per missing + // dep, regardless of how many refresh-inert accounts we observe. A full + // polling cycle could otherwise log per-account. + const custodyPartialDepsLogged = new Set<'resolver' | 'reporter'>() const recordOutcome = (result: RefreshAllQuotaResult) => { results.push(result) const payload = { @@ -308,6 +341,134 @@ export async function refreshAllQuota( continue } + // Refresh-inert arm: a manifest entry OR a tombstone sentinel makes + // local refresh inert regardless of `claustrum.enabled` (spec §3). + // The local-refresh block below is therefore unreachable for a + // refresh-inert account — the resolver decides what goes on the wire. + // Only `isFallbackRefreshInert` is required to enter; a partial + // wiring (resolver or reporter missing) fails closed instead of + // falling through into local refresh, because resuming a local + // refresher against a vault-held family is the split-custody + // incident the refresh gate exists to prevent. + if ( + deps.isFallbackRefreshInert && + (await deps.isFallbackRefreshInert(acct as OAuthAccount, storage)) + ) { + if (!deps.resolveFallbackAccess) { + if (!custodyPartialDepsLogged.has('resolver')) { + logger.warn('custody deps incomplete: resolver absent', { + pid: process.pid, + accountId: acct.id, + }) + custodyPartialDepsLogged.add('resolver') + } + recordOutcome({ + account: acct.id, + ok: false, + error: CUSTODY_DEPS_INCOMPLETE, + }) + continue + } + let access: Awaited< + ReturnType> + > + try { + access = await deps.resolveFallbackAccess( + acct as OAuthAccount, + storage, + ) + } catch (resolveError) { + recordOutcome({ + account: acct.id, + ok: false, + error: errorMessage(resolveError), + }) + continue + } + if (access === CUSTODY_REFUSE || access === CUSTODY_EXCLUDED) { + recordOutcome({ + account: acct.id, + ok: false, + error: 'custody: no vault credential', + }) + continue + } + // Vault-provenance probe requires a reporter — a quota 401 on a + // vault-served credential MUST reach the vault (spec §6.4), so + // probing without one would be the silent-401 failure of issue + // #118 recreated under custody. Refuse the probe up front. + // Local-provenance probes need no reporter: a local 401 is not + // credential evidence (the vault is not on the wire). + if (access.provenance !== 'local' && !deps.reportCustodyAuthFailure) { + if (!custodyPartialDepsLogged.has('reporter')) { + logger.warn( + 'custody deps incomplete: reporter absent; vault probe refused', + { pid: process.pid, accountId: acct.id }, + ) + custodyPartialDepsLogged.add('reporter') + } + recordOutcome({ + account: acct.id, + ok: false, + error: CUSTODY_DEPS_INCOMPLETE, + }) + continue + } + try { + const snap = await whamFn({ + accessToken: access.token, + fetchImpl: deps.fetchImpl, + now: deps.now, + accountId: (acct as OAuthAccount).accountId, + accountKey: acct.id, + }) + deps.quotaManager.setFallback( + acct.id, + { + quota: snap, + refreshAfter: deps.now() + 5 * 60_000, + checkedAt: deps.now(), + }, + access.token, + true, + (acct as OAuthAccount).accountId, + ) + quotaUpdated = true + recordOutcome({ account: acct.id, ok: true }) + } catch (quotaError) { + if (!isUnauthorized(quotaError)) throw quotaError + // Quota-endpoint 401. The vault-served provenance is the only + // signal that this token came from the vault — a local-provenance + // 401 (an enrolled, non-tombstoned account) is not reported and, + // because the local-refresh block is skipped, does not trigger a + // forced refresh either. A 429 is never a report. + if (access.provenance !== 'local') { + try { + await ( + deps.reportCustodyAuthFailure as NonNullable< + typeof deps.reportCustodyAuthFailure + > + )({ + handle: access.provenance.handle, + providerStatus: 401, + recordVersion: access.provenance.recordVersion, + }) + } catch (reportError) { + logger.warn('custody auth-failure report failed', { + accountId: acct.id, + error: errorMessage(reportError), + }) + } + } + recordOutcome({ + account: acct.id, + ok: false, + error: errorMessage(quotaError), + }) + } + continue + } + let refreshed: OAuthAccount try { refreshed = await deps.fallbackManager.refreshAccount(acct, storage) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1eebf78..7f1a121 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -26,8 +26,11 @@ import { import { getConfigDir, getConfigPath, getSettings } from './config' import { type AccountStorage, + claustrumMode, + FALLBACK_REFRESH_LOCK_TTL_MS, type FallbackAccount, FallbackAccountManager, + fallbackRefreshLockName, getKillswitchThresholdsForAccount, isCostZeroingEnabled, isKillswitchEnabled, @@ -41,6 +44,7 @@ import { type OAuthQuotaSnapshot, type RoutingMode, shouldFallbackStatus, + withAccountStoreTransaction, } from './core/accounts' import { BackgroundQuotaRefresh, @@ -57,8 +61,39 @@ import { CacheKeepManager, getCacheKeepWindow, } from './core/cachekeep' +import { + CUSTODY_EXCLUDED, + CUSTODY_REFUSE, + CustodyTombstoneRefreshError, + refreshInert, + resolveFallbackAccess, + stampVaultProvenance, + tombstoned, + type VaultProvenance, +} from './core/custody.ts' +import { + type CustodyBootstrap, + classifyMainAuthSlot, + mainAccountIdFromServedCredential, + reconcileMainSlotBeforeHooks, + recordVerifiedInProcessMainLogin, +} from './core/custody-host-slot.ts' +import { + CUSTODY_OWNING_PROVIDER, + custodyManifestHandles, + readCustodyManifest, +} from './core/custody-manifest.ts' +import { + acquireCustodyTransitionMutex, + type CustodyHostAuth, + enterClaustrumMode, + leaveClaustrumMode, + MAIN_REFRESH_LOCK_NAME, + releaseCustodyLoginLeaseAfterHostWrite, +} from './core/custody-transition.ts' import { base64UrlEncode, + beginAccountLogin, beginDeviceAuth, buildAuthorizeUrl, completeDeviceAuth, @@ -68,6 +103,7 @@ import { generatePKCE, parseJwtClaims, startOAuthServer, + type TokenResponse, USER_AGENT, waitForOAuthCallback, } from './core/oauth' @@ -117,10 +153,12 @@ import { getSidebarStateFile, hashSidebarSessionId, isQuotaExhausted, + projectCustodyForSidebar, type QuotaWindow, removeSidebarActiveRouting, resolveSessionStickyAccount, resolveSidebarStickyAssignment, + type SidebarAccountCustody, type SidebarMachineState, type SidebarState, setSidebarLegacyRouting, @@ -168,7 +206,7 @@ const CODEX_BETA_FEATURES = 'terminal_resize_reflow' const CODEX_VERSION = '0.153.0' const CODEX_USER_AGENT = `codex_exec/${CODEX_VERSION} (Debian 12.0.0; aarch64) unknown (codex_exec; ${CODEX_VERSION})` const CODEX_SANDBOX = 'seccomp' -const MAIN_REFRESH_LOCK_NAME = 'main-refresh' +export const getMainRefreshLockName = () => MAIN_REFRESH_LOCK_NAME export const MAIN_REFRESH_LOCK_TTL_MS = 2 * 60_000 export const MAIN_REFRESH_LEASE_TTL_MS = 90_000 const CONCURRENT_MAIN_REFRESH_WAIT_MS = 4_000 @@ -183,6 +221,11 @@ const DEFAULT_MID_STREAM_RATE_LIMIT_RESET_MS = 60_000 const HANDLED_SENTINEL = '__OPENCODE_OPENAI_AUTH_COMMAND_HANDLED__' let bootQuotaSeedStarted = false + +export function __resetBootQuotaSeedForTest(): void { + bootQuotaSeedStarted = false +} + const logModels = createLogger('models') let loggedCostRestoration = false let warnedCostCatalogUnavailable = false @@ -234,6 +277,19 @@ interface ResetTargetResolverDeps { loadAccounts: typeof loadAccounts accountStoragePath: string now: () => number + isFallbackRefreshInert?: ( + account: OAuthAccount, + storage: AccountStorage, + ) => Promise + resolveFallbackAccess?: ( + account: OAuthAccount, + storage: AccountStorage, + ) => ReturnType + reportAuthFailure?: (params: { + handle: string + providerStatus: number + recordVersion: number + }) => Promise } function resetTargetNeedsRefresh( @@ -311,6 +367,7 @@ export function createResetTargetResolver(deps: ResetTargetResolverDeps) { let resolved = account if ( + !(await deps.isFallbackRefreshInert?.(resolved, storage)) && resetTargetNeedsRefresh( resolved.access, resolved.expires, @@ -320,7 +377,15 @@ export function createResetTargetResolver(deps: ResetTargetResolverDeps) { ) { resolved = await deps.refreshFallbackAccount(resolved, storage) } - if (!resolved.access) { + const accessResolution = deps.resolveFallbackAccess + ? await deps.resolveFallbackAccess(resolved, storage) + : resolved.access + ? { token: resolved.access, provenance: 'local' as const } + : CUSTODY_REFUSE + if ( + accessResolution === CUSTODY_REFUSE || + accessResolution === CUSTODY_EXCLUDED + ) { throw new ResetTargetResolutionError( 'token_unavailable', `Fallback account ${accountKey} has no usable access token.`, @@ -352,8 +417,18 @@ export function createResetTargetResolver(deps: ResetTargetResolverDeps) { return { accountKey, label: resolved.label ?? accountKey, - accessToken: resolved.access, + accessToken: accessResolution.token, chatgptAccountId: freshAccount.accountId, + onAuthFailure: + accessResolution.provenance === 'local' + ? undefined + : async (status: number) => { + await deps.reportAuthFailure?.({ + handle: accessResolution.provenance.handle, + providerStatus: status, + recordVersion: accessResolution.provenance.recordVersion, + }) + }, } } } @@ -389,6 +464,35 @@ interface CodexAuthPluginOptions { codexApiEndpoint?: string experimentalWebSockets?: boolean responsesLite?: boolean + custody?: { + /** Test seam: the transport backing the loader-owned runtime. */ + transport: ClaustrumCacheTransportLike + /** Test seam: override the connection-file detection result. */ + detection?: 'available' | 'absent' + /** Test seam: observes the loader-owned runtime for explicit ticks. */ + onRuntime?: (runtime: CustodyRuntime) => void + /** Test seam: controls the runtime clock for expiry-bound scenarios. */ + now?: () => number + /** Test seam: controls bounded host-write observation without real timers. */ + sleep?: (ms: number) => Promise + /** Test seam: observes a host-write observation deadline warning. */ + warn?: (message: string) => void + /** Test seam: observes the account lock around custody binding checks. */ + withFallbackAccountLock?: CommandContext['withFallbackAccountLock'] + /** Test seam: replaces external OAuth I/O while preserving the hook callback. */ + authorize?: { + browser?: () => Promise<{ + url: string + tokens: Promise + cleanup?: () => void + }> + headless?: () => Promise<{ + url: string + instructions: string + tokens: Promise + }> + } + } } interface CodexSessionMetadata { @@ -680,11 +784,39 @@ function stampWindowCheckedAt( return { ...window, checkedAt: entryCheckedAt } } +import { + __createCustodyRuntimeForTest, + type ClaustrumCacheTransportLike, + type CustodyRuntime, + custodyMinTtlMs, +} from './core/custody-runtime.ts' + +export { + __createCustodyRuntimeForTest, + __resetSweepFailureLogDedupeForTest, + type ClaustrumCacheTransportLike, + type CustodyRuntime, + type CustodyRuntimeOptions, +} from './core/custody-runtime.ts' + +function lookupManifestHandle( + manifest: ReturnType extends Promise + ? R + : never, + accountId: string, +): string | undefined { + return custodyManifestHandles(manifest).get(accountId) +} + export function buildSidebarMachineState( qm: QuotaManager, store: AccountStorage, now = Date.now(), mainAccountIdentity = store.mainAccountId, + projectCustody?: ( + account: FallbackAccount, + now: number, + ) => SidebarAccountCustody | undefined, ): SidebarMachineState { const mainEntry = qm.getMain() const mainQuota = mainEntry?.quota @@ -717,6 +849,11 @@ export function buildSidebarMachineState( .map((account) => { const fallbackEntry = qm.getFallback(account.id) const fallbackQuota = fallbackEntry?.quota + // Sync projection: the loader pre-resolves custody state once per write + // (cache peek is async; the runtime owns the map) and threads a sync + // lookup in. An absent callback leaves `custody` unset, which is the + // pre-custody shape — the normalizer drops it without rendering. + const custodyProjection = projectCustody?.(account, now) return { id: account.id, label: (account as { label?: string }).label, @@ -744,6 +881,7 @@ export function buildSidebarMachineState( ...(fallbackQuota?.resetCreditsAvailable !== undefined ? { resetCredits: fallbackQuota.resetCreditsAvailable } : {}), + ...(custodyProjection ? { custody: custodyProjection } : {}), } }), route: store.routing?.mode ?? 'main-first', @@ -945,8 +1083,118 @@ export async function CodexAuthPlugin( // command.execute.before reads this; if null (auth not loaded yet), // the command is rejected with a message. let cmdCtx: CommandContext | null = null + const hostAuth = input.client.auth as unknown as { + all(): Promise> + get(input: { path: { id: string } }): Promise + set(input: { + path: { id: string } + body: { type: 'oauth'; access: string; refresh: string; expires: number } + }): Promise + } let activeRpcServer: RpcServerHandle | null = null let sidebarStateFileForEvents: string | undefined + // Custody runtime — assigned inside the loader so dispose can close the + // vendored client and clear the custody tick timer after the loader has + // returned. Built unconditionally so a custody-disabled process still has + // a runtime to dispose (no-op tick + close). + let custodyRuntimeRef: CustodyRuntime | undefined + // The runtime accepts this factory-owned bootstrap rather than opening a second connection. + // allowing the runtime path to open a second Claustrum connection. + const custodyBootstrap: CustodyBootstrap = {} + const custodyOptions = options.custody + const custodyLogger = createLogger('custody') + + function createCustodyRuntime( + storage: AccountStorage | null, + auth?: CustodyHostAuth, + ): CustodyRuntime { + return __createCustodyRuntimeForTest({ + storage, + configPath: getConfigPath(), + loadAccounts, + mutateAccounts, + withAccountStoreTransaction, + readCustodyManifest, + acquireRefreshFileLock, + auth, + ...(custodyOptions + ? { + detectClaustrumConnection: async () => + custodyOptions.detection === 'absent' + ? { status: 'absent' as const, path: 'test' } + : { + status: 'available' as const, + schema: 1, + wireVersion: 1, + endpoints: [], + }, + cacheConnector: async () => custodyOptions.transport, + } + : {}), + logger: { + info: (msg, meta) => + custodyLogger.info(msg, meta ?? {}) as unknown as undefined, + warn: (msg, meta) => + custodyLogger.warn(msg, meta ?? {}) as unknown as undefined, + debug: (msg, meta) => + custodyLogger.debug(msg, meta ?? {}) as unknown as undefined, + error: (msg, meta) => + custodyLogger.error(msg, meta ?? {}) as unknown as undefined, + }, + now: custodyOptions?.now, + }) + } + + const factoryStorage = await loadAccounts(getConfigPath()) + const factoryManifest = await readCustodyManifest() + const factoryAuth = input.client.auth as { + get?: (input: { path: { id: string } }) => Promise + all?: () => Promise> + set: CustodyHostAuth['set'] + } + if (factoryAuth.get && factoryAuth.all) { + if (claustrumMode(factoryStorage ?? {}) === 'claustrum') { + custodyRuntimeRef = createCustodyRuntime(factoryStorage, { + all: factoryAuth.all, + get: factoryAuth.get, + set: factoryAuth.set, + }) + custodyOptions?.onRuntime?.(custodyRuntimeRef) + await custodyRuntimeRef.boot() + } + const factoryCache = custodyRuntimeRef?.getCache() + if (factoryCache) custodyBootstrap.cache = factoryCache + custodyBootstrap.mainVerdict = await reconcileMainSlotBeforeHooks({ + client: { auth: factoryAuth }, + mode: claustrumMode(factoryStorage ?? {}), + manifest: factoryManifest, + mainAccountId: factoryStorage?.mainAccountId, + getCredential: factoryCache + ? async (handle) => { + const credential = await factoryCache.get( + handle, + custodyMinTtlMs(factoryStorage), + ) + return { access: credential.payload.access } + } + : undefined, + isReauth: factoryCache + ? (handle) => factoryCache.isReauth(handle) + : undefined, + now: Date.now, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }) + if (custodyBootstrap.mainVerdict) { + const sidebar = await getSidebarState() + await setSidebarMachineState({ + ...sidebar, + main: { + ...sidebar.main, + custody: projectCustodyForSidebar(custodyBootstrap.mainVerdict), + }, + }) + } + } // Per-loader poller: each plugin invocation owns its timer and callback, so // one loader disposing or re-starting never stops or overwrites another's @@ -980,6 +1228,7 @@ export async function CodexAuthPlugin( return { async dispose() { backgroundQuotaRefresh.stop() + custodyRuntimeRef?.dispose() for (const websocketFetch of websocketFetches) websocketFetch.close() websocketFetches.length = 0 if (activeRpcServer) { @@ -1120,16 +1369,22 @@ export async function CodexAuthPlugin( const auth = await getAuth() if (auth.type !== 'oauth') return {} + const mainSlot = classifyMainAuthSlot(auth) + const recognizedMainTombstone = + mainSlot.kind === 'tombstone' || mainSlot.kind === 'empty' + // Migration: seed the multi-account store from the existing token (idempotent) - await migrateIfNeeded( - { - type: 'oauth', - access: auth.access ?? '', - refresh: auth.refresh ?? '', - expires: auth.expires ?? 0, - }, - getConfigPath(), - ) + if (!recognizedMainTombstone) { + await migrateIfNeeded( + { + type: 'oauth', + access: auth.access ?? '', + refresh: auth.refresh ?? '', + expires: auth.expires ?? 0, + }, + getConfigPath(), + ) + } // Construct managers for push-only quota updates from response headers. // Wrap the first boot-time read so a corrupt store surfaces a clear, @@ -1185,7 +1440,7 @@ export async function CodexAuthPlugin( // (migrateIfNeeded only sets it once on first run). The CLI add path // rejects against the persisted value — acceptable because the plugin // refreshes it here each time the auth loader runs. - if (storage && auth.access) { + if (storage && auth.access && !recognizedMainTombstone) { const liveAccountId = extractAccountId({ id_token: '', access_token: auth.access, @@ -1242,14 +1497,161 @@ export async function CodexAuthPlugin( now: opts.now, }), quotaManager, + custody: { readManifest: readCustodyManifest }, onFallbackStorageChanged: invalidateRequestStorageCache, }) + // ------------------------------------------------------------------- + // Custody runtime — vendored client, cache, completion sweep, tick. + // Constructed unconditionally so the boot sweep can resolve before + // the background refresh is armed and so dispose() can close the + // cache + transport regardless of whether custody is enabled. + // ------------------------------------------------------------------- + const custodyRuntime = + custodyRuntimeRef ?? createCustodyRuntime(storage) + if (!custodyRuntimeRef) { + custodyOptions?.onRuntime?.(custodyRuntime) + await custodyRuntime.boot() + custodyRuntimeRef = custodyRuntime + } + if (recognizedMainTombstone) { + const manifest = await readCustodyManifest() + const handle = lookupManifestHandle(manifest, 'main') + const cache = custodyRuntime.getCache() + if (handle && cache) { + try { + const credential = await cache.get( + handle, + custodyMinTtlMs(storage), + ) + const servedMainAccountId = mainAccountIdFromServedCredential( + credential.payload.access, + ) + if ( + servedMainAccountId && + servedMainAccountId !== storage?.mainAccountId + ) { + await mutateAccounts((current) => { + current.mainAccountId = servedMainAccountId + return current + }, getConfigPath()) + if (storage) storage.mainAccountId = servedMainAccountId + custodyBootstrap.mainAccountId = servedMainAccountId + invalidateRequestStorageCache() + } + } catch { + // The factory verdict already records vault cold/reauth; the loader + // must preserve its inert state instead of turning it into a crash. + } + } + } + // The loader owns the detached first tick so direct runtime callers + // can observe boot completion without background work racing them. + void custodyRuntime.runTick().catch((error) => + custodyLogger.warn('custody first tick failed', { + error: error instanceof Error ? error.message : String(error), + }), + ) + custodyRuntimeRef = custodyRuntime // Start background refresh only when fallback accounts are configured; // single-account paths must not create extra token refresh traffic. + // The boot order above guarantees the initial completion sweep has + // resolved — any `enrolling` account has been tombstoned before the + // background loop starts gating on `refreshInert`. if (storage && storage.accounts.length > 0) { fallbackManager.startBackgroundRefresh() } + // ------------------------------------------------------------------- + // Custody deps for the four quota constructions (spec §6.6). One + // builder, used everywhere; omission at any one site fails closed + // via `custody-deps-incomplete` (the poller's own guard). Each + // closure captures `storage` and the live custodyRuntime so the + // resolver and reporter see fresh state per invocation. + // ------------------------------------------------------------------- + const custodyRuntimeForDeps = custodyRuntime + async function isFallbackAccountRefreshInert( + account: OAuthAccount, + _currentStorage: AccountStorage, + ): Promise { + const manifest = await readCustodyManifest() + return refreshInert(account, manifest, CUSTODY_OWNING_PROVIDER) + } + async function resolveAccountAccessForCustody( + account: OAuthAccount, + currentStorage: AccountStorage, + ): ReturnType { + const manifest = await readCustodyManifest() + const cache = custodyRuntimeForDeps.getCache() + const handle = lookupManifestHandle(manifest, account.id) + if (!cache || !handle) { + return resolveFallbackAccess(account, currentStorage, manifest) + } + return resolveFallbackAccess(account, currentStorage, manifest, { + cache, + manifestHandle: handle, + requestPath: true, + now: custodyOptions?.now ?? Date.now, + refreshBeforeExpiryMs: + (currentStorage.refresh?.refreshBeforeExpiryMinutes ?? 240) * + 60_000, + completeEnrollmentDeps: { + loadAccounts, + readCustodyManifest, + acquireRefreshFileLock, + configPath: getConfigPath(), + cache, + minTtlMs: custodyMinTtlMs(currentStorage), + mutateAccounts, + provider: CUSTODY_OWNING_PROVIDER, + now: Date.now, + }, + }) + } + async function reportAuthFailureForCustody(params: { + handle: string + providerStatus: number + recordVersion: number + }): Promise { + const cache = custodyRuntimeForDeps.getCache() + if (!cache) return + await cache.reportAuthFailure({ + handle: params.handle, + providerStatus: params.providerStatus, + recordVersion: params.recordVersion, + }) + } + function buildRefreshAllQuotaDeps( + overrides: Partial< + Pick< + Parameters[0], + 'respectBackoff' | 'skipFresherThanMs' | 'readSidebarState' + > + > = {}, + ): Parameters[0] { + return { + getAuth, + codexRefreshFn, + refreshMainWithLease, + fallbackManager, + quotaManager, + loadAccounts, + writeSidebarState: writeMachineSidebarState, + client: input.client as Parameters< + typeof refreshAllQuota + >[0]['client'], + fetchImpl: fetch, + now: Date.now, + configPath: getConfigPath(), + storageMainAccountId: storage?.mainAccountId, + isOAuthAccountFn: isOAuthAccount, + whamFn: whamUsageFn, + isFallbackRefreshInert: isFallbackAccountRefreshInert, + resolveFallbackAccess: resolveAccountAccessForCustody, + reportCustodyAuthFailure: reportAuthFailureForCustody, + ...overrides, + } + } + // ------------------------------------------------------------------- // CacheKeepManager — prompt-cache warmer for idle main-agent sessions // ------------------------------------------------------------------- @@ -1350,6 +1752,21 @@ export async function CodexAuthPlugin( mainRefreshPromise = (async () => { const freshAuth = await getAuth() if (freshAuth.type !== 'oauth') throw new Error('not oauth') + if ( + tombstoned( + { + id: 'main', + type: 'oauth', + access: freshAuth.access ?? '', + refresh: freshAuth.refresh ?? '', + expires: freshAuth.expires ?? 0, + addedAt: 0, + }, + CUSTODY_OWNING_PROVIDER, + ) + ) { + throw new CustodyTombstoneRefreshError(CUSTODY_OWNING_PROVIDER) + } if (!freshAuth.refresh) { throw new Error('Token refresh failed: missing refresh token') } @@ -1490,13 +1907,38 @@ export async function CodexAuthPlugin( : undefined if (!account) throw new Error(`fallback account ${accountId} not found`) - const refreshed = await fallbackManager.refreshAccount( - account, - fbStorage ?? { version: 1 as const, accounts: [account] }, + const currentStorage = fbStorage ?? { + version: 1 as const, + accounts: [account], + } + let resolved = account + if ( + !(await isFallbackAccountRefreshInert(account, currentStorage)) + ) { + resolved = await fallbackManager.refreshAccount( + account, + currentStorage, + ) + } + const access = await resolveAccountAccessForCustody( + resolved, + currentStorage, ) - if (!refreshed.access) + if (access === CUSTODY_REFUSE || access === CUSTODY_EXCLUDED) throw new Error(`no access token for ${accountId}`) - return refreshed.access + return { + token: access.token, + onAuthFailure: + access.provenance === 'local' + ? undefined + : async (status: number) => { + await reportAuthFailureForCustody({ + handle: access.provenance.handle, + providerStatus: status, + recordVersion: access.provenance.recordVersion, + }) + }, + } }, codexResponsesUrl: codexApiEndpoint, logger: cacheKeepLogger, @@ -1698,10 +2140,20 @@ export async function CodexAuthPlugin( store, Date.now(), mainAccountIdentity, + runtimeCustodyProjection, ), boundSidebarFile, ) } + // Closure-resolved once per loader; the runtime owns the cached + // projection map, the writer reads it sync. + function runtimeCustodyProjection( + account: FallbackAccount, + currentNow: number, + ): SidebarAccountCustody | undefined { + if (!isOAuthAccount(account)) return undefined + return custodyRuntimeForDeps.getCustodyProjection(account, currentNow) + } async function writeRequestSidebarRouting( sessionId: string | undefined, @@ -1758,11 +2210,159 @@ export async function CodexAuthPlugin( // Start the loopback RPC server so the TUI can drain notifications and // dispatch apply commands. // ------------------------------------------------------------------- + const defaultWithFallbackAccountLock = async ( + accountId: string, + action: () => Promise, + ): Promise => { + const lock = await acquireRefreshFileLock({ + name: fallbackRefreshLockName(accountId), + ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS, + path: getConfigPath(), + renew: true, + }) + if (!lock) throw new Error('Fallback account lock unavailable') + try { + return await action() + } finally { + await lock.release() + } + } + const withFallbackAccountLock = + custodyOptions?.withFallbackAccountLock ?? + defaultWithFallbackAccountLock + const checkUsableCustodyBinding = async (account: OAuthAccount) => { + const manifest = await readCustodyManifest() + if (!manifest.ok) { + return { + ready: false as const, + reason: 'manifest-unreadable' as const, + } + } + const handle = lookupManifestHandle(manifest, account.id) + if (!handle) + return { ready: false as const, reason: 'no-handle' as const } + const cache = custodyRuntime.getCache() + if (!cache) { + return { ready: false as const, reason: 'vault-cold' as const } + } + if (cache.isReauth(handle, custodyOptions?.now?.() ?? Date.now())) { + return { ready: false as const, reason: 'vault-reauth' as const } + } + if (cache.isBlocked(handle)) { + return { ready: false as const, reason: 'vault-cold' as const } + } + try { + const credential = await cache.get(handle, custodyMinTtlMs(storage)) + const accountId = mainAccountIdFromServedCredential( + credential.payload.access, + ) + if ( + !accountId || + (account.accountId && account.accountId !== accountId) + ) { + return { + ready: false as const, + reason: 'identity-mismatch' as const, + } + } + return { ready: true as const, accountId } + } catch { + return { + ready: false as const, + reason: cache.isReauth( + handle, + custodyOptions?.now?.() ?? Date.now(), + ) + ? ('vault-reauth' as const) + : ('vault-cold' as const), + } + } + } cmdCtx = { accountStoragePath: getConfigPath(), quotaManager, loadAccounts, client: input.client as CommandContext['client'], + beginAccountLogin, + withFallbackAccountLock, + checkUsableCustodyBinding, + enterClaustrumMode: async () => { + const current = await loadAccounts(getConfigPath()) + const accountIds = (current?.accounts ?? []) + .filter( + (account): account is OAuthAccount => + account.type === 'oauth' && account.enabled !== false, + ) + .map((account) => account.id) + return enterClaustrumMode({ + accountIds, + acquireLock: ({ name, renew }) => + acquireRefreshFileLock({ + name, + ttlMs: + name === MAIN_REFRESH_LOCK_NAME + ? MAIN_REFRESH_LOCK_TTL_MS + : FALLBACK_REFRESH_LOCK_TTL_MS, + path: getConfigPath(), + renew, + }), + withStoreTransaction: (action) => + withAccountStoreTransaction(action, getConfigPath()), + readManifest: readCustodyManifest, + preflight: async ({ accountId, handle }) => { + const cache = custodyRuntime.getCache() + if (!cache || cache.isBlocked(handle)) return 'vault-cold' + if ( + cache.isReauth(handle, custodyOptions?.now?.() ?? Date.now()) + ) { + return 'vault-reauth' + } + try { + const credential = await cache.get( + handle, + custodyMinTtlMs(current), + ) + const servedAccountId = mainAccountIdFromServedCredential( + credential.payload.access, + ) + if ( + !servedAccountId || + (accountId && accountId !== servedAccountId) + ) { + return 'identity-mismatch' + } + return 'ready' + } catch { + return cache.isReauth( + handle, + custodyOptions?.now?.() ?? Date.now(), + ) + ? 'vault-reauth' + : 'vault-cold' + } + }, + auth: { + all: () => hostAuth.all(), + get: (value) => hostAuth.get(value), + set: async (value) => { + await hostAuth.set(value) + }, + }, + warn: (message) => custodyLogger.warn(message), + }) + }, + leaveClaustrumMode: () => + leaveClaustrumMode({ + acquireLock: ({ name, renew }) => + acquireRefreshFileLock({ + name, + ttlMs: MAIN_REFRESH_LOCK_TTL_MS, + path: getConfigPath(), + renew, + }), + withStoreTransaction: (action) => + withAccountStoreTransaction(action, getConfigPath()), + }), resolveResetTarget: createResetTargetResolver({ getAuth, refreshMainWithLease, @@ -1771,6 +2371,9 @@ export async function CodexAuthPlugin( loadAccounts, accountStoragePath: getConfigPath(), now: Date.now, + isFallbackRefreshInert: isFallbackAccountRefreshInert, + resolveFallbackAccess: resolveAccountAccessForCustody, + reportAuthFailure: reportAuthFailureForCustody, }), ...buildResetRedemptionDeps(), cacheKeepManager, @@ -1798,41 +2401,10 @@ export async function CodexAuthPlugin( await writeMachineSidebarState(quotaManager, store) }, refreshAllQuota: async () => - refreshAllQuota({ - getAuth, - codexRefreshFn, - refreshMainWithLease, - fallbackManager, - quotaManager, - loadAccounts, - writeSidebarState: writeMachineSidebarState, - client: input.client as CommandContext['client'], - fetchImpl: fetch, - now: Date.now, - configPath: getConfigPath(), - storageMainAccountId: storage?.mainAccountId, - isOAuthAccountFn: isOAuthAccount, - whamFn: whamUsageFn, - }), + refreshAllQuota(buildRefreshAllQuotaDeps()), refreshResetTargetQuota: async (accountKey) => { const results = await refreshAllQuota( - { - getAuth, - codexRefreshFn, - refreshMainWithLease, - fallbackManager, - quotaManager, - loadAccounts, - writeSidebarState: writeMachineSidebarState, - client: input.client as CommandContext['client'], - fetchImpl: fetch, - now: Date.now, - configPath: getConfigPath(), - storageMainAccountId: storage?.mainAccountId, - isOAuthAccountFn: isOAuthAccount, - whamFn: whamUsageFn, - respectBackoff: false, - }, + buildRefreshAllQuotaDeps({ respectBackoff: false }), { accountKey }, ) return ( @@ -1887,12 +2459,38 @@ export async function CodexAuthPlugin( // sendWithAccessToken — the one primitive that both main and fallback // sends call. Wraps the existing Codex transform + send. // ------------------------------------------------------------------- + const responseVaultProvenance = new WeakMap() + + function observeVaultAuthFailure(response: Response, url: URL): void { + const provenance = responseVaultProvenance.get(response) + if ( + !provenance || + url.hostname !== 'chatgpt.com' || + !url.pathname.startsWith('/backend-api/codex/') + ) { + return + } + if (response.status >= 200 && response.status < 300) { + custodyRuntimeForDeps + .getCache() + ?.markVaultSuccess(provenance.handle) + return + } + if (response.status !== 401) return + void reportAuthFailureForCustody({ + handle: provenance.handle, + providerStatus: response.status, + recordVersion: provenance.recordVersion, + }).catch(() => {}) + } + async function sendWithAccessToken( requestInput: RequestInfo | URL, init: RequestInit | undefined, accessToken: string, accountId?: string, keepwarmAccountKey: string = 'main', + provenance?: VaultProvenance | 'local', ): Promise { const headers = effectiveRequestHeaders(requestInput, init) headers.delete('x-api-key') @@ -1933,6 +2531,15 @@ export async function CodexAuthPlugin( parsed.pathname.includes('/chat/completions') ? new URL(codexApiEndpoint) : parsed + const stamp = (response: Response) => { + const stamped = stampVaultProvenance( + response, + provenance, + responseVaultProvenance, + ) + observeVaultAuthFailure(stamped, url) + return stamped + } const prepared = prepareCodexRequest({ init: { @@ -1984,11 +2591,13 @@ export async function CodexAuthPlugin( keepwarmCapture.isSubagent, ) } - return websocketFetch(url, requestInit) + return stamp(await websocketFetch(url, requestInit)) } const finalInit = OpenAIWebSocketPool.withoutInternalHeaders(requestInit) - if (typeof finalInit?.body !== 'string') return fetch(url, finalInit) + if (typeof finalInit?.body !== 'string') { + return stamp(await fetch(url, finalInit)) + } // Keepwarm capture: track every request body for idle // prompt-cache warming. Cheap — stores the already-serialized string. @@ -2021,7 +2630,7 @@ export async function CodexAuthPlugin( headers: finalInit.headers, status: response.status, }) - return translateHostedWebSearchResponse(response) + return stamp(translateHostedWebSearchResponse(response)) } catch (error) { await dumpCodexRequest({ sessionID, @@ -2270,6 +2879,7 @@ export async function CodexAuthPlugin( // ------------------------------------------------------------------- type FallbackCandidate = { access: string + provenance: VaultProvenance | 'local' accountId?: string keepwarmAccountKey: string quotaAccountId: string @@ -2289,6 +2899,7 @@ export async function CodexAuthPlugin( accountId: string wireAccountId?: string access: string + provenance?: VaultProvenance | 'local' keepwarmAccountKey: string fallback?: FallbackAccount quota: AccountQuota | null | undefined @@ -2367,8 +2978,14 @@ export async function CodexAuthPlugin( ] const usableFallbacks = await fallbackManager.getUsableFallbackAccounts(input.storage) + if (!input.storage) return roster for (const fallback of usableFallbacks) { - if (!fallback.access) continue + const access = await resolveAccountAccessForCustody( + fallback, + input.storage, + ) + if (access === CUSTODY_REFUSE || access === CUSTODY_EXCLUDED) + continue const fileEntry = input.sidebarState.fallbacks.find( (account) => account.id === fallback.id, ) @@ -2394,7 +3011,8 @@ export async function CodexAuthPlugin( roster.push({ accountId: fallback.id, wireAccountId: fallback.accountId, - access: fallback.access, + access: access.token, + provenance: access.provenance, keepwarmAccountKey: fallback.id, fallback, quota: freshest.quota, @@ -2578,16 +3196,23 @@ export async function CodexAuthPlugin( const usableFallbacks = await fallbackManager.getUsableFallbackAccounts(fallbackStorage) const candidates: FallbackCandidate[] = [] + if (!fallbackStorage) + return { current: [], retained: [], skipped: [] } for (const fb of usableFallbacks) { - if (fb.access) { - candidates.push({ - access: fb.access, - accountId: fb.accountId, - keepwarmAccountKey: fb.id, - quotaAccountId: fb.id, - fallback: fb, - }) - } + const access = await resolveAccountAccessForCustody( + fb, + fallbackStorage, + ) + if (access === CUSTODY_REFUSE || access === CUSTODY_EXCLUDED) + continue + candidates.push({ + access: access.token, + provenance: access.provenance, + accountId: fb.accountId, + keepwarmAccountKey: fb.id, + quotaAccountId: fb.id, + fallback: fb, + }) } // Mid-stream rate-limit mark: never re-try a fallback a prior request // just exhausted mid-generation. Unlike the killswitch quota filter @@ -2708,6 +3333,7 @@ export async function CodexAuthPlugin( candidate.access, candidate.accountId, candidate.keepwarmAccountKey, + candidate.provenance, ) } catch (error) { // A caller abort and an indeterminate transport failure both @@ -2786,6 +3412,7 @@ export async function CodexAuthPlugin( candidate.access, candidate.accountId, candidate.keepwarmAccountKey, + candidate.provenance, ) } catch (error) { if ( @@ -2849,25 +3476,9 @@ export async function CodexAuthPlugin( void writeMachineSidebarState(quotaManager, storage).catch(() => {}) // Background: refresh from the API, then the sidebar shows fresh numbers - void refreshAllQuota({ - getAuth, - codexRefreshFn, - refreshMainWithLease, - fallbackManager, - quotaManager, - loadAccounts, - writeSidebarState: writeMachineSidebarState, - client: input.client as Parameters< - typeof refreshAllQuota - >[0]['client'], - fetchImpl: fetch, - now: Date.now, - configPath: getConfigPath(), - storageMainAccountId: storage?.mainAccountId, - isOAuthAccountFn: isOAuthAccount, - whamFn: whamUsageFn, - respectBackoff: true, - }).catch((error) => + void refreshAllQuota( + buildRefreshAllQuotaDeps({ respectBackoff: true }), + ).catch((error) => logQ.warn('boot quota seed failed', { pid: process.pid, error: errorMessage(error), @@ -2877,28 +3488,11 @@ export async function CodexAuthPlugin( backgroundQuotaRefresh.start( async () => { - const results = await refreshQuotaInBackground({ - getAuth, - codexRefreshFn, - refreshMainWithLease, - fallbackManager, - quotaManager, - loadAccounts, - writeSidebarState: (qm, store) => - backgroundQuotaRefresh.isStopped() - ? Promise.resolve() - : writeMachineSidebarState(qm, store), - client: input.client as Parameters< - typeof refreshAllQuota - >[0]['client'], - fetchImpl: fetch, - now: Date.now, - configPath: getConfigPath(), - storageMainAccountId: storage?.mainAccountId, - isOAuthAccountFn: isOAuthAccount, - whamFn: whamUsageFn, - readSidebarState: () => getSidebarState(boundSidebarFile), - }) + const results = await refreshQuotaInBackground( + buildRefreshAllQuotaDeps({ + readSidebarState: () => getSidebarState(boundSidebarFile), + }), + ) const failures = results.filter((result) => !result.ok) if (failures.length > 0) { logQ.warn('background quota refresh completed with failures', { @@ -3052,6 +3646,7 @@ export async function CodexAuthPlugin( stickyCandidate.access, stickyCandidate.wireAccountId, stickyCandidate.keepwarmAccountKey, + stickyCandidate.provenance, ) const pushStickyQuota = async ( @@ -3114,6 +3709,7 @@ export async function CodexAuthPlugin( replacement.access, replacement.wireAccountId, replacement.keepwarmAccountKey, + replacement.provenance, ) previousResponse.body?.cancel().catch(() => {}) stickyCandidate = replacement @@ -3267,6 +3863,7 @@ export async function CodexAuthPlugin( primaryAccess, mainAccountIdentity, 'main', + undefined, ) } } @@ -3378,35 +3975,78 @@ export async function CodexAuthPlugin( label: 'ChatGPT Pro/Plus (browser)', type: 'oauth', authorize: async () => { - const { redirectUri } = await startOAuthServer() - const pkce = await generatePKCE() - const state = base64UrlEncode( - crypto.getRandomValues(new Uint8Array(32)).buffer, - ) - const authUrl = buildAuthorizeUrl(redirectUri, pkce, state) - - const callbackPromise = waitForOAuthCallback(pkce, state) + const mutex = await acquireCustodyTransitionMutex() + try { + const flow = custodyOptions?.authorize?.browser + ? await custodyOptions.authorize.browser() + : await (async () => { + const { redirectUri } = await startOAuthServer() + const pkce = await generatePKCE() + const state = base64UrlEncode( + crypto.getRandomValues(new Uint8Array(32)).buffer, + ) + return { + url: buildAuthorizeUrl(redirectUri, pkce, state), + tokens: waitForOAuthCallback(pkce, state), + cleanup: () => flowCleanup(state), + } + })() - return { - url: authUrl, - instructions: - 'Complete authorization in your browser. This window will close automatically.', - method: 'auto' as const, - callback: async () => { - try { - const tokens = await callbackPromise - const accountId = extractAccountId(tokens) - return { - type: 'success' as const, - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, - accountId, + return { + url: flow.url, + instructions: + 'Complete authorization in your browser. This window will close automatically.', + method: 'auto' as const, + callback: async () => { + try { + const tokens = await flow.tokens + const result = { + type: 'success' as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: + (custodyOptions?.now ?? Date.now)() + + (tokens.expires_in ?? 3600) * 1000, + accountId: extractAccountId(tokens), + } + void releaseCustodyLoginLeaseAfterHostWrite({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + getAuth: async () => { + const auth = await hostAuth.get({ + path: { id: 'openai' }, + }) + return isRecord(auth) && + typeof auth.access === 'string' && + typeof auth.refresh === 'string' + ? { access: auth.access, refresh: auth.refresh } + : undefined + }, + onObserved: ({ access, refresh }) => + recordVerifiedInProcessMainLogin({ + type: 'oauth', + access, + refresh, + }), + release: () => mutex.release(), + warn: (message) => + custodyOptions?.warn?.(message) ?? + custodyLogger.warn(message), + now: custodyOptions?.now ?? Date.now, + sleep: custodyOptions?.sleep ?? ((ms) => Bun.sleep(ms)), + }).catch(() => mutex.release()) + return result + } catch (error) { + await mutex.release() + throw error + } finally { + flow.cleanup?.() } - } finally { - flowCleanup(state) - } - }, + }, + } + } catch (error) { + await mutex.release() + throw error } }, }, @@ -3414,26 +4054,71 @@ export async function CodexAuthPlugin( label: 'ChatGPT Pro/Plus (headless)', type: 'oauth', authorize: async () => { - const { deviceData, url, instructions } = await beginDeviceAuth() - - return { - url, - instructions, - method: 'auto' as const, - async callback() { - try { - const tokens = await completeDeviceAuth(deviceData) - return { - type: 'success' as const, - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, - accountId: extractAccountId(tokens), + const mutex = await acquireCustodyTransitionMutex() + try { + const flow = custodyOptions?.authorize?.headless + ? await custodyOptions.authorize.headless() + : await (async () => { + const { deviceData, url, instructions } = + await beginDeviceAuth() + return { + url, + instructions, + tokens: completeDeviceAuth(deviceData), + } + })() + return { + url: flow.url, + instructions: flow.instructions, + method: 'auto' as const, + async callback() { + try { + const tokens = await flow.tokens + const result = { + type: 'success' as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: + (custodyOptions?.now ?? Date.now)() + + (tokens.expires_in ?? 3600) * 1000, + accountId: extractAccountId(tokens), + } + void releaseCustodyLoginLeaseAfterHostWrite({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + getAuth: async () => { + const auth = await hostAuth.get({ + path: { id: 'openai' }, + }) + return isRecord(auth) && + typeof auth.access === 'string' && + typeof auth.refresh === 'string' + ? { access: auth.access, refresh: auth.refresh } + : undefined + }, + onObserved: ({ access, refresh }) => + recordVerifiedInProcessMainLogin({ + type: 'oauth', + access, + refresh, + }), + release: () => mutex.release(), + warn: (message) => + custodyOptions?.warn?.(message) ?? + custodyLogger.warn(message), + now: custodyOptions?.now ?? Date.now, + sleep: custodyOptions?.sleep ?? ((ms) => Bun.sleep(ms)), + }).catch(() => mutex.release()) + return result + } catch { + await mutex.release() + return { type: 'failed' as const } } - } catch { - return { type: 'failed' as const } - } - }, + }, + } + } catch (error) { + await mutex.release() + throw error } }, }, diff --git a/packages/opencode/src/logger.ts b/packages/opencode/src/logger.ts index ab56152..ae5baa0 100644 --- a/packages/opencode/src/logger.ts +++ b/packages/opencode/src/logger.ts @@ -52,7 +52,8 @@ function isSecretKey(key: string): boolean { if (k.endsWith('token') && !k.endsWith('tokens')) return true return false } -const TOKEN_VALUE = /\b(Bearer\s+[\w.-]+|sk-[\w-]+|eyJ[\w.-]+)\b/g +const TOKEN_VALUE = + /\b(Bearer\s+[\w.-]+|sk-[\w-]+|eyJ[\w.-]+|ckh_[A-Za-z0-9_-]{20,})\b/g export function redact(value: unknown): unknown { return redactInner(value, new WeakSet()) } @@ -138,8 +139,9 @@ function safeSerialize(data: unknown): string { function emit(channel: string, level: Level, message: string, data?: unknown) { if (ORDER[level] > ORDER[configuredLevel()]) return + const redactedMessage = redact(message) const line = - `[${new Date().toISOString()}] ${level.toUpperCase()} [${channel}] ${message}` + + `[${new Date().toISOString()}] ${level.toUpperCase()} [${channel}] ${redactedMessage}` + (data === undefined ? '' : safeSerialize(data)) + '\n' buffer.push(line) diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index 76f1b7d..761282d 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -88,6 +88,17 @@ export interface SidebarAccountState { killed: boolean enabled: boolean resetCredits?: number + custody?: SidebarAccountCustody +} + +export type SidebarCustodyState = 'vault' | 'needsLogin' | 'local' | 'inert' + +export type SidebarCustodyReason = CustodyInertReason | 'corrupt' + +export interface SidebarAccountCustody { + state: SidebarCustodyState + reason?: SidebarCustodyReason + recordVersion?: number } export interface ActiveRoutingEntry { @@ -132,6 +143,7 @@ export interface SidebarState { quota: AccountQuota | null /** ChatGPT identity of the main account this quota belongs to. */ mainAccountId?: string + custody?: SidebarAccountCustody killed: boolean quotaBackedOff?: boolean quotaBackoffUntil?: number @@ -155,6 +167,11 @@ import { createHash, randomUUID } from 'node:crypto' import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { + CUSTODY_INERT_REASONS, + type CustodyInertReason, + type CustodyVerdict, +} from './core/custody-state' import { acquireRefreshFileLock } from './core/refresh-file-lock' import { createLogger } from './logger' @@ -183,6 +200,49 @@ function resetCreditsField(value: unknown): { resetCredits?: number } { return credits !== undefined ? { resetCredits: credits } : {} } +const CUSTODY_STATES = new Set([ + 'vault', + 'needsLogin', + 'local', + 'inert', +]) + +const CUSTODY_REASONS = new Set([ + ...CUSTODY_INERT_REASONS, + 'corrupt', +]) + +/** + * Tolerant reader for the per-fallback `custody` projection. Unknown state + * or reason values are dropped (NOT replaced with a default — a stale state + * file with an experimental `vaultHealing` value must not silently render + * as `local`, it must render as no-projection-at-all). Valid values round- + * trip byte-identical. The output contains only `state` and `reason`. + */ +function normalizeSidebarCustody( + value: unknown, +): SidebarAccountCustody | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + const c = value as Record + if ( + typeof c.state !== 'string' || + !CUSTODY_STATES.has(c.state as SidebarCustodyState) + ) { + return undefined + } + const state = c.state as SidebarCustodyState + const out: SidebarAccountCustody = { state } + if ( + typeof c.reason === 'string' && + CUSTODY_REASONS.has(c.reason as SidebarCustodyReason) + ) { + out.reason = c.reason as SidebarCustodyReason + } + return out +} + function normalizeActiveRouting(value: unknown): ActiveRoutingMap | undefined { if (value === null || typeof value !== 'object' || Array.isArray(value)) { return undefined @@ -276,6 +336,23 @@ export function getSidebarStateFile(): string { return process.env[STATE_FILE_ENV] || DEFAULT_STATE_FILE } +export function projectCustodyForSidebar( + verdict: CustodyVerdict, +): SidebarAccountCustody { + switch (verdict.kind) { + case 'LOCAL': + return { state: 'local' } + case 'VAULT': + return { state: 'vault' } + case 'INERT': + return { state: 'inert', reason: verdict.reason } + case 'NEEDS_LOGIN': + return verdict.reason === 'corrupt' + ? { state: 'needsLogin', reason: 'corrupt' } + : { state: 'needsLogin' } + } +} + export const DEFAULT_SIDEBAR_STATE: SidebarState = { main: { quota: null, killed: false }, fallbacks: [], @@ -315,6 +392,10 @@ export function normalizeSidebarState(raw: unknown): SidebarState { ...(typeof m.mainAccountId === 'string' ? { mainAccountId: m.mainAccountId } : {}), + ...(() => { + const custody = normalizeSidebarCustody(m.custody) + return custody ? { custody } : {} + })(), // Preserve optional backoff fields if present ...(typeof m.quotaBackedOff === 'boolean' ? { quotaBackedOff: m.quotaBackedOff } @@ -347,17 +428,21 @@ export function normalizeSidebarState(raw: unknown): SidebarState { !Array.isArray(entry) && typeof (entry as Record).id === 'string', ) - .map((e) => ({ - id: e.id as string, - label: typeof e.label === 'string' ? e.label : undefined, - ...(typeof e.accountId === 'string' - ? { accountId: e.accountId } - : {}), - quota: ('quota' in e ? e.quota : null) as AccountQuota | null, - killed: typeof e.killed === 'boolean' ? e.killed : false, - enabled: typeof e.enabled === 'boolean' ? e.enabled : true, - ...resetCreditsField(e.resetCredits), - })) + .map((e) => { + const custody = normalizeSidebarCustody(e.custody) + return { + id: e.id as string, + label: typeof e.label === 'string' ? e.label : undefined, + ...(typeof e.accountId === 'string' + ? { accountId: e.accountId } + : {}), + quota: ('quota' in e ? e.quota : null) as AccountQuota | null, + killed: typeof e.killed === 'boolean' ? e.killed : false, + enabled: typeof e.enabled === 'boolean' ? e.enabled : true, + ...resetCreditsField(e.resetCredits), + ...(custody ? { custody } : {}), + } + }) : [] // activeId — string or undefined diff --git a/packages/opencode/src/tests/accounts-store.test.ts b/packages/opencode/src/tests/accounts-store.test.ts index 7bf9d6a..7745727 100644 --- a/packages/opencode/src/tests/accounts-store.test.ts +++ b/packages/opencode/src/tests/accounts-store.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, writeFileSync, } from 'node:fs' import { writeFile } from 'node:fs/promises' @@ -15,7 +16,9 @@ import type { AccountStorage, OAuthAccount, } from '../core/accounts.ts' +import { refreshInert } from '../core/custody.ts' import { acquireRefreshFileLock } from '../core/refresh-file-lock.ts' +import { localCustody } from './custody-fixtures.ts' import { FLOOR_AUTH_FILE, FLOOR_LOG_FILE, @@ -44,6 +47,15 @@ afterEach(() => { } catch {} }) +describe('AccountManager custody injection', () => { + it('does not allow manager construction without custody injection', () => { + const omitted = { configPath: cfgPath } + // @ts-expect-error Required because an omitted policy reader silently re-enables local refresh. + const rejected: AccountManagerOptions = omitted + void rejected + }) +}) + function oauthAccount( id: string, overrides: Partial = {}, @@ -91,7 +103,10 @@ describe('request-path bookkeeping never fails the caller', () => { ) breakStateWrites() - const manager = new FallbackAccountManager({ configPath: cfgPath }) + const manager = new FallbackAccountManager({ + custody: localCustody, + configPath: cfgPath, + }) // Must resolve, not reject: the caller has a provider response to return. expect(await manager.markUsed(account).then(() => 'resolved')).toBe( @@ -119,6 +134,7 @@ describe('request-path bookkeeping never fails the caller', () => { breakStateWrites() const manager = new FallbackAccountManager({ + custody: localCustody, configPath: cfgPath, refreshFn: async () => ({ access: 'rotated-access', @@ -171,6 +187,212 @@ function createManagerRemovingAccountOnFirstLoad( } describe('accounts store', () => { + it('RETAINS a corrupt OAuth row as gone and refresh-inert when its binding exists', async () => { + const { isOAuthAccount, loadAccounts } = await import('../core/accounts.ts') + const { enrollmentManifest } = await import('./custody-fixtures.ts') + writeFileSync( + cfgPath, + `${JSON.stringify({ + version: 1, + accounts: [ + { + id: 'corrupt-fallback', + type: 'oauth', + enabled: true, + refresh: '', + }, + ], + })}\n`, + ) + writeFileSync( + statePath, + `${JSON.stringify({ version: 1, accounts: {} })}\n`, + ) + + const storage = await loadAccounts(cfgPath) + const account = storage?.accounts[0] + + expect(account).toEqual({ + id: 'corrupt-fallback', + type: 'oauth', + enabled: true, + corrupt: true, + }) + expect( + account?.type === 'oauth' && + refreshInert(account, enrollmentManifest(account.id), 'openai'), + ).toBe(true) + expect(account && isOAuthAccount(account)).toBe(false) + }) + + it('round-trips a corrupt OAuth marker through save without changing roster order', async () => { + const { loadAccounts, saveAccounts } = await import('../core/accounts.ts') + writeFileSync( + cfgPath, + `${JSON.stringify({ + version: 1, + accounts: [ + { id: 'first', type: 'oauth', refresh: 'first-refresh' }, + { id: 'corrupt-fallback', type: 'oauth', refresh: '' }, + { id: 'last', type: 'oauth', refresh: 'last-refresh' }, + ], + })}\n`, + ) + writeFileSync( + statePath, + `${JSON.stringify({ version: 1, accounts: {} })}\n`, + ) + + const loaded = await loadAccounts(cfgPath) + if (!loaded) throw new Error('expected account storage') + await saveAccounts(loaded, cfgPath) + const reloaded = await loadAccounts(cfgPath) + + expect(reloaded?.accounts.map((account) => account.id)).toEqual([ + 'first', + 'corrupt-fallback', + 'last', + ]) + expect(reloaded?.accounts[1]).toMatchObject({ + id: 'corrupt-fallback', + type: 'oauth', + corrupt: true, + }) + }) + + it('keeps a corrupt OAuth marker when a sibling row is mutated', async () => { + const { loadAccounts, mutateAccounts } = await import('../core/accounts.ts') + writeFileSync( + cfgPath, + `${JSON.stringify({ + version: 1, + accounts: [ + { id: 'corrupt-fallback', type: 'oauth', refresh: '' }, + { id: 'healthy', type: 'oauth', refresh: 'healthy-refresh' }, + ], + })}\n`, + ) + writeFileSync( + statePath, + `${JSON.stringify({ version: 1, accounts: {} })}\n`, + ) + + await mutateAccounts((current) => { + const healthy = current.accounts.find( + (account) => account.id === 'healthy', + ) + if (healthy) healthy.enabled = false + return current + }, cfgPath) + + const reloaded = await loadAccounts(cfgPath) + expect(reloaded?.accounts.map((account) => account.id)).toEqual([ + 'corrupt-fallback', + 'healthy', + ]) + expect(reloaded?.accounts[0]).toMatchObject({ + id: 'corrupt-fallback', + corrupt: true, + }) + expect(reloaded?.accounts[1]?.enabled).toBe(false) + }) + + it('persists row history only in config and records removed account ids', async () => { + const { loadAccounts, mutateAccounts, saveAccounts } = await import( + '../core/accounts.ts' + ) + const storage: AccountStorage = { + version: 1, + claustrum: { mode: 'local', rowHistory: ['previous'] }, + accounts: [oauthAccount('kept'), oauthAccount('removed')], + } + await saveAccounts(storage, cfgPath) + + expect((await loadAccounts(cfgPath))?.claustrum?.rowHistory).toEqual([ + 'previous', + ]) + expect( + JSON.parse(readFileSync(cfgPath, 'utf8')).claustrum.rowHistory, + ).toEqual(['previous']) + expect( + JSON.parse(readFileSync(statePath, 'utf8')).claustrum, + ).toBeUndefined() + + await mutateAccounts( + (current) => ({ + ...current, + accounts: current.accounts.filter( + (account) => account.id !== 'removed', + ), + }), + cfgPath, + ) + + const after = await loadAccounts(cfgPath) + expect(after?.accounts.map((account) => account.id)).toEqual(['kept']) + expect(after?.claustrum?.rowHistory).toEqual(['previous', 'removed']) + }) + + it('missing claustrum config loads as local without rewriting the file', async () => { + const accounts = await import('../core/accounts.ts') + const beforeExists = existsSync(cfgPath) + const beforeBytes = beforeExists ? readFileSync(cfgPath, 'utf8') : undefined + const beforeMtimeMs = beforeExists ? statSync(cfgPath).mtimeMs : undefined + + const storage = await accounts.loadAccounts(cfgPath) + + expect(accounts.claustrumMode(storage)).toBe('local') + expect(existsSync(cfgPath)).toBe(beforeExists) + expect( + existsSync(cfgPath) ? readFileSync(cfgPath, 'utf8') : undefined, + ).toBe(beforeBytes) + expect(existsSync(cfgPath) ? statSync(cfgPath).mtimeMs : undefined).toBe( + beforeMtimeMs, + ) + + const existingSource = JSON.stringify({ version: 1, accounts: [] }) + writeFileSync(cfgPath, existingSource) + const existingBytes = readFileSync(cfgPath, 'utf8') + const existingMtimeMs = statSync(cfgPath).mtimeMs + + expect(accounts.claustrumMode(await accounts.loadAccounts(cfgPath))).toBe( + 'local', + ) + expect(readFileSync(cfgPath, 'utf8')).toBe(existingBytes) + expect(statSync(cfgPath).mtimeMs).toBe(existingMtimeMs) + }) + + it('mode and takeover fingerprints round-trip in one config write', async () => { + const accounts = await import('../core/accounts.ts') + const transition = { + manifestRevision: 'manifest-revision', + storeGeneration: 'store-generation', + fingerprints: { + main: 'main-fingerprint', + fallbacks: { 'fallback-1': 'fallback-fingerprint' }, + }, + } + writeFileSync( + cfgPath, + JSON.stringify({ version: 1, accounts: [], unknownSetting: true }), + ) + + await accounts.writeClaustrumModeAndTransition( + cfgPath, + 'claustrum', + transition, + ) + + expect(existsSync(statePath)).toBe(false) + const config = JSON.parse(readFileSync(cfgPath, 'utf8')) + expect(config.unknownSetting).toBe(true) + expect(config.claustrum.mode).toBe('claustrum') + expect(config.claustrum.transition).toEqual(transition) + expect(accounts.claustrumMode(await accounts.loadAccounts(cfgPath))).toBe( + 'claustrum', + ) + }) + it('load/save round-trip: accounts, main provider, version', async () => { const { loadAccounts, saveAccounts } = await import('../core/accounts.ts') @@ -772,6 +994,7 @@ describe('removed fallback refresh guard', () => { account.id, cfgPath, { + custody: localCustody, configPath: cfgPath, now: () => now, refreshFn: async () => { @@ -841,6 +1064,7 @@ describe('removed fallback refresh guard', () => { }) => void) | undefined const manager = new FallbackAccountManager({ + custody: localCustody, configPath: cfgPath, now: () => now, refreshFn: async () => { @@ -918,6 +1142,7 @@ describe('removed fallback refresh guard', () => { account.id, cfgPath, { + custody: localCustody, configPath: cfgPath, now: () => now, refreshFn: async () => { diff --git a/packages/opencode/src/tests/cli.test.ts b/packages/opencode/src/tests/cli.test.ts index 26b20ee..9fb5e04 100644 --- a/packages/opencode/src/tests/cli.test.ts +++ b/packages/opencode/src/tests/cli.test.ts @@ -1,6 +1,14 @@ import { describe, expect, mock, test } from 'bun:test' import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { + loadAccounts, + saveAccounts, + withAccountStoreTransaction, +} from '../core/accounts.ts' const packageRoot = fileURLToPath(new URL('../../', import.meta.url)) @@ -54,4 +62,47 @@ describe('CLI login guardrails', () => { '"main" is a reserved account id; choose a different label.', ) }) + + test('refuses a completed CLI login under Claustrum before writing credentials', async () => { + const directory = mkdtempSync(join(tmpdir(), 'oai-cli-claustrum-')) + const path = join(directory, 'accounts.json') + const errors: string[] = [] + try { + await saveAccounts({ version: 1, accounts: [] }, path) + await withAccountStoreTransaction(async (transaction) => { + await transaction.writeMode('claustrum') + }, path) + + const { runCli } = await import('../cli.ts') + const code = await runCli(['login', '--headless'], { + beginAccountLogin: async () => ({ + url: 'https://example.test/login', + instructions: 'complete login', + completion: Promise.resolve({ + id: 'fallback-new', + type: 'oauth' as const, + access: 'access', + refresh: 'refresh', + expires: 1, + enabled: true, + addedAt: 1, + lastUsed: 1, + lastRefreshedAt: 1, + }), + }), + getAccountStoragePath: () => path, + openUrl: () => {}, + error: (message) => errors.push(message), + log: () => {}, + }) + + expect(code).toBe(1) + expect(errors).toContain( + '\nError: Claustrum mode is active. Run /openai-account local before adding a fallback account.', + ) + expect((await loadAccounts(path))?.accounts).toEqual([]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) }) diff --git a/packages/opencode/src/tests/command-dialogs.test.ts b/packages/opencode/src/tests/command-dialogs.test.ts index b2c756d..a8706a0 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -6,13 +6,24 @@ import type { TuiPluginApi } from '@opencode-ai/plugin/tui' import { flushForTest, setLogLevel } from '../logger.js' import type { OpenDialogPayload } from '../rpc/protocol.js' import { + accountDialogModeOption, buildAccountDialogRows, buildCachekeepDialogOptions, + buildFallbackAccountOptions, formatQuotaWindows, openCommandDialog, } from '../tui/command-dialogs' describe('command dialogs', () => { + async function waitUntil(predicate: () => boolean): Promise { + const deadline = Date.now() + 1_000 + while (Date.now() < deadline) { + if (predicate()) return + await Bun.sleep(5) + } + throw new Error('dialog did not render before timeout') + } + test('cachekeep modal shows Turn on when disabled', () => { const options = buildCachekeepDialogOptions({ command: 'openai-cachekeep', @@ -143,6 +154,89 @@ describe('command dialogs', () => { }) }) + test('account dialog projects the global custody mode into explicit enter and exit actions', () => { + expect(accountDialogModeOption('local')).toEqual({ + title: 'Enter Claustrum', + value: '__claustrum__', + description: 'Verify every enabled account before custody takes over', + }) + }) + + test('account dialog shows Leave Claustrum after the enter result updates its knobs', async () => { + const harness = makeResetDialogHarness() + const apply = mock(async () => ({ + text: 'entered', + knobs: { claustrumMode: 'claustrum' }, + })) + + openCommandDialog( + harness.api, + { + command: 'openai-account', + text: '', + knobs: { claustrumMode: 'local' }, + }, + apply, + ) + await waitUntil(() => harness.replaceCount.value > 0) + harness.renderDialog() + + expect(harness.select('__claustrum__')).toBe(true) + await waitUntil(() => harness.replaceCount.value > 1) + harness.renderDialog() + + expect(harness.getSelectProps()?.options).toContainEqual( + expect.objectContaining({ + title: 'Leave Claustrum', + value: '__local__', + }), + ) + }) + + test('account rows and fallback actions expose mode verbs without custody on or off', () => { + const rows = buildAccountDialogRows({ + main: { quota: null, killed: false }, + fallbacks: [ + { + id: 'fallback-a', + label: 'Fallback A', + quota: null, + killed: false, + enabled: false, + }, + ], + activeId: 'main', + route: 'main-first', + lastUpdated: Date.now(), + }) + const options = buildFallbackAccountOptions({ + id: 'fallback-a', + label: 'Fallback A', + enabled: false, + index: 0, + count: 1, + }) + const values = options.map((option) => option.value) + const titles = options.map((option) => option.title.toLowerCase()) + + expect(values).toContain('enable') + expect(values).toContain('remove') + expect( + rows + .map((row) => row.title) + .join(' ') + .toLowerCase(), + ).not.toContain('custody on') + expect( + rows + .map((row) => row.title) + .join(' ') + .toLowerCase(), + ).not.toContain('custody off') + expect(titles.join(' ')).not.toContain('custody on') + expect(titles.join(' ')).not.toContain('custody off') + }) + type ResetSelectOption = { title: string value: string diff --git a/packages/opencode/src/tests/commands.test.ts b/packages/opencode/src/tests/commands.test.ts index 48195c9..f63a290 100644 --- a/packages/opencode/src/tests/commands.test.ts +++ b/packages/opencode/src/tests/commands.test.ts @@ -1,5 +1,4 @@ import { - afterAll, afterEach, beforeEach, describe, @@ -18,17 +17,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CommandContext } from '../commands' -// Static import for tests that don't need mocking. import { buildDialogPayload, renderResetCoordinatorResult } from '../commands' -// Snapshot the REAL oauth module exports at load time (before any mock.module -// runs). bun's mock.module leaks process-wide and mock.restore() does NOT undo -// it, so without restoring here the beginAccountLogin stub below would poison -// every later test file that imports ../core/oauth. We spread into a PLAIN object -// so the snapshot holds the original function references even after the live -// namespace is later replaced; afterAll re-installs it. -import * as oauthLiveNamespace from '../core/oauth' - -const oauthRealExports = { ...oauthLiveNamespace } import type { AccountQuotaWindow, @@ -41,6 +30,7 @@ import { type OAuthAccount, saveAccounts, } from '../core/accounts' +import { CUSTODY_INERT_REASONS } from '../core/custody-state.ts' import { QuotaManager } from '../core/quota-manager' import { runResetCreditRedemption } from '../core/reset-credits' import { buildResetRedemptionDeps, createResetTargetResolver } from '../index' @@ -99,6 +89,19 @@ function makeClient(): CommandContext['client'] { } as unknown as CommandContext['client'] } +const withFallbackAccountLock: CommandContext['withFallbackAccountLock'] = + async (_id, action) => action() + +function withAccountLogin( + ctx: CommandContext, + beginAccountLogin: unknown, +): CommandContext { + return { + ...ctx, + beginAccountLogin: beginAccountLogin as CommandContext['beginAccountLogin'], + } +} + function fetchStub( implementation: ( input: RequestInfo | URL, @@ -256,6 +259,7 @@ async function makeResetCommandHarness( quotaManager, loadAccounts, client: makeClient(), + withFallbackAccountLock, resolveResetTarget, fetchImpl: makeResetWire(fixture), now: () => now, @@ -342,6 +346,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } const payload = await buildDialogPayload('openai-routing', '', ctx) @@ -359,6 +364,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } // Set to fallback-first @@ -380,6 +386,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, } const payload = await buildDialogPayload( @@ -409,6 +416,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, } const payload = await buildDialogPayload('openai-account', '', ctx) @@ -426,6 +434,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, sessionId: 'session-a', clearStickyRouting, } @@ -454,6 +463,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, sessionId: 'sticky-status-session', getStickyRouting: async () => 'fallback-1', } @@ -478,6 +488,7 @@ describe('commands', () => { }), loadAccounts, client: makeClient(), + withFallbackAccountLock, sessionId: 'raw-command-session', clearStickyRouting: async () => true, cacheKeepManager: { @@ -511,6 +522,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, clearStickyRouting, } @@ -609,6 +621,7 @@ describe('commands', () => { quotaManager, loadAccounts, client: makeClient(), + withFallbackAccountLock, sessionId: sessionA, cacheKeepManager, clearStickyRouting: (sessionId) => @@ -650,6 +663,7 @@ describe('commands', () => { }), loadAccounts, client: makeClient(), + withFallbackAccountLock, } const payload = await buildDialogPayload('openai-routing', alias, ctx) @@ -690,6 +704,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, } // Every account-command surface that returns an accounts knob. @@ -808,6 +823,7 @@ describe('commands', () => { // Inject the stale snapshot as what the handler reads for display. loadAccounts: (async () => staleSnapshot) as typeof loadAccounts, client: makeClient(), + withFallbackAccountLock, } await buildDialogPayload('openai-routing', 'fallback-first', ctx) @@ -836,6 +852,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, cacheKeepManager: { status: () => ({ running: false, @@ -882,6 +899,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, setCacheKeepEnabled, cacheKeepManager: { start, @@ -929,6 +947,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, setCacheKeepSubagents, cacheKeepManager: { status: () => ({ @@ -976,6 +995,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, setCacheKeepSustain, cacheKeepManager: { status: () => ({ @@ -1028,6 +1048,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, } const always = await buildDialogPayload( @@ -1050,6 +1071,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, cacheKeepManager: { start, status: () => ({ @@ -1091,6 +1113,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, setCacheKeepWindow, cacheKeepManager: { status: () => ({ @@ -1139,6 +1162,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, setCacheKeepWindow, cacheKeepManager: { status: () => ({ @@ -1185,6 +1209,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, setCacheKeepWindow, cacheKeepManager: { status: () => ({ @@ -1224,6 +1249,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, cacheKeepManager: { status: () => ({ running: true, @@ -1279,6 +1305,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client, + withFallbackAccountLock, refreshSidebar: async () => { refreshCalls.push(1) }, @@ -1310,6 +1337,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } // Seed two oauth accounts, then strip 'broken' from the state file. @@ -1364,6 +1392,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } await saveAccounts( { @@ -1403,6 +1432,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client, + withFallbackAccountLock, refreshSidebar: async () => { refreshCalls.push(1) }, @@ -1439,6 +1469,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } // Gate: start from 'info' (debug suppressed) @@ -1495,6 +1526,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } const offPayload = await buildDialogPayload('openai-dump', '', ctx) @@ -1520,6 +1552,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } const payload = await buildDialogPayload('openai-killswitch', '', ctx) @@ -1540,6 +1573,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } const payload = await buildDialogPayload('openai-quota', '', ctx) @@ -1576,6 +1610,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, refreshAllQuota: async () => { qm.setMain('access-main', { quota: makeQuotaSnapshot(15), @@ -1640,6 +1675,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } const payload = await buildDialogPayload('openai-quota', '', ctx) @@ -1663,6 +1699,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, refreshAllQuota: async () => { qm.setMain('access-main', { quota: makeQuotaSnapshot(10), @@ -1713,6 +1750,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, refreshAllQuota: async () => { // Fresh main, stale fallback, and the poll itself reports success for // both — the shape that made a 31-hour-old bar look current. @@ -1758,6 +1796,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, refreshAllQuota: async () => [ { account: 'main', ok: true }, { @@ -1794,6 +1833,7 @@ describe('commands', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, // refreshAllQuota intentionally omitted } @@ -1991,6 +2031,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, resolveResetTarget, fetchImpl: fetchStub(async () => Response.json({})), now: () => now, @@ -2015,6 +2056,7 @@ describe('commands', () => { quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), loadAccounts, client: makeClient(), + withFallbackAccountLock, } const payload = await buildDialogPayload('openai-reset', '', ctx) @@ -2024,6 +2066,47 @@ describe('commands', () => { expect(payload.knobs).toEqual({}) }) + test('reset preview reports a vault-served wham 401 before marking the account ineligible', async () => { + const reports: number[] = [] + const ctx: CommandContext = { + accountStoragePath: configPath, + quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), + loadAccounts, + client: makeClient(), + withFallbackAccountLock, + resolveResetTarget: async () => ({ + accountKey: 'fallback-a', + label: 'fallback-a', + accessToken: 'vault-access', + chatgptAccountId: 'chatgpt-fallback-a', + onAuthFailure: async (status) => { + reports.push(status) + }, + }), + fetchImpl: fetchStub(async (input) => { + if (String(input).includes('/wham/usage')) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + return Response.json({ credits: [], available_count: 0 }) + }), + now: () => Date.parse('2026-07-17T12:00:00.000Z'), + randomUUID: () => 'uuid', + refreshResetTargetQuota: async (accountKey) => ({ + account: accountKey, + ok: true, + }), + } + + const payload = await buildDialogPayload( + 'openai-reset', + 'select fallback-a', + ctx, + ) + + expect(payload.knobs.code).toBe('not_eligible') + expect(reports).toEqual([401]) + }) + test('reset identity resolver returns tagged displayable target errors', async () => { const now = Date.parse('2026-07-17T12:00:00.000Z') const resolver = () => @@ -3209,7 +3292,7 @@ describe('commands', () => { }) // ----------------------------------------------------------------------- -// Account add command (uses mock.module for beginAccountLogin) +// Account add command // ----------------------------------------------------------------------- describe('commands (add)', () => { let tmpDir: string @@ -3245,12 +3328,6 @@ describe('commands (add)', () => { } }) - // mock.module('../core/oauth', ...) below leaks process-wide; re-install the - // real module so later test files (e.g. oauth.test.ts) see the genuine exports. - afterAll(() => { - mock.module('../core/oauth', () => oauthRealExports) - }) - test('/openai-account add returns dialog with auth URL', async () => { const resolveAccount = makeAccount('added-acct', { label: 'work' }) const beginSpy = mock((_opts?: unknown) => @@ -3260,13 +3337,6 @@ describe('commands (add)', () => { completion: Promise.resolve(resolveAccount), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - // Dynamic re-import to pick up the mock - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3276,9 +3346,14 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } - const payload = await bdp('openai-account', 'add work', ctx) + const payload = await buildDialogPayload( + 'openai-account', + 'add work', + withAccountLogin(ctx, beginSpy), + ) expect(payload.command).toBe('openai-account') expect(payload.text).toContain('https://auth.openai.com/oauth/authorize') @@ -3298,12 +3373,6 @@ describe('commands (add)', () => { completion: completionPromise, }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3316,9 +3385,14 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client, + withFallbackAccountLock, } - const payload = await bdp('openai-account', 'add work', ctx) + const payload = await buildDialogPayload( + 'openai-account', + 'add work', + withAccountLogin(ctx, beginSpy), + ) expect(payload.text).toContain('Add OpenAI Account') // Resolve the detached completion @@ -3347,12 +3421,6 @@ describe('commands (add)', () => { completion: Promise.resolve(resolveAccount), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3362,16 +3430,25 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } // First add - await bdp('openai-account', 'add personal', ctx) + await buildDialogPayload( + 'openai-account', + 'add personal', + withAccountLogin(ctx, beginSpy), + ) await waitUntil( async () => ((await loadAccounts(configPath))?.accounts.length ?? 0) >= 1, ) // Second add with same label - await bdp('openai-account', 'add personal', ctx) + await buildDialogPayload( + 'openai-account', + 'add personal', + withAccountLogin(ctx, beginSpy), + ) // Absence assertion: the duplicate must NOT be added, so there is no // observable effect to poll for. Wait long enough for the completion to // have run and been rejected. @@ -3390,12 +3467,6 @@ describe('commands (add)', () => { completion: Promise.resolve(resolveAccount), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3405,9 +3476,14 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } - await bdp('openai-account', 'add fb', ctx) + await buildDialogPayload( + 'openai-account', + 'add fb', + withAccountLogin(ctx, beginSpy), + ) await waitUntil( async () => ((await loadAccounts(configPath))?.accounts.length ?? 0) >= 1, ) @@ -3438,12 +3514,6 @@ describe('commands (add)', () => { completion: Promise.resolve(resolveAccount), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3453,9 +3523,14 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } - await bdp('openai-account', 'add test', ctx) + await buildDialogPayload( + 'openai-account', + 'add test', + withAccountLogin(ctx, beginSpy), + ) // Absence assertion: the main account must NOT be added as a fallback, so // there is no observable effect to poll for. Wait long enough for the // completion to have run and been rejected. @@ -3485,12 +3560,6 @@ describe('commands (add)', () => { completion: Promise.resolve(resolveAccount), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3501,12 +3570,17 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, notify: (payload) => { notifyCalls.push({ text: payload.text }) }, } - await bdp('openai-account', 'add test', ctx) + await buildDialogPayload( + 'openai-account', + 'add test', + withAccountLogin(ctx, beginSpy), + ) await waitUntil(() => notifyCalls.length >= 1) expect(notifyCalls.length).toBe(1) @@ -3521,12 +3595,6 @@ describe('commands (add)', () => { completion: Promise.reject(new Error('OAuth timeout')), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3537,12 +3605,17 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, notify: (payload) => { notifyCalls.push({ text: payload.text }) }, } - await bdp('openai-account', 'add test', ctx) + await buildDialogPayload( + 'openai-account', + 'add test', + withAccountLogin(ctx, beginSpy), + ) await waitUntil(() => notifyCalls.length >= 1) expect(notifyCalls.length).toBe(1) @@ -3557,12 +3630,6 @@ describe('commands (add)', () => { completion: new Promise(() => {}), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3572,9 +3639,14 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } - const payload = await bdp('openai-account', 'add work', ctx) + const payload = await buildDialogPayload( + 'openai-account', + 'add work', + withAccountLogin(ctx, beginSpy), + ) expect(payload.command).toBe('openai-account') expect(payload.knobs.url).toBe( @@ -3594,12 +3666,6 @@ describe('commands (add)', () => { completion: new Promise(() => {}), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3609,9 +3675,14 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } - const payload = await bdp('openai-account', 'add --headless', ctx) + const payload = await buildDialogPayload( + 'openai-account', + 'add --headless', + withAccountLogin(ctx, beginSpy), + ) expect(payload.command).toBe('openai-account') expect(payload.knobs.verificationUrl).toBe( @@ -3630,12 +3701,6 @@ describe('commands (add)', () => { completion: Promise.resolve(resolveAccount), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3646,12 +3711,17 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, refreshSidebar: async () => { refreshCalls.push(1) }, } - await bdp('openai-account', 'add work', ctx) + await buildDialogPayload( + 'openai-account', + 'add work', + withAccountLogin(ctx, beginSpy), + ) await waitUntil(() => refreshCalls.length >= 1) expect(refreshCalls.length).toBe(1) @@ -3667,12 +3737,6 @@ describe('commands (add)', () => { completion: new Promise(() => {}), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3682,9 +3746,14 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } - await bdp('openai-account', 'add my-label', ctx) + await buildDialogPayload( + 'openai-account', + 'add my-label', + withAccountLogin(ctx, beginSpy), + ) expect(beginSpy).toHaveBeenCalled() const callArg = beginSpy.mock.calls[0]?.[0] as @@ -3701,12 +3770,6 @@ describe('commands (add)', () => { completion: new Promise(() => {}), }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3716,9 +3779,14 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, } - await bdp('openai-account', 'add --headless my-label', ctx) + await buildDialogPayload( + 'openai-account', + 'add --headless my-label', + withAccountLogin(ctx, beginSpy), + ) expect(beginSpy).toHaveBeenCalled() const callArg = beginSpy.mock.calls[0]?.[0] as @@ -3740,12 +3808,6 @@ describe('commands (add)', () => { completion: completionPromise, }), ) - mock.module('../core/oauth', () => { - const actual = require('../core/oauth') - return { ...actual, beginAccountLogin: beginSpy } - }) - - const { buildDialogPayload: bdp } = await import('../commands') const qm = new QuotaManager({ storage: { version: 1 as const, accounts: [] }, @@ -3757,13 +3819,18 @@ describe('commands (add)', () => { quotaManager: qm, loadAccounts, client: makeClient(), + withFallbackAccountLock, sessionId: 'session-one', notify: (payload) => { firstSessionCalls.push(payload.text) }, } - await bdp('openai-account', 'add work', ctx) + await buildDialogPayload( + 'openai-account', + 'add work', + withAccountLogin(ctx, beginSpy), + ) ctx.sessionId = 'session-two' ctx.notify = (payload) => { secondSessionCalls.push(payload.text) @@ -3777,3 +3844,290 @@ describe('commands (add)', () => { expect(secondSessionCalls).toHaveLength(0) }) }) + +describe('commands (claustrum mode)', () => { + let tmpDir: string + let configPath: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'openai-auth-cmd-claustrum-')) + configPath = join(tmpDir, 'openai-auth.json') + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function context(overrides: Partial = {}): CommandContext { + return { + accountStoragePath: configPath, + quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), + loadAccounts, + client: makeClient(), + withFallbackAccountLock: async (_id, action) => action(), + ...overrides, + } + } + + test('claustrum enters through the readiness barrier and renders the cross-window warning', async () => { + const enterClaustrumMode = mock(async () => ({ + status: 'completed' as const, + outcomes: { 'fallback-a': 'tombstoned' as const }, + })) + + const payload = await buildDialogPayload( + 'openai-account', + 'claustrum', + context({ enterClaustrumMode }), + ) + + expect(enterClaustrumMode).toHaveBeenCalledTimes(1) + expect(payload.text).toContain( + 'do not run a login in another OpenCode window during this transition', + ) + expect(payload.text).toContain('fallback-a') + }) + + test('local exits through the shared mode lock and tells the operator how to finish the exit', async () => { + const leaveClaustrumMode = mock(async () => {}) + + const payload = await buildDialogPayload( + 'openai-account', + 'local', + context({ leaveClaustrumMode }), + ) + + expect(leaveClaustrumMode).toHaveBeenCalledTimes(1) + expect(payload.text).toContain('/login openai') + expect(payload.text).toContain('ck auth') + }) + + test('transition apply knobs reload the persisted mode after each barrier', async () => { + await saveAccounts( + { version: 1, accounts: [], claustrum: { mode: 'local' } }, + configPath, + ) + const enterClaustrumMode = mock(async () => { + await saveAccounts( + { version: 1, accounts: [], claustrum: { mode: 'claustrum' } }, + configPath, + ) + return { status: 'completed' as const, outcomes: {} } + }) + + const entered = await buildDialogPayload( + 'openai-account', + 'claustrum', + context({ enterClaustrumMode }), + ) + + expect(entered.knobs.claustrumMode).toBe('claustrum') + + const leaveClaustrumMode = mock(async () => { + await saveAccounts( + { version: 1, accounts: [], claustrum: { mode: 'local' } }, + configPath, + ) + }) + const left = await buildDialogPayload( + 'openai-account', + 'local', + context({ leaveClaustrumMode }), + ) + + expect(left.knobs.claustrumMode).toBe('local') + }) + + test('account status and help retain Claustrum mode verbs without custody on or off', async () => { + await saveAccounts( + { + version: 1, + accounts: [makeAccount('fallback-a')], + claustrum: { mode: 'local' }, + }, + configPath, + ) + + const status = await buildDialogPayload('openai-account', '', context()) + const help = await buildDialogPayload('openai-account', 'help', context()) + + for (const payload of [status, help]) { + expect(payload.text).toContain('claustrum') + expect(payload.text.toLowerCase()).not.toContain('custody on') + expect(payload.text.toLowerCase()).not.toContain('custody off') + } + }) + + test('enable re-reads claustrum mode under the account lock and binds a pending row before enabling it', async () => { + await saveAccounts( + { + version: 1, + accounts: [makeAccount('fallback-a', { enabled: false })], + claustrum: { mode: 'claustrum' }, + }, + configPath, + ) + const withFallbackAccountLock = mock(async (_id, action) => action()) + const checkUsableCustodyBinding = mock(async () => ({ + ready: true as const, + accountId: 'served-account', + })) + + const payload = await buildDialogPayload( + 'openai-account', + 'enable fallback-a', + context({ withFallbackAccountLock, checkUsableCustodyBinding }), + ) + + expect(withFallbackAccountLock).toHaveBeenCalledWith( + 'fallback-a', + expect.any(Function), + ) + expect(checkUsableCustodyBinding).toHaveBeenCalledTimes(1) + expect((await loadAccounts(configPath))?.accounts[0]).toMatchObject({ + id: 'fallback-a', + accountId: 'served-account', + enabled: true, + }) + expect(payload.text).toContain('enabled') + }) + + test('enable observes a mode switch that happens while its account lock is held', async () => { + await saveAccounts( + { + version: 1, + accounts: [makeAccount('fallback-a', { enabled: false })], + claustrum: { mode: 'local' }, + }, + configPath, + ) + let markLockEntered!: () => void + const lockEntered = new Promise((resolve) => { + markLockEntered = resolve + }) + let releaseAction!: () => void + const allowAction = new Promise((resolve) => { + releaseAction = resolve + }) + const withFallbackAccountLock = async ( + _id: string, + action: () => Promise, + ): Promise => { + markLockEntered() + await allowAction + return action() + } + const run = buildDialogPayload( + 'openai-account', + 'enable fallback-a', + context({ + withFallbackAccountLock, + checkUsableCustodyBinding: async () => ({ + ready: false as const, + reason: 'vault-cold' as const, + }), + }), + ) + + await lockEntered + await saveAccounts( + { + version: 1, + accounts: [makeAccount('fallback-a', { enabled: false })], + claustrum: { mode: 'claustrum' }, + }, + configPath, + ) + releaseAction() + const payload = await run + + expect(payload.text).toContain('vault-cold') + expect((await loadAccounts(configPath))?.accounts[0]?.enabled).toBe(false) + }) + + for (const reason of ['vault-cold', 'vault-reauth'] as const) { + test(`enable keeps a claustrum row disabled when its binding is ${reason}`, async () => { + expect(CUSTODY_INERT_REASONS).toContain(reason) + await saveAccounts( + { + version: 1, + accounts: [makeAccount('fallback-a', { enabled: false })], + claustrum: { mode: 'claustrum' }, + }, + configPath, + ) + + const payload = await buildDialogPayload( + 'openai-account', + 'enable fallback-a', + context({ + withFallbackAccountLock: async (_id, action) => action(), + checkUsableCustodyBinding: async () => ({ + ready: false as const, + reason, + }), + }), + ) + + expect((await loadAccounts(configPath))?.accounts[0]?.enabled).toBe(false) + expect(payload.text).toContain(reason) + }) + } + + test('add completion refuses persistence when claustrum begins before OAuth finishes', async () => { + let resolveAccount!: (account: OAuthAccount) => void + const completion = new Promise((resolve) => { + resolveAccount = resolve + }) + const beginAccountLogin = async () => ({ + url: 'https://auth.openai.com/test', + instructions: 'test', + completion, + }) + const notifications: string[] = [] + + await buildDialogPayload( + 'openai-account', + 'add work', + context({ + notify: (payload) => { + notifications.push(payload.text) + }, + beginAccountLogin: + beginAccountLogin as CommandContext['beginAccountLogin'], + withFallbackAccountLock: async (_id, action) => action(), + }), + ) + await saveAccounts( + { version: 1, accounts: [], claustrum: { mode: 'claustrum' } }, + configPath, + ) + resolveAccount(makeAccount('fallback-a', { label: 'work' })) + await waitUntil(() => notifications.length === 1) + + expect((await loadAccounts(configPath))?.accounts).toEqual([]) + expect(notifications[0]).toContain('/openai-account local') + }) + + test('remove leaves the custody manifest bytes untouched', async () => { + await saveAccounts( + { version: 1, accounts: [makeAccount('fallback-a')] }, + configPath, + ) + const manifestPath = join(tmpDir, 'opencode-handles.json') + const manifestBytes = '{"version":1,"providers":["fixture"]}\n' + const prior = process.env.CLAUSTRUM_OPENCODE_HANDLES + try { + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + writeFileSync(manifestPath, manifestBytes) + + await buildDialogPayload('openai-account', 'remove fallback-a', context()) + + expect(readFileSync(manifestPath, 'utf8')).toBe(manifestBytes) + expect((await loadAccounts(configPath))?.accounts).toEqual([]) + } finally { + if (prior === undefined) delete process.env.CLAUSTRUM_OPENCODE_HANDLES + else process.env.CLAUSTRUM_OPENCODE_HANDLES = prior + } + }) +}) diff --git a/packages/opencode/src/tests/custody-authorize.test.ts b/packages/opencode/src/tests/custody-authorize.test.ts new file mode 100644 index 0000000..d528262 --- /dev/null +++ b/packages/opencode/src/tests/custody-authorize.test.ts @@ -0,0 +1,611 @@ +import { describe, expect, mock, test } from 'bun:test' +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { saveAccounts } from '../core/accounts.ts' +import { acquireCustodyTransitionMutex } from '../core/custody-transition.ts' +import { CodexAuthPlugin } from '../index.ts' +import { getSidebarState } from '../sidebar-state.ts' +import { + claustrumConfig, + enrollmentManifest, + liveStorage, +} from './custody-fixtures.ts' + +type Deferred = { + promise: Promise + resolve(value: T): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +async function waitForSleep( + sleeps: Array>, + count = 1, +): Promise { + for (let turn = 0; turn < 32; turn += 1) { + if (sleeps.length >= count) return + await Promise.resolve() + } + throw new Error('expected host readback poller to sleep') +} + +type TokenResponse = { + access_token: string + refresh_token: string + id_token: string + expires_in: number +} + +type Authorize = () => Promise<{ + callback(): Promise<{ access: string; refresh: string }> +}> + +type HostOauth = { + type: 'oauth' + access: string + refresh: string + expires: number +} + +type VerifiedMainLoginRecord = { + mainSlotFamilyFingerprint(slot: HostOauth): string + hasVerifiedInProcessMainLogin(slot: HostOauth): boolean +} + +async function verifiedMainLoginRecord(): Promise { + return (await import( + '../core/custody-host-slot.ts' + )) as unknown as VerifiedMainLoginRecord +} + +async function withFreshLocalFactory( + slot: HostOauth, + run: (start: () => Promise) => Promise, +): Promise { + const directory = mkdtempSync(join(tmpdir(), 'custody-authorize-local-')) + const configPath = join(directory, 'openai-auth.json') + const manifestPath = join(directory, 'opencode-handles.json') + const prior = { + config: process.env.OPENCODE_OPENAI_AUTH_FILE, + state: process.env.OPENCODE_OPENAI_AUTH_STATE_FILE, + sidebar: process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE, + log: process.env.OPENCODE_OPENAI_AUTH_LOG_FILE, + manifest: process.env.CLAUSTRUM_OPENCODE_HANDLES, + } + let hooks: Awaited> | undefined + try { + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join(directory, 'state.json') + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = join( + directory, + 'sidebar.json', + ) + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = join(directory, 'test.log') + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + await saveAccounts( + liveStorage([], { claustrum: claustrumConfig({ mode: 'local' }) }), + configPath, + ) + const manifest = enrollmentManifest('main') + if (!manifest.ok) throw new Error('expected manifest fixture') + writeFileSync(manifestPath, JSON.stringify(manifest.value)) + chmodSync(manifestPath, 0o600) + + await run(async () => { + await hooks?.dispose?.() + hooks = await CodexAuthPlugin( + { + client: { + auth: { + get: async () => slot, + all: async () => ({ openai: slot }), + set: async () => {}, + }, + }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: directory, + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { + custody: { + transport: { + getCredential: async () => { + throw new Error('not used') + }, + statusCredential: async () => ({ + ready: false, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 0, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + }, + }, + ) + }) + } finally { + await hooks?.dispose?.() + for (const [key, value] of Object.entries(prior)) { + const envKey = + key === 'config' + ? 'OPENCODE_OPENAI_AUTH_FILE' + : key === 'state' + ? 'OPENCODE_OPENAI_AUTH_STATE_FILE' + : key === 'sidebar' + ? 'OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE' + : key === 'log' + ? 'OPENCODE_OPENAI_AUTH_LOG_FILE' + : 'CLAUSTRUM_OPENCODE_HANDLES' + if (value === undefined) delete process.env[envKey] + else process.env[envKey] = value + } + rmSync(directory, { recursive: true, force: true }) + } +} + +async function makeAuthorizeMethods() { + const browserTokens = deferred() + const headlessTokens = deferred() + const browserStarted = mock(async () => ({ + url: 'http://test/callback', + tokens: browserTokens.promise, + })) + const headlessStarted = mock(async () => ({ + url: 'http://test/device', + instructions: 'test', + tokens: headlessTokens.promise, + })) + + let hostSlot: HostOauth | undefined + let rejectAuthGet = false + let now = 0 + const sleeps: Array> = [] + const warnings: string[] = [] + const hostAuthSet = mock( + async ({ body }: { path: { id: string }; body: HostOauth }) => { + hostSlot = body + }, + ) + const hooks = await CodexAuthPlugin( + { + client: { + auth: { + all: async () => ({}), + get: async () => { + if (rejectAuthGet) throw new Error('host read failed') + return hostSlot + }, + set: hostAuthSet, + }, + }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: '', + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { + custody: { + transport: { + getCredential: async () => { + throw new Error('not used') + }, + statusCredential: async () => ({ + ready: false, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 0, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + now: () => now, + sleep: async () => { + const next = deferred() + sleeps.push(next) + await next.promise + }, + authorize: { browser: browserStarted, headless: headlessStarted }, + warn: (message: string) => warnings.push(message), + }, + }, + ) + const methods = hooks.auth?.methods as + | Array<{ authorize: Authorize; label: string }> + | undefined + if (!methods) throw new Error('expected auth methods') + + return { + browser: methods[0]!.authorize, + headless: methods[1]!.authorize, + browserStarted, + headlessStarted, + browserTokens, + headlessTokens, + hostSet: (access: string, refresh = 'refresh') => { + hostSlot = { type: 'oauth', access, refresh, expires: 60_000 } + }, + otherWindowSet: async (access: string, refresh = 'refresh') => { + await hostAuthSet({ + path: { id: 'openai' }, + body: { type: 'oauth', access, refresh, expires: 60_000 }, + }) + }, + hostSlot: () => hostSlot, + advanceTo: (next: number) => { + now = next + }, + rejectHostReads: () => { + rejectAuthGet = true + }, + sleeps, + warnings, + dispose: hooks.dispose, + } +} + +describe('production authorize custody leases', () => { + test.each([ + ['browser', 'minted-browser'], + ['headless', 'minted-headless'], + ] as const)( + '%s waits behind the production transition mutex before OAuth starts', + async (kind, access) => { + const fixture = await makeAuthorizeMethods() + const held = await acquireCustodyTransitionMutex() + try { + const authorize = + kind === 'browser' ? fixture.browser : fixture.headless + const pending = authorize() + await Promise.resolve() + + expect(fixture.browserStarted).not.toHaveBeenCalled() + expect(fixture.headlessStarted).not.toHaveBeenCalled() + + await held.release() + const flow = await pending + expect(fixture.browserStarted).toHaveBeenCalledTimes( + kind === 'browser' ? 1 : 0, + ) + expect(fixture.headlessStarted).toHaveBeenCalledTimes( + kind === 'headless' ? 1 : 0, + ) + + if (kind === 'browser') { + fixture.browserTokens.resolve({ + access_token: access, + refresh_token: 'refresh', + id_token: 'id', + expires_in: 60, + }) + } else { + fixture.headlessTokens.resolve({ + access_token: access, + refresh_token: 'refresh', + id_token: 'id', + expires_in: 60, + }) + } + await flow.callback() + await waitForSleep(fixture.sleeps) + fixture.hostSet(access) + fixture.sleeps[0]!.resolve() + const released = await acquireCustodyTransitionMutex() + await released.release() + } finally { + await held.release() + await fixture.dispose?.() + } + }, + ) + + test('retains the production callback lease until exact host readback', async () => { + const fixture = await makeAuthorizeMethods() + try { + const flow = await fixture.browser() + fixture.browserTokens.resolve({ + access_token: 'minted-access', + refresh_token: 'refresh', + id_token: 'id', + expires_in: 60, + }) + await flow.callback() + await waitForSleep(fixture.sleeps) + + let barrierAcquired = false + const barrier = acquireCustodyTransitionMutex().then(async (lease) => { + barrierAcquired = true + await lease.release() + }) + for (let turn = 0; turn < 32; turn += 1) await Promise.resolve() + expect(barrierAcquired).toBe(false) + + fixture.hostSet('minted-access') + fixture.sleeps[0]!.resolve() + await barrier + expect(barrierAcquired).toBe(true) + } finally { + await fixture.dispose?.() + } + }) + + test.each([ + ['browser', 'minted-browser'], + ['headless', 'minted-headless'], + ] as const)( + '%s releases at the five-second bound when the host never writes', + async (kind, access) => { + const fixture = await makeAuthorizeMethods() + try { + const flow = await (kind === 'browser' + ? fixture.browser + : fixture.headless)() + const tokens = + kind === 'browser' ? fixture.browserTokens : fixture.headlessTokens + tokens.resolve({ + access_token: access, + refresh_token: 'refresh', + id_token: 'id', + expires_in: 60, + }) + + await expect(flow.callback()).resolves.toMatchObject({ access }) + await waitForSleep(fixture.sleeps) + fixture.advanceTo(5_000) + fixture.sleeps[0]!.resolve() + + const acquired = await acquireCustodyTransitionMutex() + await acquired.release() + expect(fixture.warnings).toEqual([ + 'host write not observed within 5s; lease released', + ]) + } finally { + await fixture.dispose?.() + } + }, + ) + + test.each([ + ['browser', 'minted-browser'], + ['headless', 'minted-headless'], + ] as const)( + '%s contains a rejected host readback and releases at the bound', + async (kind, access) => { + const fixture = await makeAuthorizeMethods() + try { + const flow = await (kind === 'browser' + ? fixture.browser + : fixture.headless)() + const tokens = + kind === 'browser' ? fixture.browserTokens : fixture.headlessTokens + tokens.resolve({ + access_token: access, + refresh_token: 'refresh', + id_token: 'id', + expires_in: 60, + }) + fixture.rejectHostReads() + + await expect(flow.callback()).resolves.toMatchObject({ access }) + await waitForSleep(fixture.sleeps) + fixture.advanceTo(5_000) + fixture.sleeps[0]!.resolve() + + const acquired = await acquireCustodyTransitionMutex() + await acquired.release() + expect(fixture.warnings).toEqual([ + 'host write not observed within 5s; lease released', + ]) + } finally { + await fixture.dispose?.() + } + }, + ) + + test.each([ + ['browser', 'minted-browser'], + ['headless', 'minted-headless'], + ] as const)( + '%s ignores a different host token until the five-second bound', + async (kind, access) => { + const fixture = await makeAuthorizeMethods() + try { + const flow = await (kind === 'browser' + ? fixture.browser + : fixture.headless)() + const tokens = + kind === 'browser' ? fixture.browserTokens : fixture.headlessTokens + tokens.resolve({ + access_token: access, + refresh_token: 'refresh', + id_token: 'id', + expires_in: 60, + }) + await flow.callback() + await waitForSleep(fixture.sleeps) + + fixture.hostSet('different-access') + fixture.sleeps[0]!.resolve() + await waitForSleep(fixture.sleeps, 2) + + let acquired = false + const pending = acquireCustodyTransitionMutex().then(async (lease) => { + acquired = true + await lease.release() + }) + await Promise.resolve() + expect(acquired).toBe(false) + + fixture.advanceTo(5_000) + fixture.sleeps[1]!.resolve() + await pending + expect(fixture.warnings).toEqual([ + 'host write not observed within 5s; lease released', + ]) + } finally { + await fixture.dispose?.() + } + }, + ) + + test('keeps bound manifest bytes after a local authorize callback writes a new host family', async () => { + const directory = mkdtempSync(join(tmpdir(), 'custody-authorize-local-')) + const manifestPath = join(directory, 'opencode-handles.json') + const configPath = join(directory, 'openai-auth.json') + const priorManifestPath = process.env.CLAUSTRUM_OPENCODE_HANDLES + const priorConfigPath = process.env.OPENCODE_OPENAI_AUTH_FILE + let fixture: Awaited> | undefined + try { + const manifest = enrollmentManifest('main') + if (!manifest.ok) throw new Error('expected enrollment manifest') + writeFileSync(manifestPath, JSON.stringify(manifest.value)) + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + const before = readFileSync(manifestPath) + const record = await verifiedMainLoginRecord() + fixture = await makeAuthorizeMethods() + fixture.hostSet('before-login', 'before-refresh') + const beforeFingerprint = record.mainSlotFamilyFingerprint( + fixture.hostSlot()!, + ) + + const flow = await fixture.browser() + fixture.browserTokens.resolve({ + access_token: 'after-login', + refresh_token: 'after-refresh', + id_token: 'id', + expires_in: 60, + }) + const result = await flow.callback() + await waitForSleep(fixture.sleeps) + fixture.hostSet(result.access, result.refresh) + fixture.sleeps[0]!.resolve() + for (let turn = 0; turn < 32; turn += 1) await Promise.resolve() + + const afterFingerprint = record.mainSlotFamilyFingerprint( + fixture.hostSlot()!, + ) + expect(afterFingerprint).not.toBe(beforeFingerprint) + expect(afterFingerprint).toBe( + record.mainSlotFamilyFingerprint({ + type: 'oauth', + access: 'after-login', + refresh: 'after-refresh', + expires: 60_000, + }), + ) + expect(readFileSync(manifestPath)).toEqual(before) + } finally { + await fixture?.dispose?.() + if (priorManifestPath === undefined) { + delete process.env.CLAUSTRUM_OPENCODE_HANDLES + } else { + process.env.CLAUSTRUM_OPENCODE_HANDLES = priorManifestPath + } + if (priorConfigPath === undefined) { + delete process.env.OPENCODE_OPENAI_AUTH_FILE + } else { + process.env.OPENCODE_OPENAI_AUTH_FILE = priorConfigPath + } + rmSync(directory, { recursive: true, force: true }) + } + }) + + test('records only the authorize callback family after its exact host readback', async () => { + const fixture = await makeAuthorizeMethods() + try { + const record = await verifiedMainLoginRecord() + const flow = await fixture.browser() + fixture.browserTokens.resolve({ + access_token: 'minted-access', + refresh_token: 'minted-refresh', + id_token: 'id', + expires_in: 60, + }) + const result = await flow.callback() + await waitForSleep(fixture.sleeps) + + await fixture.otherWindowSet('other-access', 'other-refresh') + fixture.sleeps[0]!.resolve() + await waitForSleep(fixture.sleeps, 2) + expect(record.hasVerifiedInProcessMainLogin(fixture.hostSlot()!)).toBe( + false, + ) + + fixture.hostSet(result.access, result.refresh) + fixture.sleeps[1]!.resolve() + for (let turn = 0; turn < 32; turn += 1) await Promise.resolve() + + expect(record.hasVerifiedInProcessMainLogin(fixture.hostSlot()!)).toBe( + true, + ) + } finally { + await fixture.dispose?.() + } + }) + + test('a local bound slot serves only after its authorize callback verifies that exact family', async () => { + const record = await verifiedMainLoginRecord() + const restoredSlot: HostOauth = { + type: 'oauth', + access: 'verified-access', + refresh: 'verified-refresh', + expires: 60_000, + } + + expect(record.hasVerifiedInProcessMainLogin(restoredSlot)).toBe(false) + await withFreshLocalFactory(restoredSlot, async (start) => { + await start() + expect((await getSidebarState()).main.custody).toEqual({ + state: 'inert', + reason: 'needs-login', + }) + + const fixture = await makeAuthorizeMethods() + try { + const flow = await fixture.headless() + fixture.headlessTokens.resolve({ + access_token: 'verified-access', + refresh_token: 'verified-refresh', + id_token: 'id', + expires_in: 60, + }) + const result = await flow.callback() + await waitForSleep(fixture.sleeps) + fixture.hostSet(result.access, result.refresh) + fixture.sleeps[0]!.resolve() + for (let turn = 0; turn < 32; turn += 1) await Promise.resolve() + + expect(record.hasVerifiedInProcessMainLogin(fixture.hostSlot()!)).toBe( + true, + ) + } finally { + await fixture.dispose?.() + } + + await start() + expect((await getSidebarState()).main.custody).toEqual({ + state: 'local', + }) + }) + }) +}) diff --git a/packages/opencode/src/tests/custody-fixtures.ts b/packages/opencode/src/tests/custody-fixtures.ts new file mode 100644 index 0000000..c606cfc --- /dev/null +++ b/packages/opencode/src/tests/custody-fixtures.ts @@ -0,0 +1,164 @@ +/** + * Shared fixtures for custody tests. + * + * The custody test files (`custody.test.ts`, `custody-refresh.test.ts`) need + * the same factory inputs — sentinel accounts, live accounts, manifest + * snapshots — and drifted apart as each file grew its own copy. Keeping the + * factories here ensures both files pin the same tombstone id, expiry math, + * and owning-provider manifest shape, so any future drift shows up as a + * single shared-helper edit instead of two divergent copies. + */ + +import type { AccountStorage, OAuthAccount } from '../core/accounts.ts' +import { CUSTODY_TOMBSTONE_PREFIX } from '../core/custody.ts' +import { + type CustodyManifestReadResult, + manifestRevision, +} from '../core/custody-manifest.ts' +import type { + ClaustrumMode, + CustodyTransitionState, +} from '../core/custody-transition.ts' + +const CUSTODY_PROVIDER = 'openai' + +export const TOMBSTONE_OPENAI = `${CUSTODY_TOMBSTONE_PREFIX}${CUSTODY_PROVIDER}` +export const CUSTODY_FIXTURE_NOW = 4_102_444_800_000 + +function custodyJwt( + accountId: string | undefined, + options: { + expiresInSec?: number + nestedAccountClaim: boolean + tag?: string + }, +): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString( + 'base64url', + ) + const claims: Record = {} + if (options.expiresInSec !== undefined) { + claims.exp = Math.floor(Date.now() / 1000) + options.expiresInSec + } + if (accountId) { + if (options.nestedAccountClaim) { + claims['https://api.openai.com/auth'] = { + chatgpt_account_id: accountId, + } + } else { + claims.chatgpt_account_id = accountId + } + } + if (options.tag) claims.tag = options.tag + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url') + return `${header}.${payload}.sig` +} + +export function makeCustodyJwt( + accountId: string | undefined, + expiresInSec = 600, +): string { + return custodyJwt(accountId, { expiresInSec, nestedAccountClaim: true }) +} + +export function makeCustodyRequestJwt(accountId: string, tag?: string): string { + return custodyJwt(accountId, { nestedAccountClaim: false, tag }) +} + +export function makeSentinelAccount( + overrides: Partial = {}, +): OAuthAccount { + return { + id: 'custody-1', + type: 'oauth', + access: '', + refresh: TOMBSTONE_OPENAI, + expires: 0, + addedAt: 1_000, + ...overrides, + } +} + +export function liveAccount( + id: string, + overrides: Partial = {}, + now = CUSTODY_FIXTURE_NOW, +): OAuthAccount { + return { + id, + type: 'oauth', + access: `acc-${id}`, + refresh: `ref-${id}`, + expires: now + 3_600_000, + addedAt: 1_000, + ...overrides, + } +} + +export function liveStorage( + accounts: OAuthAccount[], + overrides: Partial = {}, +): AccountStorage { + return { + version: 1, + main: { type: 'opencode', provider: CUSTODY_PROVIDER }, + accounts, + ...overrides, + } +} + +export function claustrumConfig( + options: { + mode?: ClaustrumMode + transition?: CustodyTransitionState + rowHistory?: string[] + } = {}, +): NonNullable { + return { + mode: options.mode ?? 'claustrum', + ...(options.transition ? { transition: options.transition } : {}), + ...(options.rowHistory ? { rowHistory: options.rowHistory } : {}), + } +} + +export function withClaustrumMode(storage: AccountStorage): AccountStorage { + return { + ...storage, + claustrum: claustrumConfig({ mode: 'claustrum' }), + } +} + +export function emptyManifest(): CustodyManifestReadResult { + const value = { version: 1 as const, providers: [] } + return { + ok: true, + value, + revision: manifestRevision(JSON.stringify(value)), + } +} + +export const localCustody = { readManifest: async () => emptyManifest() } + +export function enrollmentManifest(label: string): CustodyManifestReadResult { + const suffix = + label === 'custody-1' + ? 'a'.repeat(43) + : Buffer.from(label).toString('base64url').padEnd(43, 'a').slice(0, 43) + const handle = `ckh_${suffix}` + const value = { + version: 1 as const, + providers: [ + { + provider: CUSTODY_PROVIDER, + shape: 'oauth' as const, + serve: 'openai-auth', + accounts: [{ label, handle, credential_id: `oauth:openai:${label}` }], + }, + ], + } + return { + ok: true, + value, + revision: manifestRevision(JSON.stringify(value)), + } +} diff --git a/packages/opencode/src/tests/custody-main.test.ts b/packages/opencode/src/tests/custody-main.test.ts new file mode 100644 index 0000000..4bca998 --- /dev/null +++ b/packages/opencode/src/tests/custody-main.test.ts @@ -0,0 +1,681 @@ +import { describe, expect, test } from 'bun:test' +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadAccounts, saveAccounts } from '../core/accounts.ts' +import { + canonicalCustodyTombstone, + custodyTombstoneKey, +} from '../core/custody.ts' +import { + classifyMainAuthSlot, + confirmMainAuthSlot, + reconcileMainSlotBeforeHooks, +} from '../core/custody-host-slot.ts' +import { type ClaustrumCacheTransportLike, CodexAuthPlugin } from '../index.ts' +import { getSidebarState } from '../sidebar-state.ts' +import { + CUSTODY_FIXTURE_NOW, + claustrumConfig, + enrollmentManifest, + liveStorage, +} from './custody-fixtures.ts' + +const canonicalTombstone = canonicalCustodyTombstone('openai') + +function mainJwt(accountId: string | undefined): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString( + 'base64url', + ) + const payload = Buffer.from( + JSON.stringify({ + 'https://api.openai.com/auth': accountId + ? { chatgpt_account_id: accountId } + : {}, + }), + ).toString('base64url') + return `${header}.${payload}.sig` +} + +type MainOauth = { + type: 'oauth' + access: string + refresh: string + expires: number +} + +async function withMainLoader( + options: { + auth: MainOauth + storage: ReturnType + transport: ClaustrumCacheTransportLike + slotAbsent?: boolean + }, + run: (input: { + loader: ( + getAuth: () => Promise, + ctx: unknown, + ) => Promise + configPath: string + authSetCalls: () => number + }) => Promise, +): Promise { + const directory = mkdtempSync(join(tmpdir(), 'custody-main-')) + const configPath = join(directory, 'openai-auth.json') + const manifestPath = join(directory, 'opencode-handles.json') + const prior = { + config: process.env.OPENCODE_OPENAI_AUTH_FILE, + state: process.env.OPENCODE_OPENAI_AUTH_STATE_FILE, + sidebar: process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE, + log: process.env.OPENCODE_OPENAI_AUTH_LOG_FILE, + manifest: process.env.CLAUSTRUM_OPENCODE_HANDLES, + } + let hooks: Awaited> | undefined + let authSetCalls = 0 + try { + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join(directory, 'state.json') + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = join( + directory, + 'sidebar.json', + ) + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = join(directory, 'test.log') + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + await saveAccounts(options.storage, configPath) + const manifest = enrollmentManifest('main') + if (!manifest.ok) throw new Error('expected manifest fixture') + mkdirSync(directory, { recursive: true, mode: 0o700 }) + writeFileSync(manifestPath, JSON.stringify(manifest.value), { mode: 0o600 }) + chmodSync(manifestPath, 0o600) + hooks = await CodexAuthPlugin( + { + client: { + auth: { + get: async () => (options.slotAbsent ? undefined : options.auth), + all: async () => + options.slotAbsent + ? { anthropic: { type: 'oauth' } } + : { openai: options.auth }, + set: async () => { + authSetCalls += 1 + }, + }, + }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: directory, + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { custody: { transport: options.transport, detection: 'available' } }, + ) + const loader = hooks.auth?.loader + if (!loader) throw new Error('expected auth loader') + await run({ + loader: loader as ( + getAuth: () => Promise, + ctx: unknown, + ) => Promise, + configPath, + authSetCalls: () => authSetCalls, + }) + } finally { + await hooks?.dispose?.() + for (const [key, value] of Object.entries(prior)) { + const envKey = + key === 'config' + ? 'OPENCODE_OPENAI_AUTH_FILE' + : key === 'state' + ? 'OPENCODE_OPENAI_AUTH_STATE_FILE' + : key === 'sidebar' + ? 'OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE' + : key === 'log' + ? 'OPENCODE_OPENAI_AUTH_LOG_FILE' + : 'CLAUSTRUM_OPENCODE_HANDLES' + if (value === undefined) delete process.env[envKey] + else process.env[envKey] = value + } + rmSync(directory, { recursive: true, force: true }) + } +} + +describe('main host slot', () => { + test('recognizes canonical and partial tombstones without treating either as real', () => { + expect(classifyMainAuthSlot(canonicalTombstone)).toEqual({ + kind: 'tombstone', + oauth: canonicalTombstone, + }) + expect( + classifyMainAuthSlot({ + type: 'oauth', + access: 'partial', + refresh: custodyTombstoneKey('openai'), + expires: 1, + }), + ).toMatchObject({ kind: 'empty' }) + }) + + test('confirms slot absence only after two separated undefined reads with non-empty auth maps', async () => { + let now = 10_000 + let getCalls = 0 + let allCalls = 0 + + const result = await confirmMainAuthSlot({ + client: { + auth: { + get: async () => { + getCalls += 1 + return undefined + }, + all: async () => { + allCalls += 1 + return { anthropic: { type: 'oauth' } } + }, + }, + }, + now: () => now, + sleep: async (ms) => { + now += ms + }, + }) + + expect(result).toEqual({ kind: 'slot-absent' }) + expect(getCalls).toBe(2) + expect(allCalls).toBe(2) + expect(now).toBe(10_250) + }) + + test('leaves a torn empty auth map indeterminate rather than declaring the slot absent', async () => { + let now = 10_000 + const result = await confirmMainAuthSlot({ + client: { + auth: { + get: async () => undefined, + all: async () => ({}), + }, + }, + now: () => now, + sleep: async (ms) => { + now += ms + }, + }) + + expect(result).toEqual({ kind: 'indeterminate' }) + }) + + test('does not declare absence from one undefined read when the next read contains an oauth slot', async () => { + let reads = 0 + let now = 10_000 + const real = { + type: 'oauth' as const, + access: 'access', + refresh: 'refresh', + expires: 1, + } + + const result = await confirmMainAuthSlot({ + client: { + auth: { + get: async () => (reads++ === 0 ? undefined : real), + all: async () => ({ anthropic: { type: 'oauth' } }), + }, + }, + now: () => now, + sleep: async (ms) => { + now += ms + }, + }) + + expect(result).toEqual({ kind: 'real', oauth: real }) + }) + + test('returns the slot-absent custody verdict through the factory adapter without host writes', async () => { + let authSetCalls = 0 + let now = 10_000 + const verdict = await reconcileMainSlotBeforeHooks({ + client: { + auth: { + get: async () => undefined, + all: async () => ({ anthropic: { type: 'oauth' } }), + set: async () => { + authSetCalls += 1 + }, + }, + }, + now: () => now, + sleep: async (ms) => { + now += ms + }, + mode: 'claustrum', + manifest: enrollmentManifest('main'), + getCredential: async () => ({ access: mainJwt('acct-served') }), + }) + + expect(verdict).toEqual({ + kind: 'INERT', + reason: 'takeover-incomplete/slot-absent', + }) + expect(authSetCalls).toBe(0) + }) + + test('reports identity mismatch when the factory binding disputes the served main JWT', async () => { + let now = 10_000 + const verdict = await reconcileMainSlotBeforeHooks({ + client: { + auth: { + get: async () => undefined, + all: async () => ({ anthropic: { type: 'oauth' } }), + }, + }, + now: () => now, + sleep: async (ms) => { + now += ms + }, + mode: 'claustrum', + manifest: enrollmentManifest('main'), + mainAccountId: 'stored-main', + getCredential: async () => ({ access: mainJwt('other-main') }), + }) + + expect(verdict).toEqual({ kind: 'INERT', reason: 'identity-mismatch' }) + }) + + test('keeps a prior main identity when an empty tombstone retains a local JWT and the vault is cold', async () => { + const empty = { + type: 'oauth' as const, + access: mainJwt('local-leftover'), + refresh: custodyTombstoneKey('openai'), + expires: 1, + } + await withMainLoader( + { + auth: empty, + storage: liveStorage([], { + mainAccountId: 'vault-derived', + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + transport: { + getCredential: async () => { + throw new Error('vault cold') + }, + statusCredential: async () => ({ + ready: false, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 0, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + }, + async ({ loader, configPath, authSetCalls }) => { + await expect(loader(async () => empty, {})).resolves.toBeDefined() + expect((await loadAccounts(configPath))?.mainAccountId).toBe( + 'vault-derived', + ) + expect(authSetCalls()).toBe(0) + }, + ) + }) + + test('keeps the loader alive when a bound tombstone vault reports reauth', async () => { + await withMainLoader( + { + auth: canonicalTombstone, + storage: liveStorage([], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + transport: { + getCredential: async () => { + throw new Error('vault needs reauth') + }, + statusCredential: async () => ({ + ready: false, + lastErrorCode: 'reauth', + leaseHeld: false, + recordVersion: 0, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + }, + async ({ loader, authSetCalls }) => { + await expect( + loader(async () => canonicalTombstone, {}), + ).resolves.toBeDefined() + expect(authSetCalls()).toBe(0) + }, + ) + }) + + test('writes the factory slot-absent verdict into the main sidebar row', async () => { + await withMainLoader( + { + auth: canonicalTombstone, + storage: liveStorage([], { + mainAccountId: 'stored-main', + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + slotAbsent: true, + transport: { + getCredential: async () => ({ + material: mainJwt('stored-main'), + recordVersion: 1, + expiresAtMs: CUSTODY_FIXTURE_NOW + 60_000, + }), + statusCredential: async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + }, + async () => { + expect((await getSidebarState()).main.custody).toEqual({ + state: 'inert', + reason: 'takeover-incomplete/slot-absent', + }) + }, + ) + }) + + test('writes identity mismatch when the factory slot-absent binding disputes the served main JWT', async () => { + await withMainLoader( + { + auth: canonicalTombstone, + storage: liveStorage([], { + mainAccountId: 'stored-main', + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + slotAbsent: true, + transport: { + getCredential: async () => ({ + material: mainJwt('other-main'), + recordVersion: 1, + expiresAtMs: CUSTODY_FIXTURE_NOW + 60_000, + }), + statusCredential: async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + }, + async () => { + expect((await getSidebarState()).main.custody).toEqual({ + state: 'inert', + reason: 'identity-mismatch', + }) + }, + ) + }) + + test('does not acquire refresh state or call the token endpoint for a tombstoned main slot', async () => { + const originalFetch = globalThis.fetch + const urls: string[] = [] + globalThis.fetch = (async (url: string | URL | Request) => { + urls.push(String(url)) + return new Response('{}', { status: 200 }) + }) as typeof globalThis.fetch + try { + await withMainLoader( + { + auth: canonicalTombstone, + storage: liveStorage([], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + transport: { + getCredential: async () => { + throw new Error('vault cold') + }, + statusCredential: async () => ({ + ready: false, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 0, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + }, + async ({ loader, configPath }) => { + const result = (await loader(async () => canonicalTombstone, {})) as { + fetch?: typeof globalThis.fetch + } + if (!result.fetch) throw new Error('expected fetch override') + await result.fetch( + 'https://chatgpt.com/backend-api/codex/responses', + { + method: 'POST', + body: '{}', + }, + ) + expect(urls).toEqual([ + 'https://chatgpt.com/backend-api/codex/responses', + ]) + expect( + (await loadAccounts(configPath))?.refresh?.mainRefreshLeaseId, + ).toBeUndefined() + expect( + (await loadAccounts(configPath))?.refresh?.mainLastRefreshError, + ).toBeUndefined() + }, + ) + } finally { + globalThis.fetch = originalFetch + } + }) + + test('derives main identity from the served vault JWT before migration can inspect the tombstone', async () => { + const directory = mkdtempSync(join(tmpdir(), 'custody-main-loader-')) + const configPath = join(directory, 'openai-auth.json') + const manifestPath = join(directory, 'opencode-handles.json') + const priorConfigPath = process.env.OPENCODE_OPENAI_AUTH_FILE + const priorStatePath = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + const priorManifestPath = process.env.CLAUSTRUM_OPENCODE_HANDLES + const priorSidebarPath = process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE + const priorLogPath = process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + let authSetCalls = 0 + let credentialCalls = 0 + let runtimeCalls = 0 + let hooks: Awaited> | undefined + const transport: ClaustrumCacheTransportLike = { + getCredential: async () => { + credentialCalls += 1 + return { + material: mainJwt('served-main'), + recordVersion: 1, + expiresAtMs: CUSTODY_FIXTURE_NOW + 60_000, + } + }, + statusCredential: async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + }), + reportAuthFailure: async () => {}, + close: () => {}, + } + + try { + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join( + directory, + 'state.json', + ) + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = join( + directory, + 'sidebar.json', + ) + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = join(directory, 'test.log') + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + await saveAccounts( + liveStorage([], { + mainAccountId: 'locally-minted-label', + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + configPath, + ) + mkdirSync(directory, { recursive: true, mode: 0o700 }) + const manifest = enrollmentManifest('main') + if (!manifest.ok) throw new Error('expected manifest fixture') + writeFileSync(manifestPath, JSON.stringify(manifest.value), { + mode: 0o600, + }) + chmodSync(manifestPath, 0o600) + + hooks = await CodexAuthPlugin( + { + client: { + auth: { + get: async () => canonicalTombstone, + all: async () => ({ openai: canonicalTombstone }), + set: async () => { + authSetCalls += 1 + }, + }, + }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: directory, + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { + custody: { + transport, + detection: 'available', + onRuntime: () => { + runtimeCalls += 1 + }, + }, + }, + ) + const loader = hooks.auth?.loader + if (!loader) throw new Error('expected auth loader') + expect(credentialCalls).toBe(1) + expect(runtimeCalls).toBe(1) + expect(authSetCalls).toBe(0) + await loader(async () => canonicalTombstone, {} as never) + + expect((await loadAccounts(configPath))?.mainAccountId).toBe( + 'served-main', + ) + expect(runtimeCalls).toBe(1) + expect(authSetCalls).toBe(0) + } finally { + await hooks?.dispose?.() + if (priorConfigPath === undefined) + delete process.env.OPENCODE_OPENAI_AUTH_FILE + else process.env.OPENCODE_OPENAI_AUTH_FILE = priorConfigPath + if (priorStatePath === undefined) + delete process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + else process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = priorStatePath + if (priorManifestPath === undefined) + delete process.env.CLAUSTRUM_OPENCODE_HANDLES + else process.env.CLAUSTRUM_OPENCODE_HANDLES = priorManifestPath + if (priorSidebarPath === undefined) + delete process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE + else + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = priorSidebarPath + if (priorLogPath === undefined) + delete process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + else process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = priorLogPath + rmSync(directory, { recursive: true, force: true }) + } + }) + + test('does not migrate a recognized main tombstone into a new local account store', async () => { + const directory = mkdtempSync(join(tmpdir(), 'custody-main-no-migration-')) + const configPath = join(directory, 'openai-auth.json') + const priorConfigPath = process.env.OPENCODE_OPENAI_AUTH_FILE + const priorStatePath = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + const priorSidebarPath = process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE + const priorLogPath = process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + let hooks: Awaited> | undefined + + try { + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join( + directory, + 'state.json', + ) + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = join( + directory, + 'sidebar.json', + ) + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = join(directory, 'test.log') + hooks = await CodexAuthPlugin( + { + client: { + auth: { + get: async () => canonicalTombstone, + all: async () => ({ openai: canonicalTombstone }), + set: async () => {}, + }, + }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: directory, + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { + custody: { + transport: { + getCredential: async () => { + throw new Error('should not read vault without custody mode') + }, + statusCredential: async () => ({ + ready: false, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 0, + }), + reportAuthFailure: async () => {}, + close: () => {}, + }, + detection: 'available', + }, + }, + ) + const loader = hooks.auth?.loader + if (!loader) throw new Error('expected auth loader') + await loader(async () => canonicalTombstone, {} as never) + + expect(await loadAccounts(configPath)).toBeNull() + } finally { + await hooks?.dispose?.() + if (priorConfigPath === undefined) + delete process.env.OPENCODE_OPENAI_AUTH_FILE + else process.env.OPENCODE_OPENAI_AUTH_FILE = priorConfigPath + if (priorStatePath === undefined) + delete process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + else process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = priorStatePath + if (priorSidebarPath === undefined) + delete process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE + else + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = priorSidebarPath + if (priorLogPath === undefined) + delete process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + else process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = priorLogPath + rmSync(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/opencode/src/tests/custody-quota.test.ts b/packages/opencode/src/tests/custody-quota.test.ts new file mode 100644 index 0000000..dabd48d --- /dev/null +++ b/packages/opencode/src/tests/custody-quota.test.ts @@ -0,0 +1,718 @@ +/** + * Quota-poll behaviour under vault custody. + * + * The poll loop in `refresh-all-quota.ts` must treat a refresh-inert account + * (manifest entry OR tombstone sentinel) as never-locally-refreshed: it + * resolves a probe token through the custody resolver, calls `whamFn`, and + * reports a 401 to the vault ONLY when the token came from the vault. + * + * The toggle is irrelevant on this path: an entry-present account probes + * with its valid LOCAL token but never enters local refresh, even with + * `claustrum.mode:'local'`. The existing forced-401 refresh and the + * pre-poll refresh are not reached for refresh-inert accounts. + * + * The injected deps are optional — absent deps mean pre-custody behaviour, + * which the existing `refresh-all-quota.test.ts` suite pins. Each test + * here is named for the behaviour it guards (the deliberately defeated branch is + * stated as behaviour in the name). + */ + +import { describe, expect, it, mock } from 'bun:test' +import type { AccountQuotaWindow, OAuthQuotaSnapshot } from '../core/accounts' +import { + CUSTODY_EXCLUDED, + CUSTODY_REFUSE, + CustodyTombstoneRefreshError, +} from '../core/custody.ts' +import type { whamUsageFn } from '../core/provider.ts' +import { QuotaManager } from '../core/quota-manager.ts' +import { + CUSTODY_DEPS_INCOMPLETE, + type RefreshAllQuotaDeps, + refreshAllQuota, +} from '../core/refresh-all-quota.ts' +import { DEFAULT_SIDEBAR_STATE } from '../sidebar-state.ts' +import { makeSentinelAccount, TOMBSTONE_OPENAI } from './custody-fixtures.ts' + +type Provenance = 'local' | { handle: string; recordVersion: number } + +type ResolverResult = + | { token: string; provenance: Provenance } + | typeof CUSTODY_REFUSE + | typeof CUSTODY_EXCLUDED + +function makeQuotaSnapshot(usedPercent: number): OAuthQuotaSnapshot { + const window: AccountQuotaWindow = { + usedPercent, + remainingPercent: 100 - usedPercent, + checkedAt: Date.now(), + } + return { primary: window } +} + +type CustodyDepsShape = 'all' | 'none' | 'no-resolver' | 'no-reporter' + +interface MakeDepsOptions { + refreshInert?: boolean + resolverResult?: + | ResolverResult + | (() => ResolverResult | Promise) + reportCalls?: Array<{ + handle: string + providerStatus: number + recordVersion: number + }> + reportImpl?: (params: { + handle: string + providerStatus: number + recordVersion: number + }) => Promise + whamBehaviour?: (input: Parameters[0]) => unknown + /** Selects which custody deps are wired. Default 'all' (everything + * injected); 'none' injects nothing; 'no-resolver'/'no-reporter' + * omit one of the optional deps to test the partial-injection + * fail-closed path. */ + injectCustodyDeps?: CustodyDepsShape + skipFresherThanMs?: number + now?: () => number + /** Inject a pre-seeded QuotaManager (used by the freshness test). */ + quotaManager?: QuotaManager + accountId?: string + /** Use a tombstoned account as the only fallback (sentinel access/refresh, expires 0). */ + tombstoned?: boolean + /** Throw on a tombstoned account when refreshAccount is called — mirrors real + * FallbackAccountManager behaviour when the gate is absent. */ + tombstoneRefreshThrows?: boolean + /** Captured warn messages (matches the `quota` logger.debug/warn pair). */ + logger?: { debug: ReturnType; warn: ReturnType } +} + +type DepsWithMocks = RefreshAllQuotaDeps & { + refreshAccount: ReturnType + whamFn: ReturnType + _refreshCalls: Array + _whamCalls: Array<{ accessToken: string; accountKey: string }> + _reportCalls: Array<{ + handle: string + providerStatus: number + recordVersion: number + }> + _resolverCalls: Array<{ account: unknown; storage: unknown }> +} + +function makeDeps(opts: MakeDepsOptions = {}) { + const qm = + opts.quotaManager ?? + new QuotaManager({ + storage: { version: 1 as const, accounts: [] }, + }) + + const accountId = opts.accountId ?? 'acct-1' + const fallbackAccount = opts.tombstoned + ? makeSentinelAccount({ id: 'fb-1', accountId }) + : { + id: 'fb-1', + type: 'oauth' as const, + access: 'acc-local-fb1', + refresh: 'ref-local-fb1', + expires: Date.now() + 3600_000, + enabled: true, + accountId, + } + const storage = { + version: 1 as const, + accounts: [fallbackAccount], + mainAccountId: 'chatgpt-main', + } + + const refreshAccount = mock(async (acct: unknown) => acct) + const refreshCalls: Array = [] + refreshAccount.mockImplementation(async (acct: unknown) => { + refreshCalls.push(acct) + // Mirror real FallbackAccountManager: a tombstoned account throws the + // tombstone-class error when the local refresh is invoked. + if ( + opts.tombstoneRefreshThrows && + (acct as { access?: string })?.access === TOMBSTONE_OPENAI + ) { + throw new CustodyTombstoneRefreshError('openai') + } + return acct + }) + + const whamCalls: Array<{ accessToken: string; accountKey: string }> = [] + const whamFn = mock( + async ( + input: Parameters[0], + ): Promise => { + whamCalls.push({ + accessToken: input.accessToken, + accountKey: input.accountKey ?? '', + }) + if (opts.whamBehaviour) + return opts.whamBehaviour(input) as OAuthQuotaSnapshot + return makeQuotaSnapshot(30) + }, + ) + + const reportCalls: NonNullable = + opts.reportCalls ?? [] + const reportImpl: NonNullable = + opts.reportImpl ?? + (async (params) => { + reportCalls.push(params) + }) + + const resolverCalls: Array<{ account: unknown; storage: unknown }> = [] + const resolverResultFn = + typeof opts.resolverResult === 'function' + ? opts.resolverResult + : () => opts.resolverResult as ResolverResult + + const deps: RefreshAllQuotaDeps = { + getAuth: mock(async () => ({ + type: 'oauth' as const, + access: 'acc-main', + refresh: 'ref-main', + expires: Date.now() + 3600_000, + })), + codexRefreshFn: mock(async () => ({ + access: 'acc-refreshed', + refresh: 'ref-new', + expires: Date.now() + 7200_000, + })), + refreshMainWithLease: mock(async () => ({ + access: 'acc-refreshed', + refresh: 'ref-new', + expires: Date.now() + 7200_000, + })), + fallbackManager: { + refreshAccount: + refreshAccount as unknown as RefreshAllQuotaDeps['fallbackManager']['refreshAccount'], + } as unknown as RefreshAllQuotaDeps['fallbackManager'], + quotaManager: qm, + loadAccounts: mock(async () => storage), + writeSidebarState: mock(async () => {}), + client: { + auth: { + set: mock(async () => {}), + }, + }, + fetchImpl: fetch, + now: opts.now ?? (() => Date.now()), + configPath: '/tmp/test-config.json', + storageMainAccountId: 'chatgpt-main', + isOAuthAccountFn: ((a: unknown) => + (a as { type?: string })?.type === + 'oauth') as RefreshAllQuotaDeps['isOAuthAccountFn'], + whamFn, + readSidebarState: mock(async () => DEFAULT_SIDEBAR_STATE), + } + if (opts.skipFresherThanMs !== undefined) { + deps.skipFresherThanMs = opts.skipFresherThanMs + } + if (opts.logger) { + deps.logger = opts.logger as unknown as RefreshAllQuotaDeps['logger'] + } + + const shape: CustodyDepsShape = opts.injectCustodyDeps ?? 'all' + if (shape !== 'none') { + deps.isFallbackRefreshInert = mock( + async () => opts.refreshInert ?? false, + ) as never + if (shape !== 'no-resolver') { + deps.resolveFallbackAccess = mock( + async (account: unknown, store: unknown) => { + resolverCalls.push({ account, storage: store }) + return await resolverResultFn() + }, + ) as never + } + if (shape !== 'no-reporter') { + deps.reportCustodyAuthFailure = mock(reportImpl) as never + } + } + + const extended = deps as DepsWithMocks + extended.refreshAccount = refreshAccount + extended.whamFn = whamFn + extended._refreshCalls = refreshCalls + extended._whamCalls = whamCalls + extended._reportCalls = reportCalls + extended._resolverCalls = resolverCalls + + return extended +} + +describe('refresh-inert quota poll', () => { + it('custodied account + vault token + 2xx → quota pushed, no local refresh', async () => { + const deps = makeDeps({ + refreshInert: true, + resolverResult: { + token: 'vault-served-access', + provenance: { + handle: 'ckh_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + recordVersion: 42, + }, + }, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(true) + // Vault token went on the wire, not the local one. + expect(deps.whamFn).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'vault-served-access' }), + ) + // No local refresh for a refresh-inert account. + expect(deps.refreshAccount).not.toHaveBeenCalled() + // Quota pushed. + expect( + deps.quotaManager.getFallback('fb-1')?.quota?.primary?.usedPercent, + ).toBe(30) + // No vault report on 2xx. + expect( + (deps as unknown as { _reportCalls: unknown[] })._reportCalls, + ).toHaveLength(0) + }) + + it('custodied account + vault token + 401 → exactly one report carrying that recordVersion, no local refresh, tombstone unchanged', async () => { + const reportCalls: Array<{ + handle: string + providerStatus: number + recordVersion: number + }> = [] + const deps = makeDeps({ + refreshInert: true, + resolverResult: { + token: 'vault-served-access', + provenance: { + handle: 'ckh_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + recordVersion: 42, + }, + }, + whamBehaviour: () => { + throw Object.assign(new Error('wham usage check failed: 401'), { + status: 401, + }) + }, + reportCalls: reportCalls as Array<{ + handle: string + providerStatus: number + recordVersion: number + }>, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(false) + // Single report, with the call-context recordVersion and 401 status. + expect(reportCalls).toEqual([ + { + handle: 'ckh_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + providerStatus: 401, + recordVersion: 42, + }, + ]) + // No local refresh at either site — even on a 401. + expect(deps.refreshAccount).not.toHaveBeenCalled() + // No fallback quota was pushed. + expect(deps.quotaManager.getFallback('fb-1')).toBeNull() + }) + + it('records a vault 401 outcome when the custody reporter throws', async () => { + const logger = { debug: mock(() => undefined), warn: mock(() => undefined) } + const deps = makeDeps({ + refreshInert: true, + resolverResult: { + token: 'vault-served-access', + provenance: { + handle: 'ckh_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + recordVersion: 43, + }, + }, + whamBehaviour: () => { + throw Object.assign(new Error('wham usage check failed: 401'), { + status: 401, + }) + }, + reportImpl: async () => { + throw new Error('report transport unavailable') + }, + logger, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + expect(results).toEqual([ + { + account: 'fb-1', + ok: false, + error: 'wham usage check failed: 401', + }, + ]) + const warnMessages = logger.warn.mock.calls as unknown as Array< + [string, unknown?] + > + expect(warnMessages.map(([message]) => message)).toContain( + 'custody auth-failure report failed', + ) + }) + + it('enrolling account + live local token → wham uses local, zero local refresh, a 401 is neither reported nor force-refreshed', async () => { + const reportCalls: Array<{ + handle: string + providerStatus: number + recordVersion: number + }> = [] + const deps = makeDeps({ + refreshInert: true, + resolverResult: { token: 'acc-local-fb1', provenance: 'local' }, + whamBehaviour: () => { + throw Object.assign(new Error('wham usage check failed: 401'), { + status: 401, + }) + }, + reportCalls, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + // local token went on the wire. + expect(deps.whamFn).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'acc-local-fb1' }), + ) + // No refresh — neither the pre-poll nor the forced-401 site. + expect(deps.refreshAccount).not.toHaveBeenCalled() + // 401 from local provenance is NOT reported. + expect(reportCalls).toHaveLength(0) + // Account surfaces the failure. + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(false) + // No fallback quota pushed. + expect(deps.quotaManager.getFallback('fb-1')).toBeNull() + }) + + it('toggle off + manifest entry + valid local token → local provenance, zero local refresh', async () => { + // Spec: an enrolled, non-tombstoned account with toggle off is "enrolling" + // and serves its local access token. The refreshInert gate keeps the loop + // OUT of the local-refresh block entirely. + const deps = makeDeps({ + refreshInert: true, + resolverResult: { token: 'acc-local-fb1', provenance: 'local' }, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + expect(deps.whamFn).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'acc-local-fb1' }), + ) + expect(deps.refreshAccount).not.toHaveBeenCalled() + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(true) + }) + + it('mutating the refreshInert gate to false on an enrolling account forces a local refresh (regression guard)', async () => { + // Putting the toggle back in the gate (i.e. treating refreshInert as + // false) must allow the existing local-refresh block to run for a + // non-refresh-inert account. + const deps = makeDeps({ + refreshInert: false, + resolverResult: { token: 'acc-local-fb1', provenance: 'local' }, + }) + + await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + // Now the local-refresh block runs. + expect(deps.refreshAccount).toHaveBeenCalled() + }) + + it('vault wham 429 → no report', async () => { + const reportCalls: Array<{ + handle: string + providerStatus: number + recordVersion: number + }> = [] + const deps = makeDeps({ + refreshInert: true, + resolverResult: { + token: 'vault-served-access', + provenance: { + handle: 'ckh_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + recordVersion: 42, + }, + }, + whamBehaviour: () => { + throw Object.assign(new Error('wham usage check failed: 429'), { + status: 429, + }) + }, + reportCalls, + }) + + await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + expect(reportCalls).toHaveLength(0) + expect(deps.refreshAccount).not.toHaveBeenCalled() + }) + + it('plain local account 401 → existing forced local refresh still happens (regression guard)', async () => { + // When the refreshInert gate is false, the existing local-refresh block + // remains in charge — a 401 from the quota endpoint must still trigger + // the forced refresh introduced by #118, AND that forced refresh must + // be called with `force: true` (a non-forced refresh would not rotate + // the rejected token). + const deps = makeDeps({ + refreshInert: false, + whamBehaviour: (input: { accessToken: string }) => { + if (input.accessToken === 'acc-local-fb1') { + throw Object.assign(new Error('wham usage check failed: 401'), { + status: 401, + }) + } + return makeQuotaSnapshot(10) + }, + }) + + await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + // Pre-poll call (force not set) plus the forced-401 call (force: true). + // The forced call is the regression-critical one: pin it explicitly. + expect(deps.refreshAccount).toHaveBeenCalledWith( + expect.objectContaining({ id: 'fb-1' }), + expect.anything(), + expect.objectContaining({ force: true }), + ) + }) + + it('CUSTODY_REFUSE → no wham, ok:false', async () => { + const deps = makeDeps({ + refreshInert: true, + resolverResult: CUSTODY_REFUSE, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(false) + expect(deps.whamFn).not.toHaveBeenCalled() + expect(deps.refreshAccount).not.toHaveBeenCalled() + }) + + it('CUSTODY_EXCLUDED → no wham, ok:false', async () => { + const deps = makeDeps({ + refreshInert: true, + resolverResult: CUSTODY_EXCLUDED, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(false) + expect(deps.whamFn).not.toHaveBeenCalled() + expect(deps.refreshAccount).not.toHaveBeenCalled() + }) + + it('fresh quota snapshot on a refresh-inert account → freshness skip fires before the resolver arm', async () => { + // A refresh-inert account whose quota was checked inside the freshness + // window must NOT call the resolver or wham — the freshness skip is + // upstream of the custody arm in the loop. Moving the arm above the + // freshness skip would cause a probe here. + const now = Date.now() + const qm = new QuotaManager({ + storage: { version: 1 as const, accounts: [] }, + now: () => now, + }) + qm.setFallback( + 'fb-1', + { + quota: makeQuotaSnapshot(5), + refreshAfter: now + 5 * 60_000, + checkedAt: now - 60_000, // 1 minute ago — within the 4-minute window + }, + 'acc-local-fb1', + false, + 'acct-1', + ) + const deps = makeDeps({ + refreshInert: true, + skipFresherThanMs: 4 * 60_000, + now: () => now, + quotaManager: qm, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(true) + expect(deps.whamFn).not.toHaveBeenCalled() + expect(deps.resolveFallbackAccess as never).not.toHaveBeenCalled() + }) + + it('refresh-inert account + resolver absent → ok:false with custody-deps-incomplete, no local refresh', async () => { + // Partial injection: the predicate is wired (so the loop knows the + // account is refresh-inert) but the resolver is not. The arm must + // fail closed and surface a typed reason — a fall-through into local + // refresh would resume a refresher over a vault-held family. + const warn = mock(() => {}) + const deps = makeDeps({ + refreshInert: true, + injectCustodyDeps: 'no-resolver', + logger: { debug: mock(() => {}), warn }, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(false) + expect(fb?.error).toBe(CUSTODY_DEPS_INCOMPLETE) + expect(deps.refreshAccount).not.toHaveBeenCalled() + expect(deps.whamFn).not.toHaveBeenCalled() + // The "deps incomplete" warn fired at least once with a stable, typed + // message; subsequent refresh-inert accounts in the same poll dedupe + // (one warn per poll per missing dep) — a full poll-cycle warn count + // is bounded, not per-account. + const partialWarns = warn.mock.calls.filter((call) => + String((call as unknown[])[0] ?? '').includes('custody deps incomplete'), + ) + expect(partialWarns).toHaveLength(1) + // The warn payload does not contain a handle or token field. + const head = partialWarns[0] + if (!head) throw new Error('expected one partial-deps warn') + const payload = JSON.stringify(head) + expect(payload).not.toMatch(/ckh_/) + expect(payload).not.toMatch(/acc-/) + }) + + it('refresh-inert account + reporter absent → vault probe refused: whamFn never called, outcome custody-deps-incomplete', async () => { + // Partial injection: resolver is wired but reporter is not. The arm + // enters, the resolver returns a vault-served credential, and we + // refuse to probe at all — a vault-served probe whose 401 cannot reach + // the vault is the silent-401 failure of issue #118 recreated under + // custody (a quota 401 on a vault-served probe must reach + // the vault). The local-refresh block is bypassed. + const warn = mock(() => {}) + const deps = makeDeps({ + refreshInert: true, + resolverResult: { + token: 'vault-served-access', + provenance: { + handle: 'ckh_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + recordVersion: 42, + }, + }, + whamBehaviour: () => { + throw Object.assign(new Error('wham usage check failed: 401'), { + status: 401, + }) + }, + injectCustodyDeps: 'no-reporter', + logger: { debug: mock(() => {}), warn }, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + // Probe was refused BEFORE whamFn could be called. + expect(deps.whamFn).not.toHaveBeenCalled() + // Local-refresh block was bypassed. + expect(deps.refreshAccount).not.toHaveBeenCalled() + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(false) + expect(fb?.error).toBe(CUSTODY_DEPS_INCOMPLETE) + // The dedicated warn fired (not just the per-outcome recordOutcome warn). + const refuseWarns = warn.mock.calls.filter((call) => + String((call as unknown[])[0] ?? '').includes('vault probe refused'), + ) + expect(refuseWarns).toHaveLength(1) + const payload = JSON.stringify(refuseWarns[0]) + expect(payload).not.toMatch(/ckh_/) + expect(payload).not.toMatch(/acc-/) + }) + + it('refresh-inert account + reporter absent → LOCAL-provenance probe still happens', async () => { + // A local-provenance 401 is not credential evidence — the vault is not + // on the wire, so the reporter-absent guard must NOT fire. The probe + // happens, and a local 401 is silently logged as the failure. + const warn = mock(() => {}) + const deps = makeDeps({ + refreshInert: true, + resolverResult: { token: 'acc-local-fb1', provenance: 'local' }, + whamBehaviour: () => { + throw Object.assign(new Error('wham usage check failed: 401'), { + status: 401, + }) + }, + injectCustodyDeps: 'no-reporter', + logger: { debug: mock(() => {}), warn }, + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + // Probe happened with the local token. + expect(deps.whamFn).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'acc-local-fb1' }), + ) + // No "vault probe refused" warn — local probes are allowed without a reporter. + const refuseWarns = warn.mock.calls.filter((call) => + String((call as unknown[])[0] ?? '').includes('vault probe refused'), + ) + expect(refuseWarns).toHaveLength(0) + expect(deps.refreshAccount).not.toHaveBeenCalled() + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(false) + }) + + it('fails closed when a refresh-inert account has no resolver', async () => { + // With the refresh-inert branch removed but the local-refresh block + // remaining, a tombstoned account goes through the existing refresh + // path which exposes the sentinel's expired expiry — the loop exits + // with the documented error and `whamFn` is never invoked. Simulate + // the missing branch by having the gate say "not inert" so the + // resolver arm does not fire and the local-refresh block runs. + const deps = makeDeps({ + tombstoned: true, + refreshInert: false, // branch removed: gate says "not inert", arm skips + tombstoneRefreshThrows: true, + whamBehaviour: () => makeQuotaSnapshot(10), + }) + + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + expect(deps.whamFn).not.toHaveBeenCalled() + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(false) + expect(fb?.error?.toLowerCase()).toContain('no usable access token') + }) + + it('does not refresh locally when custody dependencies are incomplete', async () => { + // Without the custody deps wired in, the loop falls into the local + // refresh block which invokes the manager's choke point. The choke + // point throws `CustodyTombstoneRefreshError` for a tombstoned account, + // which the inner refresh catch logs at warn — the tombstone error + // IS observed on the quota path even though the inner catch keeps + // the loop moving. The signal is the inner-catch's warn payload. + const debug = mock(() => {}) + const warn = mock(() => {}) + const deps = makeDeps({ + tombstoned: true, + injectCustodyDeps: 'none', + refreshInert: false, + tombstoneRefreshThrows: true, + logger: { debug, warn }, + }) + + await refreshAllQuota(deps, { accountKey: 'fb-1' }) + + // The choke-point threw the tombstone error → refreshAccount observed + // it; the inner refresh catch did NOT itself re-throw, so the only + // observable signal that the tombstone error traversed the quota path + // is that refreshAccount was reached for a tombstoned account. + expect(deps.refreshAccount).toHaveBeenCalled() + const called = deps.refreshAccount.mock.calls[0]?.[0] as + | { refresh?: string } + | undefined + expect(called?.refresh).toBe(TOMBSTONE_OPENAI) + }) +}) diff --git a/packages/opencode/src/tests/custody-refresh.test.ts b/packages/opencode/src/tests/custody-refresh.test.ts new file mode 100644 index 0000000..c172d02 --- /dev/null +++ b/packages/opencode/src/tests/custody-refresh.test.ts @@ -0,0 +1,809 @@ +/** + * Custody refresh gates. + * + * Each local fallback refresh path must refuse to invoke the injected refresh + * provider when the account is `refreshInert` (custody-manifest entry OR + * tombstone sentinel in storage), regardless of claustrum mode. The storage + * mode never participates in this gate: changing it + * must not resurrect a local refresher over a vault-held family. + * + * The choke point lives in `refreshAccountNow` — every `this.load()` inside + * it (and inside `waitForConcurrentFallbackRefresh`) re-evaluates the gate + * with the reloaded account and the current manifest snapshot, throwing + * `CustodyTombstoneRefreshError` when true. + * + * The error writers (`recordRefreshError`, `recordQuotaRefreshError`) refuse + * to persist a tombstone refresh — the tombstone class is the wired-in + * short-circuit, and stamping `lastRefreshError` with it would re-arm the + * refresh backoff against an inert account. + * + * Each gated branch is exercised by a standard RED-then-GREEN run; the + * named tests are the witnesses. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type AccountManagerOptions, + type AccountStorage, + FallbackAccountManager, + fallbackRefreshLockName, + loadAccounts, + type OAuthAccount, + saveAccounts, +} from '../core/accounts.ts' +import { CustodyTombstoneRefreshError } from '../core/custody.ts' +import type { CustodyManifestReadResult } from '../core/custody-manifest.ts' +import { acquireRefreshFileLock } from '../core/refresh-file-lock.ts' +import { + claustrumConfig, + emptyManifest, + enrollmentManifest, + liveAccount, + liveStorage, + makeSentinelAccount, + TOMBSTONE_OPENAI, +} from './custody-fixtures.ts' +import { + FLOOR_AUTH_FILE, + FLOOR_CLAUSTRUM_HANDLES, + FLOOR_STATE_FILE, +} from './setup-env.ts' + +const CUSTODY_PROVIDER = 'openai' + +let dir: string +let cfgPath: string +let statePath: string +let handlesPath: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'custody-refresh-')) + cfgPath = join(dir, 'openai-auth.json') + statePath = join(dir, 'openai-auth-state.json') + process.env.OPENCODE_OPENAI_AUTH_FILE = cfgPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = statePath + handlesPath = join(dir, 'opencode-handles.json') + process.env.CLAUSTRUM_OPENCODE_HANDLES = handlesPath +}) + +afterEach(() => { + process.env.OPENCODE_OPENAI_AUTH_FILE = FLOOR_AUTH_FILE + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = FLOOR_STATE_FILE + process.env.CLAUSTRUM_OPENCODE_HANDLES = FLOOR_CLAUSTRUM_HANDLES + try { + rmSync(dir, { recursive: true, force: true }) + } catch {} +}) + +// --------------------------------------------------------------------------- +// Contract: error shape itself +// --------------------------------------------------------------------------- + +describe('CustodyTombstoneRefreshError contract', () => { + it('has status 503 and isRefreshError=true', () => { + const error = new CustodyTombstoneRefreshError(CUSTODY_PROVIDER) + expect(error.status).toBe(503) + expect(error.isRefreshError).toBe(true) + expect(error.code).toBe('CUSTODY_TOMBSTONED') + expect(error.name).toBe('CustodyTombstoneRefreshError') + }) +}) + +// --------------------------------------------------------------------------- +// Choke point: refreshAccountNow throws on refreshInert +// --------------------------------------------------------------------------- + +describe('choke point (refreshAccountNow) refuses refreshInert accounts', () => { + it('tombstoned storage account + no manifest entry → throws before refreshFn', async () => { + const account = makeSentinelAccount() + await saveAccounts(liveStorage([account]), cfgPath) + const storage = (await loadAccounts(cfgPath))! + + let refreshFnCalls = 0 + let observedRefreshToken: string | undefined + const manager = new FallbackAccountManager({ + configPath: cfgPath, + custody: { readManifest: () => Promise.resolve(emptyManifest()) }, + refreshFn: async ({ refreshToken }) => { + refreshFnCalls++ + observedRefreshToken = refreshToken + return { + access: 'unused', + refresh: 'unused', + expires: Date.now() + 3_600_000, + expiresIn: 3600, + } + }, + }) + + let thrown: unknown + try { + await manager.refreshAccount(account, storage, { force: true }) + } catch (e) { + thrown = e + } + + // Removing the choke-point assertion lets refreshFn observe the sentinel + // as the refresh token — the assertion below pins the gate in place. + expect(refreshFnCalls).toBe(0) + expect(observedRefreshToken).toBeUndefined() + expect(thrown).toBeInstanceOf(CustodyTombstoneRefreshError) + }) + + it('enrolled manifest account in local mode → throws before refreshFn', async () => { + const account = liveAccount('enrolling-1') + await saveAccounts(liveStorage([account]), cfgPath) + const storage = (await loadAccounts(cfgPath))! + + let refreshFnCalls = 0 + const manager = new FallbackAccountManager({ + configPath: cfgPath, + custody: { + readManifest: () => Promise.resolve(enrollmentManifest('enrolling-1')), + }, + refreshFn: async () => { + refreshFnCalls++ + return { + access: 'unused', + refresh: 'unused', + expires: Date.now() + 3_600_000, + expiresIn: 3600, + } + }, + }) + + let thrown: unknown + try { + await manager.refreshAccount(account, storage, { force: true }) + } catch (e) { + thrown = e + } + + expect(refreshFnCalls).toBe(0) + expect(thrown).toBeInstanceOf(CustodyTombstoneRefreshError) + }) + + it('tombstoned storage account refuses before a manifest read can fail', async () => { + const account = makeSentinelAccount({ access: '' }) + await saveAccounts(liveStorage([account]), cfgPath) + const storage = (await loadAccounts(cfgPath))! + let manifestReads = 0 + const manager = new FallbackAccountManager({ + configPath: cfgPath, + custody: { + readManifest: async () => { + manifestReads++ + throw new Error('manifest reader must not run') + }, + }, + }) + + await expect( + manager.refreshAccount(account, storage, { force: true }), + ).rejects.toBeInstanceOf(CustodyTombstoneRefreshError) + expect(manifestReads).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// getUsableFallbackAccounts candidate shape +// --------------------------------------------------------------------------- + +describe('getUsableFallbackAccounts candidate shape', () => { + it('enrolling + valid local token → present in usable, zero refreshFn calls', async () => { + // Enrolling (manifest entry, not tombstoned) stays a usable candidate — + // it serves its local access token while that token is valid. The local + // refresher must NOT run. + const account = liveAccount('enr-1', { expires: Date.now() + 3_600_000 }) + await saveAccounts(liveStorage([account]), cfgPath) + const storage = (await loadAccounts(cfgPath))! + + const { manager, refreshCalls } = await makeSpyManager({ + readManifest: () => Promise.resolve(enrollmentManifest('enr-1')), + }) + + const usable = await manager.getUsableFallbackAccounts(storage) + expect(usable).toHaveLength(1) + expect(usable[0]?.id).toBe('enr-1') + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) + + it('keeps an expired enrolling account for the request resolver without refreshing it', async () => { + // The resolver completes an expired enrollment inline. Selection must keep + // the account available while still preventing a local refresh. + const account = liveAccount('enr-2', { expires: Date.now() - 1_000 }) + await saveAccounts(liveStorage([account]), cfgPath) + const storage = (await loadAccounts(cfgPath))! + + const { manager, refreshCalls } = await makeSpyManager({ + readManifest: () => Promise.resolve(enrollmentManifest('enr-2')), + }) + + const usable = await manager.getUsableFallbackAccounts(storage) + expect(usable).toHaveLength(1) + expect(usable[0]?.id).toBe('enr-2') + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) + + it('tombstoned account stays selectable without local refresh', async () => { + const account = makeSentinelAccount({ id: 'tomb-1' }) + await saveAccounts(liveStorage([account]), cfgPath) + const storage = (await loadAccounts(cfgPath))! + + const { manager, refreshCalls } = await makeSpyManager({ + readManifest: () => Promise.resolve(emptyManifest()), + }) + + const usable = await manager.getUsableFallbackAccounts(storage) + expect(usable).toHaveLength(1) + expect(usable[0]?.id).toBe('tomb-1') + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// refreshAccountNow — per-reload re-checks (one test per reload site) +// --------------------------------------------------------------------------- + +// Overrides `load` so the manager injects the tombstone sentinel into the +// in-memory storage on the Nth call. Each test sets `injectAt` to the call +// number that should observe the tombstone. +class InjectTombstoneOnLoad extends FallbackAccountManager { + loadCalls = 0 + constructor( + options: AccountManagerOptions, + private readonly injectAt: number, + private readonly targetId: string, + ) { + super(options) + } + override async load(): Promise { + this.loadCalls++ + const loaded = await super.load() + if ( + this.loadCalls === this.injectAt && + loaded && + Array.isArray(loaded.accounts) + ) { + const target = loaded.accounts.find((a) => a.id === this.targetId) + if (target && target.type === 'oauth') { + target.access = TOMBSTONE_OPENAI + target.refresh = TOMBSTONE_OPENAI + target.expires = 0 + } + } + return loaded + } +} + +describe('refreshAccountNow per-reload choke-point re-checks', () => { + it('under-lock load (call 2) sees tombstone injected mid-flight → throws', async () => { + const accountId = 'ul-1' + const account = liveAccount(accountId, { expires: Date.now() - 1_000 }) + await saveAccounts(liveStorage([account]), cfgPath) + + let refreshFnCalls = 0 + let observedRefreshToken: string | undefined + const manager = new InjectTombstoneOnLoad( + { + configPath: cfgPath, + custody: { readManifest: () => Promise.resolve(emptyManifest()) }, + refreshFn: async ({ refreshToken }) => { + refreshFnCalls++ + observedRefreshToken = refreshToken + return { + access: 'unused', + refresh: 'unused', + expires: Date.now() + 3_600_000, + expiresIn: 3600, + } + }, + }, + 2, + accountId, + ) + + const storage = (await loadAccounts(cfgPath))! + let thrown: unknown + try { + await manager.refreshAccount(account, storage, { force: true }) + } catch (e) { + thrown = e + } + + expect(thrown).toBeInstanceOf(CustodyTombstoneRefreshError) + expect(refreshFnCalls).toBe(0) + expect(observedRefreshToken).toBeUndefined() + }) + + it('post-save load (call 3) sees tombstone injected mid-flight → throws', async () => { + const accountId = 'ps-1' + const account = liveAccount(accountId, { expires: Date.now() - 1_000 }) + await saveAccounts(liveStorage([account]), cfgPath) + + let refreshFnCalls = 0 + const manager = new InjectTombstoneOnLoad( + { + configPath: cfgPath, + custody: { readManifest: () => Promise.resolve(emptyManifest()) }, + refreshFn: async () => { + refreshFnCalls++ + return { + access: 'rotated-access', + refresh: 'rotated-refresh', + expires: Date.now() + 3_600_000, + expiresIn: 3600, + } + }, + }, + 3, + accountId, + ) + + const storage = (await loadAccounts(cfgPath))! + let thrown: unknown + try { + await manager.refreshAccount(account, storage, { force: true }) + } catch (e) { + thrown = e + } + + expect(thrown).toBeInstanceOf(CustodyTombstoneRefreshError) + // The refreshFn ran successfully (it returned valid tokens); the choke + // point fires only on the post-save load, AFTER the save. + expect(refreshFnCalls).toBe(1) + }) +}) + +// --------------------------------------------------------------------------- +// Manager entry gates — four groups +// --------------------------------------------------------------------------- + +// Spies on the public `refreshAccount` method so the entry-gate tests can +// observe that the manager entry-gate prevents the call to `refreshAccount` +// itself, distinct from the choke-point preventing refreshFn inside it. +class SpyManager extends FallbackAccountManager { + refreshAccountCalls: OAuthAccount[] = [] + override async refreshAccount( + account: OAuthAccount, + storage: AccountStorage, + options: { force?: boolean } = {}, + ): Promise { + this.refreshAccountCalls.push(account) + return super.refreshAccount(account, storage, options) + } +} + +async function makeSpyManager(opts: { + refreshFn?: AccountManagerOptions['refreshFn'] + readManifest?: () => Promise +}): Promise<{ + manager: SpyManager + refreshCalls: () => number +}> { + let calls = 0 + const refreshFn = + opts.refreshFn ?? + (async () => { + calls++ + return { + access: 'unused', + refresh: 'unused', + expires: Date.now() + 3_600_000, + expiresIn: 3600, + } + }) + const tracked: AccountManagerOptions['refreshFn'] = async (input) => { + calls++ + return refreshFn(input) + } + const manager = new SpyManager({ + configPath: cfgPath, + custody: { + readManifest: + opts.readManifest ?? (() => Promise.resolve(emptyManifest())), + }, + refreshFn: tracked, + fetchQuotaFn: async () => { + throw new Error('no fetchQuotaFn configured for this test') + }, + }) + return { manager, refreshCalls: () => calls } +} + +describe('manager entry gates skip refreshInert accounts', () => { + it('getUsableFallbackAccounts: tombstoned account → refreshAccount NOT invoked', async () => { + const account = makeSentinelAccount({ id: 'a-1' }) + await saveAccounts(liveStorage([account]), cfgPath) + const storage = (await loadAccounts(cfgPath))! + + const { manager, refreshCalls } = await makeSpyManager({ + readManifest: () => Promise.resolve(emptyManifest()), + }) + + const usable = await manager.getUsableFallbackAccounts(storage) + expect(usable.map((candidate) => candidate.id)).toEqual(['a-1']) + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) + + it('refreshDueAccounts: tombstoned account → refreshAccount NOT invoked', async () => { + const account = makeSentinelAccount({ id: 'a-2' }) + await saveAccounts(liveStorage([account]), cfgPath) + + const { manager, refreshCalls } = await makeSpyManager({}) + await manager.refreshDueAccounts() + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) + + it('refreshQuotaForDueAccounts: tombstoned account → refreshAccount NOT invoked', async () => { + const account = makeSentinelAccount({ id: 'a-3' }) + await saveAccounts(liveStorage([account]), cfgPath) + + const { manager, refreshCalls } = await makeSpyManager({}) + await manager.refreshQuotaForDueAccounts() + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) + + it('refreshQuotaForAllAccounts: tombstoned account → refreshAccount NOT invoked', async () => { + const account = makeSentinelAccount({ id: 'a-4' }) + await saveAccounts(liveStorage([account]), cfgPath) + + const { manager, refreshCalls } = await makeSpyManager({}) + await manager.refreshQuotaForAllAccounts({ force: true }) + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// enrolled + local mode + live secret → ZERO refreshFn +// --------------------------------------------------------------------------- + +describe('enrolled + local mode → skip local refresh', () => { + it('getUsableFallbackAccounts: ZERO refreshFn calls', async () => { + const account = liveAccount('enrolled-1', { expires: Date.now() - 1_000 }) + await saveAccounts( + liveStorage([account], { + claustrum: claustrumConfig({ mode: 'local' }), + }), + cfgPath, + ) + const storage = (await loadAccounts(cfgPath))! + + const { manager: rawManager, refreshCalls } = await makeSpyManager({ + readManifest: () => Promise.resolve(enrollmentManifest('enrolled-1')), + }) + + await rawManager.getUsableFallbackAccounts(storage) + expect(refreshCalls()).toBe(0) + expect(rawManager.refreshAccountCalls).toHaveLength(0) + }) + + it('refreshDueAccounts: ZERO refreshFn calls', async () => { + const account = liveAccount('enrolled-2', { expires: Date.now() - 1_000 }) + await saveAccounts( + liveStorage([account], { + claustrum: claustrumConfig({ mode: 'local' }), + }), + cfgPath, + ) + + const { manager, refreshCalls } = await makeSpyManager({ + readManifest: () => Promise.resolve(enrollmentManifest('enrolled-2')), + }) + + await manager.refreshDueAccounts() + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) + + it('refreshQuotaForDueAccounts: ZERO refreshFn calls', async () => { + const account = liveAccount('enrolled-3', { expires: Date.now() - 1_000 }) + await saveAccounts( + liveStorage([account], { + claustrum: claustrumConfig({ mode: 'local' }), + }), + cfgPath, + ) + + const { manager, refreshCalls } = await makeSpyManager({ + readManifest: () => Promise.resolve(enrollmentManifest('enrolled-3')), + }) + + await manager.refreshQuotaForDueAccounts() + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) + + it('refreshQuotaForAllAccounts: ZERO refreshFn calls', async () => { + const account = liveAccount('enrolled-4', { expires: Date.now() - 1_000 }) + await saveAccounts( + liveStorage([account], { + claustrum: claustrumConfig({ mode: 'local' }), + }), + cfgPath, + ) + + const { manager, refreshCalls } = await makeSpyManager({ + readManifest: () => Promise.resolve(enrollmentManifest('enrolled-4')), + }) + + await manager.refreshQuotaForAllAccounts({ force: true }) + expect(refreshCalls()).toBe(0) + expect(manager.refreshAccountCalls).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// error writers ignore CustodyTombstoneRefreshError +// --------------------------------------------------------------------------- + +describe('recordRefreshError refuses to persist CustodyTombstoneRefreshError', () => { + it('refreshDueAccounts catch path: tombstone error → lastRefreshError undefined on disk', async () => { + // Live account + empty manifest — the entry gate does NOT fire (refreshInert + // is false), so refreshFn is invoked and recordRefreshError runs in the + // catch. Without the ignore, the tombstone class stamps a permanent error. + const account = liveAccount('er-1', { expires: Date.now() - 1_000 }) + await saveAccounts(liveStorage([account]), cfgPath) + + let refreshFnCalls = 0 + const manager = new FallbackAccountManager({ + configPath: cfgPath, + custody: { readManifest: () => Promise.resolve(emptyManifest()) }, + refreshFn: async () => { + refreshFnCalls++ + throw new CustodyTombstoneRefreshError(CUSTODY_PROVIDER) + }, + }) + + await manager.refreshDueAccounts() + expect(refreshFnCalls).toBe(1) + + const persisted = await loadAccounts(cfgPath) + const stored = persisted?.accounts.find((a) => a.id === 'er-1') as + | OAuthAccount + | undefined + expect(stored).toBeDefined() + if (stored && stored.type === 'oauth') { + expect(stored.lastRefreshError).toBeUndefined() + } + }) + + it('refreshQuotaForDueAccounts catch path: tombstone error → neither error stamped', async () => { + const account = liveAccount('er-2', { expires: Date.now() - 1_000 }) + await saveAccounts(liveStorage([account]), cfgPath) + + let quotaCalls = 0 + const manager = new FallbackAccountManager({ + configPath: cfgPath, + custody: { readManifest: () => Promise.resolve(emptyManifest()) }, + refreshFn: async () => ({ + access: 'rotated', + refresh: 'rotated', + expires: Date.now() + 3_600_000, + expiresIn: 3600, + }), + fetchQuotaFn: async () => { + quotaCalls++ + throw new CustodyTombstoneRefreshError(CUSTODY_PROVIDER) + }, + }) + + await manager.refreshQuotaForDueAccounts() + expect(quotaCalls).toBe(1) + + const persisted = await loadAccounts(cfgPath) + const stored = persisted?.accounts.find((a) => a.id === 'er-2') as + | OAuthAccount + | undefined + expect(stored).toBeDefined() + if (stored && stored.type === 'oauth') { + expect(stored.lastQuotaRefreshError).toBeUndefined() + expect(stored.lastRefreshError).toBeUndefined() + } + }) +}) + +// --------------------------------------------------------------------------- +// waiter (waitForConcurrentFallbackRefresh) per-poll refreshInert re-check +// --------------------------------------------------------------------------- + +async function acquireLockExternally(accountId: string) { + return acquireRefreshFileLock({ + name: fallbackRefreshLockName(accountId), + ttlMs: 60_000, + path: cfgPath, + renew: false, + }) +} + +describe('waiter (waitForConcurrentFallbackRefresh) re-evaluates refreshInert per poll', () => { + it('force:true caller enters before the tombstone, lands inside the wait → throws', async () => { + const accountId = 'waiter-poll' + const account = liveAccount(accountId, { expires: Date.now() - 1_000 }) + await saveAccounts(liveStorage([account]), cfgPath) + + const holder = await acquireLockExternally(accountId) + expect(holder).not.toBeNull() + if (!holder) throw new Error('failed to acquire lock externally') + + let observedRefreshToken: string | undefined + const manager = new FallbackAccountManager({ + configPath: cfgPath, + custody: { readManifest: () => Promise.resolve(emptyManifest()) }, + refreshFn: async ({ refreshToken }) => { + observedRefreshToken = refreshToken + return { + access: 'unused', + refresh: 'unused', + expires: Date.now() + 3_600_000, + expiresIn: 3600, + } + }, + }) + + const storage = (await loadAccounts(cfgPath))! + const refreshPromise = manager.refreshAccount(account, storage, { + force: true, + }) + + // Wait long enough for the waiter to be inside its first poll cycle, then + // tombstone the storage on disk. The poll picks up the new state on its + // next read. + await new Promise((resolve) => setTimeout(resolve, 150)) + const tombstoned = (await loadAccounts(cfgPath))! + const target = tombstoned.accounts.find((a) => a.id === accountId) as + | OAuthAccount + | undefined + expect(target).toBeDefined() + if (target && target.type === 'oauth') { + target.access = TOMBSTONE_OPENAI + target.refresh = TOMBSTONE_OPENAI + target.expires = 0 + } + await saveAccounts(tombstoned, cfgPath) + + let thrown: unknown + try { + await refreshPromise + } catch (e) { + thrown = e + } + + await holder.release() + + expect(thrown).toBeInstanceOf(CustodyTombstoneRefreshError) + // Sentinel must NEVER reach refreshFn. + expect(observedRefreshToken).not.toBe(TOMBSTONE_OPENAI) + }) + + it('manifest-only change while polling → throws before any return', async () => { + const accountId = 'waiter-poll' + const account = liveAccount(accountId, { expires: Date.now() - 1_000 }) + await saveAccounts(liveStorage([account]), cfgPath) + + const holder = await acquireLockExternally(accountId) + expect(holder).not.toBeNull() + if (!holder) throw new Error('failed to acquire lock externally') + + let manifestMode: 'empty' | 'enrolled' = 'empty' + const manager = new FallbackAccountManager({ + configPath: cfgPath, + custody: { + readManifest: () => + Promise.resolve( + manifestMode === 'empty' + ? emptyManifest() + : enrollmentManifest(accountId), + ), + }, + refreshFn: async () => { + return { + access: 'unused', + refresh: 'unused', + expires: Date.now() + 3_600_000, + expiresIn: 3600, + } + }, + }) + + const storage = (await loadAccounts(cfgPath))! + const refreshPromise = manager.refreshAccount(account, storage, { + force: true, + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + manifestMode = 'enrolled' // local mode is implicit when claustrum is omitted + + let thrown: unknown + try { + await refreshPromise + } catch (e) { + thrown = e + } + + await holder.release() + + expect(thrown).toBeInstanceOf(CustodyTombstoneRefreshError) + }) +}) + +// --------------------------------------------------------------------------- +// Backoff key — permanent error + changed refresh token → attempts again +// --------------------------------------------------------------------------- + +describe('refresh backoff keyed by refresh-token hash', () => { + // The non-force path is the only path that consults refreshBackoffActive. + // Driving `refreshDueAccounts` (or any caller that lets the per-call + // backoff check run) is the only way to exercise the hash-keying — + // `refreshAccount({force:true})` short-circuits before the check and + // would never observe a key mismatch. + it('permanent error stamped on T1 + storage token now T2 → refreshDueAccounts attempts again', async () => { + const now = 1_700_000_000_000 + const account = liveAccount('bk-1', { + access: 'old-access', + refresh: 'T2-fresh', + expires: now - 1_000, // expired → due path runs the backoff check + lastRefreshError: { + message: 'Token refresh failed: 401', + checkedAt: now - 60_000, + nextRetryAt: now + 24 * 60 * 60_000, // 24h permanent backoff window + retryCount: 1, + tokenHash: + // sha256("T1-stale") — distinct from the storage's current T2 hash. + '0'.repeat(64), + }, + }) + await saveAccounts(liveStorage([account]), cfgPath) + + let refreshFnCalls = 0 + let observedRefreshToken: string | undefined + const manager = new FallbackAccountManager({ + configPath: cfgPath, + now: () => now, + custody: { readManifest: () => Promise.resolve(emptyManifest()) }, + refreshFn: async ({ refreshToken }) => { + refreshFnCalls++ + observedRefreshToken = refreshToken + return { + access: 'T2-rotated-access', + refresh: 'T2-rotated-refresh', + expires: now + 3_600_000, + expiresIn: 3600, + } + }, + }) + + await manager.refreshDueAccounts() + // Token-hash mismatch on the backoff key is the bypass: the account's + // current refresh token (T2) does not hash to the key the error was + // stamped with (T1), so the backoff is inert. + expect(refreshFnCalls).toBe(1) + expect(observedRefreshToken).toBe('T2-fresh') + }) +}) + +// --------------------------------------------------------------------------- +// Exports consumed by the enroll verb +// --------------------------------------------------------------------------- + +describe('lock-name + TTL exports for the refresh choke point', () => { + it('exports FALLBACK_REFRESH_LOCK_TTL_MS and fallbackRefreshLockName', async () => { + const mod = await import('../core/accounts.ts') + expect(typeof mod.FALLBACK_REFRESH_LOCK_TTL_MS).toBe('number') + expect(typeof mod.fallbackRefreshLockName).toBe('function') + expect(mod.FALLBACK_REFRESH_LOCK_TTL_MS).toBe(10 * 60_000) + expect( + mod.fallbackRefreshLockName('a').startsWith('fallback-oauth-refresh-'), + ).toBe(true) + }) +}) diff --git a/packages/opencode/src/tests/custody-request.test.ts b/packages/opencode/src/tests/custody-request.test.ts new file mode 100644 index 0000000..ec55140 --- /dev/null +++ b/packages/opencode/src/tests/custody-request.test.ts @@ -0,0 +1,1617 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + FallbackAccountManager, + loadAccounts, + type OAuthAccount, +} from '../core/accounts.ts' +import type { CacheKeepManager } from '../core/cachekeep.ts' +import { + ClaustrumCredentialCache, + CUSTODY_REFUSE, + resolveFallbackAccess, + stampVaultProvenance, + type VaultProvenance, +} from '../core/custody.ts' +import { CUSTODY_INERT_REASONS } from '../core/custody-state.ts' +import { + __resetBootQuotaSeedForTest, + type ClaustrumCacheTransportLike, + CodexAuthPlugin, + type CustodyRuntime, + createResetTargetResolver, +} from '../index.ts' +import { hashSidebarSessionId } from '../sidebar-state.ts' +import { + claustrumConfig, + emptyManifest, + enrollmentManifest, + liveAccount, + liveStorage, + makeCustodyRequestJwt, + makeSentinelAccount, + TOMBSTONE_OPENAI, +} from './custody-fixtures.ts' +import { + FLOOR_AUTH_FILE, + FLOOR_CLAUSTRUM_HANDLES, + FLOOR_LOG_FILE, + FLOOR_SIDEBAR_STATE_FILE, + FLOOR_STATE_FILE, +} from './setup-env.ts' + +function enrollingAccount(overrides: Partial = {}): OAuthAccount { + return { + id: 'custody-1', + type: 'oauth', + access: 'local-access', + refresh: 'local-refresh', + expires: 1_000, + addedAt: 1, + accountId: 'acct-1', + ...overrides, + } +} + +async function withCustodyLoader( + options: { + accounts: OAuthAccount[] + routing?: { mode: 'main-first' | 'fallback-first' | 'sticky-balanced' } + claustrumEnabled?: boolean + credential?: { material: string; recordVersion: number } | undefined + credentialForGet?: () => { material: string; recordVersion: number } + now?: () => number + sidebar?: Record + observeRequest?: ( + authorization: string, + url: string, + configPath: string, + ) => Promise | void + withFallbackAccountLock?: ( + accountId: string, + action: () => Promise, + ) => Promise + respond: (authorization: string, url: string) => number + }, + run: (input: { + fetchOverride: typeof globalThis.fetch + authorizations: string[] + reports: Array<{ recordVersion: number; reporterSource: string }> + gets: () => number + runtime: CustodyRuntime + cacheKeepManager: { + track: CacheKeepManager['track'] + tick: CacheKeepManager['tick'] + } + configPath: string + commandHook: (input: { + command: string + arguments: string + sessionID: string + }) => Promise + }) => Promise, +): Promise { + const directory = mkdtempSync(join(tmpdir(), 'custody-request-loader-')) + const configPath = join(directory, 'openai-auth.json') + const manifestPath = join(directory, 'handles.json') + const manifest = enrollmentManifest(options.accounts[0]?.id ?? 'custody-1') + if (!manifest.ok) throw new Error('expected manifest fixture') + const originalFetch = globalThis.fetch + const authorizations: string[] = [] + const reports: Array<{ recordVersion: number; reporterSource: string }> = [] + let gets = 0 + let runtime: CustodyRuntime | undefined + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join(directory, 'state.json') + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = join( + directory, + 'sidebar.json', + ) + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = join(directory, 'test.log') + process.env.OPENCODE_CONFIG_DIR = directory + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: options.accounts, + claustrum: claustrumConfig({ + mode: options.claustrumEnabled === false ? 'local' : 'claustrum', + }), + routing: options.routing, + }), + ) + writeFileSync(manifestPath, JSON.stringify(manifest.value)) + if (options.sidebar) { + writeFileSync( + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE, + JSON.stringify(options.sidebar), + ) + } + chmodSync(manifestPath, 0o600) + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get('authorization') ?? '' + const urlText = String(url) + await options.observeRequest?.(authorization, urlText, configPath) + if (urlText.endsWith('/responses')) authorizations.push(authorization) + return new Response('{}', { + status: options.respond(authorization, urlText), + }) + }) as typeof globalThis.fetch + const transport: ClaustrumCacheTransportLike = { + async getCredential() { + gets++ + const credential = options.credentialForGet?.() ?? options.credential + if (!credential) throw new Error('vault unavailable') + return { + ...credential, + expiresAtMs: (options.now ?? Date.now)() + 60_000, + } + }, + async statusCredential() { + return { + ready: Boolean(options.credential), + lastErrorCode: options.credential ? null : 'unavailable', + leaseHeld: false, + recordVersion: options.credential?.recordVersion ?? 0, + } + }, + async reportAuthFailure(params) { + reports.push({ + recordVersion: params.recordVersion, + reporterSource: params.reporterSource, + }) + }, + close() {}, + } + const hooks = await CodexAuthPlugin( + { + client: { + auth: { set: async () => {} }, + session: { promptAsync: async () => {} }, + }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: directory, + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { + custody: { + transport, + detection: 'available', + now: options.now, + onRuntime: (value) => { + runtime = value + }, + withFallbackAccountLock: options.withFallbackAccountLock, + }, + }, + ) + try { + const loader = hooks.auth?.loader + if (!loader) throw new Error('expected auth loader') + const result = await loader( + async () => ({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3_600_000, + }), + {} as never, + ) + const fetchOverride = (result as { fetch?: typeof globalThis.fetch }).fetch + if (!fetchOverride) throw new Error('expected fetch override') + const cacheKeepManager = ( + globalThis as typeof globalThis & { + __openaiAuthCacheKeepManager?: CacheKeepManager + } + ).__openaiAuthCacheKeepManager + if (!cacheKeepManager) throw new Error('expected cachekeep manager') + if (!runtime) throw new Error('expected custody runtime') + const commandHook = ( + hooks as unknown as { + 'command.execute.before'?: (input: { + command: string + arguments: string + sessionID: string + }) => Promise + } + )['command.execute.before'] + if (!commandHook) throw new Error('expected command hook') + await run({ + fetchOverride, + authorizations, + reports, + gets: () => gets, + runtime, + cacheKeepManager, + configPath, + commandHook, + }) + } finally { + await hooks.dispose?.() + globalThis.fetch = originalFetch + process.env.OPENCODE_OPENAI_AUTH_FILE = FLOOR_AUTH_FILE + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = FLOOR_STATE_FILE + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = + FLOOR_SIDEBAR_STATE_FILE + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = FLOOR_LOG_FILE + process.env.CLAUSTRUM_OPENCODE_HANDLES = FLOOR_CLAUSTRUM_HANDLES + delete process.env.OPENCODE_CONFIG_DIR + rmSync(directory, { recursive: true, force: true }) + } +} + +function codexRequest(sessionId?: string): [string, RequestInit] { + return [ + 'https://chatgpt.com/backend-api/codex/responses', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(sessionId ? { 'x-session-affinity': sessionId } : {}), + }, + body: JSON.stringify({ model: 'gpt-5.5', input: [] }), + }, + ] +} + +describe('custody request resolution', () => { + beforeEach(() => { + __resetBootQuotaSeedForTest() + }) + + it('refuses enabling a bound row when the served custody identity differs', async () => { + const account = liveAccount('binding-mismatch', { + enabled: false, + accountId: 'row-account', + }) + await withCustodyLoader( + { + accounts: [account], + credential: { + material: makeCustodyRequestJwt('served-account'), + recordVersion: 1, + }, + respond: () => 200, + }, + async ({ commandHook, configPath }) => { + await commandHook({ + command: 'openai-account', + arguments: `enable ${account.id}`, + sessionID: 'binding-mismatch', + }).catch(() => {}) + + expect(CUSTODY_INERT_REASONS).toContain('identity-mismatch') + expect((await loadAccounts(configPath))?.accounts[0]).toMatchObject({ + accountId: 'row-account', + enabled: false, + }) + }, + ) + }) + + it('binds a pending row under the account lock before enabling it', async () => { + const account = liveAccount('binding-pending', { enabled: false }) + let lockHeld = false + let boundWhileLocked = false + await withCustodyLoader( + { + accounts: [account], + credential: { + material: makeCustodyRequestJwt('served-account'), + recordVersion: 1, + }, + respond: () => 200, + withFallbackAccountLock: async (_id, action) => { + lockHeld = true + try { + const result = await action() + boundWhileLocked = + (await loadAccounts(process.env.OPENCODE_OPENAI_AUTH_FILE)) + ?.accounts[0]?.accountId === 'served-account' + return result + } finally { + lockHeld = false + } + }, + }, + async ({ commandHook, configPath }) => { + await commandHook({ + command: 'openai-account', + arguments: `enable ${account.id}`, + sessionID: 'binding-pending', + }).catch(() => {}) + + expect(lockHeld).toBe(false) + expect(boundWhileLocked).toBe(true) + expect((await loadAccounts(configPath))?.accounts[0]).toMatchObject({ + accountId: 'served-account', + enabled: true, + }) + }, + ) + }) + + it('uses the configured custody transport in the loader', async () => { + const directory = mkdtempSync(join(tmpdir(), 'custody-loader-')) + const configPath = join(directory, 'openai-auth.json') + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join(directory, 'state.json') + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = join( + directory, + 'sidebar.json', + ) + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = join(directory, 'test.log') + process.env.OPENCODE_CONFIG_DIR = directory + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [], + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + ) + let closes = 0 + const transport: ClaustrumCacheTransportLike = { + async getCredential() { + throw new Error('no credential requested') + }, + async statusCredential() { + return { + ready: false, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 0, + } + }, + async reportAuthFailure() {}, + close() { + closes++ + }, + } + const hooks = await CodexAuthPlugin( + { + client: { auth: { set: async () => {} } }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: directory, + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { + custody: { + transport, + detection: 'available', + }, + }, + ) + try { + const loader = hooks.auth?.loader + if (!loader) throw new Error('expected auth loader') + await loader( + async () => ({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3_600_000, + }), + {} as never, + ) + await hooks.dispose?.() + expect(closes).toBeGreaterThan(0) + } finally { + await hooks.dispose?.() + process.env.OPENCODE_OPENAI_AUTH_FILE = FLOOR_AUTH_FILE + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = FLOOR_STATE_FILE + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = + FLOOR_SIDEBAR_STATE_FILE + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = FLOOR_LOG_FILE + delete process.env.OPENCODE_CONFIG_DIR + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('persists the tombstone before sending an enrollment credential', async () => { + const fallback = enrollingAccount() + const vaultAccess = makeCustodyRequestJwt('acct-1') + let observed: OAuthAccount | undefined + await withCustodyLoader( + { + accounts: [fallback], + credential: { material: vaultAccess, recordVersion: 18 }, + observeRequest: async (authorization, url, configPath) => { + if (!url.endsWith('/responses')) return + if (authorization !== `Bearer ${vaultAccess}`) return + const storage = await loadAccounts(configPath) + const account = storage?.accounts.find( + (candidate) => candidate.id === fallback.id, + ) + if (account?.type === 'oauth' && !account.corrupt) observed = account + }, + respond: (authorization, url) => + url.endsWith('/responses') && authorization === 'Bearer main-access' + ? 401 + : 200, + }, + async ({ fetchOverride }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(200) + }, + ) + expect(observed).toMatchObject({ + access: makeSentinelAccount().access, + refresh: makeSentinelAccount().refresh, + expires: 0, + }) + }) + + it('binds an absent account id from the first served credential', async () => { + const fallback = makeSentinelAccount({ + id: 'custody-1', + accountId: undefined, + }) + await withCustodyLoader( + { + accounts: [fallback], + credential: { + material: makeCustodyRequestJwt('acct-bound'), + recordVersion: 19, + }, + respond: () => 200, + }, + async ({ fetchOverride, configPath }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(200) + const bound = (await loadAccounts(configPath))?.accounts.find( + (account) => account.id === fallback.id, + ) + expect(bound).toMatchObject({ accountId: 'acct-bound' }) + }, + ) + }) + + it('reconciles an expired bound row to the vault before fallback routing', async () => { + const expired = enrollingAccount() + const next = enrollingAccount({ + id: 'next', + access: 'next-access', + refresh: 'next-refresh', + expires: Date.now() + 100_000, + }) + await withCustodyLoader( + { + accounts: [expired, next], + routing: { mode: 'fallback-first' }, + credential: { + material: makeCustodyRequestJwt('acct-1'), + recordVersion: 18, + }, + respond: (authorization) => + authorization === 'Bearer main-access' ? 401 : 200, + }, + async ({ fetchOverride, authorizations, configPath }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(200) + expect(authorizations).toContain( + `Bearer ${makeCustodyRequestJwt('acct-1')}`, + ) + const storage = await loadAccounts(configPath) + const preserved = storage?.accounts.find( + (account) => account.id === expired.id, + ) + expect(preserved?.type).toBe('oauth') + if (preserved?.type !== 'oauth') + throw new Error('expected oauth account') + expect(preserved).toMatchObject({ + access: '', + refresh: 'claustrum-tombstone:v1:openai', + expires: 0, + }) + }, + ) + }) + + it('sends the vault bearer and fences repeated fallback-first 401 reports', async () => { + const directory = mkdtempSync(join(tmpdir(), 'custody-fallback-first-')) + const configPath = join(directory, 'openai-auth.json') + const manifestPath = join(directory, 'handles.json') + const fallback = makeSentinelAccount({ + id: 'fallback-1', + accountId: 'acct-1', + }) + const manifest = enrollmentManifest(fallback.id) + if (!manifest.ok) throw new Error('expected manifest fixture') + const vaultAccess = makeCustodyRequestJwt('acct-1') + const authorizations: string[] = [] + const reports: Array<{ + handle: string + providerStatus: number + recordVersion: number + reporterSource: 'direct' | 'relay_status_field' | 'relay_message_parse' + }> = [] + let vaultGets = 0 + const originalFetch = globalThis.fetch + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join(directory, 'state.json') + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = join( + directory, + 'sidebar.json', + ) + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = join(directory, 'test.log') + process.env.OPENCODE_CONFIG_DIR = directory + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [fallback], + claustrum: claustrumConfig({ mode: 'claustrum' }), + routing: { mode: 'fallback-first' }, + }), + ) + writeFileSync(manifestPath, JSON.stringify(manifest.value)) + chmodSync(manifestPath, 0o600) + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const urlText = String(url) + if (urlText.endsWith('/responses')) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return new Response('{}', { + status: urlText.includes('wham') ? 200 : 401, + }) + }) as typeof globalThis.fetch + const transport: ClaustrumCacheTransportLike = { + async getCredential() { + vaultGets++ + return { + material: vaultAccess, + recordVersion: 17, + expiresAtMs: Date.now() + 60_000, + } + }, + async statusCredential() { + return { + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 17, + } + }, + async reportAuthFailure(params) { + reports.push(params) + }, + close() {}, + } + const hooks = await CodexAuthPlugin( + { + client: { auth: { set: async () => {} } }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: directory, + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { custody: { transport, detection: 'available' } }, + ) + try { + const loader = hooks.auth?.loader + if (!loader) throw new Error('expected auth loader') + const result = await loader( + async () => ({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3_600_000, + }), + {} as never, + ) + const fetchOverride = (result as { fetch?: typeof globalThis.fetch }) + .fetch + if (!fetchOverride) throw new Error('expected fetch override') + expect(vaultGets).toBeGreaterThan(0) + const response = await fetchOverride( + 'https://chatgpt.com/backend-api/codex/responses', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.5', input: [] }), + }, + ) + expect(response.status).toBe(401) + expect(authorizations.filter(Boolean)[0]).toBe(`Bearer ${vaultAccess}`) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([ + { + handle: manifest.value.providers[0]!.accounts[0]!.handle, + providerStatus: 401, + recordVersion: 17, + reporterSource: 'direct', + }, + ]) + await fetchOverride('https://chatgpt.com/backend-api/codex/responses', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.5', input: [] }), + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toHaveLength(1) + } finally { + await hooks.dispose?.() + globalThis.fetch = originalFetch + process.env.OPENCODE_OPENAI_AUTH_FILE = FLOOR_AUTH_FILE + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = FLOOR_STATE_FILE + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = + FLOOR_SIDEBAR_STATE_FILE + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = FLOOR_LOG_FILE + process.env.CLAUSTRUM_OPENCODE_HANDLES = FLOOR_CLAUSTRUM_HANDLES + delete process.env.OPENCODE_CONFIG_DIR + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('continues past a refused custodied fallback to a healthy local fallback', async () => { + const refused = makeSentinelAccount({ + id: 'refused', + accountId: 'acct-refused', + }) + const local = liveAccount('local', { accountId: 'acct-local' }) + await withCustodyLoader( + { + accounts: [refused, local], + routing: { mode: 'fallback-first' }, + credential: undefined, + respond: () => 200, + }, + async ({ fetchOverride, authorizations, gets }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(200) + expect(gets()).toBeGreaterThan(0) + expect(authorizations.filter(Boolean)).toEqual([ + `Bearer ${local.access}`, + ]) + expect(authorizations.join(' ')).not.toContain(TOMBSTONE_OPENAI) + }, + ) + }) + + it('omits a disabled custody tombstone from fallback sends', async () => { + const tombstone = makeSentinelAccount({ + id: 'disabled', + accountId: 'acct-disabled', + }) + await withCustodyLoader( + { + accounts: [tombstone], + routing: { mode: 'fallback-first' }, + claustrumEnabled: false, + credential: { + material: makeCustodyRequestJwt('acct-disabled'), + recordVersion: 11, + }, + respond: () => 200, + }, + async ({ fetchOverride, authorizations }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(200) + expect(authorizations).toEqual(['Bearer main-access']) + expect(authorizations.join(' ')).not.toContain(TOMBSTONE_OPENAI) + }, + ) + }) + + it('reports the served vault version after a reactive fallback 401', async () => { + const fallback = makeSentinelAccount({ + id: 'reactive', + accountId: 'acct-reactive', + }) + const vaultAccess = makeCustodyRequestJwt('acct-reactive') + await withCustodyLoader( + { + accounts: [fallback], + credential: { material: vaultAccess, recordVersion: 23 }, + respond: (_authorization, url) => + url.endsWith('/responses') ? 401 : 200, + }, + async ({ fetchOverride, authorizations, reports }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(401) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(authorizations).toEqual([ + 'Bearer main-access', + `Bearer ${vaultAccess}`, + ]) + expect(reports).toEqual([ + { recordVersion: 23, reporterSource: 'direct' }, + ]) + }, + ) + }) + + it('uses the vault bearer for a sticky fallback send', async () => { + const fallback = makeSentinelAccount({ + id: 'sticky', + accountId: 'acct-sticky', + }) + const vaultAccess = makeCustodyRequestJwt('acct-sticky') + const checkedAt = Date.now() + await withCustodyLoader( + { + accounts: [fallback], + routing: { mode: 'sticky-balanced' }, + credential: { material: vaultAccess, recordVersion: 41 }, + sidebar: { + main: { quota: { primary: { remainingPercent: 1, checkedAt } } }, + fallbacks: [ + { + id: fallback.id, + accountId: fallback.accountId, + enabled: true, + quota: { primary: { remainingPercent: 100, checkedAt } }, + }, + ], + }, + respond: () => 200, + }, + async ({ fetchOverride, authorizations }) => { + const [url, init] = codexRequest('sticky-custody-session') + expect((await fetchOverride(url, init)).status).toBe(200) + expect(authorizations).toEqual([`Bearer ${vaultAccess}`]) + }, + ) + }) + + it('reports the served vault version after a sticky fallback 401', async () => { + const fallback = makeSentinelAccount({ + id: 'sticky-401', + accountId: 'acct-sticky-401', + }) + const vaultAccess = makeCustodyRequestJwt('acct-sticky-401') + const checkedAt = Date.now() + await withCustodyLoader( + { + accounts: [fallback], + routing: { mode: 'sticky-balanced' }, + credential: { material: vaultAccess, recordVersion: 43 }, + sidebar: { + main: { quota: { primary: { remainingPercent: 1, checkedAt } } }, + fallbacks: [ + { + id: fallback.id, + accountId: fallback.accountId, + enabled: true, + quota: { primary: { remainingPercent: 100, checkedAt } }, + }, + ], + }, + respond: () => 401, + }, + async ({ fetchOverride, reports }) => { + const [url, init] = codexRequest('sticky-custody-401') + expect((await fetchOverride(url, init)).status).toBe(401) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([ + { recordVersion: 43, reporterSource: 'direct' }, + ]) + }, + ) + }) + + it('keeps a due manifest account out of local refresh during cachekeep replay', async () => { + const fallback = enrollingAccount({ + id: 'cachekeep-local', + expires: Date.now() - 1_000, + }) + const originalRefresh = FallbackAccountManager.prototype.refreshAccount + let refreshes = 0 + FallbackAccountManager.prototype.refreshAccount = async function (...args) { + refreshes++ + return originalRefresh.apply(this, args) + } + try { + await withCustodyLoader( + { + accounts: [fallback], + claustrumEnabled: false, + respond: () => 200, + }, + async ({ cacheKeepManager }) => { + cacheKeepManager.track( + 'cachekeep-local', + JSON.stringify({ model: 'gpt-5.5', input: [] }), + fallback.id, + ) + const target = ( + cacheKeepManager as never as { + targets: Map + } + ).targets.get('cachekeep-local') + if (!target) throw new Error('expected cachekeep target') + target.cacheExpiresAt = Date.now() + await cacheKeepManager.tick() + expect(refreshes).toBe(0) + }, + ) + } finally { + FallbackAccountManager.prototype.refreshAccount = originalRefresh + } + }) + + it('uses the vault bearer during a cachekeep replay', async () => { + const fallback = makeSentinelAccount({ + id: 'cachekeep-vault', + accountId: 'acct-cachekeep-vault', + }) + const vaultAccess = makeCustodyRequestJwt('acct-cachekeep-vault') + await withCustodyLoader( + { + accounts: [fallback], + credential: { material: vaultAccess, recordVersion: 53 }, + respond: () => 200, + }, + async ({ cacheKeepManager, authorizations }) => { + cacheKeepManager.track( + 'cachekeep-vault', + JSON.stringify({ model: 'gpt-5.5', input: [] }), + fallback.id, + ) + const target = ( + cacheKeepManager as never as { + targets: Map + } + ).targets.get('cachekeep-vault') + if (!target) throw new Error('expected cachekeep target') + target.cacheExpiresAt = Date.now() + await cacheKeepManager.tick() + expect(authorizations).toContain(`Bearer ${vaultAccess}`) + }, + ) + }) + + it('reports the served vault version after a cachekeep replay 401', async () => { + const fallback = makeSentinelAccount({ + id: 'cachekeep-vault-401', + accountId: 'acct-cachekeep-vault-401', + }) + const vaultAccess = makeCustodyRequestJwt('acct-cachekeep-vault-401') + await withCustodyLoader( + { + accounts: [fallback], + credential: { material: vaultAccess, recordVersion: 54 }, + respond: (_authorization, url) => + url.endsWith('/responses') ? 401 : 200, + }, + async ({ cacheKeepManager, reports }) => { + cacheKeepManager.track( + 'cachekeep-vault-401', + JSON.stringify({ model: 'gpt-5.5', input: [] }), + fallback.id, + ) + const target = ( + cacheKeepManager as never as { + targets: Map + } + ).targets.get('cachekeep-vault-401') + if (!target) throw new Error('expected cachekeep target') + target.cacheExpiresAt = Date.now() + await cacheKeepManager.tick() + expect(reports).toEqual([ + { recordVersion: 54, reporterSource: 'direct' }, + ]) + }, + ) + }) + + it('does not report a local cachekeep replay 401', async () => { + const fallback = liveAccount('cachekeep-local-401', { + accountId: 'acct-cachekeep-local-401', + expires: Date.now() + 24 * 60 * 60_000, + }) + await withCustodyLoader( + { + accounts: [fallback], + claustrumEnabled: false, + respond: (_authorization, url) => + url.endsWith('/responses') ? 401 : 200, + }, + async ({ cacheKeepManager, reports }) => { + cacheKeepManager.track( + 'cachekeep-local-401', + JSON.stringify({ model: 'gpt-5.5', input: [] }), + fallback.id, + ) + const target = ( + cacheKeepManager as never as { + targets: Map + } + ).targets.get('cachekeep-local-401') + if (!target) throw new Error('expected cachekeep target') + target.cacheExpiresAt = Date.now() + await cacheKeepManager.tick() + expect(reports).toEqual([]) + }, + ) + }) + + it('reports a vault 401 from the sticky replacement send', async () => { + const directory = mkdtempSync(join(tmpdir(), 'custody-sticky-replacement-')) + const configPath = join(directory, 'openai-auth.json') + const manifestPath = join(directory, 'handles.json') + const sidebarPath = join(directory, 'sidebar.json') + const sessionId = 'sticky-replacement-session' + const checkedAt = Date.now() + 5 * 60_000 + const local = liveAccount('sticky-local', { + accountId: 'acct-sticky-local', + enabled: true, + }) + const vault = makeSentinelAccount({ + id: 'sticky-vault', + accountId: 'acct-sticky-vault', + enabled: true, + }) + const manifest = enrollmentManifest(vault.id) + if (!manifest.ok) throw new Error('expected manifest fixture') + const vaultAccess = makeCustodyRequestJwt('acct-sticky-vault') + const authorizations: string[] = [] + const reports: Array<{ recordVersion: number; reporterSource: string }> = [] + const originalFetch = globalThis.fetch + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join(directory, 'state.json') + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = sidebarPath + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = join(directory, 'test.log') + process.env.OPENCODE_CONFIG_DIR = directory + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [local, vault], + claustrum: claustrumConfig({ mode: 'claustrum' }), + routing: { mode: 'sticky-balanced' }, + }), + ) + writeFileSync(manifestPath, JSON.stringify(manifest.value)) + chmodSync(manifestPath, 0o600) + writeFileSync( + sidebarPath, + JSON.stringify({ + main: { + quota: { + primary: { remainingPercent: 20, checkedAt }, + }, + mainAccountId: 'acc-main', + }, + fallbacks: [ + { + id: local.id, + accountId: local.accountId, + enabled: true, + quota: { primary: { remainingPercent: 90, checkedAt } }, + }, + { + id: vault.id, + accountId: vault.accountId, + enabled: true, + quota: { primary: { remainingPercent: 100, checkedAt } }, + }, + ], + stickyAssignments: { + [hashSidebarSessionId(sessionId)]: { + accountId: local.id, + assignedAt: Date.now(), + lastSeenAt: Date.now(), + inputBytes: 1, + }, + }, + }), + ) + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const urlText = String(url) + if (urlText.includes('wham')) { + return new Response('{}', { status: 500 }) + } + if (urlText.endsWith('/responses')) { + authorizations.push( + new Headers(init?.headers).get('authorization') ?? '', + ) + } + return new Response('{}', { status: 401 }) + }) as typeof globalThis.fetch + const transport: ClaustrumCacheTransportLike = { + async getCredential() { + return { + material: vaultAccess, + recordVersion: 71, + expiresAtMs: Date.now() + 60_000, + } + }, + async statusCredential() { + return { + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 71, + } + }, + async reportAuthFailure(params) { + reports.push({ + recordVersion: params.recordVersion, + reporterSource: params.reporterSource, + }) + }, + close() {}, + } + const hooks = await CodexAuthPlugin( + { + client: { auth: { set: async () => {} } }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: directory, + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { custody: { transport, detection: 'available' } }, + ) + try { + const loader = hooks.auth?.loader + if (!loader) throw new Error('expected auth loader') + const result = await loader( + async () => ({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3_600_000, + }), + {} as never, + ) + const fetchOverride = (result as { fetch?: typeof globalThis.fetch }) + .fetch + if (!fetchOverride) throw new Error('expected fetch override') + const response = await fetchOverride( + 'https://chatgpt.com/backend-api/codex/responses', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'session-id': sessionId, + }, + body: JSON.stringify({ model: 'gpt-5.5', input: [] }), + }, + ) + expect(response.status).toBe(401) + expect(authorizations).toEqual([ + `Bearer ${local.access}`, + `Bearer ${vaultAccess}`, + ]) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([{ recordVersion: 71, reporterSource: 'direct' }]) + } finally { + await hooks.dispose?.() + globalThis.fetch = originalFetch + process.env.OPENCODE_OPENAI_AUTH_FILE = FLOOR_AUTH_FILE + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = FLOOR_STATE_FILE + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = + FLOOR_SIDEBAR_STATE_FILE + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = FLOOR_LOG_FILE + process.env.CLAUSTRUM_OPENCODE_HANDLES = FLOOR_CLAUSTRUM_HANDLES + delete process.env.OPENCODE_CONFIG_DIR + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('refuses a reported vault credential until a tick refills the request cache', async () => { + const fallback = makeSentinelAccount({ + id: 'invalidate-request-cache', + accountId: 'acct-invalidate-request-cache', + }) + const vault17 = makeCustodyRequestJwt('acct-invalidate-request-cache') + const vault18 = makeCustodyRequestJwt( + 'acct-invalidate-request-cache', + 'v18', + ) + let credential = { material: vault17, recordVersion: 17 } + await withCustodyLoader( + { + accounts: [fallback], + routing: { mode: 'fallback-first' }, + credentialForGet: () => credential, + respond: (authorization, url) => + url.endsWith('/responses') && authorization !== 'Bearer main-access' + ? 401 + : 200, + }, + async ({ fetchOverride, authorizations, reports, runtime }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(200) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([ + { recordVersion: 17, reporterSource: 'direct' }, + ]) + + const afterRejected = authorizations.length + expect((await fetchOverride(url, init)).status).toBe(200) + expect(authorizations.slice(afterRejected)).toEqual([ + 'Bearer main-access', + ]) + + credential = { material: vault18, recordVersion: 18 } + await runtime.runTick() + expect((await fetchOverride(url, init)).status).toBe(200) + expect(authorizations).toContain(`Bearer ${vault18}`) + }, + ) + }) + + it('bounds repeated vault 401 reports until a later vault success resets the handle', async () => { + const fallback = makeSentinelAccount({ + id: 'bound-request-cache', + accountId: 'acct-bound-request-cache', + }) + const manifest = enrollmentManifest(fallback.id) + if (!manifest.ok) throw new Error('expected manifest fixture') + const handle = manifest.value.providers[0]!.accounts[0]!.handle + let clock = Date.now() + let version = 17 + let vaultSucceeds = false + await withCustodyLoader( + { + accounts: [fallback], + routing: { mode: 'fallback-first' }, + now: () => clock, + credentialForGet: () => ({ + material: makeCustodyRequestJwt( + 'acct-bound-request-cache', + String(version), + ), + recordVersion: version, + }), + respond: (authorization, url) => { + if (!url.endsWith('/responses')) return 200 + if (authorization === 'Bearer main-access') return 200 + return vaultSucceeds ? 200 : 401 + }, + }, + async ({ fetchOverride, reports, gets, runtime, authorizations }) => { + const [url, init] = codexRequest() + await fetchOverride(url, init) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([ + { recordVersion: 17, reporterSource: 'direct' }, + ]) + + version = 18 + await runtime.runTick() + await fetchOverride(url, init) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([ + { recordVersion: 17, reporterSource: 'direct' }, + { recordVersion: 18, reporterSource: 'direct' }, + ]) + + const getsBeforeReauthTick = gets() + version = 19 + await runtime.runTick() + expect(gets()).toBe(getsBeforeReauthTick) + const reauthRequestStart = authorizations.length + await fetchOverride(url, init) + expect(authorizations.slice(reauthRequestStart)).toEqual([ + 'Bearer main-access', + ]) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toHaveLength(2) + + clock += 60 * 60_000 + 1 + version = 20 + await runtime.runTick() + expect(runtime.getCache()?.isReauth(handle, clock)).toBe(false) + expect(runtime.getCache()?.isBlocked(handle)).toBe(false) + expect((await runtime.getCache()?.peek(handle))?.recordVersion).toBe(20) + await fetchOverride(url, init) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([ + { recordVersion: 17, reporterSource: 'direct' }, + { recordVersion: 18, reporterSource: 'direct' }, + { recordVersion: 20, reporterSource: 'direct' }, + ]) + + version = 21 + await runtime.runTick() + vaultSucceeds = true + expect((await fetchOverride(url, init)).status).toBe(200) + expect(authorizations.at(-1)).toBe( + `Bearer ${makeCustodyRequestJwt('acct-bound-request-cache', '21')}`, + ) + + vaultSucceeds = false + await fetchOverride(url, init) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([ + { recordVersion: 17, reporterSource: 'direct' }, + { recordVersion: 18, reporterSource: 'direct' }, + { recordVersion: 20, reporterSource: 'direct' }, + { recordVersion: 21, reporterSource: 'direct' }, + ]) + }, + ) + }) + + describe('vault failure filtering', () => { + it('does not report a local 401', async () => { + await withCustodyLoader( + { accounts: [], respond: () => 401 }, + async ({ fetchOverride, reports }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(401) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([]) + }, + ) + }) + + it('does not report a vault 403', async () => { + const fallback = makeSentinelAccount({ + id: 'forbidden', + accountId: 'acct-forbidden', + }) + const vaultAccess = makeCustodyRequestJwt('acct-forbidden') + await withCustodyLoader( + { + accounts: [fallback], + routing: { mode: 'fallback-first' }, + credential: { material: vaultAccess, recordVersion: 29 }, + respond: () => 403, + }, + async ({ fetchOverride, reports }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(403) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([]) + }, + ) + }) + + it('does not report a vault 429', async () => { + const fallback = makeSentinelAccount({ + id: 'limited', + accountId: 'acct-limited', + }) + const vaultAccess = makeCustodyRequestJwt('acct-limited') + await withCustodyLoader( + { + accounts: [fallback], + routing: { mode: 'fallback-first' }, + credential: { material: vaultAccess, recordVersion: 31 }, + respond: () => 429, + }, + async ({ fetchOverride, reports }) => { + const [url, init] = codexRequest() + expect((await fetchOverride(url, init)).status).toBe(429) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([]) + }, + ) + }) + + it('does not report a vault 401 outside the Codex endpoint', async () => { + const fallback = makeSentinelAccount({ + id: 'outside', + accountId: 'acct-outside', + }) + const vaultAccess = makeCustodyRequestJwt('acct-outside') + await withCustodyLoader( + { + accounts: [fallback], + credential: { material: vaultAccess, recordVersion: 37 }, + respond: (_authorization, url) => (url.includes('wham') ? 200 : 401), + }, + async ({ fetchOverride, reports }) => { + const init: RequestInit = { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.5', input: [] }), + } + expect( + (await fetchOverride('https://example.test/responses', init)) + .status, + ).toBe(401) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([]) + }, + ) + }) + }) + + it('refuses a bound real row under claustrum before it can serve local access', async () => { + const account = enrollingAccount({ expires: 100_000 }) + const storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + const manifest = enrollmentManifest(account.id) + if (!manifest.ok) throw new Error('expected manifest fixture') + const handle = manifest.value.providers[0]?.accounts[0]?.handle + if (!handle) throw new Error('expected fixture handle') + let vaultGets = 0 + const cache = new ClaustrumCredentialCache({ + connector: async () => + ({ + async getCredential() { + vaultGets++ + throw new Error('local token should serve') + }, + async statusCredential() { + return { + ready: false, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 0, + } + }, + async reportAuthFailure() {}, + close() {}, + }) as never, + }) + const result = await resolveFallbackAccess(account, storage, manifest, { + cache, + manifestHandle: handle, + refreshBeforeExpiryMs: 60_000, + now: () => 1_000, + }) + + expect(result).toBe(CUSTODY_REFUSE) + expect(vaultGets).toBe(0) + cache.close() + }) + + it('serves a local account after its manifest entry is removed', async () => { + const account = enrollingAccount({ expires: 1_000 }) + const storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + const result = await resolveFallbackAccess( + account, + storage, + emptyManifest(), + { + now: () => 1_000, + }, + ) + + expect(result).toEqual({ token: 'local-access', provenance: 'local' }) + }) + + it('completes a due enrollment before serving vault access', async () => { + const account = enrollingAccount() + let storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + const manifest = enrollmentManifest(account.id) + if (!manifest.ok) throw new Error('expected manifest fixture') + const handle = manifest.value.providers[0]?.accounts[0]?.handle + if (!handle) throw new Error('expected fixture handle') + const vaultAccess = makeCustodyRequestJwt('acct-1') + const cache = new ClaustrumCredentialCache({ + connector: async () => + ({ + async getCredential() { + return { + material: vaultAccess, + recordVersion: 7, + expiresAtMs: 10_000, + } + }, + async statusCredential() { + return { + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 7, + } + }, + async reportAuthFailure() {}, + close() {}, + }) as never, + }) + + const result = await resolveFallbackAccess(account, storage, manifest, { + cache, + manifestHandle: handle, + refreshBeforeExpiryMs: 60_000, + now: () => 1_000, + completeEnrollmentDeps: { + loadAccounts: async () => storage, + readCustodyManifest: async () => manifest, + acquireRefreshFileLock: async () => + ({ release: async () => {} }) as never, + configPath: 'memory', + cache, + minTtlMs: 30_000, + mutateAccounts: async (mutate) => { + storage = mutate(storage) ?? storage + }, + }, + }) + + expect(result).toEqual({ + token: vaultAccess, + provenance: { handle, recordVersion: 7 }, + }) + const completed = storage.accounts[0] + expect(completed?.type).toBe('oauth') + if (completed?.type !== 'oauth') throw new Error('expected oauth account') + expect(completed.access).not.toBe('local-access') + cache.close() + }) + + it('refuses a due enrollment when the served claim differs from its local identity', async () => { + const account = enrollingAccount() + let storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + const manifest = enrollmentManifest(account.id) + if (!manifest.ok) throw new Error('expected manifest fixture') + const handle = manifest.value.providers[0]?.accounts[0]?.handle + if (!handle) throw new Error('expected fixture handle') + const cache = new ClaustrumCredentialCache({ + connector: async () => + ({ + async getCredential() { + return { + material: makeCustodyRequestJwt('wrong-account'), + recordVersion: 8, + expiresAtMs: 10_000, + } + }, + async statusCredential() { + return { + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 8, + } + }, + async reportAuthFailure() {}, + close() {}, + }) as never, + }) + + const result = await resolveFallbackAccess(account, storage, manifest, { + cache, + manifestHandle: handle, + refreshBeforeExpiryMs: 60_000, + now: () => 1_000, + completeEnrollmentDeps: { + loadAccounts: async () => storage, + readCustodyManifest: async () => manifest, + acquireRefreshFileLock: async () => + ({ release: async () => {} }) as never, + configPath: 'memory', + cache, + minTtlMs: 30_000, + mutateAccounts: async (mutate) => { + storage = mutate(storage) ?? storage + }, + }, + }) + + expect(result).toBe(CUSTODY_REFUSE) + const intact = storage.accounts[0] + expect(intact?.type).toBe('oauth') + if (intact?.type !== 'oauth') throw new Error('expected oauth account') + expect(intact.access).toBe('local-access') + expect(intact.refresh).toBe('local-refresh') + cache.close() + }) + + it('refuses a due enrollment when completion cannot fetch vault material', async () => { + const account = enrollingAccount() + const storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + const manifest = enrollmentManifest(account.id) + if (!manifest.ok) throw new Error('expected manifest fixture') + const handle = manifest.value.providers[0]?.accounts[0]?.handle + if (!handle) throw new Error('expected fixture handle') + const cache = new ClaustrumCredentialCache({ + connector: async () => + ({ + async getCredential() { + throw new Error('vault unavailable') + }, + async statusCredential() { + return { + ready: false, + lastErrorCode: 'unavailable', + leaseHeld: false, + recordVersion: 0, + } + }, + async reportAuthFailure() {}, + close() {}, + }) as never, + }) + + const result = await resolveFallbackAccess(account, storage, manifest, { + cache, + manifestHandle: handle, + refreshBeforeExpiryMs: 60_000, + now: () => 1_000, + completeEnrollmentDeps: { + loadAccounts: async () => storage, + readCustodyManifest: async () => manifest, + acquireRefreshFileLock: async () => + ({ release: async () => {} }) as never, + configPath: 'memory', + cache, + minTtlMs: 30_000, + mutateAccounts: async () => {}, + }, + }) + + expect(result).toBe(CUSTODY_REFUSE) + cache.close() + }) + + it('skips reset refresh when a fallback is refresh-inert', async () => { + const account = liveAccount('custody-1', { expires: 0 }) + const storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'local' }), + }) + let refreshes = 0 + const resolve = createResetTargetResolver({ + getAuth: async () => ({ type: 'oauth' }), + refreshMainWithLease: async () => ({ + access: 'main-access', + refresh: 'main-refresh', + expires: 100_000, + }), + refreshFallbackAccount: async () => { + refreshes++ + return account + }, + loadAccounts: async () => storage, + accountStoragePath: 'memory', + now: () => 1_000, + isFallbackRefreshInert: async () => true, + resolveFallbackAccess: async () => ({ + token: 'vault-access', + provenance: { handle: 'ckh_test', recordVersion: 7 }, + }), + }) + + await expect(resolve(account.id)).resolves.toMatchObject({ + accessToken: 'vault-access', + }) + expect(refreshes).toBe(0) + }) + + it('records only vault provenance for a response', () => { + const response = new Response(null, { status: 401 }) + const provenance = new WeakMap() + + stampVaultProvenance(response, 'local', provenance) + expect(provenance.get(response)).toBeUndefined() + + stampVaultProvenance( + response, + { handle: 'ckh_test', recordVersion: 17 }, + provenance, + ) + expect(provenance.get(response)).toEqual({ + handle: 'ckh_test', + recordVersion: 17, + }) + }) +}) diff --git a/packages/opencode/src/tests/custody-runtime.test.ts b/packages/opencode/src/tests/custody-runtime.test.ts new file mode 100644 index 0000000..90074e3 --- /dev/null +++ b/packages/opencode/src/tests/custody-runtime.test.ts @@ -0,0 +1,2376 @@ +/** + * Loader-level custody runtime tests. + * + * The runtime owns one vendored client + cache per process, runs the boot + * completion sweep before the background refresh is armed, ticks every five + * minutes with jitter, and projects custody state into the sidebar. These + * tests drive the runtime directly through `__createCustodyRuntimeForTest` + * with injectable deps; the loader-order case drives `CodexAuthPlugin` through + * its custody seam so the handoff to background refresh is observable. + */ + +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + AccountStorage, + CorruptOAuthAccount, + OAuthAccount, +} from '../core/accounts.ts' +import { + FallbackAccountManager, + loadAccounts, + mutateAccounts, + saveAccounts, + withAccountStoreTransaction, +} from '../core/accounts.ts' +import { CUSTODY_TOMBSTONE_PREFIX } from '../core/custody.ts' +import { + type CustodyManifestReadResult, + defaultCustodyManifestPath, + readCustodyManifest, +} from '../core/custody-manifest.ts' +import { custodySlotFingerprint } from '../core/custody-transition.ts' +import { CUSTODY_DEPS_INCOMPLETE } from '../core/refresh-all-quota.ts' +import { acquireRefreshFileLock } from '../core/refresh-file-lock.ts' +import { + __createCustodyRuntimeForTest, + __resetSweepFailureLogDedupeForTest, + type ClaustrumCacheTransportLike, + CodexAuthPlugin, + type CustodyRuntimeOptions, +} from '../index.ts' +import type { detectClaustrumConnection } from '../vendor/claustrum-client/index.ts' +import { + claustrumConfig, + emptyManifest, + enrollmentManifest, + liveAccount, + liveStorage, + makeCustodyJwt, + makeSentinelAccount, + TOMBSTONE_OPENAI, + withClaustrumMode, +} from './custody-fixtures.ts' +import { + FLOOR_CLAUSTRUM_HANDLES, + FLOOR_CLAUSTRUM_HANDLES_LOCK, +} from './setup-env.ts' + +const HANDLE = 'ckh_ZmItMQaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + +let scratchDir: string +let configPath: string +let manifestPath: string +let originalManifestEnv: string | undefined +let originalStateEnv: string | undefined + +type Detections = 'available' | 'absent' | 'malformed' +type DetectionResult = Awaited> + +function detectOverride( + detections: Detections, +): () => Promise { + return async () => { + if (detections === 'available') { + return { + status: 'available', + schema: 1, + wireVersion: 1, + endpoints: [], + } + } + if (detections === 'absent') { + return { status: 'absent', path: manifestPath } + } + return { status: 'malformed', path: manifestPath, reason: 'bad shape' } + } +} + +interface CapturedTransport { + getCalls: Array<{ handle: string; minTtlMs?: number }> + reportCalls: Array<{ + handle: string + providerStatus: number + recordVersion: number + }> + statusCalls: Array<{ handle: string }> + closeCalls: number +} + +function makeTransport( + behaviour: (input: { handle: string; minTtlMs?: number }) => + | { + material: string + recordVersion: number + expiresAtMs: number + } + | Promise<{ + material: string + recordVersion: number + expiresAtMs: number + }>, +): { transport: ClaustrumCacheTransportLike; captured: CapturedTransport } { + const captured: CapturedTransport = { + getCalls: [], + reportCalls: [], + statusCalls: [], + closeCalls: 0, + } + const transport: ClaustrumCacheTransportLike = { + getCredential: async (handle, minTtlMs) => { + captured.getCalls.push({ handle, minTtlMs }) + return behaviour({ handle, minTtlMs }) + }, + statusCredential: async (handle) => { + captured.statusCalls.push({ handle }) + return { + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + } + }, + reportAuthFailure: async (params) => { + captured.reportCalls.push(params) + }, + close: () => { + captured.closeCalls += 1 + }, + } + return { transport, captured } +} + +async function writeStorageWithManifest( + storage: AccountStorage, + manifest: CustodyManifestReadResult, +): Promise { + if (!manifest.ok) return + // Serialise the owning provider only — the reader ignores other blocks. + const file = { + version: manifest.value.version, + providers: manifest.value.providers, + } + await saveAccounts(storage, configPath) + mkdirSync(scratchDir, { recursive: true, mode: 0o700 }) + writeFileSync(manifestPath, JSON.stringify(file), { mode: 0o600 }) + chmodSync(manifestPath, 0o600) +} + +async function writeCorruptStorageWithManifest( + storage: AccountStorage, + manifest: CustodyManifestReadResult, +): Promise { + await saveAccounts(storage, configPath) + if (!manifest.ok) return + writeFileSync(manifestPath, JSON.stringify(manifest.value), { mode: 0o600 }) + chmodSync(manifestPath, 0o600) +} + +async function writeBoundRealFixture(): Promise { + const account = liveAccount('fb-1', { accountId: 'acct-1' }) + await writeStorageWithManifest( + withClaustrumMode(liveStorage([account])), + enrollmentManifest(account.id), + ) + return account +} + +beforeEach(() => { + scratchDir = mkdtempSync(join(tmpdir(), 'custody-runtime-')) + configPath = join(scratchDir, 'openai-auth.json') + manifestPath = join(scratchDir, 'opencode-handles.json') + originalManifestEnv = process.env.CLAUSTRUM_OPENCODE_HANDLES + originalStateEnv = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join(scratchDir, 'state.json') + __resetSweepFailureLogDedupeForTest() +}) + +afterEach(() => { + process.env.CLAUSTRUM_OPENCODE_HANDLES = + originalManifestEnv ?? FLOOR_CLAUSTRUM_HANDLES + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = originalStateEnv + try { + rmSync(scratchDir, { recursive: true, force: true }) + } catch {} +}) + +function makeLogger() { + return { + info: mock((_msg: string, _meta?: unknown) => undefined), + warn: mock((_msg: string, _meta?: unknown) => undefined), + debug: mock((_msg: string, _meta?: unknown) => undefined), + error: mock((_msg: string, _meta?: unknown) => undefined), + } +} + +function makeOptions( + overrides: Partial & { + storage: AccountStorage | null + transport: ClaustrumCacheTransportLike + detection: Detections + logger?: CustodyRuntimeOptions['logger'] + }, +): CustodyRuntimeOptions { + const { storage: _ignoredStorage, ...rest } = overrides + void _ignoredStorage + const storage = overrides.storage + ? { + ...overrides.storage, + claustrum: + overrides.storage.claustrum ?? claustrumConfig({ mode: 'claustrum' }), + } + : overrides.storage + return { + storage, + configPath, + manifestPath, + detectClaustrumConnection: detectOverride(overrides.detection), + cacheConnector: async () => overrides.transport, + loadAccounts: (path?: string) => loadAccounts(path ?? configPath), + mutateAccounts, + withAccountStoreTransaction, + readCustodyManifest, + acquireRefreshFileLock: async () => ({ + release: async () => {}, + }), + logger: overrides.logger ?? makeLogger(), + ...rest, + } +} + +function corruptAccount( + id = 'fb-1', + overrides: Partial = {}, +): CorruptOAuthAccount { + return { + id, + type: 'oauth', + corrupt: true, + enabled: false, + accountId: 'acct-1', + addedAt: 123, + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Detection +// --------------------------------------------------------------------------- + +describe('custody detection', () => { + it('logs once at info and creates no client or timer when the connection file is absent', async () => { + const transport: ClaustrumCacheTransportLike = { + getCredential: mock(async () => { + throw new Error('should not be called') + }), + statusCredential: mock(async () => { + throw new Error('should not be called') + }), + reportAuthFailure: mock(async () => undefined), + close: () => {}, + } + const logger = makeLogger() + const setIntervalFn = mock( + () => 0 as unknown as ReturnType, + ) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: liveStorage([liveAccount('fb-1')]), + transport, + detection: 'absent', + logger, + setIntervalFn, + clearIntervalFn: mock(() => undefined), + }), + ) + await runtime.boot() + expect(runtime.isEnabled()).toBe(false) + expect(runtime.getCache()).toBeUndefined() + expect(runtime.getTransport()).toBeUndefined() + // The absent case is logged once at info. + const infoMessages = logger.info.mock.calls.map(([msg]) => msg) + expect( + infoMessages.some((msg) => msg.includes('custody not configured')), + ).toBe(true) + expect(setIntervalFn).not.toHaveBeenCalled() + runtime.dispose() + }) + + it('logs once at warn and disables custody for the process when the connection file is malformed', async () => { + const transport: ClaustrumCacheTransportLike = { + getCredential: mock(async () => { + throw new Error('should not be called') + }), + statusCredential: mock(async () => { + throw new Error('should not be called') + }), + reportAuthFailure: mock(async () => undefined), + close: () => {}, + } + const logger = makeLogger() + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: liveStorage([liveAccount('fb-1')]), + transport, + detection: 'malformed', + logger, + }), + ) + await runtime.boot() + expect(runtime.isEnabled()).toBe(false) + expect(runtime.getCache()).toBeUndefined() + const warnMessages = logger.warn.mock.calls.map(([msg]) => msg) + expect( + warnMessages.some((msg) => msg.includes('connection malformed')), + ).toBe(true) + runtime.dispose() + }) +}) + +describe('orphan binding discovery', () => { + it('creates one binding-pending row before the manifest join across two runtimes', async () => { + const storage = liveStorage([], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + await writeStorageWithManifest(storage, enrollmentManifest('new-account')) + const { transport } = makeTransport(() => { + throw new Error('binding stays pending until a credential serves') + }) + let mutations = 0 + let lockAcquisitions = 0 + let releaseFirst = () => {} + let markFirstAcquired = () => {} + const firstAcquired = new Promise((resolve) => { + markFirstAcquired = resolve + }) + const holdFirst = new Promise((resolve) => { + releaseFirst = resolve + }) + const joinSawRow: boolean[] = [] + const shared = { + storage, + transport, + detection: 'available' as const, + acquireRefreshFileLock, + mutateAccounts: async (...args: Parameters) => { + mutations += 1 + return mutateAccounts(...args) + }, + onReconcileStep: async ( + step: 'discovery-lock-acquired' | 'enabled-manifest-join', + ) => { + if (step === 'discovery-lock-acquired') { + lockAcquisitions += 1 + if (lockAcquisitions === 1) { + markFirstAcquired() + await holdFirst + } + return + } + joinSawRow.push( + (await loadAccounts(configPath))?.accounts.some( + (account) => account.id === 'new-account', + ) ?? false, + ) + }, + } + const first = __createCustodyRuntimeForTest(makeOptions(shared)) + const second = __createCustodyRuntimeForTest(makeOptions(shared)) + + const firstBoot = first.boot() + await firstAcquired + let secondSettled = false + const secondBoot = second.boot().then(() => { + secondSettled = true + }) + for (let turn = 0; turn < 32; turn += 1) await Promise.resolve() + expect(secondSettled).toBe(false) + releaseFirst() + await Promise.all([firstBoot, secondBoot]) + + const after = await loadAccounts(configPath) + expect(after?.accounts).toEqual([ + { + id: 'new-account', + type: 'oauth', + access: '', + refresh: TOMBSTONE_OPENAI, + expires: 0, + enabled: true, + }, + ]) + expect(after?.claustrum?.rowHistory).toEqual(['new-account']) + expect(mutations).toBe(1) + expect(lockAcquisitions).toBe(2) + expect(joinSawRow).toEqual([true, true]) + first.dispose() + second.dispose() + }) + + it('logs an orphan once under local mode without creating a row or client', async () => { + const storage = liveStorage([], { + claustrum: claustrumConfig({ mode: 'local' }), + }) + await writeStorageWithManifest(storage, enrollmentManifest('new-account')) + const logger = makeLogger() + let connections = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport: makeTransport(() => { + throw new Error('unused') + }).transport, + detection: 'available', + logger, + cacheConnector: async () => { + connections += 1 + throw new Error('must not connect') + }, + }), + ) + + await runtime.boot() + + expect((await loadAccounts(configPath))?.accounts).toEqual([]) + expect(connections).toBe(0) + expect( + logger.info.mock.calls.filter(([message]) => + message.includes('orphan-binding: awaiting discovery'), + ), + ).toHaveLength(1) + runtime.dispose() + }) + + it('names a removed row from history and recreates it under claustrum', async () => { + const storage = liveStorage([], { + claustrum: claustrumConfig({ + mode: 'claustrum', + rowHistory: ['removed-account'], + }), + }) + await writeStorageWithManifest( + storage, + enrollmentManifest('removed-account'), + ) + const logger = makeLogger() + const { transport } = makeTransport(() => { + throw new Error('vault unavailable') + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ storage, transport, detection: 'available', logger }), + ) + + await runtime.boot() + + expect((await loadAccounts(configPath))?.accounts[0]).toMatchObject({ + id: 'removed-account', + refresh: TOMBSTONE_OPENAI, + }) + expect( + logger.info.mock.calls.some(([message]) => + message.includes('orphan-binding: row removed'), + ), + ).toBe(true) + runtime.dispose() + }) +}) + +describe('manifest revision reconciliation', () => { + it('does not rediscover a removed row when the manifest revision is unchanged', async () => { + const storage = liveStorage([], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + await writeStorageWithManifest(storage, enrollmentManifest('unchanged')) + const { transport } = makeTransport(() => { + throw new Error('vault unavailable') + }) + let mutations = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + mutateAccounts: async (...args: Parameters) => { + mutations += 1 + return mutateAccounts(...args) + }, + }), + ) + await runtime.boot() + await mutateAccounts( + (current) => ({ + ...current, + accounts: current.accounts.filter( + (account) => account.id !== 'unchanged', + ), + }), + configPath, + ) + mutations = 0 + + await runtime.runTick() + + expect(mutations).toBe(0) + expect((await loadAccounts(configPath))?.accounts).toEqual([]) + runtime.dispose() + }) + + it('discovers a new manifest row on the first tick after its revision changes', async () => { + const storage = liveStorage([], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + await writeStorageWithManifest(storage, emptyManifest()) + const { transport } = makeTransport(() => { + throw new Error('vault unavailable') + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ storage, transport, detection: 'available' }), + ) + await runtime.boot() + const nextManifest = enrollmentManifest('revision-account') + if (!nextManifest.ok) throw new Error('expected manifest') + writeFileSync(manifestPath, JSON.stringify(nextManifest.value)) + + await runtime.runTick() + + expect((await loadAccounts(configPath))?.accounts[0]).toMatchObject({ + id: 'revision-account', + refresh: TOMBSTONE_OPENAI, + accountId: undefined, + }) + runtime.dispose() + }) + + it('refuses an unreadable manifest before credential inspection and projects its cause', async () => { + const account = liveAccount('fb-1') + const storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + await saveAccounts(storage, configPath) + const { transport, captured } = makeTransport(() => { + throw new Error('must not inspect vault credentials') + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + readCustodyManifest: async () => ({ + ok: false, + reason: 'invalid', + message: 'invalid manifest', + }), + }), + ) + + await runtime.boot() + + expect(captured.getCalls).toEqual([]) + expect(runtime.getCustodyProjection(account, Date.now())).toEqual({ + state: 'inert', + reason: 'manifest-unreadable', + }) + runtime.dispose() + }) + + it('refuses an unreadable manifest on tick before writes or credential inspection', async () => { + const account = makeSentinelAccount({ id: 'tick-unreadable' }) + const storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + await writeStorageWithManifest(storage, enrollmentManifest(account.id)) + const { transport, captured } = makeTransport(() => { + throw new Error('must not inspect vault credentials') + }) + let mutations = 0 + let joins = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + mutateAccounts: async (...args: Parameters) => { + mutations += 1 + return mutateAccounts(...args) + }, + onReconcileStep: (step) => { + if (step === 'enabled-manifest-join') joins += 1 + }, + }), + ) + await runtime.boot() + captured.getCalls.length = 0 + mutations = 0 + joins = 0 + writeFileSync(manifestPath, '{not-json') + + await runtime.runTick() + + expect(mutations).toBe(0) + expect(captured.getCalls).toEqual([]) + expect(joins).toBe(0) + expect(runtime.getCustodyProjection(account, Date.now())).toEqual({ + state: 'inert', + reason: 'manifest-unreadable', + }) + expect((await loadAccounts(configPath))?.accounts[0]).toEqual(account) + runtime.dispose() + }) +}) + +describe('fingerprint-gated reconciliation resume', () => { + it('tombstones a matching fallback row and clears the completed transition', async () => { + const account = liveAccount('fb-1') + const storage = liveStorage([account], { + claustrum: claustrumConfig({ + mode: 'claustrum', + transition: { + manifestRevision: 'revision-1', + storeGeneration: 'generation-1', + fingerprints: { + fallbacks: { + [account.id]: custodySlotFingerprint( + account.access!, + account.refresh, + ), + }, + }, + }, + }), + }) + await writeStorageWithManifest(storage, enrollmentManifest(account.id)) + const { transport } = makeTransport(() => { + throw new Error('resume must not depend on the vault') + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ storage, transport, detection: 'available' }), + ) + + await runtime.boot() + + const after = await loadAccounts(configPath) + expect(after?.accounts[0]).toMatchObject({ + access: '', + refresh: TOMBSTONE_OPENAI, + expires: 0, + }) + expect(after?.claustrum?.transition).toBeUndefined() + runtime.dispose() + }) + + it('keeps a mismatched fallback real and retains all transition fingerprints', async () => { + const account = liveAccount('fb-1', { access: 'new-local-access' }) + const transition = { + manifestRevision: 'revision-1', + storeGeneration: 'generation-1', + fingerprints: { + fallbacks: { + [account.id]: custodySlotFingerprint( + 'old-local-access', + account.refresh, + ), + }, + }, + } + const storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum', transition }), + }) + await writeStorageWithManifest(storage, enrollmentManifest(account.id)) + const { transport } = makeTransport(() => { + throw new Error('resume must not depend on the vault') + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ storage, transport, detection: 'available' }), + ) + + await runtime.boot() + + const after = await loadAccounts(configPath) + expect(after?.accounts[0]).toMatchObject({ + access: 'new-local-access', + refresh: account.refresh, + }) + expect(after?.claustrum?.transition).toEqual(transition) + runtime.dispose() + }) + + it('skips an already-tombstoned fallback before comparing its stale fingerprint', async () => { + const account = makeSentinelAccount({ id: 'fb-1' }) + const storage = liveStorage([account], { + claustrum: claustrumConfig({ + mode: 'claustrum', + transition: { + manifestRevision: 'revision-1', + storeGeneration: 'generation-1', + fingerprints: { + fallbacks: { + [account.id]: custodySlotFingerprint('old-access', 'old-refresh'), + }, + }, + }, + }), + }) + await writeStorageWithManifest(storage, enrollmentManifest(account.id)) + const { transport } = makeTransport(() => { + throw new Error('resume must not depend on the vault') + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ storage, transport, detection: 'available' }), + ) + + await runtime.boot() + + expect( + (await loadAccounts(configPath))?.claustrum?.transition, + ).toBeUndefined() + runtime.dispose() + }) + + it('retains the transition while any persisted fallback fingerprint is incomplete', async () => { + const matching = liveAccount('matching') + const mismatch = liveAccount('mismatch', { access: 'new-access' }) + const transition = { + manifestRevision: 'revision-1', + storeGeneration: 'generation-1', + fingerprints: { + fallbacks: { + matching: custodySlotFingerprint(matching.access!, matching.refresh), + mismatch: custodySlotFingerprint('old-access', mismatch.refresh), + }, + }, + } + const storage = liveStorage([matching, mismatch], { + claustrum: claustrumConfig({ mode: 'claustrum', transition }), + }) + await writeStorageWithManifest(storage, enrollmentManifest(matching.id)) + const { transport } = makeTransport(() => { + throw new Error('resume must not depend on the vault') + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ storage, transport, detection: 'available' }), + ) + + await runtime.boot() + + const after = await loadAccounts(configPath) + expect(after?.accounts[0]).toMatchObject({ refresh: TOMBSTONE_OPENAI }) + expect(after?.accounts[1]).toMatchObject({ access: 'new-access' }) + expect(after?.claustrum?.transition).toEqual(transition) + runtime.dispose() + }) + + it('resumes main through the guarded host-slot path and retries an empty auth map on tick', async () => { + const main = { + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: 1, + } + const transition = { + manifestRevision: 'revision-1', + storeGeneration: 'generation-1', + fingerprints: { + main: custodySlotFingerprint(main.access, main.refresh), + fallbacks: {}, + }, + } + const storage = liveStorage([], { + claustrum: claustrumConfig({ mode: 'claustrum', transition }), + }) + await writeStorageWithManifest(storage, enrollmentManifest('main')) + const { transport } = makeTransport(() => { + throw new Error('resume must not depend on the vault') + }) + let empty = true + let writes = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + auth: { + all: async () => (empty ? {} : { openai: main }), + get: async () => main, + set: async ({ body }) => { + writes += 1 + Object.assign(main, body) + }, + }, + }), + ) + + await runtime.boot() + expect(writes).toBe(0) + expect((await loadAccounts(configPath))?.claustrum?.transition).toEqual( + transition, + ) + + empty = false + await runtime.runTick() + expect(writes).toBe(1) + expect(main).toMatchObject({ + access: '', + refresh: TOMBSTONE_OPENAI, + expires: 0, + }) + expect( + (await loadAccounts(configPath))?.claustrum?.transition, + ).toBeUndefined() + runtime.dispose() + }) + + it('keeps main resume incomplete when the post-write readback is real', async () => { + const main = { + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: 1, + } + const transition = { + manifestRevision: 'revision-1', + storeGeneration: 'generation-1', + fingerprints: { + main: custodySlotFingerprint(main.access, main.refresh), + fallbacks: {}, + }, + } + const storage = liveStorage([], { + claustrum: claustrumConfig({ mode: 'claustrum', transition }), + }) + await writeStorageWithManifest(storage, enrollmentManifest('main')) + const { transport } = makeTransport(() => { + throw new Error('resume must not depend on the vault') + }) + let writes = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + auth: { + all: async () => ({ openai: main }), + get: async () => main, + set: async () => { + writes += 1 + }, + }, + }), + ) + + await runtime.boot() + + expect(writes).toBe(1) + expect((await loadAccounts(configPath))?.claustrum?.transition).toEqual( + transition, + ) + runtime.dispose() + }) +}) + +describe('real fallback reconciliation', () => { + for (const [vaultState, reason] of [ + ['cold', 'takeover-incomplete/vault-unavailable'], + ['needs_reauth', 'takeover-incomplete/vault-unavailable'], + ] as const) { + it(`retains real local material inert when the vault is ${vaultState}`, async () => { + const account = liveAccount('fb-1', { accountId: 'acct-1' }) + const storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + await writeStorageWithManifest(storage, enrollmentManifest(account.id)) + const { transport } = makeTransport(() => { + throw new Error('vault unavailable') + }) + let writes = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + resolveFallbackVaultState: async () => vaultState, + mutateAccounts: async (transform, path) => { + writes += 1 + return mutateAccounts(transform, path) + }, + }), + ) + + await runtime.boot() + await runtime.runTick() + + expect(writes).toBe(0) + expect((await loadAccounts(configPath))?.accounts[0]).toMatchObject({ + access: account.access, + refresh: account.refresh, + expires: account.expires, + }) + expect(runtime.getCustodyProjection(account, Date.now())).toEqual({ + state: 'inert', + reason, + }) + runtime.dispose() + }) + } +}) + +// --------------------------------------------------------------------------- +// Warm / tick +// --------------------------------------------------------------------------- + +describe('custody warm and tick', () => { + it('resolves the warm within the bound and the cache is immediately peekable', async () => { + const account = liveAccount('fb-1', { accountId: 'acct-1' }) + const manifest = enrollmentManifest('fb-1') + await writeStorageWithManifest(liveStorage([account]), manifest) + const { transport, captured } = makeTransport(({ handle }) => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 7, + expiresAtMs: Date.now() + 600_000, + })) + const logger = makeLogger() + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + transport, + detection: 'available', + logger, + }), + ) + const start = Date.now() + await runtime.boot() + const elapsed = Date.now() - start + expect(elapsed).toBeLessThan(500) + const cache = runtime.getCache() + expect(cache).toBeDefined() + expect(captured.getCalls.length).toBeGreaterThan(0) + const peeked = await cache?.peek(HANDLE) + expect(peeked?.recordVersion).toBe(7) + runtime.dispose() + }) + + it('does not delay the loader past the warm bound when the vault is slow; populates later', async () => { + const account = makeSentinelAccount({ id: 'fb-1', accountId: 'acct-1' }) + await writeStorageWithManifest( + liveStorage([account]), + enrollmentManifest('fb-1'), + ) + let releaseWarm = () => {} + const warmReleased = new Promise((resolve) => { + releaseWarm = resolve + }) + const slowTransport: ClaustrumCacheTransportLike = { + getCredential: mock(async () => { + await warmReleased + return { + material: makeCustodyJwt('acct-1'), + recordVersion: 9, + expiresAtMs: Date.now() + 600_000, + } + }), + statusCredential: mock(async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 9, + })), + reportAuthFailure: mock(async () => undefined), + close: () => {}, + } + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([account])), + transport: slowTransport, + detection: 'available', + }), + ) + const start = Date.now() + await runtime.boot() + const elapsed = Date.now() - start + expect(elapsed).toBeLessThan(250) + releaseWarm() + const warmed = await runtime.getCache()?.get(HANDLE) + expect(warmed?.recordVersion).toBe(9) + runtime.dispose() + }) + + it("schedules the first tick at t+0 and the interval sits inside the 5-minute ± 30 s jitter envelope; the timer is unref'd", async () => { + const account = liveAccount('fb-1', { accountId: 'acct-1' }) + await writeStorageWithManifest( + liveStorage([account]), + enrollmentManifest('fb-1'), + ) + const fakeTimer = { unref: mock(() => undefined) } + const timerHandle = fakeTimer as unknown as ReturnType + const setIntervalFn = mock( + (_cb: () => void, _ms: number): ReturnType => + timerHandle, + ) + const clearIntervalFn = mock(() => undefined) + const transport: ClaustrumCacheTransportLike = { + getCredential: mock(async () => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })), + statusCredential: mock(async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + })), + reportAuthFailure: mock(async () => undefined), + close: () => {}, + } + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([account])), + transport, + detection: 'available', + setIntervalFn: setIntervalFn as unknown as ( + callback: () => void, + intervalMs: number, + ) => ReturnType, + clearIntervalFn, + }), + ) + await runtime.boot() + expect(setIntervalFn).toHaveBeenCalled() + const callArgs = setIntervalFn.mock.calls[0] + const intervalMs = callArgs?.[1] + expect(intervalMs).toBeDefined() + expect(intervalMs!).toBeGreaterThanOrEqual(5 * 60_000 - 30_000) + expect(intervalMs!).toBeLessThanOrEqual(5 * 60_000 + 30_000) + expect(fakeTimer.unref).toHaveBeenCalled() + runtime.dispose() + }) + + it('makes zero vault calls on a manifest entry when the storage toggle is off', async () => { + const account = liveAccount('fb-1', { accountId: 'acct-1' }) + const storage: AccountStorage = { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [account], + claustrum: claustrumConfig({ mode: 'local' }), + } + await writeStorageWithManifest(storage, enrollmentManifest('fb-1')) + const transport: ClaustrumCacheTransportLike = { + getCredential: mock(async () => { + throw new Error('should not be called') + }), + statusCredential: mock(async () => { + throw new Error('should not be called') + }), + reportAuthFailure: mock(async () => undefined), + close: () => {}, + } + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + }), + ) + await runtime.boot() + await runtime.runTick() + expect(transport.getCredential).not.toHaveBeenCalled() + runtime.dispose() + }) + + it('limits to one get per enabled manifest account per tick', async () => { + const a = liveAccount('fb-1', { accountId: 'acct-1' }) + const b = liveAccount('fb-2', { accountId: 'acct-2' }) + const manifest: CustodyManifestReadResult = { + ok: true, + value: { + version: 1, + providers: [ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'fb-1', + handle: HANDLE, + credential_id: 'oauth:openai:fb-1', + }, + { + label: 'fb-2', + handle: `ckh_${'b'.repeat(43)}`, + credential_id: 'oauth:openai:fb-2', + }, + ], + }, + ], + }, + revision: 'test-manifest-revision', + } + await writeStorageWithManifest(liveStorage([a, b]), manifest) + const { transport, captured } = makeTransport(({ handle }) => ({ + material: makeCustodyJwt(handle === HANDLE ? 'acct-1' : 'acct-2'), + recordVersion: 3, + expiresAtMs: Date.now() + 600_000, + })) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: liveStorage([a, b]), + transport, + detection: 'available', + }), + ) + await runtime.boot() + const initialGetCalls = captured.getCalls.length + await runtime.runTick() + // One get per account (the sweep's force:true on each enrolling account + // is the only get for an `enrolling` account; the warm pass is one + // get per enabled account). + const delta = captured.getCalls.length - initialGetCalls + expect(delta).toBeLessThanOrEqual(2) + runtime.dispose() + }) + + it('skips the tick when the runtime is disposed', async () => { + const transport: ClaustrumCacheTransportLike = { + getCredential: mock(async () => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })), + statusCredential: mock(async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + })), + reportAuthFailure: mock(async () => undefined), + close: mock(() => undefined), + } + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: liveStorage([liveAccount('fb-1', { accountId: 'acct-1' })]), + transport, + detection: 'available', + }), + ) + await runtime.boot() + runtime.dispose() + await runtime.runTick() // should be a no-op + expect(transport.close).toHaveBeenCalled() + }) + + it('reconnects on the next tick after a cold boot and reprojects a served binding', async () => { + let now = 1_000 + const account = makeSentinelAccount({ id: 'fb-1', accountId: 'acct-1' }) + const storage = withClaustrumMode(liveStorage([account])) + await writeStorageWithManifest(storage, enrollmentManifest(account.id)) + const { transport } = makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 12, + expiresAtMs: now + 600_000, + })) + let available = false + let attempts = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + now: () => now, + cacheConnector: async () => { + attempts += 1 + if (!available) throw new Error('vault cold') + return transport + }, + }), + ) + + await runtime.boot() + expect(runtime.getCustodyProjection(account, now)).toEqual({ + state: 'inert', + reason: 'vault-cold', + }) + expect(attempts).toBe(1) + + available = true + now += 1 + await runtime.runTick() + + expect(attempts).toBe(2) + expect(runtime.getCustodyProjection(account, now)).toEqual({ + state: 'vault', + recordVersion: 12, + }) + runtime.dispose() + }) + + it('bounds a cold vault reconnect to one attempt per tick', async () => { + const account = makeSentinelAccount({ id: 'fb-1', accountId: 'acct-1' }) + const storage = withClaustrumMode(liveStorage([account])) + await writeStorageWithManifest(storage, enrollmentManifest(account.id)) + const { transport } = makeTransport(() => { + throw new Error('vault must remain cold') + }) + let attempts = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + cacheConnector: async () => { + attempts += 1 + throw new Error('vault cold') + }, + }), + ) + + await runtime.boot() + await runtime.runTick() + await runtime.runTick() + await runtime.runTick() + + expect(attempts).toBe(4) + expect(runtime.getCustodyProjection(account, Date.now())).toEqual({ + state: 'inert', + reason: 'vault-cold', + }) + runtime.dispose() + }) + + it('disposes cleanly while a tick reconnect is pending', async () => { + const account = makeSentinelAccount({ id: 'fb-1', accountId: 'acct-1' }) + const storage = withClaustrumMode(liveStorage([account])) + await writeStorageWithManifest(storage, enrollmentManifest(account.id)) + const { transport, captured } = makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + let attempts = 0 + let markReconnectEntered!: () => void + const reconnectEntered = new Promise((resolve) => { + markReconnectEntered = resolve + }) + let releaseReconnect!: () => void + const reconnectReleased = new Promise((resolve) => { + releaseReconnect = resolve + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + cacheConnector: async () => { + attempts += 1 + if (attempts === 1) throw new Error('vault cold') + markReconnectEntered() + await reconnectReleased + return transport + }, + }), + ) + + await runtime.boot() + const tick = runtime.runTick() + await reconnectEntered + runtime.dispose() + releaseReconnect() + await tick + + expect(runtime.getCache()).toBeUndefined() + expect(runtime.getTransport()).toBeUndefined() + expect(captured.closeCalls).toBe(1) + }) +}) + +// --------------------------------------------------------------------------- +// Enroll-completion sweep +// --------------------------------------------------------------------------- + +describe('enroll-completion sweep', () => { + it('does not install a tombstone into a gone main host slot', async () => { + const storage = liveStorage([], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + await writeStorageWithManifest(storage, enrollmentManifest('main')) + const { transport } = makeTransport(() => ({ + material: makeCustodyJwt('acct-main'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + let accountMutations = 0 + let hostWrites = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + mutateAccounts: async (transform, path) => { + accountMutations += 1 + return mutateAccounts(transform, path) + }, + auth: { + all: async () => ({ anthropic: { type: 'oauth' } }), + get: async () => undefined, + set: async () => { + hostWrites += 1 + }, + }, + }), + ) + + await runtime.boot() + + expect(accountMutations).toBe(0) + expect(hostWrites).toBe(0) + runtime.dispose() + }) + + for (const vaultState of ['serves', 'cold', 'needs_reauth'] as const) { + it(`installs the exact fallback tombstone over a corrupt marker when the vault ${vaultState}`, async () => { + const corrupt = corruptAccount() + const storage: AccountStorage = { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [corrupt], + claustrum: claustrumConfig({ mode: 'claustrum' }), + } + await writeCorruptStorageWithManifest( + storage, + enrollmentManifest(corrupt.id), + ) + const { transport } = makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + resolveFallbackVaultState: async () => vaultState, + }), + ) + + expect((await loadAccounts(configPath))?.accounts[0]).toMatchObject({ + id: 'fb-1', + corrupt: true, + }) + + await runtime.boot() + + expect((await loadAccounts(configPath))?.accounts).toEqual([ + { + id: 'fb-1', + type: 'oauth', + access: '', + refresh: TOMBSTONE_OPENAI, + expires: 0, + enabled: false, + accountId: 'acct-1', + addedAt: 123, + }, + ]) + runtime.dispose() + }) + } + + for (const vaultState of ['no_handle', 'identity_mismatch'] as const) { + it(`does not install a corrupt fallback marker for ${vaultState}`, async () => { + const corrupt = corruptAccount() + const storage: AccountStorage = { + version: 1, + accounts: [corrupt], + claustrum: claustrumConfig({ mode: 'claustrum' }), + } + await writeCorruptStorageWithManifest( + storage, + enrollmentManifest(corrupt.id), + ) + const { transport } = makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + resolveFallbackVaultState: async () => vaultState, + }), + ) + + await runtime.boot() + + expect((await loadAccounts(configPath))?.accounts).toEqual([corrupt]) + runtime.dispose() + }) + } + + it('does not install a corrupt fallback marker without a manifest binding', async () => { + const corrupt = corruptAccount() + const storage: AccountStorage = { + version: 1, + accounts: [corrupt], + claustrum: claustrumConfig({ mode: 'claustrum' }), + } + await writeCorruptStorageWithManifest(storage, emptyManifest()) + const { transport, captured } = makeTransport(() => { + throw new Error('vault must not be consulted') + }) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ storage, transport, detection: 'available' }), + ) + + await runtime.boot() + + expect((await loadAccounts(configPath))?.accounts).toEqual([corrupt]) + expect(captured.getCalls).toEqual([]) + runtime.dispose() + }) + + it('does not install when the manifest handle changes before the account lock', async () => { + const corrupt = corruptAccount() + const storage: AccountStorage = { + version: 1, + accounts: [corrupt], + claustrum: claustrumConfig({ mode: 'claustrum' }), + } + await writeCorruptStorageWithManifest( + storage, + enrollmentManifest(corrupt.id), + ) + const { transport } = makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + let writes = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + resolveFallbackVaultState: async () => 'serves', + acquireRefreshFileLock: async () => { + const manifest = enrollmentManifest(corrupt.id) + if (!manifest.ok) throw new Error('expected manifest') + manifest.value.providers[0]!.accounts[0]!.handle = + `ckh_${'z'.repeat(43)}` + writeFileSync(manifestPath, JSON.stringify(manifest.value)) + return { release: async () => {} } + }, + mutateAccounts: async (transform, path) => { + writes += 1 + return mutateAccounts(transform, path) + }, + }), + ) + + await runtime.boot() + + expect(writes).toBe(0) + expect((await loadAccounts(configPath))?.accounts[0]).toMatchObject({ + corrupt: true, + }) + runtime.dispose() + }) + + it('does not install when mode changes before the account lock', async () => { + const corrupt = corruptAccount() + const storage: AccountStorage = { + version: 1, + accounts: [corrupt], + claustrum: claustrumConfig({ mode: 'claustrum' }), + } + await writeCorruptStorageWithManifest( + storage, + enrollmentManifest(corrupt.id), + ) + const { transport } = makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + let writes = 0 + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + resolveFallbackVaultState: async () => 'serves', + acquireRefreshFileLock: async () => { + writeFileSync( + configPath, + JSON.stringify({ + ...storage, + claustrum: claustrumConfig({ mode: 'local' }), + }), + ) + return { release: async () => {} } + }, + mutateAccounts: async (transform, path) => { + writes += 1 + return mutateAccounts(transform, path) + }, + }), + ) + + await runtime.boot() + + expect(writes).toBe(0) + expect((await loadAccounts(configPath))?.accounts[0]).toMatchObject({ + corrupt: true, + }) + runtime.dispose() + }) + + it('preserves fallback roster order and enabled metadata during install', async () => { + const first = liveAccount('fb-first') + const corrupt = corruptAccount('fb-corrupt', { enabled: true }) + const last = liveAccount('fb-last') + const storage: AccountStorage = { + version: 1, + accounts: [first, corrupt, last], + claustrum: claustrumConfig({ mode: 'claustrum' }), + } + await writeCorruptStorageWithManifest( + storage, + enrollmentManifest(corrupt.id), + ) + const { transport } = makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage, + transport, + detection: 'available', + resolveFallbackVaultState: async () => 'serves', + }), + ) + + await runtime.boot() + + const after = await loadAccounts(configPath) + expect(after?.accounts.map((account) => account.id)).toEqual([ + 'fb-first', + 'fb-corrupt', + 'fb-last', + ]) + expect(after?.accounts[1]?.enabled).toBe(true) + runtime.dispose() + }) + + it('re-reads the corrupt marker under the refresh lock so two processes install once', async () => { + const corrupt = corruptAccount() + const storage: AccountStorage = { + version: 1, + accounts: [corrupt], + claustrum: claustrumConfig({ mode: 'claustrum' }), + } + await writeCorruptStorageWithManifest( + storage, + enrollmentManifest(corrupt.id), + ) + let installs = 0 + const countingMutate: typeof mutateAccounts = async (transform, path) => + mutateAccounts((current) => { + const before = current.accounts.find( + (account) => account.id === corrupt.id, + ) + const next = transform(current) + if (before?.type === 'oauth' && before.corrupt) installs += 1 + return next + }, path) + const firstWithCount = __createCustodyRuntimeForTest({ + ...makeOptions({ + storage, + transport: makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })).transport, + detection: 'available', + resolveFallbackVaultState: async () => 'serves', + acquireRefreshFileLock, + }), + mutateAccounts: countingMutate, + }) + const secondWithCount = __createCustodyRuntimeForTest({ + ...makeOptions({ + storage, + transport: makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })).transport, + detection: 'available', + resolveFallbackVaultState: async () => 'serves', + acquireRefreshFileLock, + }), + mutateAccounts: countingMutate, + }) + + await Promise.all([firstWithCount.boot(), secondWithCount.boot()]) + + expect(installs).toBe(1) + firstWithCount.dispose() + secondWithCount.dispose() + }) + + it('writes both tombstone fields and expiry in exactly one account mutation', async () => { + const live = liveAccount('fb-1', { accountId: 'acct-1' }) + await writeStorageWithManifest( + liveStorage([live]), + enrollmentManifest(live.id), + ) + const { transport } = makeTransport(() => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 21, + expiresAtMs: Date.now() + 600_000, + })) + const writes: OAuthAccount[] = [] + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([live])), + transport, + detection: 'available', + mutateAccounts: async (transform, path) => { + const before = await loadAccounts(path) + if (before) { + const after = transform(before) + const changed = after?.accounts.find( + (account) => account.id === live.id, + ) + if (changed?.type === 'oauth' && !changed.corrupt) + writes.push(changed) + } + return mutateAccounts(transform, path) + }, + }), + ) + await runtime.boot() + expect(writes).toEqual([ + expect.objectContaining({ + access: '', + refresh: TOMBSTONE_OPENAI, + expires: 0, + }), + ]) + runtime.dispose() + }) + + it('completes an enrolling account before boot returns: a manifest entry with no enroll having run lands the tombstone', async () => { + const live = liveAccount('fb-1', { accountId: 'acct-1' }) + // Live access/refresh; no enroll has run. + await saveAccounts(liveStorage([live]), configPath) + mkdirSync(scratchDir, { recursive: true, mode: 0o700 }) + writeFileSync( + manifestPath, + JSON.stringify({ + version: 1, + providers: [ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'fb-1', + handle: HANDLE, + credential_id: 'oauth:openai:fb-1', + }, + ], + }, + ], + }), + { mode: 0o600 }, + ) + chmodSync(manifestPath, 0o600) + const { transport, captured } = makeTransport(({ handle }) => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 11, + expiresAtMs: Date.now() + 600_000, + })) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([live])), + transport, + detection: 'available', + }), + ) + await runtime.boot() + const after = await loadAccounts(configPath) + const tombstonedAccount = after?.accounts.find((a) => a.id === 'fb-1') + if (tombstonedAccount?.type !== 'oauth') + throw new Error('expected oauth account') + expect(tombstonedAccount.access).toBe('') + expect(tombstonedAccount.refresh).toBe(TOMBSTONE_OPENAI) + expect(tombstonedAccount.expires).toBe(0) + // Manifest entry untouched by the sweep. + const manifestRaw = JSON.parse(await Bun.file(manifestPath).text()) as { + providers: Array<{ accounts: Array }> + } + expect(manifestRaw.providers[0]?.accounts.length).toBe(1) + expect(captured.getCalls.length).toBeGreaterThan(0) + runtime.dispose() + }) + + it('refuses the sweep when the served claim differs from the local account id; the local family is intact', async () => { + const live = liveAccount('fb-1', { accountId: 'acct-local' }) + await saveAccounts(liveStorage([live]), configPath) + mkdirSync(scratchDir, { recursive: true, mode: 0o700 }) + writeFileSync( + manifestPath, + JSON.stringify({ + version: 1, + providers: [ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'fb-1', + handle: HANDLE, + credential_id: 'oauth:openai:fb-1', + }, + ], + }, + ], + }), + { mode: 0o600 }, + ) + chmodSync(manifestPath, 0o600) + // Served token carries a DIFFERENT ChatGPT account id. + const { transport } = makeTransport(({ handle }) => ({ + material: makeCustodyJwt('acct-foreign'), + recordVersion: 12, + expiresAtMs: Date.now() + 600_000, + })) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([live])), + transport, + detection: 'available', + }), + ) + await runtime.boot() + const after = await loadAccounts(configPath) + const account = after?.accounts.find((a) => a.id === 'fb-1') + if (account?.type !== 'oauth') throw new Error('expected oauth account') + // Local family intact — no tombstone, no write. + expect(account.access).toBe('acc-fb-1') + expect(account.refresh).toBe('ref-fb-1') + expect(account.expires).toBeGreaterThan(0) + // Manifest entry untouched. + const manifestRaw = JSON.parse(await Bun.file(manifestPath).text()) as { + providers: Array<{ accounts: Array }> + } + expect(manifestRaw.providers[0]?.accounts.length).toBe(1) + runtime.dispose() + }) + + it('refuses the sweep when the served access token has no claims (nullClaim)', async () => { + const live = liveAccount('fb-1', { accountId: 'acct-1' }) + await saveAccounts(liveStorage([live]), configPath) + mkdirSync(scratchDir, { recursive: true, mode: 0o700 }) + writeFileSync( + manifestPath, + JSON.stringify({ + version: 1, + providers: [ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'fb-1', + handle: HANDLE, + credential_id: 'oauth:openai:fb-1', + }, + ], + }, + ], + }), + { mode: 0o600 }, + ) + chmodSync(manifestPath, 0o600) + const { transport } = makeTransport(({ handle }) => ({ + material: 'not-a-jwt', + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([live])), + transport, + detection: 'available', + }), + ) + await runtime.boot() + const after = await loadAccounts(configPath) + const account = after?.accounts.find((a) => a.id === 'fb-1') + if (account?.type !== 'oauth') throw new Error('expected oauth account') + expect(account.access).toBe('acc-fb-1') + expect(account.refresh).toBe('ref-fb-1') + runtime.dispose() + }) + + it('skips the sweep when the per-account refresh lock is busy and never waits', async () => { + const live = liveAccount('fb-1', { accountId: 'acct-1' }) + await saveAccounts(liveStorage([live]), configPath) + mkdirSync(scratchDir, { recursive: true, mode: 0o700 }) + writeFileSync( + manifestPath, + JSON.stringify({ + version: 1, + providers: [ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'fb-1', + handle: HANDLE, + credential_id: 'oauth:openai:fb-1', + }, + ], + }, + ], + }), + { mode: 0o600 }, + ) + chmodSync(manifestPath, 0o600) + const { transport } = makeTransport(({ handle }) => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([live])), + transport, + detection: 'available', + acquireRefreshFileLock: async () => null, + }), + ) + await runtime.boot() + const after = await loadAccounts(configPath) + const account = after?.accounts.find((a) => a.id === 'fb-1') + if (account?.type !== 'oauth') throw new Error('expected oauth account') + expect(account.access).toBe('acc-fb-1') + runtime.dispose() + }) +}) + +// --------------------------------------------------------------------------- +// Disposal +// --------------------------------------------------------------------------- + +describe('custody runtime disposal', () => { + it('closes the cache and transport on dispose and is idempotent', async () => { + const transport: ClaustrumCacheTransportLike = { + getCredential: mock(async () => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })), + statusCredential: mock(async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + })), + reportAuthFailure: mock(async () => undefined), + close: mock(() => undefined), + } + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: liveStorage([liveAccount('fb-1', { accountId: 'acct-1' })]), + transport, + detection: 'available', + }), + ) + await runtime.boot() + runtime.dispose() + runtime.dispose() + await Promise.resolve() + expect(transport.close).toHaveBeenCalledTimes(1) + }) +}) + +// --------------------------------------------------------------------------- +// Boot order +// --------------------------------------------------------------------------- + +describe('custody boot order', () => { + it('does not arm fallback refresh until a blocked enrollment completion has tombstoned storage', async () => { + const live = liveAccount('fb-1', { accountId: 'acct-1' }) + const manifest = enrollmentManifest(live.id) + if (!manifest.ok) throw new Error('expected manifest fixture') + writeFileSync( + configPath, + JSON.stringify({ + ...liveStorage([live]), + claustrum: claustrumConfig({ mode: 'claustrum' }), + }), + ) + writeFileSync(manifestPath, JSON.stringify(manifest.value)) + chmodSync(manifestPath, 0o600) + + let enteredResolve!: () => void + let releaseResolve!: () => void + const entered = new Promise((resolve) => { + enteredResolve = resolve + }) + const release = new Promise((resolve) => { + releaseResolve = resolve + }) + const transport: ClaustrumCacheTransportLike = { + async getCredential() { + enteredResolve() + await release + return { + material: makeCustodyJwt('acct-1'), + recordVersion: 31, + expiresAtMs: Date.now() + 600_000, + } + }, + async statusCredential() { + return { + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 31, + } + }, + async reportAuthFailure() {}, + close() {}, + } + const originalStart = + FallbackAccountManager.prototype.startBackgroundRefresh + const starts = mock(function (this: FallbackAccountManager) { + return originalStart.call(this) + }) + FallbackAccountManager.prototype.startBackgroundRefresh = starts + const originalAuthFile = process.env.OPENCODE_OPENAI_AUTH_FILE + const originalStateFile = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE + const originalSidebarFile = + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE + const originalLogFile = process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + const originalConfigDir = process.env.OPENCODE_CONFIG_DIR + process.env.OPENCODE_OPENAI_AUTH_FILE = configPath + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = join(scratchDir, 'state.json') + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = join( + scratchDir, + 'sidebar.json', + ) + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = join(scratchDir, 'test.log') + process.env.OPENCODE_CONFIG_DIR = scratchDir + let hooks: Awaited> | undefined + try { + hooks = await CodexAuthPlugin( + { + client: { auth: { set: async () => {} } }, + project: { id: 'test', name: 'test' }, + directory: '', + worktree: scratchDir, + experimental_workspace: { register: () => {} }, + serverUrl: new URL('http://localhost:0'), + $: {}, + } as never, + { custody: { transport, detection: 'available' } }, + ) + const loader = hooks.auth?.loader + if (!loader) throw new Error('expected auth loader') + const loading = loader( + async () => ({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3_600_000, + }), + {} as never, + ) + await entered + expect(starts).not.toHaveBeenCalled() + releaseResolve() + await loading + expect(starts).toHaveBeenCalledTimes(1) + const after = await loadAccounts(configPath) + const tombstoned = after?.accounts.find( + (account) => account.id === live.id, + ) + if (tombstoned?.type !== 'oauth') + throw new Error('expected oauth account') + expect(tombstoned).toMatchObject({ + access: '', + refresh: TOMBSTONE_OPENAI, + expires: 0, + }) + } finally { + releaseResolve() + await hooks?.dispose?.() + FallbackAccountManager.prototype.startBackgroundRefresh = originalStart + process.env.OPENCODE_OPENAI_AUTH_FILE = originalAuthFile + process.env.OPENCODE_OPENAI_AUTH_STATE_FILE = originalStateFile + process.env.OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE = originalSidebarFile + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = originalLogFile + if (originalConfigDir === undefined) + delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = originalConfigDir + } + }) + + it('runs the initial completion sweep before the first tick fires', async () => { + const live = liveAccount('fb-1', { accountId: 'acct-1' }) + await saveAccounts(liveStorage([live]), configPath) + mkdirSync(scratchDir, { recursive: true, mode: 0o700 }) + writeFileSync( + manifestPath, + JSON.stringify({ + version: 1, + providers: [ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'fb-1', + handle: HANDLE, + credential_id: 'oauth:openai:fb-1', + }, + ], + }, + ], + }), + { mode: 0o600 }, + ) + chmodSync(manifestPath, 0o600) + const sweepEvents: string[] = [] + const { transport } = makeTransport(({ handle }) => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + })) + // Wrap the transport to track call order vs `setInterval`. + const wrapped: ClaustrumCacheTransportLike = { + ...transport, + getCredential: async (...args) => { + sweepEvents.push('get') + return transport.getCredential(...args) + }, + } + let tickScheduledAt: number | undefined + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([live])), + transport: wrapped, + detection: 'available', + setIntervalFn: (cb, ms) => { + tickScheduledAt = sweepEvents.length + cb() + return 0 as unknown as ReturnType + }, + clearIntervalFn: mock(() => undefined), + }), + ) + await runtime.boot() + expect(tickScheduledAt).toBeDefined() + // The completion sweep runs BEFORE the timer is scheduled (and the first + // tick is fire-and-forget, but the get order pins it). + expect(sweepEvents[0]).toBe('get') + runtime.dispose() + }) +}) + +// --------------------------------------------------------------------------- +// Quota construction builder +// --------------------------------------------------------------------------- + +describe('quota construction dep wiring', () => { + it('omitting the resolver + reporter fails closed with custody-deps-incomplete', async () => { + const { refreshAllQuota } = await import('../core/refresh-all-quota.ts') + const deps = { + getAuth: async () => ({ + type: 'oauth' as const, + access: 'acc', + refresh: 'ref', + expires: Date.now() + 3600_000, + }), + codexRefreshFn: async () => ({ + access: 'acc', + refresh: 'ref', + expires: Date.now() + 3600_000, + }), + refreshMainWithLease: async () => ({ + access: 'acc', + refresh: 'ref', + expires: Date.now() + 3600_000, + }), + fallbackManager: { + refreshAccount: async (a: unknown) => a, + }, + quotaManager: { + setFallback: () => undefined, + isFallbackBackedOff: () => false, + peekFallbackForPolicy: () => undefined, + }, + loadAccounts: async () => ({ + version: 1 as const, + mainAccountId: 'main', + accounts: [ + { + id: 'fb-1', + type: 'oauth' as const, + access: 'acc-fb1', + refresh: 'ref-fb1', + expires: Date.now() + 3600_000, + accountId: 'acct-1', + }, + ], + }), + writeSidebarState: async () => undefined, + client: { auth: { set: async () => undefined } }, + fetchImpl: fetch, + now: () => Date.now(), + configPath: '/tmp/test.json', + storageMainAccountId: 'main', + isOAuthAccountFn: () => true, + whamFn: async () => ({ + primary: { usedPercent: 0, remainingPercent: 100 }, + }), + // Only isFallbackRefreshInert is wired; resolver + reporter missing — + // mirrors what happens at one quota construction if the spread drops + // the custody deps entirely. The poller must fail closed. + isFallbackRefreshInert: async () => true, + } as unknown as Parameters[0] + const results = await refreshAllQuota(deps, { accountKey: 'fb-1' }) + const fb = results.find((r) => r.account === 'fb-1') + expect(fb?.ok).toBe(false) + expect(fb?.error).toBe(CUSTODY_DEPS_INCOMPLETE) + }) +}) + +// --------------------------------------------------------------------------- +// Sweep failure log +// --------------------------------------------------------------------------- + +describe('sweep failure log dedupe', () => { + it('logs once per account+reason within an hour; emits again past the hour', async () => { + const account = await writeBoundRealFixture() + const logger1 = makeLogger() + let clock = 1_000_000 + const transport1: ClaustrumCacheTransportLike = { + getCredential: mock(async () => { + throw Object.assign(new Error('boom'), { action: 'gone' }) + }), + statusCredential: mock(async () => { + throw new Error('should not be called') + }), + reportAuthFailure: mock(async () => undefined), + close: () => undefined, + } + const runtime1 = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([account])), + transport: transport1, + detection: 'available', + logger: logger1, + now: () => clock, + }), + ) + await runtime1.boot() + // Two failures within the hour: only one warn line. + await runtime1.runTick() + const failed1 = logger1.warn.mock.calls.filter(([msg]) => + msg.includes('enroll-completion sweep failed'), + ) + expect(failed1).toHaveLength(1) + const firstMeta = failed1[0]?.[1] as Record + expect(firstMeta.accountId).toBe('fb-1') + expect(firstMeta.reason).toBe('gone') + runtime1.dispose() + // Advance the clock past the hour; the next failure emits again. + clock += 60 * 60_000 + 1_000 + __resetSweepFailureLogDedupeForTest() + const logger2 = makeLogger() + const transport2: ClaustrumCacheTransportLike = { + getCredential: mock(async () => { + throw Object.assign(new Error('boom2'), { action: 'gone' }) + }), + statusCredential: mock(async () => { + throw new Error('should not be called') + }), + reportAuthFailure: mock(async () => undefined), + close: () => undefined, + } + const runtime2 = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([account])), + transport: transport2, + detection: 'available', + logger: logger2, + now: () => clock, + }), + ) + await runtime2.boot() + const failed2 = logger2.warn.mock.calls.filter(([msg]) => + msg.includes('enroll-completion sweep failed'), + ) + expect(failed2).toHaveLength(1) + runtime2.dispose() + }) +}) + +// --------------------------------------------------------------------------- +// recordVersion projection +// --------------------------------------------------------------------------- + +describe('recordVersion projection', () => { + it('the warm pass threads the served recordVersion into the sidebar projection', async () => { + const account = liveAccount('fb-1', { accountId: 'acct-1' }) + // Start already-tombstoned so the warm pass is the active path. + await saveAccounts( + liveStorage([makeSentinelAccount({ id: 'fb-1', accountId: 'acct-1' })]), + configPath, + ) + mkdirSync(scratchDir, { recursive: true, mode: 0o700 }) + writeFileSync( + manifestPath, + JSON.stringify({ + version: 1, + providers: [ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'fb-1', + handle: HANDLE, + credential_id: 'oauth:openai:fb-1', + }, + ], + }, + ], + }), + { mode: 0o600 }, + ) + chmodSync(manifestPath, 0o600) + const transport: ClaustrumCacheTransportLike = { + getCredential: mock(async () => ({ + material: makeCustodyJwt('acct-1'), + recordVersion: 17, + expiresAtMs: Date.now() + 600_000, + })), + statusCredential: mock(async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 17, + })), + reportAuthFailure: mock(async () => undefined), + close: () => undefined, + } + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([account])), + transport, + detection: 'available', + }), + ) + await runtime.boot() + await runtime.runTick() + const projection = runtime.getCustodyProjection(account, Date.now()) + expect(projection?.state).toBe('vault') + expect(projection?.recordVersion).toBe(17) + const json = JSON.stringify(projection) + expect(json).not.toContain(HANDLE) + expect(json).not.toContain('material') + runtime.dispose() + }) +}) + +// --------------------------------------------------------------------------- +// Under-lock re-check +// --------------------------------------------------------------------------- + +describe('under-lock re-check', () => { + it('skips the sweep without get or write when the account is no longer enrolling under the lock', async () => { + const account = await writeBoundRealFixture() + let lockAcquired = 0 + const getCalls: string[] = [] + const transport: ClaustrumCacheTransportLike = { + getCredential: mock(async () => { + getCalls.push('called') + return { + material: makeCustodyJwt('acct-1'), + recordVersion: 1, + expiresAtMs: Date.now() + 600_000, + } + }), + statusCredential: mock(async () => ({ + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + })), + reportAuthFailure: mock(async () => undefined), + close: () => undefined, + } + const runtime = __createCustodyRuntimeForTest( + makeOptions({ + storage: withClaustrumMode(liveStorage([account])), + transport, + detection: 'available', + acquireRefreshFileLock: async () => { + lockAcquired += 1 + // Tombstone the account between the pre-lock check and the lock + // acquisition. The under-lock re-check must see a non-enrolling + // account and skip the sweep. The first tick's warm pass still + // issues one get (it observes the now-tombstoned account as a + // valid refresh-inert target); with the re-check, no SWEEP get + // is issued. Dropping the re-check would add a second get. + await saveAccounts( + liveStorage([ + makeSentinelAccount({ id: 'fb-1', accountId: 'acct-1' }), + ]), + configPath, + ) + return { release: async () => undefined } + }, + }), + ) + await runtime.boot() + expect(lockAcquired).toBe(1) + // One get from the warm pass; zero from the sweep under the re-check. + expect(getCalls).toHaveLength(1) + runtime.dispose() + }) +}) + +// Keep the floor import referenced so a deletion in setup-env surfaces here. +void FLOOR_CLAUSTRUM_HANDLES_LOCK +void defaultCustodyManifestPath +void CUSTODY_TOMBSTONE_PREFIX +void emptyManifest diff --git a/packages/opencode/src/tests/custody-sidebar.test.ts b/packages/opencode/src/tests/custody-sidebar.test.ts new file mode 100644 index 0000000..bf2be3e --- /dev/null +++ b/packages/opencode/src/tests/custody-sidebar.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type AccountStorage, + loadAccounts, + saveAccounts, +} from '../core/accounts.ts' +import { + CUSTODY_INERT_REASONS, + type CustodyVerdict, +} from '../core/custody-state.ts' +import { + DEFAULT_SIDEBAR_STATE, + normalizeSidebarState, + projectCustodyForSidebar, + type SidebarState, +} from '../sidebar-state.ts' +import { claustrumConfig } from './custody-fixtures.ts' + +describe('plugin-wide claustrum mode', () => { + test('round-trips explicit local and claustrum modes on disk', async () => { + const authDir = mkdtempSync(join(tmpdir(), 'oai-custody-sidebar-')) + const cfgPath = join(authDir, 'openai-auth.json') + try { + for (const mode of ['local', 'claustrum'] as const) { + const storage: AccountStorage = { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [], + claustrum: claustrumConfig({ mode }), + } + await saveAccounts(storage, cfgPath) + expect((await loadAccounts(cfgPath))?.claustrum?.mode).toBe(mode) + } + } finally { + rmSync(authDir, { recursive: true, force: true }) + } + }) + + test('keeps a config with no claustrum block byte-identical on read', async () => { + const authDir = mkdtempSync(join(tmpdir(), 'oai-custody-sidebar-')) + const cfgPath = join(authDir, 'openai-auth.json') + try { + const raw = JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [], + }) + writeFileSync(cfgPath, raw) + const loaded = await loadAccounts(cfgPath) + expect(loaded?.claustrum).toBeUndefined() + expect(readFileSync(cfgPath, 'utf8')).toBe(raw) + } finally { + rmSync(authDir, { recursive: true, force: true }) + } + }) +}) + +function stateWithCustody(custody: unknown): SidebarState { + return { + ...DEFAULT_SIDEBAR_STATE, + fallbacks: [ + { + id: 'fallback-1', + label: undefined, + quota: null, + killed: false, + enabled: true, + custody: custody as never, + }, + ], + } +} + +describe('projectCustodyForSidebar — v7 verdict projection', () => { + test('maps every inert reason without transition metadata', () => { + for (const reason of CUSTODY_INERT_REASONS) { + const projected = projectCustodyForSidebar({ kind: 'INERT', reason }) + expect(projected).toEqual({ state: 'inert', reason }) + expect(JSON.stringify(projected)).not.toContain('fingerprint') + expect(JSON.stringify(projected)).not.toContain('generation') + expect(JSON.stringify(projected)).not.toContain('revision') + } + }) + + test.each([ + { verdict: { kind: 'LOCAL' }, want: { state: 'local' } }, + { verdict: { kind: 'VAULT' }, want: { state: 'vault' } }, + { verdict: { kind: 'NEEDS_LOGIN' }, want: { state: 'needsLogin' } }, + { + verdict: { kind: 'NEEDS_LOGIN', reason: 'corrupt' }, + want: { state: 'needsLogin', reason: 'corrupt' }, + }, + ] as const)('projects $verdict.kind', ({ verdict, want }) => { + expect(projectCustodyForSidebar(verdict as CustodyVerdict)).toEqual(want) + }) +}) + +describe('normalizeSidebarState — v7 custody reader', () => { + test('drops unknown states and removed v6 states', () => { + for (const state of ['vaultHealing', 'vaultReauth', 'vaultGone']) { + expect( + normalizeSidebarState(stateWithCustody({ state })).fallbacks[0] + ?.custody, + ).toBeUndefined() + } + }) + + test('drops unknown and removed v6 reasons but keeps the inert state', () => { + for (const reason of [ + 'experimental', + 'unavailable', + 'gone', + 'identityMismatch', + 'nullClaim', + ]) { + expect( + normalizeSidebarState(stateWithCustody({ state: 'inert', reason })) + .fallbacks[0]?.custody, + ).toEqual({ state: 'inert' }) + } + }) + + test('round-trips every v7 state and reason exactly', () => { + for (const reason of CUSTODY_INERT_REASONS) { + expect( + normalizeSidebarState(stateWithCustody({ state: 'inert', reason })) + .fallbacks[0]?.custody, + ).toEqual({ state: 'inert', reason }) + } + expect( + normalizeSidebarState(stateWithCustody({ state: 'local' })).fallbacks[0] + ?.custody, + ).toEqual({ + state: 'local', + }) + expect( + normalizeSidebarState(stateWithCustody({ state: 'vault' })).fallbacks[0] + ?.custody, + ).toEqual({ + state: 'vault', + }) + expect( + normalizeSidebarState( + stateWithCustody({ state: 'needsLogin', reason: 'corrupt' }), + ).fallbacks[0]?.custody, + ).toEqual({ + state: 'needsLogin', + reason: 'corrupt', + }) + }) + + test('drops transition metadata from persisted output', () => { + expect( + normalizeSidebarState( + stateWithCustody({ + state: 'inert', + reason: 'takeover-incomplete', + fingerprint: 'secret', + generation: 7, + revision: 'abc', + recordVersion: 11, + }), + ).fallbacks[0]?.custody, + ).toEqual({ state: 'inert', reason: 'takeover-incomplete' }) + }) +}) diff --git a/packages/opencode/src/tests/custody-state.test.ts b/packages/opencode/src/tests/custody-state.test.ts new file mode 100644 index 0000000..93f4224 --- /dev/null +++ b/packages/opencode/src/tests/custody-state.test.ts @@ -0,0 +1,569 @@ +import { describe, expect, test } from 'bun:test' +import { + CUSTODY_INERT_REASONS, + type CustodyInertReason, + evaluateCustodyStartup, +} from '../core/custody-state.ts' + +type Local = 'real' | 'tombstone' | 'empty' | 'gone' | 'slot-absent' +type Vault = + | 'serves' + | 'cold' + | 'needs_reauth' + | 'identity_mismatch' + | 'no_handle' + +type Expected = + | { kind: 'LOCAL' } + | { + kind: 'VAULT' + canonicalize?: boolean + installTombstone?: boolean + } + | { + kind: 'INERT' + reason: CustodyInertReason + installTombstone?: boolean + canonicalize?: boolean + } + | { kind: 'NEEDS_LOGIN'; reason?: 'corrupt' } + +type LocalCase = { + name: string + manifest: 'absent' | 'present' | 'unreadable' + local: Local + want: Expected +} + +type ClaustrumCase = { + name: string + manifest: 'absent' | 'present' | 'unreadable' + local: Local + vault: Vault + fingerprintMatch?: boolean + want: Expected + mismatchWant?: Expected +} + +const vault = (value: Vault) => () => value + +const localCases: LocalCase[] = [ + { + name: 'local × absent × real', + manifest: 'absent', + local: 'real', + want: { kind: 'LOCAL' }, + }, + { + name: 'local × absent × tombstone', + manifest: 'absent', + local: 'tombstone', + want: { kind: 'NEEDS_LOGIN' }, + }, + { + name: 'local × absent × empty', + manifest: 'absent', + local: 'empty', + want: { kind: 'NEEDS_LOGIN' }, + }, + { + name: 'local × absent × gone', + manifest: 'absent', + local: 'gone', + want: { kind: 'NEEDS_LOGIN', reason: 'corrupt' }, + }, + { + name: 'local × absent × slot-absent', + manifest: 'absent', + local: 'slot-absent', + want: { kind: 'NEEDS_LOGIN' }, + }, + { + name: 'local × present × real', + manifest: 'present', + local: 'real', + want: { kind: 'INERT', reason: 'needs-login' }, + }, + { + name: 'local × present × tombstone', + manifest: 'present', + local: 'tombstone', + want: { kind: 'INERT', reason: 'mode-mismatch' }, + }, + { + name: 'local × present × empty', + manifest: 'present', + local: 'empty', + want: { kind: 'INERT', reason: 'mode-mismatch' }, + }, + { + name: 'local × present × gone', + manifest: 'present', + local: 'gone', + want: { kind: 'INERT', reason: 'corrupt-under-binding' }, + }, + { + name: 'local × present × slot-absent', + manifest: 'present', + local: 'slot-absent', + want: { kind: 'INERT', reason: 'mode-mismatch' }, + }, + { + name: 'local × unreadable × real', + manifest: 'unreadable', + local: 'real', + want: { kind: 'INERT', reason: 'manifest-unreadable' }, + }, + { + name: 'local × unreadable × tombstone', + manifest: 'unreadable', + local: 'tombstone', + want: { kind: 'INERT', reason: 'manifest-unreadable' }, + }, + { + name: 'local × unreadable × empty', + manifest: 'unreadable', + local: 'empty', + want: { kind: 'INERT', reason: 'manifest-unreadable' }, + }, + { + name: 'local × unreadable × gone', + manifest: 'unreadable', + local: 'gone', + want: { kind: 'INERT', reason: 'manifest-unreadable' }, + }, + { + name: 'local × unreadable × slot-absent', + manifest: 'unreadable', + local: 'slot-absent', + want: { kind: 'INERT', reason: 'manifest-unreadable' }, + }, +] + +const claustrumCases: ClaustrumCase[] = [ + { + name: 'claustrum × absent × real', + manifest: 'absent', + local: 'real', + vault: 'serves', + want: { kind: 'INERT', reason: 'unbound-under-claustrum' }, + }, + { + name: 'claustrum × absent × tombstone', + manifest: 'absent', + local: 'tombstone', + vault: 'serves', + want: { kind: 'INERT', reason: 'orphan-tombstone' }, + }, + { + name: 'claustrum × absent × empty', + manifest: 'absent', + local: 'empty', + vault: 'serves', + want: { kind: 'INERT', reason: 'orphan-tombstone' }, + }, + { + name: 'claustrum × absent × gone', + manifest: 'absent', + local: 'gone', + vault: 'serves', + want: { kind: 'INERT', reason: 'unbound-under-claustrum' }, + }, + { + name: 'claustrum × absent × slot-absent', + manifest: 'absent', + local: 'slot-absent', + vault: 'serves', + want: { kind: 'INERT', reason: 'unbound-under-claustrum' }, + }, + { + name: 'claustrum × present × tombstone × serves', + manifest: 'present', + local: 'tombstone', + vault: 'serves', + want: { kind: 'VAULT' }, + }, + { + name: 'claustrum × present × tombstone × cold', + manifest: 'present', + local: 'tombstone', + vault: 'cold', + want: { kind: 'INERT', reason: 'vault-cold' }, + }, + { + name: 'claustrum × present × tombstone × needs_reauth', + manifest: 'present', + local: 'tombstone', + vault: 'needs_reauth', + want: { kind: 'INERT', reason: 'vault-reauth' }, + }, + { + name: 'claustrum × present × tombstone × identity_mismatch', + manifest: 'present', + local: 'tombstone', + vault: 'identity_mismatch', + want: { kind: 'INERT', reason: 'identity-mismatch' }, + }, + { + name: 'claustrum × present × tombstone × no_handle', + manifest: 'present', + local: 'tombstone', + vault: 'no_handle', + want: { kind: 'INERT', reason: 'no-handle' }, + }, + { + name: 'claustrum × present × empty × serves', + manifest: 'present', + local: 'empty', + vault: 'serves', + want: { kind: 'VAULT', canonicalize: true }, + }, + { + name: 'claustrum × present × empty × cold', + manifest: 'present', + local: 'empty', + vault: 'cold', + want: { kind: 'INERT', reason: 'vault-cold', canonicalize: true }, + }, + { + name: 'claustrum × present × empty × needs_reauth', + manifest: 'present', + local: 'empty', + vault: 'needs_reauth', + want: { kind: 'INERT', reason: 'vault-reauth', canonicalize: true }, + }, + { + name: 'claustrum × present × empty × identity_mismatch', + manifest: 'present', + local: 'empty', + vault: 'identity_mismatch', + want: { kind: 'INERT', reason: 'identity-mismatch', canonicalize: true }, + }, + { + name: 'claustrum × present × empty × no_handle', + manifest: 'present', + local: 'empty', + vault: 'no_handle', + want: { kind: 'INERT', reason: 'no-handle', canonicalize: true }, + }, + { + name: 'claustrum × present × real × serves(match)', + manifest: 'present', + local: 'real', + vault: 'serves', + fingerprintMatch: true, + want: { kind: 'INERT', reason: 'takeover-incomplete' }, + mismatchWant: { + kind: 'INERT', + reason: 'new-local-family-under-claustrum', + }, + }, + { + name: 'claustrum × present × real × cold', + manifest: 'present', + local: 'real', + vault: 'cold', + want: { kind: 'INERT', reason: 'takeover-incomplete/vault-unavailable' }, + }, + { + name: 'claustrum × present × real × needs_reauth', + manifest: 'present', + local: 'real', + vault: 'needs_reauth', + want: { kind: 'INERT', reason: 'takeover-incomplete/vault-unavailable' }, + }, + { + name: 'claustrum × present × real × identity_mismatch', + manifest: 'present', + local: 'real', + vault: 'identity_mismatch', + want: { kind: 'INERT', reason: 'identity-mismatch' }, + }, + { + name: 'claustrum × present × real × no_handle', + manifest: 'present', + local: 'real', + vault: 'no_handle', + want: { kind: 'INERT', reason: 'no-handle' }, + }, + { + name: 'claustrum × present × gone × serves', + manifest: 'present', + local: 'gone', + vault: 'serves', + want: { kind: 'VAULT', installTombstone: true }, + }, + { + name: 'claustrum × present × gone × cold', + manifest: 'present', + local: 'gone', + vault: 'cold', + want: { kind: 'INERT', reason: 'vault-cold', installTombstone: true }, + }, + { + name: 'claustrum × present × gone × needs_reauth', + manifest: 'present', + local: 'gone', + vault: 'needs_reauth', + want: { kind: 'INERT', reason: 'vault-reauth', installTombstone: true }, + }, + { + name: 'claustrum × present × gone × identity_mismatch', + manifest: 'present', + local: 'gone', + vault: 'identity_mismatch', + want: { kind: 'INERT', reason: 'identity-mismatch' }, + }, + { + name: 'claustrum × present × gone × no_handle', + manifest: 'present', + local: 'gone', + vault: 'no_handle', + want: { kind: 'INERT', reason: 'no-handle' }, + }, + { + name: 'claustrum × present × slot-absent × serves', + manifest: 'present', + local: 'slot-absent', + vault: 'serves', + want: { kind: 'INERT', reason: 'takeover-incomplete/slot-absent' }, + }, + { + name: 'claustrum × present × slot-absent × cold', + manifest: 'present', + local: 'slot-absent', + vault: 'cold', + want: { kind: 'INERT', reason: 'takeover-incomplete/slot-absent' }, + }, + { + name: 'claustrum × present × slot-absent × needs_reauth', + manifest: 'present', + local: 'slot-absent', + vault: 'needs_reauth', + want: { kind: 'INERT', reason: 'takeover-incomplete/slot-absent' }, + }, + { + name: 'claustrum × present × slot-absent × identity_mismatch', + manifest: 'present', + local: 'slot-absent', + vault: 'identity_mismatch', + want: { kind: 'INERT', reason: 'identity-mismatch' }, + }, + { + name: 'claustrum × present × slot-absent × no_handle', + manifest: 'present', + local: 'slot-absent', + vault: 'no_handle', + want: { kind: 'INERT', reason: 'no-handle' }, + }, + { + name: 'claustrum × unreadable × all-local × all-vault', + manifest: 'unreadable', + local: 'real', + vault: 'serves', + want: { kind: 'INERT', reason: 'manifest-unreadable' }, + }, +] + +describe('evaluateCustodyStartup — §16 coordinate table', () => { + test.each(localCases)('$name', ({ manifest, local, want }) => { + expect( + evaluateCustodyStartup({ + mode: 'local', + manifest, + local, + vault: vault('serves'), + verifiedInProcessLogin: false, + }), + ).toEqual(want) + }) + + test.each(claustrumCases)('$name', (row) => { + expect( + evaluateCustodyStartup({ + mode: 'claustrum', + manifest: row.manifest, + local: row.local, + vault: vault(row.vault), + fingerprintMatch: row.fingerprintMatch, + verifiedInProcessLogin: false, + }), + ).toEqual(row.want) + + if (row.mismatchWant) { + expect( + evaluateCustodyStartup({ + mode: 'claustrum', + manifest: row.manifest, + local: row.local, + vault: vault(row.vault), + fingerprintMatch: false, + verifiedInProcessLogin: false, + }), + ).toEqual(row.mismatchWant) + } + }) +}) + +describe('evaluateCustodyStartup — §16 invariants', () => { + test('A: local mode never consults the vault accessor', () => { + expect( + evaluateCustodyStartup({ + mode: 'local', + manifest: 'absent', + local: 'real', + verifiedInProcessLogin: false, + vault: () => { + throw new Error('vault accessor was called') + }, + }), + ).toEqual({ kind: 'LOCAL' }) + }) + + test('B: unreadable manifest wins before local or vault inspection', () => { + const failureReasons = [ + 'missing', + 'permissions', + 'invalid-json', + 'invalid-shape', + 'wrong-version', + 'wrong-provider', + 'wrong-owner', + ] as const + + for (const failureReason of failureReasons) { + expect( + evaluateCustodyStartup({ + mode: 'claustrum', + manifest: 'unreadable', + manifestFailureReason: failureReason, + local: 'empty', + verifiedInProcessLogin: false, + vault: () => { + throw new Error('vault accessor was called') + }, + }), + ).toEqual({ kind: 'INERT', reason: 'manifest-unreadable' }) + } + }) + + test('C: absent manifest never consults the vault accessor', () => { + expect( + evaluateCustodyStartup({ + mode: 'claustrum', + manifest: 'absent', + local: 'tombstone', + verifiedInProcessLogin: false, + vault: () => { + throw new Error('vault accessor was called') + }, + }), + ).toEqual({ kind: 'INERT', reason: 'orphan-tombstone' }) + }) + + test('E: canonical and partial tombstones keep the same verdict kind and reason', () => { + for (const vaultState of [ + 'serves', + 'cold', + 'needs_reauth', + 'identity_mismatch', + 'no_handle', + ] as const) { + const canonical = evaluateCustodyStartup({ + mode: 'claustrum', + manifest: 'present', + local: 'tombstone', + vault: vault(vaultState), + verifiedInProcessLogin: false, + }) + const partial = evaluateCustodyStartup({ + mode: 'claustrum', + manifest: 'present', + local: 'empty', + vault: vault(vaultState), + verifiedInProcessLogin: false, + }) + expect({ + kind: partial.kind, + reason: 'reason' in partial ? partial.reason : undefined, + }).toEqual({ + kind: canonical.kind, + reason: 'reason' in canonical ? canonical.reason : undefined, + }) + expect('canonicalize' in partial && partial.canonicalize).toBe(true) + } + }) + + test('verified local re-login is the only bound real slot that serves locally', () => { + expect( + evaluateCustodyStartup({ + mode: 'local', + manifest: 'present', + local: 'real', + isMain: true, + vault: vault('serves'), + verifiedInProcessLogin: true, + }), + ).toEqual({ kind: 'LOCAL' }) + }) + + test('install only applies to fallback gone rows with a present binding', () => { + for (const row of claustrumCases) { + const verdict = evaluateCustodyStartup({ + mode: 'claustrum', + manifest: row.manifest, + local: row.local, + vault: vault(row.vault), + fingerprintMatch: row.fingerprintMatch, + verifiedInProcessLogin: false, + }) + if ( + row.manifest !== 'present' || + row.local !== 'gone' || + !['serves', 'cold', 'needs_reauth'].includes(row.vault) + ) { + expect( + 'installTombstone' in verdict && verdict.installTombstone, + ).not.toBe(true) + } + } + }) + + test('install appears on VAULT only for present fallback-gone serves', () => { + for (const row of claustrumCases) { + const verdict = evaluateCustodyStartup({ + mode: 'claustrum', + manifest: row.manifest, + local: row.local, + vault: vault(row.vault), + fingerprintMatch: row.fingerprintMatch, + verifiedInProcessLogin: false, + }) + if (verdict.kind !== 'VAULT') continue + expect(verdict.installTombstone === true).toBe( + row.manifest === 'present' && + row.local === 'gone' && + row.vault === 'serves', + ) + } + }) + + test('exports the complete v7 inert-reason vocabulary', () => { + expect(CUSTODY_INERT_REASONS).toEqual([ + 'needs-login', + 'mode-mismatch', + 'corrupt-under-binding', + 'manifest-unreadable', + 'unbound-under-claustrum', + 'orphan-tombstone', + 'vault-cold', + 'vault-reauth', + 'identity-mismatch', + 'no-handle', + 'takeover-incomplete', + 'new-local-family-under-claustrum', + 'takeover-incomplete/vault-unavailable', + 'takeover-incomplete/slot-absent', + ]) + }) +}) diff --git a/packages/opencode/src/tests/custody-transition.test.ts b/packages/opencode/src/tests/custody-transition.test.ts new file mode 100644 index 0000000..a5030b0 --- /dev/null +++ b/packages/opencode/src/tests/custody-transition.test.ts @@ -0,0 +1,747 @@ +import { describe, expect, it, mock } from 'bun:test' +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type AccountStorage, + type AccountStoreTransaction, + getAccountStatePath, + mutateAccounts, + saveAccounts, + withAccountStoreTransaction, +} from '../core/accounts.ts' +import type { TransitionResult } from '../core/custody-transition.ts' +import { liveAccount, liveStorage } from './custody-fixtures.ts' + +type Deferred = { + promise: Promise + resolve: () => void +} + +function deferred(): Deferred { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function fakeCoordinatorDeps( + options: { + storage?: AccountStorage + manifestRevisions?: string[] + preflight?: (id: string) => Promise<'ready' | 'vault-cold' | 'no-handle'> + beforeSet?: () => void + afterSet?: () => void + all?: () => Promise> + failFallbackId?: string + } = {}, +) { + const storage = + options.storage ?? + liveStorage([liveAccount('fallback-b'), liveAccount('fallback-a')]) + const authSlot = { + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: 1, + } + const traces: string[] = [] + const writes: string[] = [] + const revisions = options.manifestRevisions ?? ['revision-1', 'revision-1'] + let revisionIndex = 0 + let current = structuredClone(storage) + const released: string[] = [] + + return { + traces, + writes, + released, + storage: () => current, + authSlot, + deps: { + accountIds: current.accounts.map((account) => account.id), + acquireLock: async ({ + name, + renew, + }: { + name: string + renew: boolean + }) => { + traces.push(`acquire:${name}:${renew}`) + return { + release: async () => { + released.push(name) + traces.push(`release:${name}`) + }, + } + }, + withStoreTransaction: async ( + action: ( + transaction: AccountStoreTransaction, + ) => Promise, + ) => { + traces.push('acquire:store') + try { + return await action({ + read: async () => structuredClone(current), + write: async (next: AccountStorage) => { + const failed = + options.failFallbackId && + next.accounts.find( + (account) => + account.type === 'oauth' && + !account.corrupt && + account.id === options.failFallbackId && + account.refresh.startsWith('claustrum-tombstone'), + ) + if (failed) throw new Error(`fallback write failed: ${failed.id}`) + current = structuredClone(next) + writes.push('fallback') + }, + writeMode: async (mode, transition) => { + writes.push('mode') + current.claustrum = { + mode, + ...(transition ? { transition } : {}), + } + }, + }) + } finally { + traces.push('release:store') + } + }, + readManifest: async () => ({ + ok: true as const, + value: { + version: 1 as const, + providers: [ + { + provider: 'openai', + shape: 'oauth' as const, + serve: 'openai-auth', + accounts: [ + { + label: 'main', + handle: `ckh_${'m'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + ...current.accounts.map((account) => ({ + label: account.id, + handle: `ckh_${account.id.padEnd(43, 'x').slice(0, 43)}`, + credential_id: `oauth:openai:${account.id}`, + })), + ], + }, + ], + }, + revision: revisions[Math.min(revisionIndex++, revisions.length - 1)]!, + }), + preflight: async ({ id }: { id: string }) => + options.preflight?.(id) ?? 'ready', + auth: { + all: + options.all ?? + (async () => ({ openai: authSlot, anthropic: { type: 'oauth' } })), + get: async () => authSlot, + set: async ({ + body, + }: { + body: { access: string; refresh: string; expires: number } + }) => { + options.beforeSet?.() + authSlot.access = body.access + authSlot.refresh = body.refresh + authSlot.expires = body.expires + writes.push('main') + options.afterSet?.() + }, + }, + onStep: (step: string) => { + traces.push(step) + }, + warn: (message: string) => { + traces.push(`warn:${message}`) + }, + }, + } +} + +describe('custody transition fingerprints', () => { + it('slot fingerprints preserve the access-refresh boundary', async () => { + const transition = await import('../core/custody-transition.ts') + + expect('ab' + 'c').toBe('a' + 'bc') + expect(transition.custodySlotFingerprint('ab', 'c')).not.toBe( + transition.custodySlotFingerprint('a', 'bc'), + ) + }) + + it('account store generation changes when a new oauth row is added or enabled', async () => { + const transition = await import('../core/custody-transition.ts') + const disabled = liveAccount('fallback-1', { enabled: false }) + const enabled = { ...disabled, enabled: true } + const base: AccountStorage = { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [disabled], + } + + expect(transition.accountStoreGeneration(base)).not.toBe( + transition.accountStoreGeneration({ ...base, accounts: [enabled] }), + ) + expect(transition.accountStoreGeneration(base)).not.toBe( + transition.accountStoreGeneration({ + ...base, + accounts: [disabled, liveAccount('fallback-2')], + }), + ) + }) + + it('account store generation uses UTF-8 byte ordering for row fences', async () => { + const transition = await import('../core/custody-transition.ts') + const z = liveAccount('z') + const umlaut = liveAccount('ä') + const rows = [z, umlaut].map((account) => ({ + id: account.id, + enabled: account.enabled !== false, + accountId: account.accountId ?? '', + access: account.access ?? '', + refresh: account.refresh, + expires: account.expires ?? null, + })) + const byteOrdered = createHash('sha256') + .update(JSON.stringify(rows)) + .digest('hex') + + expect( + transition.accountStoreGeneration({ + accounts: [umlaut, z], + }), + ).toBe(byteOrdered) + }) + + it('live account expiry is deterministic from injected now unless expires is explicit', () => { + const now = 12_345 + + expect(liveAccount('fallback-1', {}, now).expires).toBe(now + 3_600_000) + expect(liveAccount('fallback-1', { expires: 99 }, now).expires).toBe(99) + }) +}) + +describe('enterClaustrumMode coordinator', () => { + it('holds the five fences and executes the normative event order', async () => { + const transition = await import('../core/custody-transition.ts') + const index = await import('../index.ts') + const fixture = fakeCoordinatorDeps() + + const result = await transition.enterClaustrumMode(fixture.deps) + + expect(result.status).toBe('completed') + expect(transition.MAIN_REFRESH_LOCK_NAME).toBe('main-refresh') + expect(index.getMainRefreshLockName()).toBe( + transition.MAIN_REFRESH_LOCK_NAME, + ) + expect(fixture.traces).toEqual([ + 'mutex-acquired', + 'acquire:claustrum-mode:true', + 'acquire:fallback-oauth-refresh-ztmmMIFaJkALBOTT:true', + 'acquire:fallback-oauth-refresh-n-FDJpEHUMmMUzc9:true', + 'acquire:main-refresh:true', + 'acquire:store', + 'captured', + 'preflight', + 'revalidated', + 'mode-written', + 'material-written', + 'release:store', + 'release:main-refresh', + 'release:fallback-oauth-refresh-n-FDJpEHUMmMUzc9', + 'release:fallback-oauth-refresh-ztmmMIFaJkALBOTT', + 'release:claustrum-mode', + 'mutex-released', + ]) + expect(fixture.writes).toEqual([ + 'mode', + 'fallback', + 'fallback', + 'main', + 'mode', + ]) + }) + + it('does not write mode when a fenced preflight is cold or a reader revision moves', async () => { + const transition = await import('../core/custody-transition.ts') + const cold = fakeCoordinatorDeps({ + preflight: async (id) => (id === 'fallback-a' ? 'vault-cold' : 'ready'), + }) + const moved = fakeCoordinatorDeps({ + manifestRevisions: ['revision-1', 'revision-2'], + }) + + await expect( + transition.enterClaustrumMode(cold.deps), + ).resolves.toMatchObject({ + status: 'aborted', + outcomes: { 'fallback-a': 'vault-cold' }, + }) + await expect( + transition.enterClaustrumMode(moved.deps), + ).resolves.toMatchObject({ + status: 'aborted', + reason: 'manifest-revision-changed', + }) + expect(cold.writes).toEqual([]) + expect(moved.writes).toEqual([]) + }) + + it('aborts before mode when the fenced store generation changes', async () => { + const transition = await import('../core/custody-transition.ts') + const fixture = fakeCoordinatorDeps({ + preflight: async () => { + fixture.storage().accounts.push(liveAccount('new-racing-row')) + return 'ready' + }, + }) + + const result = await transition.enterClaustrumMode(fixture.deps) + + expect(result).toMatchObject({ + status: 'aborted', + reason: 'store-generation-changed', + }) + expect(fixture.writes).toEqual([]) + }) + + it('does not read or write when the mode lock is unavailable', async () => { + const transition = await import('../core/custody-transition.ts') + const fixture = fakeCoordinatorDeps() + let reads = 0 + fixture.deps.readManifest = async () => { + reads++ + throw new Error('must not read') + } + fixture.deps.acquireLock = (async () => null) as never + + await expect( + transition.enterClaustrumMode(fixture.deps), + ).resolves.toMatchObject({ + status: 'aborted', + reason: 'mode-lock-unavailable', + }) + expect(reads).toBe(0) + expect(fixture.writes).toEqual([]) + }) + + it('holds both real save locks until the transaction releases', async () => { + const directory = mkdtempSync(join(tmpdir(), 'oai-custody-transition-')) + const path = join(directory, 'accounts.json') + try { + await saveAccounts(liveStorage([liveAccount('fallback-a')]), path) + const entered = deferred() + const release = deferred() + const held = withAccountStoreTransaction(async () => { + entered.resolve() + await release.promise + return { status: 'completed', outcomes: {} } + }, path) + await entered.promise + const statePath = getAccountStatePath(path) + expect(existsSync(`${path}.save.lock`)).toBe(true) + expect(existsSync(`${statePath}.save.lock`)).toBe(true) + release.resolve() + await held + expect(existsSync(`${path}.save.lock`)).toBe(false) + expect(existsSync(`${statePath}.save.lock`)).toBe(false) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('blocks a CLI-shaped account write behind the barrier store transaction', async () => { + const directory = mkdtempSync(join(tmpdir(), 'oai-custody-store-lock-')) + const path = join(directory, 'accounts.json') + const transition = await import('../core/custody-transition.ts') + const account = liveAccount('fallback-a') + const entered = deferred() + const release = deferred() + const authSlot = { + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: 1, + } + try { + await saveAccounts(liveStorage([account]), path) + + const uncontendedStarted = performance.now() + await mutateAccounts((current) => current, path) + const uncontendedMs = performance.now() - uncontendedStarted + + const barrier = transition.enterClaustrumMode({ + accountIds: [account.id], + acquireLock: async () => ({ release: async () => {} }), + withStoreTransaction: async (action) => + await withAccountStoreTransaction(action, path), + readManifest: async () => ({ + ok: true as const, + revision: 'revision-1', + value: { + version: 1 as const, + providers: [ + { + provider: 'openai', + shape: 'oauth' as const, + serve: 'openai-auth', + accounts: [ + { + label: 'main', + handle: `ckh_${'m'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + { + label: account.id, + handle: `ckh_${'a'.repeat(43)}`, + credential_id: `oauth:openai:${account.id}`, + }, + ], + }, + ], + }, + }), + preflight: async () => 'ready', + auth: { + all: async () => ({ openai: authSlot }), + get: async () => authSlot, + set: async () => {}, + }, + onStep: async (step) => { + if (step !== 'revalidated') return + entered.resolve() + await release.promise + }, + }) + await entered.promise + + let settled = false + const cliWrite = mutateAccounts((current) => { + current.accounts.push(liveAccount('cli-shaped-write')) + return current + }, path).then(() => { + settled = true + }) + await Bun.sleep(Math.max(20, Math.ceil(uncontendedMs * 2))) + expect(settled).toBe(false) + + release.resolve() + await Promise.all([barrier, cliWrite]) + expect(settled).toBe(true) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('defers a host tombstone on an empty auth map and warns exactly once', async () => { + const transition = await import('../core/custody-transition.ts') + const fixture = fakeCoordinatorDeps({ all: async () => ({}) }) + + const result = await transition.enterClaustrumMode(fixture.deps) + + expect(result.status).toBe('incomplete') + expect(result.outcomes.main).toBe('torn-read-deferred') + expect(fixture.writes).toEqual(['mode', 'fallback', 'fallback']) + expect(fixture.traces.filter((step) => step.startsWith('warn:'))).toEqual([ + 'warn:host auth store read empty; refusing to write — possible torn read', + ]) + }) + + it('writes an empty access token to the main custody tombstone', async () => { + const transition = await import('../core/custody-transition.ts') + const fixture = fakeCoordinatorDeps() + + await transition.enterClaustrumMode(fixture.deps) + + expect(fixture.authSlot).toMatchObject({ + access: '', + refresh: 'claustrum-tombstone:v1:openai', + expires: 0, + }) + }) + + it('reports a host overwrite observed by post-write readback', async () => { + const transition = await import('../core/custody-transition.ts') + const fixture = fakeCoordinatorDeps() + fixture.deps.auth.get = async () => + fixture.writes.includes('main') + ? { + type: 'oauth', + access: 'new-access', + refresh: 'new-refresh', + expires: 2, + } + : { + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: 1, + } + + const result = await transition.enterClaustrumMode(fixture.deps) + + expect(result.outcomes.main).toBe('new-local-family-under-claustrum') + }) + + it('retains the mode and prior tombstones when one fallback write fails', async () => { + const transition = await import('../core/custody-transition.ts') + const fixture = fakeCoordinatorDeps({ failFallbackId: 'fallback-b' }) + + const result = await transition.enterClaustrumMode(fixture.deps) + + expect(result).toMatchObject({ + status: 'incomplete', + outcomes: { + 'fallback-a': 'tombstoned', + 'fallback-b': 'aborted:write-failed', + }, + }) + expect(fixture.storage().claustrum?.mode).toBe('claustrum') + expect(fixture.writes).toEqual(['mode', 'fallback']) + }) + + it('resumes only a remaining row that still matches its persisted fingerprint', async () => { + const transition = await import('../core/custody-transition.ts') + const fixture = fakeCoordinatorDeps({ failFallbackId: 'fallback-b' }) + await transition.enterClaustrumMode(fixture.deps) + const changed = fixture + .storage() + .accounts.find( + (account) => account.type === 'oauth' && account.id === 'fallback-b', + ) + if (changed?.type !== 'oauth') throw new Error('missing fallback-b') + changed.access = 'new-local-access' + + const result = await transition.enterClaustrumMode(fixture.deps) + + expect(result.outcomes).toMatchObject({ + 'fallback-a': 'tombstoned', + 'fallback-b': 'new-local-family-under-claustrum', + }) + expect(changed.access).toBe('new-local-access') + }) + + it('resumes a deferred main tombstone and clears fingerprints only after completion', async () => { + const transition = await import('../core/custody-transition.ts') + let empty = true + const fixture = fakeCoordinatorDeps({ + all: async () => (empty ? {} : { openai: {} }), + }) + await transition.enterClaustrumMode(fixture.deps) + empty = false + + const result = await transition.enterClaustrumMode(fixture.deps) + + expect(result.status).toBe('completed') + expect(result.outcomes.main).toBe('tombstoned') + expect(fixture.storage().claustrum?.transition).toBeUndefined() + }) + + it('serializes a barrier behind a shared process mutex', async () => { + const transition = await import('../core/custody-transition.ts') + const held = await transition.acquireCustodyTransitionMutex() + const fixture = fakeCoordinatorDeps() + const started = deferred() + fixture.deps.onStep = (step: string) => { + fixture.traces.push(step) + if (step === 'mutex-acquired') started.resolve() + } + + const pending = transition.enterClaustrumMode(fixture.deps) + await Promise.resolve() + expect(fixture.traces).toEqual([]) + await held.release() + await started.promise + await pending + }) + + it('releases the transition mutex when the final observer throws', async () => { + const transition = await import('../core/custody-transition.ts') + const fixture = fakeCoordinatorDeps() + fixture.deps.onStep = (step: string) => { + fixture.traces.push(step) + if (step === 'mutex-released') throw new Error('observer failed') + } + + await expect( + transition.enterClaustrumMode(fixture.deps), + ).resolves.toMatchObject({ + status: 'completed', + }) + + const second = await transition.acquireCustodyTransitionMutex() + expect(typeof second.release).toBe('function') + await second.release() + }) + + it('does not start a second barrier preflight while the first holds the transition mutex', async () => { + const transition = await import('../core/custody-transition.ts') + const firstPreflightEntered = deferred() + const releaseFirstPreflight = deferred() + const first = fakeCoordinatorDeps({ + preflight: async () => { + firstPreflightEntered.resolve() + await releaseFirstPreflight.promise + return 'ready' + }, + }) + const secondPreflight = mock(async () => 'ready' as const) + const second = fakeCoordinatorDeps({ preflight: secondPreflight }) + + const firstRun = transition.enterClaustrumMode(first.deps) + await firstPreflightEntered.promise + const secondRun = transition.enterClaustrumMode(second.deps) + for (let turn = 0; turn < 32; turn += 1) await Promise.resolve() + + expect(secondPreflight).not.toHaveBeenCalled() + releaseFirstPreflight.resolve() + await firstRun + await secondRun + expect(secondPreflight).toHaveBeenCalled() + }) + + it('makes a fake authorize callback wait until the barrier releases the shared mutex', async () => { + const transition = await import('../core/custody-transition.ts') + const preflightEntered = deferred() + const releasePreflight = deferred() + const fixture = fakeCoordinatorDeps({ + preflight: async () => { + preflightEntered.resolve() + await releasePreflight.promise + return 'ready' + }, + }) + const barrier = transition.enterClaustrumMode(fixture.deps) + await preflightEntered.promise + let oauthStarted = false + const authorize = (async () => { + const lease = await transition.acquireCustodyTransitionMutex() + oauthStarted = true + await lease.release() + })() + + await Promise.resolve() + expect(oauthStarted).toBe(false) + releasePreflight.resolve() + await barrier + await authorize + expect(oauthStarted).toBe(true) + }) + + it('makes barrier step one wait until a fake authorize host set has landed', async () => { + const transition = await import('../core/custody-transition.ts') + const order: string[] = [] + const authorize = (async () => { + const lease = await transition.acquireCustodyTransitionMutex() + order.push('host-set') + await lease.release() + })() + const fixture = fakeCoordinatorDeps() + fixture.deps.onStep = (step: string) => { + if (step === 'mutex-acquired') order.push('barrier-step-1') + } + + await Promise.all([authorize, transition.enterClaustrumMode(fixture.deps)]) + + expect(order).toEqual(['host-set', 'barrier-step-1']) + }) + + it('leaves custody under the mode lock without touching account material', async () => { + const transition = await import('../core/custody-transition.ts') + const calls: string[] = [] + + await transition.leaveClaustrumMode({ + acquireLock: async ({ name, renew }) => { + calls.push(`acquire:${name}:${renew}`) + return { + release: async () => { + calls.push(`release:${name}`) + }, + } + }, + withStoreTransaction: async (action) => + await action({ + read: async () => { + throw new Error('leave must not read credential material') + }, + write: async () => { + throw new Error('leave must not write credential material') + }, + writeMode: async (mode) => { + calls.push(`mode:${mode}`) + }, + }), + }) + + expect(calls).toEqual([ + 'acquire:claustrum-mode:true', + 'mode:local', + 'release:claustrum-mode', + ]) + }) +}) + +describe('main login transition lease', () => { + it('retains the lease until host readback observes the exact minted access token', async () => { + const transition = await import('../core/custody-transition.ts') + let now = 0 + let reads = 0 + const release = mock(async () => {}) + const warn = mock(() => {}) + + await transition.releaseCustodyLoginLeaseAfterHostWrite({ + accessToken: 'minted-access', + refreshToken: 'minted-refresh', + getAuth: async () => { + reads += 1 + return reads === 2 + ? { access: 'minted-access', refresh: 'minted-refresh' } + : { access: 'other', refresh: 'other-refresh' } + }, + release, + warn, + now: () => now, + sleep: async (ms) => { + now += ms + }, + }) + + expect(release).toHaveBeenCalledTimes(1) + expect(warn).not.toHaveBeenCalled() + expect(reads).toBe(2) + }) + + it('releases and warns once when host write readback misses the five-second bound', async () => { + const transition = await import('../core/custody-transition.ts') + let now = 0 + const release = mock(async () => {}) + const warn = mock(() => {}) + + await transition.releaseCustodyLoginLeaseAfterHostWrite({ + accessToken: 'minted-access', + refreshToken: 'minted-refresh', + getAuth: async () => ({ access: 'other', refresh: 'other-refresh' }), + release, + warn, + now: () => now, + sleep: async (ms) => { + now += ms + }, + }) + + expect(release).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + 'host write not observed within 5s; lease released', + ) + expect(now).toBe(5_000) + }) +}) diff --git a/packages/opencode/src/tests/custody.test.ts b/packages/opencode/src/tests/custody.test.ts new file mode 100644 index 0000000..2b35a58 --- /dev/null +++ b/packages/opencode/src/tests/custody.test.ts @@ -0,0 +1,1546 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { + chmodSync, + constants as fsConstants, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + writeFileSync, + writeSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getAccountStoragePath } from '../core/account-paths.ts' +import { + type AccountStorage, + loadAccounts, + normalizeAccount, + saveAccounts, +} from '../core/accounts.ts' +import { + assertNoCustodyTombstoneMaterial, + ClaustrumCredentialCache, + CUSTODY_EXCLUDED, + CUSTODY_REFUSE, + CUSTODY_TOMBSTONE_PREFIX, + custodied, + custodyTombstoneKey, + enrolled, + enrolling, + excluded, + reconcileFallbackCustody, + refreshInert, + resolveFallbackAccess, + tombstoned, + type VaultProvenance, + verifyServedFallbackIdentity, +} from '../core/custody.ts' +import { readCustodyManifest } from '../core/custody-manifest.ts' +import { acquireRefreshFileLock } from '../core/refresh-file-lock.ts' +import { + CUSTODY_FIXTURE_NOW, + claustrumConfig, + enrollmentManifest, + liveAccount, + liveStorage, + makeSentinelAccount, + TOMBSTONE_OPENAI, +} from './custody-fixtures.ts' +import { + assertFloor, + FLOOR_CLAUSTRUM_HANDLES, + FLOOR_CLAUSTRUM_HANDLES_LOCK, +} from './setup-env.ts' + +const TEST_OAUTH_HANDLES_ENV = 'CLAUSTRUM_OPENCODE_HANDLES' + +let handlesDir: string +let handlesPath: string + +beforeEach(async () => { + handlesDir = mkdtempSync(join(tmpdir(), 'custody-test-')) + handlesPath = join(handlesDir, 'opencode-handles.json') + process.env[TEST_OAUTH_HANDLES_ENV] = handlesPath +}) + +afterEach(() => { + // Restore to floor — never delete, so any in-flight reads resolve to a temp path. + process.env[TEST_OAUTH_HANDLES_ENV] = FLOOR_CLAUSTRUM_HANDLES + try { + rmSync(handlesDir, { recursive: true, force: true }) + } catch {} +}) + +// --------------------------------------------------------------------------- +// Tombstone sentinel +// --------------------------------------------------------------------------- + +describe('custodyTombstoneKey', () => { + it('produces the per-provider sentinel using the documented prefix', () => { + expect(custodyTombstoneKey('openai')).toBe('claustrum-tombstone:v1:openai') + expect(CUSTODY_TOMBSTONE_PREFIX).toBe('claustrum-tombstone:v1:') + }) + + it('normalizes a canonical tombstone with empty access (preserves the account on load)', async () => { + const sentinel = makeSentinelAccount({ access: '' }) + // If normalizeAccount dropped the entry, the list would be empty and the + // tombstone would vanish. Assert it survives normalisation as an account + // with empty access, sentinel refresh, and expiry zero so callers can + // observe the tombstone rather than silently losing the entry. + const normalized = normalizeAccount({ + id: sentinel.id, + type: 'oauth', + access: sentinel.access, + refresh: sentinel.refresh, + expires: sentinel.expires, + }) + expect(normalized).not.toBeNull() + expect(normalized?.type).toBe('oauth') + if (normalized?.type !== 'oauth') throw new Error('expected oauth') + expect(normalized.access).toBe('') + expect(normalized.refresh).toBe(TOMBSTONE_OPENAI) + expect(normalized.expires).toBe(0) + }) + + it('round-trips a canonical tombstone through saveAccounts/loadAccounts', async () => { + const cfg = liveStorage([makeSentinelAccount({ access: '' })]) + await saveAccounts(cfg, getAccountStoragePath()) + const loaded = await loadAccounts() + expect(loaded).not.toBeNull() + const account = loaded?.accounts[0] + expect(account?.type).toBe('oauth') + if (account?.type !== 'oauth') throw new Error('expected oauth') + expect(account.access).toBe('') + expect(account.refresh).toBe(TOMBSTONE_OPENAI) + expect(account.expires).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// Manifest reader +// --------------------------------------------------------------------------- + +async function writeManifest(providers: unknown[]): Promise { + const fd = openSync( + handlesPath, + fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_TRUNC, + 0o600, + ) + const json = JSON.stringify({ version: 1, providers }) + writeSync(fd, json) + // Belt-and-braces: openSync with O_CREAT honours mode on POSIX but some + // platforms may differ — chmod to the desired 0o600 to keep the manifest + // reader's mode check deterministic across test runs. + chmodSync(handlesPath, 0o600) +} + +describe('readCustodyManifest', () => { + it('manifest revision changes when only parsed source whitespace changes', async () => { + const source = JSON.stringify({ + version: 1, + providers: [ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'main', + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + ], + }, + ], + }) + writeFileSync(handlesPath, source, { mode: 0o600 }) + chmodSync(handlesPath, 0o600) + const first = await readCustodyManifest(handlesPath) + writeFileSync(handlesPath, `\n${source}\n`, { mode: 0o600 }) + chmodSync(handlesPath, 0o600) + const second = await readCustodyManifest(handlesPath) + + expect(first.ok).toBe(true) + expect(second.ok).toBe(true) + if (!first.ok || !second.ok) throw new Error('expected valid manifests') + expect(first.revision).not.toBe(second.revision) + }) + + it('manifest revision is stable for the same bytes across two reads', async () => { + const source = JSON.stringify({ + version: 1, + providers: [ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'main', + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + ], + }, + ], + }) + writeFileSync(handlesPath, source, { mode: 0o600 }) + chmodSync(handlesPath, 0o600) + + const first = await readCustodyManifest(handlesPath) + const second = await readCustodyManifest(handlesPath) + + expect(first.ok).toBe(true) + expect(second.ok).toBe(true) + if (!first.ok || !second.ok) throw new Error('expected valid manifests') + expect(first.revision).toMatch(/^[a-f0-9]{64}$/) + expect(first.revision).toBe(second.revision) + }) + + it('reads a regular 0600 file owned by the current uid', async () => { + const handle = `ckh_${'a'.repeat(43)}` + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { label: 'main', handle, credential_id: 'oauth:openai:main' }, + ], + }, + ]) + const result = await readCustodyManifest(handlesPath) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value.providers).toHaveLength(1) + expect(result.value.providers[0]?.provider).toBe('openai') + expect(result.value.providers[0]?.accounts[0]?.handle).toBe(handle) + }) + + it('rejects files larger than 256 KiB', async () => { + const big = JSON.stringify({ version: 1, providers: [] }).padEnd( + 257 * 1024, + ' ', + ) + writeFileSync(handlesPath, big, { mode: 0o600 }) + const result = await readCustodyManifest(handlesPath) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.reason).toBe('tooLarge') + }) + + it('rejects handles that do not match the ckh_… pattern (mixed case)', async () => { + // Mixed-case A-Z is allowed, but missing the ckh_ prefix or wrong length is not. + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'main', + handle: `ckh_${'A'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + { + label: 'work', + handle: 'BADHANDLE', + credential_id: 'oauth:openai:work', + }, + ], + }, + ]) + const result = await readCustodyManifest(handlesPath) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.reason).toBe('invalid') + }) + + it('rejects prototype keys as identifiers', async () => { + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: '__proto__', + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:x', + }, + ], + }, + ]) + const result = await readCustodyManifest(handlesPath) + expect(result.ok).toBe(false) + }) + + it('rejects manifest version != 1', async () => { + const fd = openSync( + handlesPath, + fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_TRUNC, + 0o600, + ) + writeSync(fd, JSON.stringify({ version: 2, providers: [] })) + chmodSync(handlesPath, 0o600) + const result = await readCustodyManifest(handlesPath) + expect(result.ok).toBe(false) + }) + + it('joins case-exactly on label === account.id (not on credential_id suffix)', async () => { + // Manifest label is 'Main', local account id is 'main' — should NOT match. + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'Main', + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + ], + }, + ]) + const acct = liveAccount('main') + expect(enrolled(acct, await readCustodyManifest(handlesPath))).toBe(false) + + // Same handle + credential_id, but label exactly matches the id — IS enrolled. + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'main', + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + ], + }, + ]) + expect(enrolled(acct, await readCustodyManifest(handlesPath))).toBe(true) + }) + + it('returns invalid JSON without leaking a handle or a cause', async () => { + // An unquoted ckh_… beside a SyntaxError must produce a stable message — + // no handle in the message, no `cause` set, and the file is rejected cleanly. + const mixed = '{"providers":[{"handle":"ckh_LEAKED_HANDLE_VALUE_BAD"' + writeFileSync(handlesPath, mixed, { mode: 0o600 }) + const result = await readCustodyManifest(handlesPath) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.reason).toBe('invalid') + if (result.reason === 'invalid') { + expect(result.message).toBe('invalid JSON') + // The 'cause' field must NOT be set — the parser drops it to keep the + // SyntaxError payload (which may contain a handle) off the log/error + // surface. + expect((result as { cause?: unknown }).cause).toBeUndefined() + expect(result.message.includes('ckh_')).toBe(false) + } + }) + + it('rejects files with mode != 0600', async () => { + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'main', + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + ], + }, + ]) + chmodSync(handlesPath, 0o644) + const result = await readCustodyManifest(handlesPath) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.reason).toBe('permissions') + }) + + it('rejects files whose parent is not safe', async () => { + const unsafeParent = join(handlesDir, 'unsafe-parent') + mkdirSync(unsafeParent, { mode: 0o755, recursive: true }) + const unsafePath = join(unsafeParent, 'handles.json') + writeFileSync(unsafePath, JSON.stringify({ version: 1, providers: [] }), { + mode: 0o600, + }) + // 0o755 = no sticky bit, no group/other write — but no longer 0700 either. + // Too-permissive parent (0o755) trips the safe-parent check. + chmodSync(unsafeParent, 0o755) + const result = await readCustodyManifest(unsafePath) + expect(result.ok).toBe(false) + if (result.ok) return + expect( + result.reason === 'permissions' || result.reason === 'unsafeParent', + ).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// Predicates +// --------------------------------------------------------------------------- + +describe('predicates', () => { + it('enrolled = case-exact manifest account label === account.id (storage toggle is not a parameter)', async () => { + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'main', + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + ], + }, + ]) + const m = await readCustodyManifest(handlesPath) + const acct = liveAccount('main') + expect(enrolled(acct, m)).toBe(true) + const other = liveAccount('other') + expect(enrolled(other, m)).toBe(false) + // Storage.claustrum cannot influence enrollment — the predicate does not + // accept it as a parameter; the test name states the invariant. + }) + + it('recognizes an oauth tombstone from the exact provider refresh sentinel alone', () => { + expect(tombstoned(makeSentinelAccount(), 'openai')).toBe(true) + // A refresh mismatch remains a local credential, regardless of access. + expect( + tombstoned( + { ...makeSentinelAccount(), refresh: 'live-refresh' }, + 'openai', + ), + ).toBe(false) + // A partial or corrupt write is still custody evidence. Removing this + // refresh-only recognition would send the sentinel into local refresh. + expect( + tombstoned( + makeSentinelAccount({ + access: 'stale', + refresh: TOMBSTONE_OPENAI, + expires: CUSTODY_FIXTURE_NOW + 60_000, + }), + 'openai', + ), + ).toBe(true) + }) + + it('custodied requires claustrum mode + enrolled + tombstoned', async () => { + const m = await readCustodyManifest(handlesPath) // empty manifest + const sentinel = makeSentinelAccount() + expect( + custodied( + sentinel, + m, + liveStorage([], { claustrum: claustrumConfig({ mode: 'claustrum' }) }), + ), + ).toBe(false) + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: sentinel.id, + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:x', + }, + ], + }, + ]) + const m2 = await readCustodyManifest(handlesPath) + // Toggle OFF → not custodied. + expect( + custodied( + sentinel, + m2, + liveStorage([], { claustrum: claustrumConfig({ mode: 'local' }) }), + ), + ).toBe(false) + // Toggle ON → custodied. + expect( + custodied( + sentinel, + m2, + liveStorage([], { claustrum: claustrumConfig({ mode: 'claustrum' }) }), + ), + ).toBe(true) + }) + + it('foreign tombstone is not OpenAI custody but is refused before refresh', () => { + const foreign = makeSentinelAccount({ + access: '', + refresh: 'claustrum-tombstone:v1:anthropic', + }) + const storage = liveStorage([foreign], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + expect(custodied(foreign, enrollmentManifest(foreign.id), storage)).toBe( + false, + ) + expect(() => assertNoCustodyTombstoneMaterial(foreign.refresh)).toThrow() + }) + + it('refuses every tombstone prefix but permits empty and ordinary refresh material', () => { + expect(() => + assertNoCustodyTombstoneMaterial('claustrum-tombstone:v1:openai'), + ).toThrow() + expect(() => + assertNoCustodyTombstoneMaterial('claustrum-tombstone:v1:anthropic'), + ).toThrow() + expect(() => + assertNoCustodyTombstoneMaterial('ordinary-refresh'), + ).not.toThrow() + expect(() => assertNoCustodyTombstoneMaterial('')).not.toThrow() + }) + + it('enrolling = enrolled && !tombstoned; refreshInert = enrolled || tombstoned; excluded = tombstoned && !custodied', async () => { + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'live-acct', + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:x', + }, + { + label: 'tomb-acct', + handle: `ckh_${'b'.repeat(43)}`, + credential_id: 'oauth:openai:y', + }, + ], + }, + ]) + const m = await readCustodyManifest(handlesPath) + const live = liveAccount('live-acct') + const tomb = makeSentinelAccount() + tomb.id = 'tomb-acct' + + expect(enrolling(live, m)).toBe(true) + expect(enrolling(tomb, m)).toBe(false) + expect(refreshInert(live, m, 'openai')).toBe(true) + expect(refreshInert(tomb, m, 'openai')).toBe(true) + expect(refreshInert(liveAccount('absent'), m, 'openai')).toBe(false) + + // excluded = tombstoned && !custodied — storage toggle OFF → excluded. + expect( + excluded( + tomb, + m, + liveStorage([], { claustrum: claustrumConfig({ mode: 'local' }) }), + 'openai', + ), + ).toBe(true) + // Toggle ON → no longer excluded. + expect( + excluded( + tomb, + m, + liveStorage([], { claustrum: claustrumConfig({ mode: 'claustrum' }) }), + 'openai', + ), + ).toBe(false) + }) + + it('does not refresh an entry-present account in local mode (mode gates custodied serving, not local access)', async () => { + // A live, enrolled account in local mode is "enrolling" and its access + // token must still be served from local. The mode gates + // custodied serving only, not refresh-gate decisions. + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { + label: 'main', + handle: `ckh_${'a'.repeat(43)}`, + credential_id: 'oauth:openai:main', + }, + ], + }, + ]) + const m = await readCustodyManifest(handlesPath) + const acct = liveAccount('main') + const storage = liveStorage([acct], { + claustrum: claustrumConfig({ mode: 'local' }), + }) + // resolveFallbackAccess observes the predicates; with toggle off + live cache, + // it must serve local access, NOT consult the vault. + const result = await resolveFallbackAccess(acct, storage, m) + expect(result).not.toBe(CUSTODY_REFUSE) + expect(result).not.toBe(CUSTODY_EXCLUDED) + if (typeof result === 'symbol') + throw new Error('expected resolution object') + expect(result.provenance).toBe('local') + expect(result.token).toBe(acct.access ?? '') + }) +}) + +// --------------------------------------------------------------------------- +// Identity verifier +// --------------------------------------------------------------------------- + +function jwtFor(accountId: string): string { + const claims = { chatgpt_account_id: accountId } + const b64 = Buffer.from(JSON.stringify(claims)).toString('base64url') + return `h.${b64}.s` +} + +describe('verifyServedFallbackIdentity', () => { + it('returns nullClaim when the served access token has no claims', () => { + const acct = liveAccount('main', { accountId: 'acct-X' }) + const result = verifyServedFallbackIdentity( + { + payload: { access: 'not-a-jwt' }, + recordVersion: 1, + expiresAtMs: Date.now() + 60_000, + }, + acct, + ) + expect(result).toEqual({ reason: 'nullClaim' }) + }) + + it('returns identityMismatch / claimDiffersFromLocal when claims differ from the local accountId', () => { + const acct = liveAccount('main', { accountId: 'acct-X' }) + const result = verifyServedFallbackIdentity( + { + payload: { access: jwtFor('acct-Y') }, + recordVersion: 1, + expiresAtMs: Date.now() + 60_000, + }, + acct, + ) + expect(result).toEqual({ + reason: 'identityMismatch', + detail: 'claimDiffersFromLocal', + }) + }) + + it('verifier compares the PARSED CLAIM, not the served string field', () => { + // Mutation: bind the comparison to the served string instead of the parsed + // claim. Then a mislabelled record (served string says "acct-X" but the + // token claims a different account) would incorrectly pass. + const acct = liveAccount('main', { accountId: 'acct-X' }) + const servedAccess = jwtFor('acct-Y') // claims a different account + // Even if the servedAccountId (if present) is undefined, the verifier MUST + // still reject because the parsed claim does not match the local accountId. + const result = verifyServedFallbackIdentity( + { + payload: { access: servedAccess }, + recordVersion: 1, + expiresAtMs: Date.now() + 60_000, + }, + acct, + ) + expect(result).toEqual({ + reason: 'identityMismatch', + detail: 'claimDiffersFromLocal', + }) + }) + + it('passes when claims match the local accountId', () => { + const acct = liveAccount('main', { accountId: 'acct-X' }) + const result = verifyServedFallbackIdentity( + { + payload: { access: jwtFor('acct-X') }, + recordVersion: 1, + expiresAtMs: Date.now() + 60_000, + }, + acct, + ) + expect(result).toEqual({ reason: 'ok' }) + }) + + // The conditional branch: the vendored ServedCredential has no served id, + // so the normalized servedAccountId is undefined today. The test is `test.skip` + // with a documented reason until the wire contract adds the field. + it.skip('labelDisagreesWithClaim branch when a served id is present and disagrees', () => { + // Placeholder — pending wire contract addition. Skipped intentionally so the + // missing field does not mask the implementation gap. + }) +}) + +// --------------------------------------------------------------------------- +// Resolver outcomes +// --------------------------------------------------------------------------- + +describe('resolveFallbackAccess', () => { + it('returns local provenance for a live, non-tombstoned account', async () => { + const acct = liveAccount('main') + const m = await readCustodyManifest(handlesPath) // empty + const result = await resolveFallbackAccess(acct, liveStorage([acct]), m) + expect(typeof result).toBe('object') + if (typeof result === 'symbol') + throw new Error('expected resolution object') + expect(result.provenance).toBe('local') + expect(result.token).toBe(acct.access ?? '') + }) + + it('returns CUSTODY_REFUSE when the manifest is empty but the account is tombstoned', async () => { + const acct = makeSentinelAccount() + const m = await readCustodyManifest(handlesPath) // empty + const storage = liveStorage([acct], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + const result = await resolveFallbackAccess(acct, storage, m) + expect(result).toBe(CUSTODY_REFUSE) + }) + + it('returns CUSTODY_EXCLUDED for tombstoned accounts in local mode', async () => { + const acct = makeSentinelAccount() + const m = await readCustodyManifest(handlesPath) // empty + const storage = liveStorage([acct], { + claustrum: claustrumConfig({ mode: 'local' }), + }) + const result = await resolveFallbackAccess(acct, storage, m) + expect(result).toBe(CUSTODY_EXCLUDED) + }) + + it('mode=local + enrolled tombstone is excluded and never serves local access', async () => { + const acct = makeSentinelAccount({ access: '' }) + const manifest = enrollmentManifest(acct.id) + const storage = liveStorage([acct], { + claustrum: claustrumConfig({ mode: 'local' }), + }) + expect(await resolveFallbackAccess(acct, storage, manifest)).toBe( + CUSTODY_EXCLUDED, + ) + }) + + it('returns vault provenance for a custodied account whose live cache serves the credential', async () => { + const handle = `ckh_${'a'.repeat(43)}` + const acct = makeSentinelAccount() + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [{ label: acct.id, handle, credential_id: 'oauth:openai:x' }], + }, + ]) + const m = await readCustodyManifest(handlesPath) + const storage = liveStorage([acct], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + const served = jwtFor('acct-X') + const cache = new ClaustrumCredentialCache({ + connector: async () => + (async () => ({ + async getCredential(handleArg: string) { + return { + material: served, + recordVersion: 7, + expiresAtMs: Date.now() + 60_000, + } + }, + async statusCredential() { + return { + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 7, + } + }, + async reportAuthFailure() { + return + }, + close() {}, + }))() as never, + }) + // Seed the cache under the manifest handle. + const result = await resolveFallbackAccess(acct, storage, m, { + cache, + manifestHandle: handle, + }) + expect(typeof result).toBe('object') + if (typeof result === 'symbol') + throw new Error('expected resolution object') + expect(result.provenance).not.toBe('local') + const prov = result.provenance as VaultProvenance + expect(prov.handle).toBe(handle) + expect(prov.recordVersion).toBe(7) + expect(result.token).toBe(served) + cache.close() + }) + + it('returns CUSTODY_REFUSE for custodied account with empty cache', async () => { + const handle = `ckh_${'a'.repeat(43)}` + const acct = makeSentinelAccount() + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [{ label: acct.id, handle, credential_id: 'oauth:openai:x' }], + }, + ]) + const m = await readCustodyManifest(handlesPath) + const storage = liveStorage([acct], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + const cache = new ClaustrumCredentialCache({ + connector: async () => + (async () => ({ + async getCredential() { + throw new Error('not found') + }, + async statusCredential() { + return { + ready: false, + lastErrorCode: 'nf', + leaseHeld: false, + recordVersion: 0, + } + }, + async reportAuthFailure() { + return + }, + close() {}, + }))() as never, + }) + const result = await resolveFallbackAccess(acct, storage, m, { + cache, + manifestHandle: handle, + }) + expect(result).toBe(CUSTODY_REFUSE) + cache.close() + }) + + it('refuses a reauth handle even when its resident vault record is populated', async () => { + const handle = `ckh_${'r'.repeat(43)}` + const account = makeSentinelAccount({ accountId: 'acct-reauth' }) + await writeManifest([ + { + provider: 'openai', + shape: 'oauth', + serve: 'openai-auth', + accounts: [ + { label: account.id, handle, credential_id: 'oauth:openai:x' }, + ], + }, + ]) + const storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + const manifest = await readCustodyManifest(handlesPath) + let version = 1 + const cache = new ClaustrumCredentialCache({ + connector: async () => + makeFakeClient({ + getCredential: async () => ({ + material: jwtFor('acct-reauth'), + recordVersion: version, + expiresAtMs: Date.now() + 60_000, + }), + }) as never, + }) + await cache.get(handle) + await cache.reportAuthFailure({ + handle, + providerStatus: 401, + recordVersion: 1, + }) + version = 2 + await cache.get(handle) + await cache.reportAuthFailure({ + handle, + providerStatus: 401, + recordVersion: 2, + }) + version = 3 + await cache.get(handle) + expect(await cache.peek(handle)).toBeDefined() + expect(cache.isReauth(handle)).toBe(true) + expect( + await resolveFallbackAccess(account, storage, manifest, { + cache, + manifestHandle: handle, + requestPath: true, + }), + ).toBe(CUSTODY_REFUSE) + cache.close() + }) +}) + +describe('reconcileFallbackCustody', () => { + it('writes the canonical tombstone with empty access after vault verification', async () => { + const now = CUSTODY_FIXTURE_NOW + const account = liveAccount('completion-1', { + accountId: 'acct-completion', + }) + let storage = liveStorage([account]) + const cache = new ClaustrumCredentialCache({ + now: () => now, + connector: async () => + makeFakeClient({ + getCredential: async () => ({ + material: jwtFor('acct-completion'), + recordVersion: 7, + expiresAtMs: now + 60_000, + }), + }) as never, + }) + + const result = await reconcileFallbackCustody(account, { + loadAccounts: async () => storage, + readCustodyManifest: async () => enrollmentManifest(account.id), + acquireRefreshFileLock, + configPath: join(handlesDir, 'completion-store.json'), + cache, + minTtlMs: 30_000, + mutateAccounts: async (mutate) => { + storage = mutate(storage) ?? storage + }, + now: () => now, + }) + + expect(result).toEqual({ kind: 'succeeded', recordVersion: 7 }) + const completed = storage.accounts[0] + expect(completed?.type).toBe('oauth') + if (completed?.type !== 'oauth') throw new Error('expected oauth') + expect(completed.access).toBe('') + expect(completed.refresh).toBe(TOMBSTONE_OPENAI) + expect(completed.expires).toBe(0) + cache.close() + }) +}) + +describe('binding-pending request reconciliation', () => { + it('uses the account lock before binding the first served identity', async () => { + const account = makeSentinelAccount({ + id: 'binding-pending', + accountId: undefined, + }) + let storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + let lockCalls = 0 + const cache = new ClaustrumCredentialCache({ + connector: async () => + makeFakeClient({ + getCredential: async () => ({ + material: jwtFor('acct-bound'), + recordVersion: 9, + expiresAtMs: Date.now() + 60_000, + }), + }) as never, + }) + const manifest = enrollmentManifest(account.id) + const resolution = await resolveFallbackAccess(account, storage, manifest, { + cache, + manifestHandle: manifest.ok + ? manifest.value.providers[0]?.accounts[0]?.handle + : undefined, + requestPath: true, + completeEnrollmentDeps: { + loadAccounts: async () => storage, + readCustodyManifest: async () => manifest, + acquireRefreshFileLock: async () => { + lockCalls += 1 + return { release: async () => {} } + }, + configPath: join(handlesDir, 'binding-store.json'), + cache, + minTtlMs: 30_000, + mutateAccounts: async (mutate) => { + storage = mutate(storage) ?? storage + }, + }, + }) + + expect(lockCalls).toBe(1) + expect(storage.accounts[0]).toMatchObject({ accountId: 'acct-bound' }) + expect(resolution).toMatchObject({ token: jwtFor('acct-bound') }) + cache.close() + }) + + it('makes inline request reconciliation wait behind a parked sweep', async () => { + const account = liveAccount('serialized', { accountId: 'acct-serialized' }) + let storage = liveStorage([account], { + claustrum: claustrumConfig({ mode: 'claustrum' }), + }) + let releaseSweep = () => {} + let sweepEntered = () => {} + const entered = new Promise((resolve) => { + sweepEntered = resolve + }) + const sweepCache = new ClaustrumCredentialCache({ + connector: async () => + makeFakeClient({ + getCredential: async () => { + sweepEntered() + await new Promise((resolve) => { + releaseSweep = resolve + }) + return { + material: jwtFor('acct-serialized'), + recordVersion: 10, + expiresAtMs: Date.now() + 60_000, + } + }, + }) as never, + }) + const requestCache = new ClaustrumCredentialCache({ + connector: async () => + makeFakeClient({ + getCredential: async () => ({ + material: jwtFor('acct-serialized'), + recordVersion: 11, + expiresAtMs: Date.now() + 60_000, + }), + }) as never, + }) + const manifest = enrollmentManifest(account.id) + let lockHeld = false + let releaseWaiter = () => {} + const waitForRelease = () => + new Promise((resolve) => { + releaseWaiter = resolve + }) + const shared = { + loadAccounts: async () => storage, + readCustodyManifest: async () => manifest, + acquireRefreshFileLock: async () => { + if (lockHeld) await waitForRelease() + lockHeld = true + return { + release: async () => { + lockHeld = false + releaseWaiter() + }, + } + }, + configPath: join(handlesDir, 'serialized-store.json'), + minTtlMs: 30_000, + mutateAccounts: async ( + mutate: (current: AccountStorage) => AccountStorage | undefined, + ) => { + storage = mutate(storage) ?? storage + }, + } + const sweep = reconcileFallbackCustody(account, { + ...shared, + cache: sweepCache, + }) + await entered + + let requestSettled = false + const request = resolveFallbackAccess(account, storage, manifest, { + cache: requestCache, + manifestHandle: manifest.ok + ? manifest.value.providers[0]?.accounts[0]?.handle + : undefined, + requestPath: true, + completeEnrollmentDeps: { ...shared, cache: requestCache }, + }).then((result) => { + requestSettled = true + return result + }) + await Promise.resolve() + await Promise.resolve() + expect(requestSettled).toBe(false) + + releaseSweep() + await sweep + expect(await request).toBe(CUSTODY_REFUSE) + sweepCache.close() + requestCache.close() + }) +}) + +// --------------------------------------------------------------------------- +// Cache behaviour +// --------------------------------------------------------------------------- + +function makeFakeClient( + overrides: Partial<{ + getCredential: (h: string) => Promise + statusCredential: (h: string) => Promise + reportAuthFailure: (p: unknown) => Promise + }> = {}, +) { + return { + calls: { get: 0, status: 0, report: 0 }, + async getCredential(handle: string) { + this.calls.get++ + return overrides.getCredential + ? overrides.getCredential(handle) + : { + material: 'acc-live', + recordVersion: 1, + expiresAtMs: Date.now() + 60_000, + } + }, + async statusCredential(handle: string) { + this.calls.status++ + return overrides.statusCredential + ? overrides.statusCredential(handle) + : { + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 1, + } + }, + async reportAuthFailure(params: unknown) { + this.calls.report++ + return overrides.reportAuthFailure + ? overrides.reportAuthFailure(params) + : undefined + }, + close() {}, + } +} + +describe('ClaustrumCredentialCache', () => { + it('returns the live resident record immediately without a refresh', async () => { + const fake = makeFakeClient() + const cache = new ClaustrumCredentialCache({ + connector: async () => fake as never, + }) + const handle = `ckh_${'h'.repeat(43)}` + const first = await cache.get(handle, 30_000) + expect(fake.calls.get).toBe(1) + expect(first.payload.access).toBe('acc-live') + // Second call within TTL must hit the resident record (no second get). + const second = await cache.get(handle, 30_000) + expect(fake.calls.get).toBe(1) + expect(second.recordVersion).toBe(first.recordVersion) + cache.close() + }) + + it('does not refresh on an expired resident cache; enrollment is gated by manifest state, not record freshness', async () => { + const fake = makeFakeClient({ + getCredential: async () => ({ + material: 'acc-live', + recordVersion: 1, + expiresAtMs: Date.now() - 1_000, + }), + }) + const cache = new ClaustrumCredentialCache({ + connector: async () => fake as never, + }) + const handle = `ckh_${'e'.repeat(43)}` + const result = await cache.get(handle, 30_000) + expect(result.payload.access).toBe('acc-live') + // The cache returns the expired record (no refresh logic) — gate is in the resolver. + expect(fake.calls.get).toBe(1) + cache.close() + }) + + it('force:true bypasses the resident record but still joins the in-flight call', async () => { + let resolveOnce: ((v: unknown) => void) | undefined + const gate = new Promise((resolve) => { + resolveOnce = resolve + }) + let firstCallCount = 0 + const fake = makeFakeClient({ + getCredential: async () => { + firstCallCount++ + if (firstCallCount === 1) { + await gate + return { + material: 'first', + recordVersion: 1, + expiresAtMs: Date.now() + 60_000, + } + } + return { + material: 'second', + recordVersion: 2, + expiresAtMs: Date.now() + 60_000, + } + }, + }) + const cache = new ClaustrumCredentialCache({ + connector: async () => fake as never, + }) + const handle = `ckh_${'f'.repeat(43)}` + // Kick off the in-flight call, but DO NOT await it yet. + const inflight = cache.get(handle, 30_000) + // While the in-flight call is pending, fire two force:true reads. + const a = cache.get(handle, 30_000, { force: true }) + const b = cache.get(handle, 30_000, { force: true }) + // Resolve the gate so the in-flight call lands. + resolveOnce?.({}) + const [first, second, third] = await Promise.all([inflight, a, b]) + expect(first.recordVersion).toBe(1) + // Both force:true calls bypass the resident record and each issue one new get, + // but since they share the in-flight gate, the count should be 2 not 3. + expect(fake.calls.get).toBe(2) + expect(second.recordVersion).toBe(2) + expect(third.recordVersion).toBe(second.recordVersion) + cache.close() + }) + + it('reportAuthFailure version-fences, sends once, and invalidates exactly the reported version', async () => { + const reports: Array<{ handle: string; version: number }> = [] + let nextVersion = 17 + const fake = makeFakeClient({ + getCredential: async () => ({ + material: 'acc-live', + recordVersion: nextVersion, + expiresAtMs: Date.now() + 60_000, + }), + reportAuthFailure: async (params) => { + reports.push({ + handle: (params as { handle: string }).handle, + version: (params as { recordVersion: number }).recordVersion, + }) + // After a report, the daemon rotates to a fresh version. A poisoned + // v=17 is followed by a fresh v=18; a poisoned v=18 by v=19, etc. + nextVersion = (params as { recordVersion: number }).recordVersion + 1 + }, + }) + const cache = new ClaustrumCredentialCache({ + connector: async () => fake as never, + }) + const handle = `ckh_${'r'.repeat(43)}` + // Seed the resident record at version 17. + const seeded = await cache.get(handle, 30_000) + expect(seeded.recordVersion).toBe(17) + await cache.reportAuthFailure({ + handle, + recordVersion: 17, + providerStatus: 401, + }) + // Two more 17s must NOT trigger another report — the version fence is monotonic. + await cache.reportAuthFailure({ + handle, + recordVersion: 17, + providerStatus: 401, + }) + await cache.reportAuthFailure({ + handle, + recordVersion: 17, + providerStatus: 401, + }) + expect(reports).toEqual([{ handle, version: 17 }]) + // A higher version (18) must issue a second report. + await cache.reportAuthFailure({ + handle, + recordVersion: 18, + providerStatus: 401, + }) + expect(reports).toEqual([ + { handle, version: 17 }, + { handle, version: 18 }, + ]) + // After invalidating 17, a get must NOT return version 17 from the resident record. + const afterInvalidate = await cache.get(handle, 30_000) + expect(afterInvalidate.recordVersion).not.toBe(17) + cache.close() + }) + + it('keeps the two-cycle bound through a fresh get until a served vault request succeeds', async () => { + const reports: number[] = [] + let nextVersion = 1 + const fake = makeFakeClient({ + getCredential: async () => ({ + material: 'acc-live', + recordVersion: nextVersion, + expiresAtMs: Date.now() + 60_000, + }), + reportAuthFailure: async (params) => { + const v = (params as { recordVersion: number }).recordVersion + reports.push(v) + // Bump the daemon's next served version past the rejected ones so a + // subsequent get returns a credential the cache will accept. + nextVersion = v + 1 + }, + }) + const cache = new ClaustrumCredentialCache({ + connector: async () => fake as never, + }) + const handle = `ckh_${'b'.repeat(43)}` + // Seed at version 1. + await cache.get(handle, 30_000) + // 1st report → enters blocked. + await cache.reportAuthFailure({ + handle, + recordVersion: 1, + providerStatus: 401, + }) + // 2nd report on a NEW version → still goes through (cycle 2 of two-cycle bound). + await cache.reportAuthFailure({ + handle, + recordVersion: 2, + providerStatus: 401, + }) + // 3rd report on yet another new version → bound fires; we do NOT report. + await cache.reportAuthFailure({ + handle, + recordVersion: 3, + providerStatus: 401, + }) + expect(reports).toEqual([1, 2]) + // A fresh vault record is not evidence that it works. The fake's nextVersion + // stays at 3 because the bound suppresses the v=3 report. + const after = await cache.get(handle, 30_000) + expect(after.recordVersion).toBe(3) + expect(cache.isReauth(handle)).toBe(true) + await cache.reportAuthFailure({ + handle, + recordVersion: 3, + providerStatus: 401, + }) + expect(reports).toEqual([1, 2]) + cache.markVaultSuccess(handle) + expect(cache.isReauth(handle)).toBe(false) + await cache.reportAuthFailure({ + handle, + recordVersion: 3, + providerStatus: 401, + }) + expect(reports).toEqual([1, 2, 3]) + cache.close() + }) + + it('a new instance starts with empty process-local state', async () => { + const fake = makeFakeClient() + const a = new ClaustrumCredentialCache({ + connector: async () => fake as never, + }) + const handle = `ckh_${'n'.repeat(43)}` + await a.get(handle, 30_000) + a.close() + // After close, a fresh instance must NOT carry over the resident record. + const fake2 = makeFakeClient() + const b = new ClaustrumCredentialCache({ + connector: async () => fake2 as never, + }) + expect(await b.peek(handle)).toBeUndefined() + b.close() + }) +}) + +// --------------------------------------------------------------------------- +// Golden check (script integration) +// --------------------------------------------------------------------------- + +describe('golden fixture', () => { + it('pinned fixture pins prefix and contains the expected providers', () => { + // The fixture file under src/tests/fixtures/claustrum-golden/handles.json + // is the byte-for-byte copy from upstream — see check:claustrum-golden. + // Here we just assert that the local copy exists and contains the + // tenant-stable structure the manifest reader expects. + const fixturePath = join( + import.meta.dir, + 'fixtures', + 'claustrum-golden', + 'handles.json', + ) + expect(fixturePath.endsWith('handles.json')).toBe(true) + const source = JSON.parse(readFileSync(fixturePath, 'utf8')) as { + version: number + providers: Array<{ provider: string; shape: string; serve: string }> + } + expect(source.version).toBe(1) + expect(source.providers.length).toBeGreaterThan(0) + // Pin the structural tenant-stable fields. + const deepseek = source.providers.find((p) => p.provider === 'deepseek') + expect(deepseek).toBeDefined() + expect(deepseek?.shape).toBe('api') + const anthropic = source.providers.find((p) => p.provider === 'anthropic') + expect(anthropic).toBeDefined() + expect(anthropic?.shape).toBe('oauth') + }) +}) + +// --------------------------------------------------------------------------- +// Preload isolation assertion +// --------------------------------------------------------------------------- + +describe('setup-env preload guard', () => { + // The assertion logic accepts a `floorDir` override so the test can place + // the synthetic live-default INSIDE a fake floor and verify that the + // live-default branch (not the FLOOR_DIR branch) is what fires. Without + // the override, a poisoned path under fakeHome fails the FLOOR_DIR + // check first and the live-default branch is never exercised. + + it('refuses a path under a synthetic ~/.config-shaped directory inside an allowed floor', () => { + // Pretend the operator's home is a temp dir; the assertion must treat + // its /.config subtree as the live default and refuse any path + // resolved there, even if a misconfigured env were to point at it. + const fakeFloor = mkdtempSync(join(tmpdir(), 'setup-env-fake-floor-')) + const fakeHome = join(fakeFloor, 'home') + mkdirSync(fakeHome, { recursive: true }) + const fakeConfig = join(fakeHome, '.config') + const fakeCortexkit = join(fakeConfig, 'cortexkit') + mkdirSync(fakeCortexkit, { recursive: true }) + const poisonedPath = join(fakeCortexkit, 'opencode-handles.json') + expect(() => + assertFloor( + 'CLAUSTRUM_OPENCODE_HANDLES', + poisonedPath, + `${poisonedPath}.lock`, + { + floorDir: fakeFloor, + homeConfig: fakeConfig, + homeLocalShare: join(fakeHome, '.local', 'share'), + }, + ), + ).toThrow(/live default/) + }) + + it('refuses a path under a synthetic ~/.local/share-shaped directory inside an allowed floor', () => { + const fakeFloor = mkdtempSync(join(tmpdir(), 'setup-env-fake-floor-')) + const fakeHome = join(fakeFloor, 'home') + mkdirSync(fakeHome, { recursive: true }) + const fakeLocalShare = join(fakeHome, '.local', 'share') + const fakeCortexkit = join(fakeLocalShare, 'cortexkit') + mkdirSync(fakeCortexkit, { recursive: true }) + const poisonedPath = join(fakeCortexkit, 'opencode-handles.json') + expect(() => + assertFloor( + 'CLAUSTRUM_OPENCODE_HANDLES', + poisonedPath, + `${poisonedPath}.lock`, + { + floorDir: fakeFloor, + homeConfig: join(fakeHome, '.config'), + homeLocalShare: fakeLocalShare, + }, + ), + ).toThrow(/live default/) + }) + + it('accepts the floor path itself (sanity check the guard is not over-eager)', () => { + // The floor path resolves under FLOOR_DIR, not under any live default, + // so the guard must not throw for it. + expect(() => + assertFloor( + 'CLAUSTRUM_OPENCODE_HANDLES', + FLOOR_CLAUSTRUM_HANDLES, + FLOOR_CLAUSTRUM_HANDLES_LOCK, + ), + ).not.toThrow() + }) + + it('refuses a non-absolute path even when the live-default branches would pass', () => { + expect(() => + assertFloor('CLAUSTRUM_OPENCODE_HANDLES', 'relative/handles.json'), + ).toThrow(/not absolute/) + }) +}) + +// --------------------------------------------------------------------------- +// Cache read accessors — projection-only, no behaviour change. +// --------------------------------------------------------------------------- + +describe('cache read accessors', () => { + it('isBlocked is false on a fresh handle and stays false after a successful get', async () => { + const cache = new ClaustrumCredentialCache({ + connector: async () => makeFakeClient() as never, + }) + const handle = `ckh_${'b'.repeat(43)}` + expect(cache.isBlocked(handle)).toBe(false) + await cache.get(handle, 30_000) + expect(cache.isBlocked(handle)).toBe(false) + cache.close() + }) + + it('isBlocked is true after reportAuthFailure and false after a successful get clears it', async () => { + let nextVersion = 1 + const fake = makeFakeClient({ + getCredential: async () => ({ + material: 'acc-live', + recordVersion: nextVersion, + expiresAtMs: Date.now() + 60_000, + }), + }) + const cache = new ClaustrumCredentialCache({ + connector: async () => fake as never, + }) + const handle = `ckh_${'c'.repeat(43)}` + await cache.get(handle, 30_000) + await cache.reportAuthFailure({ + handle, + providerStatus: 401, + recordVersion: 1, + }) + expect(cache.isBlocked(handle)).toBe(true) + // The next get must observe a higher version so the rejected-version + // fence does not stall the fetch (cache invariant: rejected versions stay + // rejected until a higher version arrives). + nextVersion = 2 + await cache.get(handle, 30_000) + expect(cache.isBlocked(handle)).toBe(false) + cache.close() + }) + + it('isReauth is false when no reauth-until has been recorded', () => { + const cache = new ClaustrumCredentialCache({ + connector: async () => makeFakeClient() as never, + }) + expect(cache.isReauth('any-handle')).toBe(false) + cache.close() + }) + + it('isReauth is true inside the reauth window and false after it elapses', async () => { + const now = 1_000_000 + const fake = makeFakeClient() + const cache = new ClaustrumCredentialCache({ + connector: async () => fake as never, + now: () => now, + }) + const handle = `ckh_${'r'.repeat(43)}` + await cache.get(handle, 30_000) + // Two reports on the same version trip the reauth bound. + await cache.reportAuthFailure({ + handle, + providerStatus: 401, + recordVersion: 1, + }) + // The second report sees a new recordVersion so it is not dropped by the + // monotonic per-handle fence. + await cache.reportAuthFailure({ + handle, + providerStatus: 401, + recordVersion: 2, + }) + expect(cache.isReauth(handle, now)).toBe(true) + // One hour past the reauth deadline: no longer in reauth. + expect(cache.isReauth(handle, now + 60 * 60 * 1000 + 1)).toBe(false) + cache.close() + }) + + it('peekMetadata exposes version and expiry but never the credential material', async () => { + const fake = makeFakeClient({ + getCredential: async () => ({ + material: 'acc-secret', + recordVersion: 42, + expiresAtMs: 1_700_000_000_000, + }), + }) + const cache = new ClaustrumCredentialCache({ + connector: async () => fake as never, + }) + const handle = `ckh_${'m'.repeat(43)}` + await cache.get(handle, 30_000) + const meta = await cache.peekMetadata(handle) + expect(meta).toEqual({ recordVersion: 42, expiresAtMs: 1_700_000_000_000 }) + // No `material`, no `payload`, no token material on the metadata surface. + expect((meta as { material?: unknown }).material).toBeUndefined() + expect((meta as { payload?: unknown }).payload).toBeUndefined() + cache.close() + }) +}) diff --git a/packages/opencode/src/tests/fallback-select.test.ts b/packages/opencode/src/tests/fallback-select.test.ts index ddc2ca4..3d373bb 100644 --- a/packages/opencode/src/tests/fallback-select.test.ts +++ b/packages/opencode/src/tests/fallback-select.test.ts @@ -22,6 +22,7 @@ import { saveAccounts, } from '../core/accounts.ts' import { hashRefreshToken } from '../core/backoff.ts' +import { localCustody } from './custody-fixtures.ts' import { FLOOR_AUTH_FILE, FLOOR_STATE_FILE } from './setup-env.ts' // --------------------------------------------------------------------------- @@ -80,6 +81,7 @@ describe('fallback selection', () => { const storage = makeStorage([account]) const manager = new FallbackAccountManager({ + custody: localCustody, now: () => Date.now(), fetchImpl: fetch, }) @@ -95,6 +97,7 @@ describe('fallback selection', () => { storage.quota = { failClosedOnUnknownQuota: true, enabled: true } const manager = new FallbackAccountManager({ + custody: localCustody, now: () => Date.now(), fetchImpl: fetch, }) @@ -391,6 +394,7 @@ describe('fallback selection', () => { storage.mainAccountId = 'chatgpt-main' const manager = new FallbackAccountManager({ + custody: localCustody, now: () => Date.now(), fetchImpl: fetch, }) @@ -422,6 +426,7 @@ describe('fallback selection', () => { .mockRejectedValue(new Error('fetch failed while refreshing token')) const manager = new FallbackAccountManager({ + custody: localCustody, now: () => now, fetchImpl: fetch, refreshFn: refreshFn as AccountManagerOptions['refreshFn'], @@ -456,6 +461,7 @@ describe('fallback selection', () => { const refreshFn = jest.fn() const manager = new FallbackAccountManager({ + custody: localCustody, now: () => now, fetchImpl: fetch, refreshFn: refreshFn as AccountManagerOptions['refreshFn'], @@ -497,6 +503,7 @@ describe('fallback selection', () => { const setFallback = jest.fn() const manager = new FallbackAccountManager({ + custody: localCustody, now: () => now, fetchImpl: fetch, refreshFn: refreshFn as AccountManagerOptions['refreshFn'], @@ -532,6 +539,7 @@ describe('fallback selection', () => { }) const manager = new FallbackAccountManager({ + custody: localCustody, now: () => Date.now(), fetchImpl: fetch, fetchQuotaFn: fetchQuotaFn as AccountManagerOptions['fetchQuotaFn'], @@ -549,6 +557,7 @@ describe('fallback selection', () => { const storage = makeStorage([account]) const manager = new FallbackAccountManager({ + custody: localCustody, now: () => Date.now(), fetchImpl: fetch, // NO fetchQuotaFn injected @@ -564,6 +573,7 @@ describe('fallback selection', () => { const _storage = makeStorage([account]) const manager = new FallbackAccountManager({ + custody: localCustody, now: () => Date.now(), fetchImpl: fetch, // NO fetchQuotaFn injected @@ -590,6 +600,7 @@ describe('fallback selection', () => { const _storage = makeStorage([account]) const manager = new FallbackAccountManager({ + custody: localCustody, now: () => Date.now(), fetchImpl: fetch, }) diff --git a/packages/opencode/src/tests/fixtures/claustrum-golden/SOURCE.json b/packages/opencode/src/tests/fixtures/claustrum-golden/SOURCE.json new file mode 100644 index 0000000..dcb7554 --- /dev/null +++ b/packages/opencode/src/tests/fixtures/claustrum-golden/SOURCE.json @@ -0,0 +1,8 @@ +{ + "repo": "legion-works/claustrum", + "ref": "0e9dee77cb91e762d31a9ccb502728a69f09bcbe", + "paths": { + "tombstone": "packages/opencode/golden/tombstone.json", + "handles": "packages/opencode/golden/handles.json" + } +} diff --git a/packages/opencode/src/tests/fixtures/claustrum-golden/handles.json b/packages/opencode/src/tests/fixtures/claustrum-golden/handles.json new file mode 100644 index 0000000..9107670 --- /dev/null +++ b/packages/opencode/src/tests/fixtures/claustrum-golden/handles.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "providers": [ + { + "provider": "deepseek", + "shape": "api", + "serve": "opencode-claustrum", + "accounts": [ + { + "label": "main", + "handle": "ckh_xOHjn5GYlYiTcwEqIt0DDVGaZR3eTdcwzpOEXuTdvsw", + "credential_id": "apikey:deepseek:main" + }, + { + "label": "backup", + "handle": "ckh_YqNbR4A7Xfess4_K9XE1_7eVSwdaWF3KhtPrijOyvWA", + "credential_id": "apikey:deepseek:backup", + "superseded": [ + "ckh_MNZO_t_aIvzhQ19mAskh44KtKxJE5NbOm4ul6A1kqpY" + ] + } + ] + }, + { + "provider": "anthropic", + "shape": "oauth", + "serve": "anthropic-auth", + "accounts": [ + { + "label": "work-alt", + "handle": "ckh_-KzxNKZFF4I_Tt4sb8hT9lsFHGlltKavBnjB11-gNxc", + "credential_id": "oauth:anthropic:work-alt" + } + ] + } + ] +} diff --git a/packages/opencode/src/tests/fixtures/claustrum-golden/tombstone.json b/packages/opencode/src/tests/fixtures/claustrum-golden/tombstone.json new file mode 100644 index 0000000..b5866e4 --- /dev/null +++ b/packages/opencode/src/tests/fixtures/claustrum-golden/tombstone.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "fixtures": { + "api": { + "provider": "deepseek", + "entry": { "type": "api", "key": "claustrum-tombstone:v1:deepseek" } + }, + "oauth": { + "provider": "anthropic", + "entry": { + "type": "oauth", + "refresh": "claustrum-tombstone:v1:anthropic", + "access": "claustrum-tombstone:v1:anthropic", + "expires": 0 + } + } + } +} diff --git a/packages/opencode/src/tests/integration.test.ts b/packages/opencode/src/tests/integration.test.ts index 2009e79..3e19836 100644 --- a/packages/opencode/src/tests/integration.test.ts +++ b/packages/opencode/src/tests/integration.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, test } from 'bun:test' import { + chmodSync, mkdirSync, mkdtempSync, readFileSync, @@ -16,7 +17,9 @@ import { acquireRefreshFileLock } from '../core/refresh-file-lock.ts' import { QUOTA_STALENESS_MS } from '../core/sticky-routing.ts' import { AuthPersistError, + type ClaustrumCacheTransportLike, CodexAuthPlugin, + type CustodyRuntime, findCachekeepFallbackAccount, MAIN_REFRESH_LEASE_TTL_MS, MAIN_REFRESH_LOCK_TTL_MS, @@ -33,6 +36,11 @@ import { resolveSessionSidebarRouting, type SidebarState, } from '../sidebar-state.ts' +import { + claustrumConfig, + enrollmentManifest, + makeSentinelAccount, +} from './custody-fixtures.ts' import { FLOOR_AUTH_FILE, FLOOR_LOG_FILE, @@ -2580,6 +2588,128 @@ describe('integration: 429 → reactive fallback', () => { globalThis.fetch = originalFetch } }) + + it('reports a vault-backed 401 returned by the WebSocket branch', async () => { + const fallback = makeSentinelAccount({ + id: 'ws-custody', + accountId: 'acct-ws-custody', + enabled: true, + }) + const manifest = enrollmentManifest(fallback.id) + if (!manifest.ok) throw new Error('expected manifest fixture') + const manifestPath = join(configDir, 'handles.json') + const vaultAccess = `header.${Buffer.from(JSON.stringify({ chatgpt_account_id: fallback.accountId })).toString('base64url')}.signature` + const reports: number[] = [] + let runtime: CustodyRuntime | undefined + let hooks: Hooks | undefined + const originalFetch = globalThis.fetch + process.env.CLAUSTRUM_OPENCODE_HANDLES = manifestPath + writeFileSync(manifestPath, JSON.stringify(manifest.value)) + chmodSync(manifestPath, 0o600) + writeFileSync( + configFile, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [fallback], + claustrum: claustrumConfig({ mode: 'claustrum' }), + routing: { mode: 'fallback-first' }, + }), + ) + globalThis.fetch = (async (url: unknown) => + new Response('{}', { + status: String(url).includes('wham') ? 500 : 401, + })) as typeof globalThis.fetch + const transport: ClaustrumCacheTransportLike = { + async getCredential() { + return { + material: vaultAccess, + recordVersion: 101, + expiresAtMs: Date.now() + 60_000, + } + }, + async statusCredential() { + return { + ready: true, + lastErrorCode: null, + leaseHeld: false, + recordVersion: 101, + } + }, + async reportAuthFailure(params) { + reports.push(params.recordVersion) + }, + close() {}, + } + await withFakeWebSocket( + ({ message }) => ({ + send() { + message( + JSON.stringify({ + type: 'error', + status: 401, + error: { message: 'expired' }, + }), + ) + }, + }), + async () => { + try { + hooks = await CodexAuthPlugin(createMockPluginInput(), { + experimentalWebSockets: true, + custody: { + transport, + detection: 'available', + onRuntime: (value) => { + runtime = value + }, + }, + }) + const loader = hooks.auth?.loader + if (!loader) throw new Error('expected auth loader') + const result = await loader( + async () => ({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3_600_000, + }), + {} as never, + ) + if (!runtime) throw new Error('expected custody runtime') + await runtime.runTick() + expect(runtime.isEnabled()).toBe(true) + expect( + runtime + .getCache() + ?.peek(manifest.value.providers[0]!.accounts[0]!.handle), + ).toBeDefined() + const fetchOverride = (result as { fetch?: typeof globalThis.fetch }) + .fetch + if (!fetchOverride) throw new Error('expected fetch override') + const response = await fetchOverride( + 'https://api.openai.com/v1/responses', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.5', + input: [], + stream: true, + }), + }, + ) + expect(response.status).toBe(401) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reports).toEqual([101]) + } finally { + await hooks?.dispose?.() + } + }, + ) + globalThis.fetch = originalFetch + delete process.env.CLAUSTRUM_OPENCODE_HANDLES + }) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/tests/logger.test.ts b/packages/opencode/src/tests/logger.test.ts index 9031aab..0be8f0e 100644 --- a/packages/opencode/src/tests/logger.test.ts +++ b/packages/opencode/src/tests/logger.test.ts @@ -204,4 +204,17 @@ describe('logger redaction', () => { expect(txt).toContain('"ok":1') expect(txt).toMatch(/REDACTED|\*\*\*/) }) + + it('redacts manifest handles embedded in messages without masking short ckh tokens', async () => { + process.env.OPENCODE_OPENAI_AUTH_LOG_LEVEL = 'debug' + const { createLogger, flushForTest } = await import('../logger.ts') + const log = createLogger('transport') + const handle = `ckh_${'a'.repeat(43)}` + log.warn(`daemon rejected ${handle}; short token ckh_x remains diagnostic`) + await flushForTest() + const txt = readFileSync(logFile, 'utf8') + expect(txt).not.toContain(handle) + expect(txt).toContain('***REDACTED***') + expect(txt).toContain('ckh_x') + }) }) diff --git a/packages/opencode/src/tests/provider-backoff.test.ts b/packages/opencode/src/tests/provider-backoff.test.ts index 6ea0e97..99af121 100644 --- a/packages/opencode/src/tests/provider-backoff.test.ts +++ b/packages/opencode/src/tests/provider-backoff.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, jest, mock } from 'bun:test' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import { buildQuotaOperationError, isTransientQuotaError, @@ -82,6 +84,40 @@ describe('codexRefreshFn token validation', () => { expect(result.expires).toBe(mockNow() + 3600 * 1000) }) + it('refuses any custody tombstone prefix before touching transport', async () => { + const fetchSpy = mock(async () => new Response('{}', { status: 200 })) + + await expect( + codexRefreshFn({ + refreshToken: 'claustrum-tombstone:v1:anthropic', + fetchImpl: fetchSpy as unknown as typeof fetch, + now: mockNow, + }), + ).rejects.toThrow('custody tombstone') + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('places custody refusal before every refresh transport operation', () => { + const source = readFileSync( + fileURLToPath(new URL('../core/provider.ts', import.meta.url)), + 'utf8', + ) + const start = source.indexOf('export async function codexRefreshFn') + const end = source.indexOf('export async function whamUsageFn') + const fn = source.slice(start, end) + const refusal = fn.indexOf( + 'assertNoCustodyTombstoneMaterial(input.refreshToken)', + ) + const firstTransport = Math.min( + ...['new URL', 'URLSearchParams', 'await', 'input.fetchImpl'] + .map((token) => fn.indexOf(token)) + .filter((index) => index >= 0), + ) + + expect(refusal).toBeGreaterThanOrEqual(0) + expect(refusal).toBeLessThan(firstTransport) + }) + it('throws structured refresh error when access_token is missing', async () => { const mockFetch = mock(async () => { return new Response( diff --git a/packages/opencode/src/tests/review-fixes.test.ts b/packages/opencode/src/tests/review-fixes.test.ts index a430002..71e0b64 100644 --- a/packages/opencode/src/tests/review-fixes.test.ts +++ b/packages/opencode/src/tests/review-fixes.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { localCustody } from './custody-fixtures.ts' import { FLOOR_AUTH_FILE, FLOOR_STATE_FILE } from './setup-env.ts' // --------------------------------------------------------------------------- @@ -1173,6 +1174,7 @@ describe('REFRESH-BACKOFF — recordQuotaRefreshError arms refresh backoff only }) const manager = new FallbackAccountManager({ + custody: localCustody, configPath: cfgPath, refreshFn: async () => { throw refreshError @@ -1222,6 +1224,7 @@ describe('REFRESH-BACKOFF — recordQuotaRefreshError arms refresh backoff only // fetchQuotaFn throws a 401 with no isRefreshError (exactly as whamUsageFn does). const manager = new FallbackAccountManager({ + custody: localCustody, configPath: cfgPath, fetchQuotaFn: async () => { throw Object.assign(new Error('wham usage check failed: 401'), { @@ -1264,6 +1267,7 @@ describe('REFRESH-BACKOFF — recordQuotaRefreshError arms refresh backoff only // fetchQuotaFn throws a non-401 quota error (no isRefreshError). const manager = new FallbackAccountManager({ + custody: localCustody, configPath: cfgPath, fetchQuotaFn: async () => { throw Object.assign(new Error('wham usage check failed: 429'), { diff --git a/packages/opencode/src/tests/setup-env.ts b/packages/opencode/src/tests/setup-env.ts index fa0ec0c..8811ff8 100644 --- a/packages/opencode/src/tests/setup-env.ts +++ b/packages/opencode/src/tests/setup-env.ts @@ -11,11 +11,16 @@ * Individual tests still override these envs in beforeEach for per-test * isolation; their afterEach MUST restore to the floor value (not delete). * See the FLOOR_* exports below. + * + * After seeding each floor, the preload asserts the resolved path lives + * under FLOOR_DIR and never under the operator's live defaults. A silent + * pass here would let a future edit reintroduce the leak; the preload + * throws instead so the harness fails to start, not the test. */ import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { homedir, tmpdir } from 'node:os' +import { isAbsolute, join, resolve, sep } from 'node:path' // One unique temp dir per test process — survives the full suite run. const FLOOR_DIR = mkdtempSync(join(tmpdir(), 'openai-auth-test-floor-')) @@ -25,6 +30,13 @@ export const FLOOR_AUTH_FILE = join(FLOOR_DIR, 'openai-auth.json') export const FLOOR_STATE_FILE = join(FLOOR_DIR, 'openai-auth-state.json') export const FLOOR_LOG_FILE = join(FLOOR_DIR, 'openai-auth.log') export const FLOOR_MODELS_CACHE = join(FLOOR_DIR, 'models.json') +// Custody manifest floor: resolved under FLOOR_DIR so even an in-flight read +// that outlives a test's afterEach (background timers, deferred cache lookups) +// points at a temp path, never at the operator's live default. +// The lock sidecar is exported because the manifest writer owns it, but tests +// only need the floor path to restore the env var cleanly. +export const FLOOR_CLAUSTRUM_HANDLES = join(FLOOR_DIR, 'opencode-handles.json') +export const FLOOR_CLAUSTRUM_HANDLES_LOCK = `${FLOOR_CLAUSTRUM_HANDLES}.lock` // Set the floor values only if the env is not already set (a parent process // or CI may have pre-configured them intentionally). @@ -43,6 +55,106 @@ if (!process.env.OPENCODE_OPENAI_AUTH_LOG_FILE) { if (!process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE) { process.env.OPENCODE_OPENAI_AUTH_MODELS_CACHE = FLOOR_MODELS_CACHE } +if (!process.env.CLAUSTRUM_OPENCODE_HANDLES) { + process.env.CLAUSTRUM_OPENCODE_HANDLES = FLOOR_CLAUSTRUM_HANDLES +} + +// Preload assertions: every floor-resolved path must live under FLOOR_DIR +// AND must not be under the operator's live defaults. Resolved at preload +// time so a misconfigured env (a stale parent process, a stray export) is +// caught before any test can write to the operator's real config dir. +const HOME = homedir() +const HOME_CONFIG = join(HOME, '.config') +const HOME_LOCAL_SHARE = join(HOME, '.local', 'share') + +function isUnder(child: string, parent: string): boolean { + const c = resolve(child) + sep + const p = resolve(parent) + sep + return c.startsWith(p) +} + +// Exported for tests so they can drive the assertion with a synthetic +// `~/.config`-shaped path and prove the preload guard refuses it. The +// preload itself cannot be re-driven after import (modules cache), so the +// guard logic lives in a small pure helper that the test imports directly. +export function assertFloor( + label: string, + path: string, + lockPath?: string, + opts: { + homeConfig?: string + homeLocalShare?: string + floorDir?: string + } = {}, +): void { + const floorDir = opts.floorDir ?? FLOOR_DIR + const homeConfig = opts.homeConfig ?? HOME_CONFIG + const homeLocalShare = opts.homeLocalShare ?? HOME_LOCAL_SHARE + if (!isAbsolute(path)) { + throw new Error(`setup-env preload: ${label} is not absolute: ${path}`) + } + if (!isUnder(path, floorDir)) { + throw new Error( + `setup-env preload: ${label} resolved outside FLOOR_DIR. ` + + `expected under ${floorDir}, got ${path}`, + ) + } + if (isUnder(path, homeConfig) || isUnder(path, homeLocalShare)) { + throw new Error( + `setup-env preload: ${label} resolves under the operator's live default ` + + `(${homeConfig} or ${homeLocalShare}). got ${path}`, + ) + } + if (lockPath) { + if (!isAbsolute(lockPath)) { + throw new Error( + `setup-env preload: ${label} lock is not absolute: ${lockPath}`, + ) + } + if (!isUnder(lockPath, floorDir)) { + throw new Error( + `setup-env preload: ${label} lock resolved outside FLOOR_DIR. ` + + `expected under ${floorDir}, got ${lockPath}`, + ) + } + if (isUnder(lockPath, homeConfig) || isUnder(lockPath, homeLocalShare)) { + throw new Error( + `setup-env preload: ${label} lock resolves under the operator's live default. got ${lockPath}`, + ) + } + } +} + +function envPath(name: string): string { + const value = process.env[name] + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`setup-env preload: ${name} is not set after seeding`) + } + return value +} + +assertFloor( + 'OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE', + envPath('OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE'), +) +assertFloor('OPENCODE_OPENAI_AUTH_FILE', envPath('OPENCODE_OPENAI_AUTH_FILE')) +assertFloor( + 'OPENCODE_OPENAI_AUTH_STATE_FILE', + envPath('OPENCODE_OPENAI_AUTH_STATE_FILE'), +) +assertFloor( + 'OPENCODE_OPENAI_AUTH_LOG_FILE', + envPath('OPENCODE_OPENAI_AUTH_LOG_FILE'), +) +assertFloor( + 'OPENCODE_OPENAI_AUTH_MODELS_CACHE', + envPath('OPENCODE_OPENAI_AUTH_MODELS_CACHE'), +) +assertFloor( + 'CLAUSTRUM_OPENCODE_HANDLES', + envPath('CLAUSTRUM_OPENCODE_HANDLES'), + FLOOR_CLAUSTRUM_HANDLES_LOCK, +) // Belt-and-suspenders: remove the floor temp dir when the test process exits // so each run doesn't leak a directory under /tmp. diff --git a/packages/opencode/src/tui/command-dialogs.tsx b/packages/opencode/src/tui/command-dialogs.tsx index 6a7a015..9159f78 100644 --- a/packages/opencode/src/tui/command-dialogs.tsx +++ b/packages/opencode/src/tui/command-dialogs.tsx @@ -619,7 +619,7 @@ export function openCommandDialog( } if (payload.command === 'openai-account') { - openAccountDialog(api, apply, sessionId) + openAccountDialog(api, apply, sessionId, payload) return } @@ -676,12 +676,71 @@ export function buildAccountDialogRows( ] } +export function accountDialogModeOption(mode: unknown) { + const claustrum = mode === 'claustrum' + return { + title: claustrum ? 'Leave Claustrum' : 'Enter Claustrum', + value: claustrum ? '__local__' : '__claustrum__', + description: claustrum + ? 'Require fresh local logins before local refresh resumes' + : 'Verify every enabled account before custody takes over', + } +} + +export function buildFallbackAccountOptions(input: { + id: string + label?: string + enabled: boolean + index: number + count: number +}) { + const name = input.label ?? input.id + const options: Array<{ + title: string + value: string + description: string + }> = [ + { + title: input.enabled ? 'Disable' : 'Enable', + value: input.enabled ? 'disable' : 'enable', + description: `${input.enabled ? 'Disable' : 'Enable'} ${name}`, + }, + { + title: 'Remove', + value: 'remove', + description: `Remove ${name}`, + }, + ] + if (input.index > 0) { + options.push({ + title: 'Move up', + value: 'move_up', + description: 'Swap with the preceding fallback', + }) + } + if (input.index < input.count - 1) { + options.push({ + title: 'Move down', + value: 'move_down', + description: 'Swap with the following fallback', + }) + } + options.push({ + title: 'Back', + value: 'back', + description: 'Return to account list', + }) + return options +} + function openAccountDialog( api: TuiPluginApi, apply: ApplyFn, sessionId?: string, + payload?: OpenDialogPayload, ) { const DialogConfirm = api.ui.DialogConfirm + let claustrumMode = payload?.knobs.claustrumMode function showL1() { void getSidebarState().then((state) => { @@ -691,6 +750,7 @@ function openAccountDialog( { + if ( + option.value === '__claustrum__' || + option.value === '__local__' + ) { + const args = + option.value === '__claustrum__' ? 'claustrum' : 'local' + void apply('openai-account', args).then((r) => { + api.ui.toast({ message: r.text }) + claustrumMode = r.knobs.claustrumMode + showL1() + }) + return + } if (option.value === '__add__') { showAddFlow() return @@ -722,44 +795,12 @@ function openAccountDialog( showL1() return } - const options: Array<{ - title: string - value: string - description: string - }> = [] - - options.push({ - title: 'Remove', - value: 'remove', - description: `Remove ${fb.label ?? fb.id}`, - }) - - if (fbIndex > 0) { - const neighbor = state.fallbacks[fbIndex - 1] - if (neighbor) { - options.push({ - title: 'Move up', - value: 'move_up', - description: `Swap with ${neighbor.label ?? neighbor.id}`, - }) - } - } - - if (fbIndex < state.fallbacks.length - 1) { - const neighbor = state.fallbacks[fbIndex + 1] - if (neighbor) { - options.push({ - title: 'Move down', - value: 'move_down', - description: `Swap with ${neighbor.label ?? neighbor.id}`, - }) - } - } - - options.push({ - title: 'Back', - value: 'back', - description: 'Return to account list', + const options = buildFallbackAccountOptions({ + id: fb.id, + label: fb.label, + enabled: fb.enabled, + index: fbIndex, + count: state.fallbacks.length, }) api.ui.dialog.setSize('xlarge') @@ -785,6 +826,15 @@ function openAccountDialog( )) return } + if (option.value === 'enable' || option.value === 'disable') { + void apply('openai-account', `${option.value} ${id}`).then( + (r) => { + api.ui.toast({ message: r.text }) + showL1() + }, + ) + return + } if (option.value === 'move_up') { const neighbor = state.fallbacks[fbIndex - 1] if (neighbor) { diff --git a/packages/opencode/src/vendor/claustrum-client/UPSTREAM.md b/packages/opencode/src/vendor/claustrum-client/UPSTREAM.md new file mode 100644 index 0000000..bc704a9 --- /dev/null +++ b/packages/opencode/src/vendor/claustrum-client/UPSTREAM.md @@ -0,0 +1,49 @@ +# @cortexkit/claustrum-client vendoring + +This directory is a vendoring of `@cortexkit/claustrum-client` from +[`cortexkit/claustrum`][repo], pinned at commit `d69ceed` (PR #28 in +the upstream repository — the cut-line that exposed `manifest-lock.ts` +to consumers). The pin is temporary: once `@cortexkit/claustrum-client` +is published on the registry at or after `d69ceed`, the vendoring becomes +the published dependency and this directory +is removed. + +The six production files (`detect.ts`, `errors.ts`, `identity.ts`, +`index.ts`, `manifest-lock.ts`, `wire.ts`) are copied byte-for-byte from +`.opencode/vendor-src/claustrum-client-d69ceed/src/`. **Do not edit them +in place** — they are a snapshot of the upstream source. The replacement +condition is: swap to a published `@cortexkit/claustrum-client` release +when one exists at or after `d69ceed`, at which point this directory is +removed and the dependency flips to a normal package import. + +Review this pin on or before 2026-10-04; if `@cortexkit/claustrum-client` is not published by then, decide whether to keep vendoring or drop the feature. + +The two test files in `src/tests/` (upstream) were NOT copied; the +opencode test suite owns its own tests under `src/tests/custody.test.ts`. + +## Upstream metadata + +- **Repository:** [`cortexkit/claustrum`][repo] +- **Source path:** `src/` +- **Pinned commit:** `d69ceed` (PR #28 in `cortexkit/claustrum`) +- **License:** MIT (see vendored source headers and the repo `LICENSE`) +- **Source of truth:** the `.opencode/vendor-src/claustrum-client-d69ceed/` + tree inside this worktree; the golden `check:claustrum-golden` script + confirms the fixture byte-for-byte against upstream. + +## Diff vs the prior pin (`2c2e713`) + +- **`index.ts`** — `manifest-lock.ts` re-export added; no other public + API changes. +- **`manifest-lock.ts`** — **new file** added to the production set. + The previously-vendored 2c2e713 contained only the `wire.ts` + + `detect.ts` + `errors.ts` + `identity.ts` transport surface; the + manifest-lock primitive moved into the public export at `d69ceed` + so this consumer can read/write the opencode-handles manifest under + a tenant-bound lock without depending on an external module. +- **`src/tests/client.test.ts`** — new upstream test file. Not vendored + (the opencode suite owns its own coverage for the integration). +- **`src/tests/manifest-lock.test.ts`** — new upstream test file. Not + vendored for the same reason. + +[repo]: https://github.com/cortexkit/claustrum diff --git a/packages/opencode/src/vendor/claustrum-client/detect.ts b/packages/opencode/src/vendor/claustrum-client/detect.ts new file mode 100644 index 0000000..869f6a0 --- /dev/null +++ b/packages/opencode/src/vendor/claustrum-client/detect.ts @@ -0,0 +1,164 @@ +import { readdirSync, statSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { tmpdir, userInfo } from 'node:os' +import { join } from 'node:path' +import { + PROTOCOL_VERSION, + readConnectionFile, + type Endpoint as SubcEndpoint, +} from '@cortexkit/subc-client' + +export type ClaustrumEndpoint = SubcEndpoint + +export type ClaustrumDetection = + | { + status: 'available' + schema: number + wireVersion: number + endpoints: ClaustrumEndpoint[] + } + | { status: 'absent'; path: string } + | { status: 'malformed'; path: string; reason: string } + +const PRODUCTION_FILE_NAME = 'subc-connection.json' +const TEMP_PREFIX = 'subc-' +const TEMP_SUFFIX = '.connection.json' + +// The default discovery order MUST mirror `ck` / the daemon, because a client that picks a +// different file ends up talking to the wrong daemon. The Rust source of truth (sibling +// pin, NOT regenerated here) is: +// +// crates/credentials-module/src/bin/credentials_cli.rs :: discover_subc_connection_file +// -> XDG_RUNTIME_DIR/subc-connection.json, else +// ~/.local/share/cortexkit/run/subc-connection.json, else +// /subc-.connection.json (glob; ambiguity REFUSES) +// +// crates/subc-core/src/bootstrap.rs :: connection_file_path_with_source +// -> XDG_RUNTIME_DIR/subc-connection.json, else +// /subc-.connection.json +// where user_connection_token() is the UID on unix (via a probe-file UID read), +// else a sanitized USER/USERNAME/HOME/USERPROFILE value, else "unknown". +// +// The OLD client derived `${uid}` itself via `process.getuid()`, but the daemon may +// produce a different token (sanitized user on macOS, the literal "unknown" if all of +// the lookups miss). The glob is the only way to mirror the daemon without re-deriving +// the token (which is filesystem-side-effecting per the Rust side comment). +// The home tier resolves from `$HOME` byte-for-byte, mirroring `non_empty_env("HOME")` +// in `discover_subc_connection_file`: only the empty string is absent, and whitespace +// is not trimmed. The Rust code does NOT fall back to a `getpwuid`-style lookup; the +// client must not either, or an operator who points HOME at a per-test fixture loses +// the daemon they started there. +function homeTierPath(): string | undefined { + const homeEnv = process.env.HOME + return homeEnv ? join(homeEnv, '.local', 'share', 'cortexkit', 'run', PRODUCTION_FILE_NAME) : undefined +} + +function highestPriorityAbsentMarker(): string { + const runtime = process.env.XDG_RUNTIME_DIR?.trim() + if (runtime) return join(runtime, PRODUCTION_FILE_NAME) + const home = homeTierPath() + if (home) return home + // HOME unset — the Rust tier falls through to the tempdir glob with no fixed path. + // Surface the os-reported home so `detectClaustrumConnection` returns an `absent` + // path the operator can fix; without this, the caller gets a misleading `./...` path. + return join(userInfo().homedir, '.local', 'share', 'cortexkit', 'run', PRODUCTION_FILE_NAME) +} + +function findExistingConnectionPath(): string | undefined { + const runtime = process.env.XDG_RUNTIME_DIR?.trim() + if (runtime) { + const p = join(runtime, PRODUCTION_FILE_NAME) + if (safeIsFile(p)) return p + } + const home = homeTierPath() + if (home && safeIsFile(home)) return home + const matches = listSubcConnectionFiles(tmpdir()) + // A single matching file IS the daemon; multiple matches mean different OS users + // happened to share the temp dir. Picking one would route credential-bearing + // requests at another user's daemon, so REFUSE both picks and the absent path. + return matches.length === 1 ? matches[0] : undefined +} + +export function getDefaultClaustrumConnectionPath(): string { + return findExistingConnectionPath() ?? highestPriorityAbsentMarker() +} + +export function resolveClaustrumConnectionPath(explicit?: string): string { + return ( + explicit?.trim() || + process.env.CLAUSTRUM_SUBC_CONNECTION?.trim() || + getDefaultClaustrumConnectionPath() + ) +} + +function safeIsFile(path: string): boolean { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +function listSubcConnectionFiles(dir: string): string[] { + let entries: string[] + try { + entries = readdirSync(dir) as string[] + } catch { + return [] + } + const matches: string[] = [] + for (const name of entries) { + if (typeof name !== 'string') continue + if (!name.startsWith(TEMP_PREFIX) || !name.endsWith(TEMP_SUFFIX)) continue + const candidate = join(dir, name) + if (safeIsFile(candidate)) matches.push(candidate) + } + matches.sort() + return matches +} + +// The transport's typed reader validates `wire_version` against PROTOCOL_VERSION but does +// not currently surface the value. Read it from the raw JSON so detection reports what the +// daemon actually advertised, with the PROTOCOL_VERSION fallback for legacy files that +// omitted the additive field. +async function readAdvertisedWireVersion(path: string): Promise { + let raw: string + try { + raw = await readFile(path, 'utf8') + } catch { + return undefined + } + try { + const parsed = JSON.parse(raw) as { wire_version?: unknown } + return typeof parsed.wire_version === 'number' ? parsed.wire_version : undefined + } catch { + return undefined + } +} + +export async function detectClaustrumConnection( + explicitPath?: string, +): Promise { + const path = resolveClaustrumConnectionPath(explicitPath) + let value: Awaited> + try { + value = await readConnectionFile(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { status: 'absent', path } + } + return { + status: 'malformed', + path, + reason: 'connection file could not be read or validated', + } + } + + const advertised = await readAdvertisedWireVersion(path) + return { + status: 'available', + schema: value.schema, + wireVersion: advertised ?? PROTOCOL_VERSION, + endpoints: value.endpoints, + } +} diff --git a/packages/opencode/src/vendor/claustrum-client/errors.ts b/packages/opencode/src/vendor/claustrum-client/errors.ts new file mode 100644 index 0000000..421c68c --- /dev/null +++ b/packages/opencode/src/vendor/claustrum-client/errors.ts @@ -0,0 +1,84 @@ +export const ERROR_CLASS_WIRE_SET = [ + 'transient', + 'permanent', + 'auth_required', + 'context_overflow', +] as const + +export type ClaustrumCredentialErrorClass = (typeof ERROR_CLASS_WIRE_SET)[number] +export type ClaustrumCredentialErrorAction = + | 'gone' + | 'reauth' + | 'retry' + | 'reduce_and_retry' + +export class ClaustrumCredentialError extends Error { + readonly ['class']: ClaustrumCredentialErrorClass + + constructor( + public readonly code: string, + errorClass: ClaustrumCredentialErrorClass, + public readonly action: ClaustrumCredentialErrorAction, + ) { + super(`Claustrum credential request failed: ${code}`) + this.name = 'ClaustrumCredentialError' + this['class'] = errorClass + } +} + +type UnknownClassLogger = (errorClass: string) => void + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function credentialErrorAction( + errorClass: ClaustrumCredentialErrorClass, +): ClaustrumCredentialErrorAction { + switch (errorClass) { + case 'permanent': + return 'gone' + case 'auth_required': + return 'reauth' + case 'context_overflow': + return 'reduce_and_retry' + case 'transient': + return 'retry' + } +} + +export function asCredentialError( + response: unknown, + fallbackCode = 'invalid_response', + logUnknownClass: UnknownClassLogger = (errorClass) => console.warn(errorClass), +): ClaustrumCredentialError { + const result = isRecord(response) && isRecord(response.result) ? response.result : undefined + const error = result && isRecord(result.error) ? result.error : undefined + const rawClass = error?.class + const rawCode = error?.code + // The wire contract in `crates/credentials-module/src/read_surface.rs` carries BOTH + // `class` and `code` on every error envelope. A frame missing `code` is therefore + // malformed: even when `class` is itself in the wire set, the half-formed envelope + // is not a valid permanent reason — preserving `class` would let a malicious or + // broken peer drive a `gone` action with `{ error: { class: 'permanent' } }`. + // Anthropic's cut-line rule (unknown or absent class → transient) generalises to + // any malformed envelope; both fields must be present and class must be known. + const hasValidClass = typeof rawClass === 'string' && (ERROR_CLASS_WIRE_SET as readonly string[]).includes(rawClass) + const hasCode = typeof rawCode === 'string' + const envelopeValid = hasValidClass && hasCode + const errorClass: ClaustrumCredentialErrorClass = envelopeValid + ? rawClass as ClaustrumCredentialErrorClass + : 'transient' + if (typeof rawClass !== 'string') logUnknownClass('unknown') + else if (!hasValidClass) logUnknownClass(rawClass) + const code = hasCode ? rawCode : fallbackCode + return new ClaustrumCredentialError(code, errorClass, credentialErrorAction(errorClass)) +} + +export function hasCredentialError(response: unknown): boolean { + return ( + isRecord(response) && + isRecord(response.result) && + isRecord(response.result.error) + ) +} diff --git a/packages/opencode/src/vendor/claustrum-client/identity.ts b/packages/opencode/src/vendor/claustrum-client/identity.ts new file mode 100644 index 0000000..a393819 --- /dev/null +++ b/packages/opencode/src/vendor/claustrum-client/identity.ts @@ -0,0 +1,30 @@ +import { createHash } from 'node:crypto' +import { existsSync, realpathSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import type { BindIdentity } from '@cortexkit/subc-client' + +export function storageFingerprint(storagePath: string): string { + const absolutePath = storagePath + let canonicalPath: string + try { + canonicalPath = realpathSync(absolutePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + let ancestor = absolutePath + while (!existsSync(ancestor)) { + const parent = dirname(ancestor) + if (parent === ancestor) break + ancestor = parent + } + canonicalPath = resolve(realpathSync(ancestor), relative(ancestor, absolutePath)) + } + return createHash('sha256').update(canonicalPath).digest('hex') +} + +export function storeIdentity(projectRoot: string, storagePath: string): BindIdentity { + return { + project_root: projectRoot, + harness: 'opencode', + session: `store-${storageFingerprint(storagePath)}`, + } +} diff --git a/packages/opencode/src/vendor/claustrum-client/index.ts b/packages/opencode/src/vendor/claustrum-client/index.ts new file mode 100644 index 0000000..a1c46cb --- /dev/null +++ b/packages/opencode/src/vendor/claustrum-client/index.ts @@ -0,0 +1,31 @@ +export { + detectClaustrumConnection, + getDefaultClaustrumConnectionPath, + resolveClaustrumConnectionPath, + type ClaustrumDetection, + type ClaustrumEndpoint, +} from './detect.js' +export { storeIdentity, storageFingerprint } from './identity.js' +export { + MANIFEST_LOCK, + withManifestLock, + writeHandleFileLocked, + type ManifestHandleAccount, + type ManifestHandleFile, + type ManifestHandleProvider, +} from './manifest-lock.js' +export { + ClaustrumCredentialError, + credentialErrorAction, + ERROR_CLASS_WIRE_SET, + type ClaustrumCredentialErrorAction, + type ClaustrumCredentialErrorClass, +} from './errors.js' +export { + ClaustrumClient, + type ClaustrumClientOptions, + type ClaustrumConnector, + type ClaustrumReporterSource, + type CredentialStatus, + type ServedCredential, +} from './wire.js' diff --git a/packages/opencode/src/vendor/claustrum-client/manifest-lock.ts b/packages/opencode/src/vendor/claustrum-client/manifest-lock.ts new file mode 100644 index 0000000..7cd52c7 --- /dev/null +++ b/packages/opencode/src/vendor/claustrum-client/manifest-lock.ts @@ -0,0 +1,374 @@ +import { constants as fsConstants } from 'node:fs' +import { + chmod, + lstat, + mkdir, + open, + readFile, + rename, + rm, + stat, + unlink, +} from 'node:fs/promises' +import { randomBytes, randomInt } from 'node:crypto' +import { dirname, join } from 'node:path' + +export const MANIFEST_LOCK = { + ttlMs: 30_000, + renewEveryMs: 10_000, + ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'] as const, + staleTargetRe: /^\.lock\.stale-\d+-[A-Za-z0-9_-]+$/, +} + +const HANDLE_FILE_MAX_BYTES = 256 * 1024 +const IDENTIFIER_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/ +const HANDLE_RE = /^ckh_[A-Za-z0-9_-]{43}$/ +const FORBIDDEN_IDENTIFIERS = new Set(['__proto__', 'constructor', 'prototype']) + +export type ManifestHandleAccount = { + label: string + handle: string + credential_id: string + superseded?: string[] +} + +export type ManifestHandleProvider = { + provider: string + shape: 'api' | 'oauth' + serve: string + accounts: ManifestHandleAccount[] +} + +export type ManifestHandleFile = { + version: 1 + providers: ManifestHandleProvider[] +} + +type ManifestLockOwner = { + tenant: string + pid: number + claimed_at_ms: number + nonce: string +} + +type ManifestLockTestOptions = { + ttlMs?: number + renewEveryMs?: number + retryMinMs?: number + retryMaxMs?: number + beforeEvict?: () => Promise + afterEvict?: () => void + beforeManifestRename?: (lockPath: string) => Promise +} + +let testOptions: ManifestLockTestOptions | undefined + +export function __setManifestLockTestOptions(options?: ManifestLockTestOptions): void { + testOptions = options +} + +function randomToken(): string { + return randomBytes(16).toString('base64url') +} + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | undefined)?.code +} + +function isIdentifier(value: unknown): value is string { + return typeof value === 'string' && IDENTIFIER_RE.test(value) && !FORBIDDEN_IDENTIFIERS.has(value) +} + +function parseManifest(value: unknown): ManifestHandleFile { + if (!value || typeof value !== 'object') throw new Error('handle file must be an object') + const file = value as Record + if (file.version !== 1 || !Array.isArray(file.providers)) { + throw new Error('handle file must have version 1 and providers') + } + const providerIds = new Set() + for (const [index, provider] of file.providers.entries()) { + if (!provider || typeof provider !== 'object') throw new Error(`provider ${index} must be an object`) + const item = provider as Record + if (!isIdentifier(item.provider)) throw new Error(`provider ${index} has invalid provider`) + if (providerIds.has(item.provider)) throw new Error(`provider ${index} duplicates provider ${item.provider}`) + providerIds.add(item.provider) + } + const providers = file.providers.map((provider, index): ManifestHandleProvider => { + const item = provider as Record + const providerId = item.provider as string + if (item.shape !== 'api' && item.shape !== 'oauth') throw new Error(`provider ${index} has invalid shape`) + if (typeof item.serve !== 'string' || !item.serve) throw new Error(`provider ${index} requires serve`) + if (!Array.isArray(item.accounts) || item.accounts.length === 0) { + throw new Error(`provider ${index} has invalid accounts`) + } + const labels = new Set() + const accounts = item.accounts.map((account, accountIndex): ManifestHandleAccount => { + if (!account || typeof account !== 'object') throw new Error(`provider ${index} has invalid accounts`) + const entry = account as Record + if (!isIdentifier(entry.label)) throw new Error(`provider ${index} has an invalid account label`) + if (labels.has(entry.label)) throw new Error(`provider ${index} duplicates account label ${entry.label}`) + labels.add(entry.label) + if (typeof entry.handle !== 'string' || !HANDLE_RE.test(entry.handle)) { + throw new Error(`provider ${index} account ${entry.label} has invalid handle`) + } + if (typeof entry.credential_id !== 'string' || !entry.credential_id) { + throw new Error(`provider ${index} account ${entry.label} has invalid credential id`) + } + if (entry.superseded !== undefined && + (!Array.isArray(entry.superseded) || entry.superseded.some((handle) => typeof handle !== 'string' || !HANDLE_RE.test(handle)))) { + throw new Error(`provider ${index} account ${entry.label} has invalid superseded handle`) + } + return { + label: entry.label, + handle: entry.handle, + credential_id: entry.credential_id, + ...(entry.superseded === undefined ? {} : { superseded: [...entry.superseded] as string[] }), + } + }) + return { provider: providerId, shape: item.shape, serve: item.serve, accounts } + }) + return { version: 1, providers } +} + +function parseOwner(source: string): ManifestLockOwner { + const value = JSON.parse(source) as unknown + if (!value || typeof value !== 'object') throw new Error('manifest lock owner invalid') + const owner = value as Record + if (Object.keys(owner).sort().join('\0') !== [...MANIFEST_LOCK.ownerKeys].sort().join('\0') || + typeof owner.tenant !== 'string' || + typeof owner.pid !== 'number' || !Number.isInteger(owner.pid) || + typeof owner.claimed_at_ms !== 'number' || !Number.isFinite(owner.claimed_at_ms) || + typeof owner.nonce !== 'string') { + throw new Error('manifest lock owner invalid') + } + return owner as ManifestLockOwner +} + +async function readOwner(ownerPath: string): Promise { + return parseOwner(await readFile(ownerPath, 'utf8')) +} + +async function writeOwner(lockPath: string, owner: ManifestLockOwner): Promise { + const ownerPath = join(lockPath, 'owner') + const temporary = join(lockPath, `owner.${process.pid}.${randomToken()}.tmp`) + let handle: Awaited> | undefined + try { + handle = await open(temporary, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600) + await handle.chmod(0o600) + await handle.writeFile(`${JSON.stringify(owner)}\n`) + await handle.sync() + await handle.close() + handle = undefined + await rename(temporary, ownerPath) + } finally { + await handle?.close().catch(() => {}) + await unlink(temporary).catch(() => {}) + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +export async function withManifestLock( + path: string, + tenant: string, + fn: () => Promise | T, +): Promise { + return withManifestLockCommit(path, tenant, async () => fn()) +} + +async function withManifestLockCommit( + path: string, + tenant: string, + fn: (commitLease: () => Promise) => Promise | T, +): Promise { + const lockPath = `${path}.lock` + const ownerPath = join(lockPath, 'owner') + const ttlMs = testOptions?.ttlMs ?? MANIFEST_LOCK.ttlMs + const renewEveryMs = testOptions?.renewEveryMs ?? MANIFEST_LOCK.renewEveryMs + const retryMinMs = testOptions?.retryMinMs ?? 25 + const retryMaxMs = testOptions?.retryMaxMs ?? 75 + const nonce = randomToken() + const startedAt = Date.now() + const deadline = startedAt + ttlMs + + while (true) { + try { + await mkdir(lockPath, { mode: 0o700 }) + await writeOwner(lockPath, { tenant, pid: process.pid, claimed_at_ms: Date.now(), nonce }) + break + } catch (error) { + if (errorCode(error) !== 'EEXIST') { + if (errorCode(error) !== 'ENOENT') await rm(lockPath, { recursive: true, force: true }).catch(() => {}) + throw error + } + } + + let observed: ManifestLockOwner | undefined + try { + observed = await readOwner(ownerPath) + } catch (error) { + if (errorCode(error) !== 'ENOENT') { + if (Date.now() >= deadline) throw new Error('manifest lock busy') + } + } + if (observed && startedAt - observed.claimed_at_ms >= ttlMs) { + await testOptions?.beforeEvict?.() + const stalePath = `${lockPath}.stale-${observed.claimed_at_ms}-${randomToken()}` + try { + await rename(lockPath, stalePath) + const moved = await readOwner(join(stalePath, 'owner')).catch(() => undefined) + if (moved?.nonce === observed.nonce && moved.claimed_at_ms === observed.claimed_at_ms) { + testOptions?.afterEvict?.() + continue + } + await rename(stalePath, lockPath).catch(() => {}) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + } + } + if (Date.now() >= deadline) throw new Error('manifest lock busy') + const jitter = randomInt(retryMinMs, retryMaxMs + 1) + await sleep(Math.min(jitter, Math.max(1, deadline - Date.now()))) + } + + let renewal = Promise.resolve() + let renewalFailed = false + let renewalStopped = false + const timer = setInterval(() => { + renewal = renewal.then(async () => { + if (renewalFailed) return + try { + const current = await readOwner(ownerPath) + if (current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttlMs) throw new Error('lease lost') + await writeOwner(lockPath, { ...current, claimed_at_ms: Date.now() }) + } catch { + renewalFailed = true + } + }) + }, renewEveryMs) + timer.unref?.() + + const commitLease = async (): Promise => { + if (!renewalStopped) { + renewalStopped = true + clearInterval(timer) + await renewal + } + const current = await readOwner(ownerPath).catch(() => undefined) + if (renewalFailed || !current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttlMs) { + throw new Error('manifest lock renewal failed; write aborted') + } + } + + try { + const result = await fn(commitLease) + if (renewalFailed) throw new Error('manifest lock renewal failed; write aborted') + return result + } finally { + if (!renewalStopped) clearInterval(timer) + await renewal + const current = await readOwner(ownerPath).catch(() => undefined) + if (!current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttlMs) { + console.warn('manifest lock lease lost, not releasing', { path, tenant }) + } else { + const releasePath = `${lockPath}.release-${nonce}` + try { + await rename(lockPath, releasePath) + const moved = await readOwner(join(releasePath, 'owner')).catch(() => undefined) + if (!moved || moved.nonce !== nonce || Date.now() - moved.claimed_at_ms >= ttlMs) { + await rename(releasePath, lockPath).catch(() => {}) + console.warn('manifest lock lease lost, not releasing', { path, tenant }) + } else { + await rm(releasePath, { recursive: true, force: true }) + } + } catch { + console.warn('manifest lock lease lost, not releasing', { path, tenant }) + } + } + } +} + +async function readManifest(path: string): Promise { + let metadata: Awaited> + try { + metadata = await lstat(path) + } catch (error) { + if (errorCode(error) === 'ENOENT') return { version: 1, providers: [] } + throw error + } + if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error('handle file must be a regular file') + if ((metadata.mode & 0o777) !== 0o600) throw new Error('handle file mode must be exactly 0600') + const source = await readFile(path) + if (source.byteLength > HANDLE_FILE_MAX_BYTES) throw new Error('handle file exceeds 256 KiB') + return parseManifest(JSON.parse(source.toString('utf8'))) +} + +function foreignBlocks(file: ManifestHandleFile, tenant: string): string[] { + return file.providers.filter((provider) => provider.serve !== tenant).map((provider) => JSON.stringify(provider)) +} + +async function prepareManifestParent(path: string): Promise { + const parent = dirname(path) + await mkdir(parent, { recursive: true, mode: 0o700 }) + const parentStat = await stat(parent) + if (!parentStat.isDirectory()) throw new Error('handle file parent must be a directory') + if ((parentStat.mode & 0o002) !== 0 && (parentStat.mode & 0o1000) === 0) { + throw new Error('handle file parent is world-writable without sticky bit') + } + await chmod(parent, 0o700) +} + +async function writeManifestAtomic( + path: string, + file: ManifestHandleFile, + commitLease: () => Promise, +): Promise { + const parent = dirname(path) + const bytes = Buffer.from(JSON.stringify(file)) + if (bytes.byteLength > HANDLE_FILE_MAX_BYTES) throw new Error('handle file exceeds 256 KiB') + const temporary = join(parent, `.${path.split('/').pop()}.${process.pid}.${randomToken()}.tmp`) + let handle: Awaited> | undefined + try { + handle = await open(temporary, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600) + await handle.chmod(0o600) + await handle.writeFile(bytes) + await handle.sync() + await handle.close() + handle = undefined + await chmod(temporary, 0o600) + await testOptions?.beforeManifestRename?.(`${path}.lock`) + await commitLease() + await rename(temporary, path) + } finally { + await handle?.close().catch(() => {}) + await unlink(temporary).catch(() => {}) + } +} + +export async function writeHandleFileLocked( + path: string, + tenant: string, + mutate: (file: ManifestHandleFile) => void | ManifestHandleFile | Promise, +): Promise { + await prepareManifestParent(path) + await withManifestLockCommit(path, tenant, async (commitLease) => { + const before = await readManifest(path) + const working = structuredClone(before) + const result = await mutate(working) + const next = parseManifest(result ?? working) + const beforeForeign = foreignBlocks(before, tenant) + if (JSON.stringify(foreignBlocks(next, tenant)) !== JSON.stringify(beforeForeign)) { + throw new Error('manifest mutation changed another tenant block') + } + await writeManifestAtomic(path, next, commitLease) + const metadata = await lstat(path) + if ((metadata.mode & 0o777) !== 0o600) throw new Error('manifest readback mode is not 0600') + const readback = await readManifest(path) + if (JSON.stringify(readback) !== JSON.stringify(next)) throw new Error('manifest readback differs') + if (JSON.stringify(foreignBlocks(readback, tenant)) !== JSON.stringify(beforeForeign)) { + throw new Error('manifest readback changed another tenant block') + } + }) +} diff --git a/packages/opencode/src/vendor/claustrum-client/wire.ts b/packages/opencode/src/vendor/claustrum-client/wire.ts new file mode 100644 index 0000000..ed37fba --- /dev/null +++ b/packages/opencode/src/vendor/claustrum-client/wire.ts @@ -0,0 +1,270 @@ +import { + SubcCallError, + SubcClient, + type BindIdentity, +} from '@cortexkit/subc-client' +import { resolveClaustrumConnectionPath } from './detect.js' +import { + asCredentialError, + ClaustrumCredentialError, + hasCredentialError, +} from './errors.js' +import { storeIdentity } from './identity.js' + +const CLAUSTRUM_MODULE_ID = 'claustrum' +const RECONNECT_BACKOFF_MS = 60_000 + +type ClaustrumTransport = Pick + +export type ClaustrumConnector = (options: { + connectionFile: string + handshakeTimeoutMs?: number +}) => Promise + +export type ClaustrumClientOptions = { + connectionFile?: string + handshakeTimeoutMs?: number + projectRoot?: string + storagePath?: string + identity?: BindIdentity + connector?: ClaustrumConnector + logger?: (errorClass: string) => void +} + +export type ServedCredential = { + material: string + recordVersion: number + expiresAtMs: number | null +} + +export type CredentialStatus = { + ready: boolean + lastErrorCode: string | null + leaseHeld: boolean + recordVersion: number + stalePending?: boolean +} + +export type ClaustrumReporterSource = + | 'direct' + | 'relay_status_field' + | 'relay_message_parse' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asRecordVersion(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 + ? value + : undefined +} + +function decodeCredential(response: unknown, logUnknownClass: (errorClass: string) => void): ServedCredential { + if (hasCredentialError(response)) throw asCredentialError(response, 'invalid_response', logUnknownClass) + const result = isRecord(response) && isRecord(response.result) ? response.result : undefined + const payload = result?.payload + if ( + !Array.isArray(payload) || + payload.length === 0 || + !payload.every((value) => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255) + ) { + throw asCredentialError(response, 'invalid_response', logUnknownClass) + } + const recordVersion = asRecordVersion(result?.record_version) + if (recordVersion === undefined) { + throw asCredentialError(response, 'invalid_record_version', logUnknownClass) + } + const rawExpiresAtMs = result?.expires_at_ms + const expiresAtMs = + rawExpiresAtMs === undefined || rawExpiresAtMs === null + ? null + : typeof rawExpiresAtMs === 'number' && Number.isFinite(rawExpiresAtMs) + ? rawExpiresAtMs + : undefined + if (expiresAtMs === undefined) { + throw asCredentialError(response, 'invalid_expiry', logUnknownClass) + } + return { + material: new TextDecoder().decode(Uint8Array.from(payload)), + recordVersion, + expiresAtMs, + } +} + +function decodeStatus(response: unknown, logUnknownClass: (errorClass: string) => void): CredentialStatus { + if (hasCredentialError(response)) throw asCredentialError(response, 'invalid_response', logUnknownClass) + const result = isRecord(response) && isRecord(response.result) ? response.result : undefined + const recordVersion = asRecordVersion(result?.record_version) + if ( + typeof result?.ready !== 'boolean' || + (result?.last_error_code !== undefined && result?.last_error_code !== null && typeof result?.last_error_code !== 'string') || + typeof result?.lease_held !== 'boolean' || + recordVersion === undefined || + (result?.stale_pending !== undefined && typeof result.stale_pending !== 'boolean') + ) { + throw asCredentialError(response, 'invalid_status', logUnknownClass) + } + return { + ready: result.ready, + lastErrorCode: result.last_error_code ?? null, + leaseHeld: result.lease_held, + recordVersion, + ...(result.stale_pending === undefined ? {} : { stalePending: result.stale_pending }), + } +} + +export class ClaustrumClient { + #client: ClaustrumTransport + readonly #connector: ClaustrumConnector + readonly #connectionFile: string + readonly #handshakeTimeoutMs?: number + readonly #identity: BindIdentity + readonly #logger: (errorClass: string) => void + #reconnecting: Promise | null = null + #nextReconnectAt = 0 + #closed = false + + private constructor( + client: ClaustrumTransport, + connector: ClaustrumConnector, + connectionFile: string, + handshakeTimeoutMs: number | undefined, + identity: BindIdentity, + logger: (errorClass: string) => void, + ) { + this.#client = client + this.#connector = connector + this.#connectionFile = connectionFile + this.#handshakeTimeoutMs = handshakeTimeoutMs + this.#identity = identity + this.#logger = logger + } + + static async connect(options: ClaustrumClientOptions = {}): Promise { + const connectionFile = resolveClaustrumConnectionPath(options.connectionFile) + const connector = options.connector ?? ((connectOptions) => SubcClient.connect(connectOptions)) + const identity = options.identity ?? storeIdentity( + options.projectRoot ?? process.cwd(), + options.storagePath ?? process.cwd(), + ) + const client = await connector({ + connectionFile, + handshakeTimeoutMs: options.handshakeTimeoutMs, + }) + return new ClaustrumClient( + client, + connector, + connectionFile, + options.handshakeTimeoutMs, + identity, + options.logger ?? ((errorClass) => console.warn(errorClass)), + ) + } + + async getCredential(handle: string, minTtlMs?: number): Promise { + const response = await this.#call('credential.get', { + handle, + min_ttl_ms: minTtlMs, + force_refresh: false, + }) + return decodeCredential(response, this.#logger) + } + + async statusCredential(handle: string): Promise { + const response = await this.#call('credential.status', { handle }) + return decodeStatus(response, this.#logger) + } + + async reportAuthFailure(input: { + handle: string + providerStatus: number + recordVersion: number + reporterSource: ClaustrumReporterSource + }): Promise { + const response = await this.#call('credential.report_auth_failure', { + handle: input.handle, + provider_status: input.providerStatus, + record_version: input.recordVersion, + reporter_source: input.reporterSource, + }) + if (hasCredentialError(response)) throw asCredentialError(response, 'invalid_response', this.#logger) + const result = isRecord(response) && isRecord(response.result) ? response.result : undefined + if (result?.accepted !== true) throw asCredentialError(response, 'invalid_response', this.#logger) + } + + close(): void { + this.#closed = true + this.#client.close() + } + + async #call(method: string, params: unknown): Promise { + try { + return await this.#client.call(CLAUSTRUM_MODULE_ID, method, params, { + identity: this.#identity, + consumerIdentity: null, + }) + } catch (error) { + if (this.#shouldReconnect(error)) { + try { + await this.#reconnect() + } catch (reconnectError) { + throw this.#asTransportError(reconnectError) + } + try { + return await this.#client.call(CLAUSTRUM_MODULE_ID, method, params, { + identity: this.#identity, + consumerIdentity: null, + }) + } catch (retryError) { + throw this.#asTransportError(retryError) + } + } + throw this.#asTransportError(error) + } + } + + #asTransportError(error: unknown): ClaustrumCredentialError { + if (error instanceof ClaustrumCredentialError) return error + const code = error instanceof SubcCallError && error.code ? error.code : 'transport_error' + return new ClaustrumCredentialError(code, 'transient', 'retry') + } + + #shouldReconnect(error: unknown): boolean { + return ( + !this.#closed && + error instanceof SubcCallError && + error.kind === 'terminal' && + error.code !== 'missing_identity' && + error.code !== 'invalid_control_body' + ) + } + + async #reconnect(): Promise { + if (this.#closed) throw new Error('Claustrum client is closed') + if (this.#reconnecting) { + await this.#reconnecting + return + } + const now = Date.now() + if (now < this.#nextReconnectAt) throw new Error('Claustrum client reconnect is backed off') + this.#nextReconnectAt = now + RECONNECT_BACKOFF_MS + this.#reconnecting = this.#connector({ + connectionFile: this.#connectionFile, + handshakeTimeoutMs: this.#handshakeTimeoutMs, + }) + .then((client) => { + if (this.#closed) { + client.close() + throw new Error('Claustrum client is closed') + } + const previous = this.#client + this.#client = client + previous.close() + }) + .finally(() => { + this.#reconnecting = null + }) + await this.#reconnecting + } +}