diff --git a/AGENTS.md b/AGENTS.md index 60eec55df7..128e091223 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,8 +305,11 @@ repository CI; a maintainer has to — so the gate never disproves it; a new push still resets every box. A disproved claim unticks the matching box and keeps the PR a draft. Authors with repository push permission skip the ancestry heuristic only. As with approval requirements in -[`MAINTAINERS.md`](./MAINTAINERS.md), this is enforced by convention until -branch protection is configured. +[`MAINTAINERS.md`](./MAINTAINERS.md), the ancestry heuristic is a CI check +rather than a branch rule. The branches themselves are protected: `dev`, +`main`, and `preview` each carry an active ruleset requiring a reviewed pull +request and blocking force-pushes and deletion, so a direct push to `dev` is +rejected regardless of `--no-verify`. [`MAINTAINERS.md`](./MAINTAINERS.md) is authoritative for review and merge policy (approvals, CI requirements, security review, promotion). This file diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 84ce21d604..f7183db6ef 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -52,8 +52,8 @@ when a maintainer steps down. a new push still resets every box. A disproved claim unticks the matching box and keeps the PR a draft. Authors with repository push permission skip the ancestry heuristic only. As - with the approval requirement above, this is enforced by convention until - branch protection is configured (see the note under the change log). + with the approval requirement above, this part is enforced by convention; + the ruleset does not check ancestry (see the note under the change log). - A pull request requires approval from at least one maintainer and successful required CI checks before merge. - Authors do not approve their own pull requests. @@ -160,11 +160,21 @@ Adding or removing a maintainer requires: and release automation keep the two owners already listed for those paths, so this addition does not widen the review surface for them. - CODEOWNERS requests reviews rather than enforcing them — no branch protection - rule is configured on this repository, so code-owner approval is a convention - here, not a gate. The same is true of the approval requirement in the review - and merge policy above. Widening the security boundary, or enforcing either - of these through branch protection, is a separate decision. + Code-owner approval and the maintainer-approval requirement above are both + enforced, not conventions. `dev`, `main`, and `preview` each carry an active + repository ruleset — the classic `/branches/{branch}/protection` endpoint + returns 404 for them, which is why this file long described the repository as + unprotected. `Protect dev` (id 20763889) requires a pull request with one + approving review, code-owner review, and extra approval for unattributed + changes, and it blocks deletion and non-fast-forward pushes. Allowed merge + methods are merge and squash; rebase merges are off. + + The one carve-out is that the `maintain`/`admin` repository role holds a + `pull_request` bypass, so an owner can merge without the approval the rules + otherwise require. That is a bypass, not an exemption: "Authors do not approve + their own pull requests" above still governs, and an owner who uses the bypass + should record it on the pull request rather than leave it to be inferred from + a merge timestamp. Widening the security boundary is a separate decision. ## Security reports diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 2539fbfcbf..8da4e1f724 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -23,6 +23,10 @@ import { } from "../src/update/npm-cache-preflight.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs"; import { bootRestoreProbe, transactionalNpmUpdate } from "../src/update/transactional-install.mjs"; +import { + CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS, + isCodexCliUpdateInspectionArgv, +} from "../src/update/codex-cli-update-launch-policy.mjs"; const PKG = "@bitkyc08/opencodex"; const require = createRequire(import.meta.url); @@ -155,10 +159,9 @@ function runNpmSelfUpdate() { process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, ); /** - * Refresh the existing service without re-registering it. `service repair` discovers - * the installed backend itself and, on Windows scheduler installs, rewrites the wrapper - * assets and restarts the existing task without `schtasks /create` — the elevation a - * non-admin `ocx update` does not have. + * Refresh the existing service in place. `service repair` discovers the installed backend; + * healthy Windows scheduler registrations avoid `schtasks /create`, while stale definitions + * may be re-registered and require elevation. */ function serviceRefreshArgs() { return [launcher, "service", "repair"]; @@ -290,7 +293,8 @@ function runNpmSelfUpdate() { } } if (needDirectStart) { - // A repair needs no elevation, but it can still fail — or exit 0 while leaving + // Repair normally avoids elevation for a healthy registration, but a stale Windows + // scheduler definition can require it. It can also fail — or exit 0 while leaving // a non-viable manager. Fall back to a direct detached proxy start so the // update never leaves the user without a running proxy. console.warn( @@ -468,7 +472,7 @@ function fail(msg) { process.exit(1); } -function resolveBun() { +function resolveBun({ allowInstall = true } = {}) { // Keep direct npm-launcher starts aligned with durable service/shim installs: // a valid explicit runtime must win even when the bundled dependency exists. const override = process.env[BUN_OVERRIDE_ENV]?.trim(); @@ -493,7 +497,7 @@ function resolveBun() { // Lazy fallback: --ignore-scripts (or a failed postinstall) leaves the // ~450-byte placeholder stub. Run the bun package's own installer once. const installJs = join(bunDir, "install.js"); - if (existsSync(installJs)) { + if (allowInstall && existsSync(installJs)) { const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); if (r.status === 0) bin = findBunBinary(bunDir); } @@ -512,6 +516,12 @@ if (updateHelpRequested) { process.exit(0); } +const codexCliUpdateInspection = isCodexCliUpdateInspectionArgv(process.argv); +if (codexCliUpdateInspection && typeof process.versions.bun === "string") { + console.error("opencodex: codex-cli-update inspection must use the published Node launcher."); + process.exit(1); +} + if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) { runNpmSelfUpdate(); } @@ -519,7 +529,7 @@ if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstal // #1849 boot probe: a prior update that lost power (or double-faulted) mid-swap leaves a // backup sibling and a broken live tree. Restore before anything tries to run from the // broken tree; reap stale backups once the live tree verifies healthy. -if (isNodeModulesInstall() && !isBunGlobalInstall()) { +if (!codexCliUpdateInspection && isNodeModulesInstall() && !isBunGlobalInstall()) { try { const probe = bootRestoreProbe(resolve(here, "..")); if (probe.action === "restored") { @@ -530,7 +540,7 @@ if (isNodeModulesInstall() && !isBunGlobalInstall()) { } catch { /* the probe must never block launch */ } } -const bunRuntime = resolveBun(); +const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection }); const bun = bunRuntime.path; // Run the Bun child asynchronously and FORWARD termination signals to it, then wait @@ -554,12 +564,61 @@ const bun = bunRuntime.path; // interpolation and provider settings legitimately read the project environment. const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); +// A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the +// ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture +// the absolute form here, in the launcher, while the original cwd is still authoritative; +// resolving it later would silently reinterpret it against a different working directory. +// +// A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver +// deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` +// would make the inspector treat it as an explicit path and stop searching PATH entirely. +const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" + ? process.env.CODEX_CLI_PATH + : null; +const preBunCodexCliPath = configuredCodexCliPath !== null + && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) + ? resolve(configuredCodexCliPath) + : configuredCodexCliPath; +const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; +const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; +const preBunCodexCliManagerRoots = Object.fromEntries( + CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { + const value = process.env[name]; + return typeof value === "string" && value !== "" ? [[name, value]] : []; + }), +); const launchProof = randomBytes(32).toString("base64url"); const launchContext = JSON.stringify({ version: 1, proof: launchProof, anthropicEnvSlots: preBunAnthropicSlots, + codexCliInspectionEnv: codexCliUpdateInspection ? { + codexCliPath: preBunCodexCliPath, + path: preBunPath, + pathExt: preBunPathExt, + managerRoots: preBunCodexCliManagerRoots, + configDir: configDir(), + } : null, }); +// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as +// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than +// from the live environment. Inheriting them again would spend the 32,767-character Windows +// environment block twice, so a large-but-valid shell environment could stop the Bun child +// from spawning and fail the command before it reports anything. Drop the duplicates for the +// one-shot inspection launch only; every other launch inherits the environment unchanged. +// Windows environment names are case-insensitive, but this spread produces an ordinary +// case-sensitive object, and a real Windows environment commonly spells the variable `Path`. +// Deleting only the canonical upper-case spelling would silently leave that copy behind and +// reintroduce the duplication this block exists to prevent, so match on the lowercase form. +const inheritedEnv = { ...process.env }; +if (codexCliUpdateInspection) { + const snapshotted = new Set( + ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), + ); + for (const name of Object.keys(inheritedEnv)) { + if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; + } +} const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], { stdio: "inherit", // A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no @@ -567,7 +626,7 @@ const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, // the long-running Bun child, and closing that window kills the proxy (#1236). windowsHide: true, env: { - ...process.env, + ...inheritedEnv, [NODE_LAUNCH_CONTEXT_ENV]: launchContext, [BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source, [BUN_RUNTIME_PATH_ENV]: bunRuntime.path, diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md b/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md new file mode 100644 index 0000000000..ff81cf010a --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/000_plan.md @@ -0,0 +1,46 @@ +# Aside client + Integrations UX repair + +Unit opened 2026-08-31. Two outcomes travel together because they land on the +same page: Aside becomes an export/integration client, and the Integrations +("연결") surface stops flooding itself with rollback rows. + +They are one unit rather than two because the Aside work ADDS a twelfth card to +a page that is already too crowded to absorb one. Shipping the client first +would make the page measurably worse before it got better. + +## The two problems + +**Aside is unsupported.** Aside is a Chromium fork with a built-in browser +agent. Its custom-provider catalog lives at `~/.aside/u//models.json` +and its schema is the one Pi reads. The user on this machine already wired +opencodex into it BY HAND: the live file carries a `providers.opencodex` block +with 24 routed models, `api: "openai-completions"`, and +`apiKey: "opencodex-loopback"` — byte-identical to what `buildPiClientConfig` +emits. A hand-maintained integration is the strongest possible argument that +the client belongs in the registry. + +**The Integrations page floods.** The rollback journal renders up to 50 rows, +each with its own border, at the bottom of the overview AND again on every file +client tab. The user's words were "로그 밑에 막 다닥다닥 뜨는 히스토리" — the +per-row borders are literally what produces that texture. + +## Work phases + +| Phase | Doc | Deliverable | +|---|---|---| +| wp1 | this unit | Research and roadmap (docs only) | +| wp2 | 010 | Aside export client + integration registry | +| wp3 | 020 | Aside GUI surface, marks entry, nine locales | +| wp4 | 030 | Rollback surface redesign | +| wp5 | 040 | Brand marks for the nine clients showing a monogram | +| wp6 | 050 | Stacked PR chain | + +Research docs: 001 (Aside contract), 002 (registration checklist), +003 (Integrations UX diagnosis), 004 (brand mark provenance). + +## Ordering constraint + +wp4 and wp5 do not depend on wp2/wp3, and wp3 depends on wp2. The stack is +therefore not a single line: the Aside pair (wp2 then wp3) and the page repair +pair (wp4, wp5) are independent chains that both branch off `dev`. wp6 puts +them in review order. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md b/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md new file mode 100644 index 0000000000..d0a94cdd5c --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/001_aside_contract.md @@ -0,0 +1,158 @@ +# What Aside actually reads + +Observed on this machine, 2026-08-31, against Aside CLI `1.26.810.1915` and app +bundle `1.0.825.1`. Every claim below was read off a real file or a live process, +not inferred from documentation. + +## Account roots + +`~/.aside/accounts.json` (mode 600) carries: + +```json +{ "version": ..., "currentAccountId": 0, "accounts": [ { "id": 0, "email": "..." }, { "id": 1 } ], ... } +``` + +Per-account state lives at `~/.aside/u//`. Both `u/0` and `u/1` exist here, +so multi-account is not hypothetical. The provider catalog is +`~/.aside/u//models.json`. + +**No environment override exists.** `strings` over the CLI binary yields +`ASIDE_DAEMON_BASE_URL`, `ASIDE_CLI_*`, `ASIDE_MCP_*`, `ASIDE_PRODUCT_VARIANT`, +`ASIDE_RELEASE_BASE_URL`, `ASIDE_NATIVE_HELPER_BINDING` — and nothing that +relocates the account root. The only literal `.aside` path in the binary is +`path.join(os.homedir(), ".aside", "cli", "update-check.json")`. + +This makes Aside unlike every existing client. `dshHomeDir` honors `DSH_HOME`; +`mcodeHomeDir` honors `MINIMAX_DATA_DIR` then `MAVIS_DATA_DIR`; `piAgentDir` +honors `PI_CODING_AGENT_DIR`. Aside has no such variable to honor, so the +resolver's variable is the ACCOUNT ID, not a path. + +### Decision: read the manifest, fail closed, add no opencodex env var + +A resolver that only reads `~/.aside` cannot be tested without writing to the +user's real Aside install. Every existing path helper takes `(env, home)` and +the tests redirect `home`; Aside does the same, so the root is +`join(home, ".aside")` and tests redirect `home` exactly as +`tests/prime-client.test.ts` does. + +An earlier draft added `OPENCODEX_ASIDE_ACCOUNT` so a user could target a +non-current account. **Dropped after audit.** This registry's contract is to +honor each client's OWN override (`registry.ts:45`) — `DSH_HOME`, +`MINIMAX_DATA_DIR`, `PI_CODING_AGENT_DIR` all belong to their clients. Aside +ships no such variable, so inventing an opencodex-namespaced one is new product +behavior dressed up as path resolution. Account selection, if it is ever wanted, +is its own unit with its own surface. + +We write the account Aside itself reports as current, and nothing else. + +**Fail closed rather than fall back.** An earlier draft defaulted to account `0` +when `accounts.json` was missing or unparseable. On this machine both `u/0` and +`u/1` exist, so that fallback could name a real config file belonging to the +WRONG account and then pass the installation gate — a silent write into another +account's catalog. Instead: + +- Manifest present with a non-negative integer `currentAccountId`: use it. +- Manifest absent, unreadable, unparseable, or the id malformed: throw + `ClientPathError`. The surface reports the client as unavailable with a real + reason, exactly as an unresolvable `DSH_HOME` does today. + +A missing manifest means Aside has not established which account is current, and +there is no honest value to guess. + +**Resolve both paths from one account read.** `freezeIntegrationInput` called +`configPath` and `detectDir` separately (`writer.ts:621`), and `readIntegrationState` +did the same. Both now depend on file contents rather than only `env` and `home`, +so a manifest rewritten between the two calls could verify one account's install +and then write a different account's catalog. + +A cache was the first idea and it does not work: any cache keyed on the manifest +re-reads exactly when the manifest changes, which is the case the consistency is +needed for, and nothing tells a path helper when an operation ends. The audit +caught that contradiction. + +The fix is a seam instead. `resolveIntegrationPaths(clientId, env, home)` in the +integration registry returns the PAIR, and an optional `resolvePaths` on a client +spec lets Aside derive both from a single `asideAccountDir` call. Every other +client keeps the default behavior, so the pair stays correct for them without +any of them knowing why the seam exists. + +## The provider block + +The live `~/.aside/u/0/models.json`, provider keys in their on-disk order with +values elided: + +```json +{ "providers": { "opencodex": { + "baseUrl": "http://127.0.0.1:10100/v1", + "apiKey": "opencodex-loopback", + "api": "openai-completions", + "models": [ { "id": "...", "name": "...", "reasoning": true, + "thinkingLevelMap": { "off": null, ..., "max": "max" }, + "input": ["text","image"], "contextWindow": 1000000, + "maxTokens": 32000 } ] +} } } +``` + +Four provider keys and 24 model entries. `thinkingLevelMap` uses the same seven +pi levels (`off`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`) with `null` for +levels the model does not declare. `input` is `["text","image"]` throughout, or +`["text"]` alone. + +### The same key SET, not the same byte order + +An earlier draft called this file "byte-identical" to `buildPiClientConfig` +output. That was wrong, and the audit caught it. + +The builder emits `baseUrl`, `api`, `apiKey`, `models` +(`config-export.ts:1038`); the hand-written file has `baseUrl`, `apiKey`, +`api`, `models`. Model entries differ the same way — the builder writes `input` +before the optional reasoning fields, the live file after. `serializeDocument` +preserves insertion order, so the emitted bytes really do differ. + +The true claim is weaker and sufficient: **the same four provider keys, the same +dialect string, the same placeholder, and the same model field vocabulary.** JSON +key order is not semantic and Aside parses this file rather than diffing it. What +the builder produces is a document Aside accepts; it is not the document a human +happened to type. + +So this unit claims compatibility, not equality. The test backing it must NOT be +`buildAside(ctx) === buildPi(ctx)`: both call the same function, so that +assertion is tautological. wp2 asserts against a fixture captured from the +observed Aside shape — same key set, same dialect, placeholder rather than a +credential, model fields drawn from the observed vocabulary. + +## Reuse, and the one thing not to reuse + +`prime` set the precedent: it reuses `buildPiClientConfig` and `summarizePi` +verbatim and adds only `buildPrimeContribution`, so ownership records carry +`clientId: "prime"`. Aside follows that exactly. Restating the shape would +create a second copy of one fact, which is the bug that comment warns about. + +## loopbackOnly: true + +The provider block has four keys and none of them is `headers`. A dedicated +`x-opencodex-api-key` header has nowhere to live, so a non-loopback bind would +generate a config that 401s. Same reasoning, same verdict as `dsh`, `kimi`, +`gajae`, `mcode`, and `zcode`. + +`apiKeyEnv` is therefore `""` and `exportHint` says loopback needs no key. + +## Hazard: Aside overwrites the file while running + +The Aside skill reference states that editing `models.json` while Aside is +running risks the daemon overwriting it, and `Aside Daemon` was live during +this investigation. The pattern for this already exists: Claude Desktop's copy +says "Fully quit and reopen it for this change to take effect" +(`integrations.dialog.desktop.restart`). Aside gets the same treatment in its +`integrations.semantics.aside` string. + +This is a copy problem, not a writer problem. The writer already snapshots +before every mutation and journals what it did, so an overwrite by Aside is +recoverable the same way any drift is. + +## Not in scope + +`~/.aside/u/0/models.json` can hold a plaintext key for a user's OWN providers, +and `credentials.json` certainly does. We write one fragment, +`providers.opencodex`, and the merge layer touches nothing else. No Aside +credential is ever read, printed, or serialized. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md b/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md new file mode 100644 index 0000000000..e594069d08 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/002_registration_checklist.md @@ -0,0 +1,72 @@ +# Every surface a new export client must reach + +Derived from `git show c6fa2563d` (the `prime` client, 29 files) plus the +invariant tests. This is the checklist wp2 and wp3 execute. + +## Backend (wp2) + +- `src/clients/config-export.ts`: `asideHomeDir`/`asideAccountDir`/ + `asideConfigPath` helpers, `"aside"` in `ExportClientId`, + `buildAsideContribution`, and the `EXPORT_CLIENTS.aside` spec. The spec needs + all nine fields: `filename`, `destination`, `apiKeyEnv`, `exportHint`, + `build`, `format`, `summarize`, `buildContribution`, `loopbackOnly`. +- `src/integrations/registry.ts`: `INTEGRATION_CLIENTS.aside` with `configPath` + and `detectDir`. No `sourcePreservingYaml` (JSON) and no `writerLock`, same as + `pi` and `prime`. +- `src/cli/registry.ts`: the `export` entry's static `usage`/`summary` client + union. Acceptance itself comes from `EXPORT_CLIENT_IDS` via + `isExportClientId` in `src/cli/export-command.ts`, so this is help text only. +- `tests/aside-client.test.ts`: new, modeled on `tests/prime-client.test.ts`. + +`bun run skill:surface` is NOT implicated: its generator reads `CAPABILITIES`, +and a new `--client` value creates no capability. + +## Existing tests that assert exact lists (wp2) + +These fail until updated, which is the point: + +- `tests/client-config-export.test.ts`: ordered `EXPORT_CLIENT_IDS`. +- `tests/client-config-export-new-clients.test.ts`: the loopback-only set. +- `tests/integrations-invariants.test.ts`: the client count, and `SEED` is a + `Record` so typecheck forces an Aside fixture in + Aside's own JSON shape. +- `tests/integrations-state.test.ts`: the loopback-only set. + +## GUI (wp3) + +Five surfaces the invariant test compares against `EXPORT_CLIENT_IDS`: +`INTEGRATION_CLIENT_IDS`, the GUI `CLIENTS` tuple, `CLIENT_LABEL_KEYS` keys, +`FILE_INTEGRATION_CLIENTS`, and the hashes in `INTEGRATION_TAB_HASHES`. + +Plus three exhaustive `Record` maps that +typecheck catches: `FILE_LABEL_KEY` in `overview-clients.ts`, and +`SEMANTICS_KEY` + `TAB_LABEL_KEY` in `FileIntegrationPage.tsx`. + +**The two silent hazards.** `TABS` and `FILE_CLIENTS` in +`gui/src/pages/Integrations.tsx` are NOT exhaustive records and NOT covered by +the invariant test. Omitting Aside from either leaves typecheck and the +invariants green while the tab silently does not render. wp3 asserts both in a +GUI test rather than trusting the compiler. + +## i18n (wp3) + +Three keys across nine locales (`en`, `de`, `fr`, `ja`, `ko`, `ru`, `tr`, `zh`, +`zh-TW`): `integrations.tab.aside`, `integrations.semantics.aside`, +`api.clientConfig.clientAside`. + +"Aside" is a product name, so the tab and client labels stay English in every +locale. That means adding them to `ZH_TW_KEEP_ENGLISH` in +`gui/tests/locale-parity.test.ts` and `INTENTIONAL_ENGLISH` in +`gui/tests/fr-localization.test.ts`. The semantics string is prose and IS +translated. + +## Docs (wp3) + +`docs-site/.../reference/cli/agents.md` client union, flag table, and +destination table; `docs-site/.../guides/integrations.md` client table. Commit +`42adf4996` established that translated CLI reference pages are synchronized +too. + +The integrations guide currently lists ten clients and omits `zcode` — a real +gap found during this research. wp3 adds the missing `zcode` row alongside +`aside` rather than leaving a known hole next to a new entry. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md b/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md new file mode 100644 index 0000000000..e60c2e7c06 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/003_integrations_ux_diagnosis.md @@ -0,0 +1,96 @@ +# Why the Integrations page reads as noise + +Read against the current tree, 2026-08-31. + +## What the user is seeing + +"로그 밑에 막 다닥다닥 뜨는 히스토리" — the rollback journal. The real numbers: + +- The route `/api/client-integrations/journal` accepts only `client`. There is + no HTTP `limit` parameter; `?limit=` is ignored + (`src/server/management/integration-routes.ts:313`). +- It calls `store.listOperations()` with no limit, so `listOperations`' own + default applies: **50 rows**, newest first (`src/integrations/journal.ts:138`). +- Both consumers render the ENTIRE response with no slice — + `IntegrationsOverview.tsx:561` and `FileIntegrationPage.tsx:196`. +- Every row carries its own `1px` border and `border-radius` + (`styles-integrations.css:62`). Fifty bordered strips stacked at 6px gaps is + precisely the "다닥다닥" texture. + +So the flooding is real, and it is worse than one list: the overview shows the +global journal and every file client tab shows the same journal filtered. The +same operation is rendered twice in two places. + +Two related facts worth recording. Snapshot retention is 10 per client +(`journal.ts:54`), so of 50 visible rows at most 10 are restorable and the rest +render an "expired" badge — the list is mostly inert. And journal ROWS are never +pruned, so `journal.jsonl` is parsed in full on every request before the slice. + +## Defects, worst first + +1. **History floods and duplicates.** Above. +2. **Loading, failure, and empty are indistinguishable.** Both components do + `data ?? []` and then render the empty state, so a cold fetch, a failed + fetch, and a genuinely empty journal look identical. No retry, no stale + warning — even though `useDataSurface` exposes `state.kind` for exactly this. +3. **Undo is buried.** On the overview it sits below the summary, the API-key + row, onboarding copy, up to 15 cards, and an empty panel. The most valuable + recovery action on the page is viewports away from the switch that caused it. +4. **RestoreDialog is not modal.** It renders `` with inline + full-screen styles instead of `showModal()`, so background controls stay + reachable and focus is neither trapped nor restored. + `ConsequenceDialog.tsx:34` in the same directory does it correctly. +5. **Summary claims exceed its data.** "Last change" reads only the file-client + journal, so a Codex/Claude/Desktop/Grok change is invisible. Counts paint + zero while sources are still unsettled. The "no clients detected" panel tests + only FILE clients but its copy does not say so. +6. **Heading levels skip.** Overview goes `h2` straight to `h4` with no `h3`. + CSS targets `.integration-client-head h4` while the JSX renders `h3`. +7. **Card saturation.** No literal card-in-card, but a raised summary, a raised + API row, 15 bordered cards, a bordered empty panel, and 50 bordered history + rows give every level the same visual weight. +8. **No responsive block at all** in `styles-integrations.css`. + +## Patterns already in this repo + +Nothing here needs a new design system. + +- **Bounded pagination:** Claude Desktop reveals six rows at a time behind a + `btn btn-ghost btn-sm` show-more (`claude-desktop-lane.ts:11`, + `ClaudeDesktop.tsx:671`). `LANE_PAGE = 6` is the local precedent. +- **Disclosure:** `Logs.tsx:1112` uses native `
/` for + secondary detail. There is no generic Accordion component, and adding one is + out of scope. +- **State branching:** `DataSurfaceSkeleton`, `DataSurfaceStatus`, + `EmptyState`, `Notice` already exist. +- Virtualization (`Logs.tsx:518`) is overkill for at most 50 rows. + +## The redesign (wp4) + +**Overview.** Drop the journal block entirely. In its place, one unframed +"latest change" line directly below the summary: client, operation, time, and +its Undo or Restore-point action. Recovery moves above the fold and the +summary's "last change" scope becomes visible instead of implied. + +**Client tab.** Newest row stays visible next to the status and path. Older rows +move into a collapsed-by-default `
`, revealed six at a time. Expired +rows live only inside that disclosure, so the visible surface is the part that +can actually be undone. + +No total count is displayed: the API caps at 50 and returns neither `total` nor +`hasMore`, so any number shown would be a claim we cannot support. + +**Shared component.** The row JSX is currently duplicated in both files with +their own `KIND_KEY` maps. wp4 extracts one integrations-domain component. + +**Styling.** One list boundary with `border-top` separators instead of a border +per row. Fix `.integration-client-head h4` to `h3`, add `flex-wrap`, add a +narrow-viewport rule. + +**Also in wp4.** `RestoreDialog` adopts `ConsequenceDialog`'s modal lifecycle, +and the history resources branch on `state.kind` so cold, failed, and empty +stop looking alike. + +An HTTP `limit` parameter is deliberately NOT in wp4. It would shrink the +payload without fixing the full-file parse or the unbounded on-disk journal, and +the UI cap makes it unnecessary for this complaint. Recorded as a follow-up. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md b/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md new file mode 100644 index 0000000000..40f7ff2dec --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/004_brand_mark_provenance.md @@ -0,0 +1,73 @@ +# First-party marks for the clients showing a monogram + +`CLIENT_MARKS` (`gui/src/components/apikeys-workspace/client-config-clients.ts`) +covers 2 of 11 clients. The other nine render +`{label.slice(0, 1)}` +(`ClientConfigRow.tsx:91`). + +The file's own rule: "a client with none falls back to a monogram tile rather +than borrowing another product's logo." So every entry below needs a mark that +belongs to that product, verified, or it stays a monogram. + +## Verified sources + +Each URL below was fetched in a real browser session on 2026-08-31 and returned +the content-type shown. This is live evidence, not a guess from a repo comment. + +| Client | Product | Asset | Type | +|---|---|---|---| +| `omp` | Oh My Pi (`can1357/oh-my-pi`) | `https://omp.sh/favicon.svg` | `image/svg+xml` | +| `hermes` | Hermes Agent (`NousResearch/hermes-agent`) | `.../hermes-agent/main/website/static/img/favicon.svg` | `image/svg+xml` | +| `openclaw` | OpenClaw (`openclaw/openclaw`) | `.../openclaw/main/ui/public/favicon.svg` | `image/svg+xml` | +| `dsh` | DeepSeek Harness (`deepseek-ai/deepseek-harness`) | `.../deepseek-harness/master/website/public/favicon.svg` | `image/svg+xml` | +| `prime` | Prime Agent (`PrimeIntellect-ai/prime-agent`) | `.../prime-agent/main/assets/brand/prime-butterfly.svg` | `image/svg+xml` | +| `zcode` | ZCode (Z.ai) | `https://z-cdn.chatglm.cn/z-ai/static/logo.svg` | recorded in `_fin/260705` notes | + +Two answers that settled open questions: + +**`dsh` is first-party DeepSeek.** The repo never named a publisher, which is +why `deepseek-color.svg` could not simply be reused. Live check: DeepSeek +publishes `deepseek-ai/deepseek-harness` and scopes its packages +`@deepseek-ai/dsh-*`. The bare npm `dsh` package is unrelated +(`infusion/node-dsh`, 2016). So the harness has its own first-party favicon and +we use that rather than the provider logo. + +**`prime` has its own mark.** The `prime-butterfly.svg` in the prime-agent repo +resolves the note in `config-export.ts` that Prime is "the pi coding agent +shipped under a different brand" — the brand has an asset, so `pi.svg` must not +be reused for it. + +## Reuse instead of fetching + +`kimi`: `gui/public/provider-icons/kimi-color.svg` is already committed and is +the same Moonshot AI brand as the Kimi Code client +(`_fin/260705_provider-quota-dashboard/svg-candidates/manifest.json:41`). Point +the client at the existing asset; add no file. + +## Not resolved + +`gajae` (Gajae Code, `Yeachan-Heo/gajae-code`) publishes a mascot PNG, a +vertical logo PNG, and a base64 PNG favicon — no SVG anywhere, and the npm +package ships no icon. Every committed asset in `provider-icons/` is SVG. + +wp5 keeps `gajae` on the monogram and records the reason here. A PNG could be +committed, but it would be the only raster mark in the set and would not scale +with the 20px `` at other densities. This is the one BLOCKED item in the +unit, and it is blocked on the upstream project having no vector mark rather +than on anything we can fix. + +## Aside's own mark + +Aside ships `/Applications/Aside.app/Contents/Resources/app.icns`, which is a +local macOS icon resource rather than a distributable brand asset, so wp5 checks +for a first-party web asset the same way as the others before assigning one. If +none is verified, Aside launches on the monogram and gains its mark later — +a missing mark must not block the client. + +## README obligation + +`gui/public/provider-icons/README.md` records provenance per asset, following +the `pi.svg` precedent: asset name, fetch date, source URL, what the project is, +and whether it was modified. Its licensing note points at +`devlog/_plan/260705_provider-quota-dashboard/...`, which has since moved to +`_fin/` — wp5 fixes that stale path while it is in the file. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md b/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md new file mode 100644 index 0000000000..f0d9bb5ba0 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/010_wp2_aside_backend.md @@ -0,0 +1,168 @@ +# wp2 — Aside export client and integration registry + +Backend only. No GUI file changes; wp3 owns those. Contract read in 001, +checklist in 002. + +## src/clients/config-export.ts + +**Path helpers**, placed beside `dshHomeDir`/`mcodeHomeDir`: + +```ts +/** Aside's per-account state root. */ +export function asideHomeDir(env = process.env, home = homedir()): string { + return join(home, ".aside"); +} + +/** + * The account Aside reports as current. + * + * Unlike every other client here, Aside has no path override to honor: its CLI + * carries ASIDE_DAEMON_BASE_URL and friends but nothing that moves the account + * root. So the variable is the ACCOUNT, and Aside's own manifest is the only + * authority on it. We add no opencodex-namespaced override: this registry + * mirrors each client's variables and does not invent new ones. + * + * Throws when the manifest cannot answer. Defaulting to 0 would be a guess, and + * on a machine with u/0 and u/1 that guess writes into the wrong account's + * catalog while still passing the installed check. + */ +function asideCurrentAccountId(home: string): number + +export function asideAccountDir(env = process.env, home = homedir()): string + // join(asideHomeDir(env, home), "u", String(asideCurrentAccountId(home))) + +export function asideConfigPath(env = process.env, home = homedir()): string + // join(asideAccountDir(env, home), "models.json") +``` + +`asideCurrentAccountId` reads `/accounts.json` and returns +`currentAccountId` when it is a non-negative integer. A missing, unreadable, or +unparseable manifest, or a malformed id, throws `ClientPathError` naming the +manifest. That is the same failure shape `absoluteClientPath` uses for a bad +`DSH_HOME`, and the surfaces already render it as an unavailable client. + +**One account read per operation, via a registry seam.** `freezeIntegrationInput` +resolved `configPath` and `detectDir` in two separate calls (`writer.ts:621`), +and `readIntegrationState` did the same (`state.ts:385`). These resolvers read a +mutable file, so a switch between the calls could verify one account and write +another. + +Memoization was the first proposal and the audit rejected it correctly: a cache +invalidated by the manifest's mtime re-reads precisely when the manifest changes, +and a path helper has no way to know when an operation ends. + +So the pair becomes one call. `IntegrationClientSpec` gains an optional +`resolvePaths`, `resolveIntegrationPaths(clientId, env, home)` is the only place +that turns an id into both paths, and Aside implements `resolvePaths` by calling +`asideAccountDir` once and joining `models.json` onto it. Both writer and state +call the seam; every other client falls through to the previous behavior. + +Reading a file inside a path helper is not new: this module already calls +`existsSync` at four resolution sites (lines 236, 335, 337, 380). Parsing +contents rather than probing existence is the new part, which is why the failure +is explicit and the result is memoized. + +**Contribution builder**, beside `buildPrimeContribution`: + +```ts +function buildAsideContribution(ctx: ExportContext): ManagedContribution { + const doc = buildPiClientConfig(ctx); + return singleFragment("aside", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} +``` + +A comment records WHY the Pi builder is reused: the live +`~/.aside/u/0/models.json` on the machine this was developed on already carried +a hand-written `providers.opencodex` block whose four keys, dialect, +`opencodex-loopback` placeholder, and `thinkingLevelMap` levels match +`buildPiClientConfig` exactly. Same argument prime's comment makes, with live +evidence instead of a package manifest. + +**`ExportClientId`**: add `| "aside"`. + +**`EXPORT_CLIENTS.aside`**, after `prime`: + +```ts +aside: { + id: "aside", + filename: "aside-models.json", + destination: env => asideConfigPath(env), + apiKeyEnv: "", + exportHint: "Aside reads a non-secret placeholder from models.json; loopback needs no key.", + build: buildPiClientConfig, + format: "json", + summarize: summarizePi, + buildContribution: buildAsideContribution, + loopbackOnly: true, +}, +``` + +`loopbackOnly: true` because the provider block has exactly four keys and none +is `headers`, so the dedicated admission header has nowhere to live. The comment +says that rather than restating the general rule. + +`filename` is `aside-models.json` and not `models.json`: the download name is +what lands in a user's Downloads folder, where a bare `models.json` collides +with pi's and prime's. Prime set this precedent with `prime-models.json`. + +## src/integrations/registry.ts + +Import `asideConfigPath` and `asideAccountDir`; add: + +```ts +aside: { + id: "aside", + configPath: (env = process.env, home = homedir()) => asideConfigPath(env, home), + // The ACCOUNT directory, not ~/.aside: the CLI creates ~/.aside/cli for its + // own update check before any account exists, so the outer directory can be + // present on a machine that never signed in. + detectDir: (env = process.env, home = homedir()) => asideAccountDir(env, home), +}, +``` + +No `sourcePreservingYaml` (JSON) and no `writerLock`, same as `pi`/`prime`. + +## src/cli/registry.ts + +Add `aside` to the `export` command's `usage` and `summary` client union. +Acceptance is already dynamic through `EXPORT_CLIENT_IDS`. + +## tests/aside-client.test.ts (new) + +Modeled on `tests/prime-client.test.ts`, which locks seven properties. Aside's +cases: + +1. The generated document matches a FIXTURE captured from the observed Aside file + shape: four provider keys, `api: "openai-completions"`, the loopback + placeholder, and model fields from the observed vocabulary. Deliberately NOT + an equality check against `buildPiClientConfig`, which would be tautological + since Aside calls it. See 001 for why key order differs and why that is fine. +2. The owned document is `providers.opencodex` with `baseUrl`, `api: + "openai-completions"`, `apiKey: "opencodex-loopback"`, `models`. +3. Serialized JSON round-trips and contains no credential. +4. Contribution path is `["providers", "opencodex"]` with `clientId: "aside"`. +5. `currentAccountId: 0` resolves `/.aside/u/0/models.json`, and + `currentAccountId: 1` resolves `u/1`. +6. Absent, unreadable, unparseable, and malformed-id manifests each throw + `ClientPathError` — all four asserted, because the point is that none of them + silently picks an account. +7. `resolveIntegrationPaths("aside")` returns a config path that is the detect + directory plus `models.json`, so the two cannot name different accounts; a real + switch moves both together. A pure-path client still resolves through the + same seam. +8. `detectDir` is the account directory, so `~/.aside/cli` alone is not + "installed". +9. `loopbackOnly` is true and `apiKeyEnv` is empty. + +## Existing tests to update + +`client-config-export.test.ts` (ordered ids), `client-config-export-new-clients +.test.ts` (loopback-only set), `integrations-invariants.test.ts` (count plus an +`aside` `SEED` in real JSON shape with a user-owned sibling provider that must +survive), `integrations-state.test.ts` (loopback-only set). + +## Verification + +`bun test tests/aside-client.test.ts tests/client-config-export.test.ts +tests/client-config-export-new-clients.test.ts tests/integrations-invariants.test.ts +tests/integrations-state.test.ts` plus `bun run typecheck`. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md b/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md new file mode 100644 index 0000000000..a20b4ac208 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/020_wp3_aside_gui.md @@ -0,0 +1,83 @@ +# wp3 — Aside GUI surface + +Depends on wp2. Five invariant-compared lists, three exhaustive maps, two +hand-maintained lists the compiler cannot see, three i18n keys times nine +locales, and the docs tables. + +## Invariant-compared (test fails if omitted) + +- `gui/src/components/apikeys-workspace/client-config-clients.ts`: `"aside"` in + `CLIENTS`, `aside: "api.clientConfig.clientAside"` in `CLIENT_LABEL_KEYS`. +- `gui/src/pages/integrations/integration-api.ts`: `"aside"` in + `FILE_INTEGRATION_CLIENTS`, which widens `FileIntegrationClientId`. +- `gui/src/app-routing.ts`: `"integrations/aside"` in `INTEGRATION_TAB_HASHES`. + +## Typecheck-enforced maps + +- `overview-clients.ts`: `FILE_LABEL_KEY.aside`. +- `FileIntegrationPage.tsx`: `SEMANTICS_KEY.aside`, `TAB_LABEL_KEY.aside`. + +## The two lists nothing checks + +`TABS` and `FILE_CLIENTS` in `gui/src/pages/Integrations.tsx` are plain arrays, +not exhaustive records, and the invariant test does not read them. Omitting +Aside from either leaves every gate green and the tab simply absent. + +Closed concretely: `gui/tests/integrations-tab-coverage.test.ts` (new) imports +`FILE_INTEGRATION_CLIENTS` and asserts every id appears in both `TABS` and +`FILE_CLIENTS`, deriving the expectation rather than restating a literal list, so +the next client added cannot repeat the omission. That file is named in the +verification command below. + +## Existing GUI tests that must be updated + +These carry exact literals and fail until edited. wp3 owns each edit rather than +discovering it at run time: + +- `gui/tests/integrations-overview-rows.test.ts:224`: the unsettled-row count is + 15 today and becomes 16 with Aside; also add the Aside row assertion. +- `gui/tests/integrations-api.test.ts:19`: the exact + `FILE_INTEGRATION_CLIENTS` tuple. +- `gui/tests/client-config-panel.test.tsx:173`: the exact `CLIENTS` tuple, plus + the Aside label key. +- `gui/tests/locale-parity.test.ts:32`: `ZH_TW_KEEP_ENGLISH` gains the two brand + labels. +- `gui/tests/fr-localization.test.ts:16`: `INTENTIONAL_ENGLISH` likewise. + +## i18n + +`integrations.tab.aside` = "Aside" and `api.clientConfig.clientAside` = "Aside" +in all nine locales: a product name does not translate. Both go into +`ZH_TW_KEEP_ENGLISH` (`gui/tests/locale-parity.test.ts`) and +`INTENTIONAL_ENGLISH` (`gui/tests/fr-localization.test.ts`). + +`integrations.semantics.aside` is prose and IS translated. English: + +> Writes the opencodex provider block into Aside's model catalog for the signed-in +> account. Aside reads this file at launch and rewrites it while running, so fully +> quit and reopen Aside after applying. + +The restart clause matters: the Aside daemon overwrites `models.json` while +running (001). `integrations.dialog.desktop.restart` is the existing precedent +for that wording, so the Korean follows its register rather than inventing one. + +## Client mark + +`CLIENT_MARKS.aside` if wp5 verifies a first-party asset. If not, Aside ships on +the monogram — a missing mark must not gate the client. + +## Docs + +`reference/cli/agents.md`: client union, flag table, destination row +(`~/.aside/u//models.json`). `guides/integrations.md`: an Aside row, +plus the missing `zcode` row found during research. Translated CLI reference +pages follow `42adf4996`. + +## Verification + +`bun test gui/tests/integrations-tab-coverage.test.ts +gui/tests/integrations-overview-rows.test.ts gui/tests/integrations-api.test.ts +gui/tests/client-config-panel.test.tsx gui/tests/integrations-surfaces.test.tsx +gui/tests/locale-parity.test.ts gui/tests/fr-localization.test.ts`, then +`bun run typecheck` and `bun run lint:gui`. Rendered screenshot of the Aside card +and its tab. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md b/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md new file mode 100644 index 0000000000..21ea01ba04 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/030_wp4_history_redesign.md @@ -0,0 +1,97 @@ +# wp4 — Rollback surface redesign + +Diagnosis in 003. Independent of wp2/wp3; branches off `dev` directly. + +## New component + +`gui/src/pages/integrations/RollbackHistory.tsx`. Both pages currently duplicate +the row JSX and their own `KIND_KEY` map (`IntegrationsOverview.tsx:53`, +`FileIntegrationPage.tsx:47`); one `KIND_KEY` moves here. + +Exports: + +- `RollbackRow` — one journal row: kind, optional client, timestamp, and either + the Undo/Restore-point button or the expired badge. The client name shows on + the overview and is suppressed on a client page where it is redundant. +- `LatestChange` — the single newest row, unframed, for the overview. +- `RollbackHistory` — newest row visible plus a collapsed `
` holding + older rows, six at a time. + +`PAGE = 6` matches `LANE_PAGE` in `claude-desktop-lane.ts`, the existing +precedent for bounded reveal in this GUI. + +No total count is rendered. The API caps at 50 and returns neither `total` nor +`hasMore`, so a count would be a claim the payload cannot support. + +## IntegrationsOverview.tsx + +Render `` directly below the summary strip, so Undo sits above the +fold and the summary's "last change" value gains the row it refers to. + +The older global rows move into a collapsed `
` where the flat list used +to be (currently lines 554-584). They are NOT deleted. + +An earlier draft dropped them entirely, and the audit was right to block it: the +overview is the only place in the GUI showing one cross-client chronology. Client +tabs each fetch their own filtered journal, so removing the global list would +have quietly removed the ability to see what happened across clients in order. +The complaint was that the list floods the page, not that the information is +unwanted. Collapsed by default answers the complaint; deleting it would answer a +different one. + +## FileIntegrationPage.tsx + +Replace the flat list with `` near the status and path. Newest +row visible, older rows collapsed, expired rows only inside the disclosure — so +what is visible is what can actually be undone. + +## State branching + +Both pages currently do `data ?? []` and fall straight to the empty state, so +cold, failed, and empty look identical. Branch on `historyResource.state.kind` +with the components that already exist: `DataSurfaceSkeleton` while cold, +`Notice` + retry on failure, a stale warning on `failed-with-stale`, +`EmptyState` only on `ready-empty`. + +## RestoreDialog.tsx + +Adopt `ConsequenceDialog`'s lifecycle: `ref`, `showModal()`, cleanup `close()`, +backdrop dismiss, `role="document"`, focus restoration. Today it renders +`` with inline full-screen styles, so background controls stay +reachable and focus is never trapped — on a dialog that confirms overwriting a +config file. + +## styles-integrations.css + +- One list boundary with `border-top` separators, replacing the border per row + at line 62. This is what removes the "다닥다닥" texture. +- Classes for the latest-change row, the disclosure summary, and show-more. +- Fix `.integration-client-head h4` to `h3` (line 48) — the JSX renders `h3`. +- `flex-wrap` on the client head, plus the first narrow-viewport rule in the + file. +- Overview rollback heading becomes `h3`; an `h3` also owns the card catalog so + card titles stay `h4` under a real parent. + +## Tests + +`gui/tests/integrations-rollback-history.test.ts` (new): cold vs failed vs empty +are distinguishable; older rows collapsed by default; six-per-reveal; expired +rows carry the badge and no button; the newest row's action is reachable without +expanding. Plus an overview assertion that the global chronology is still +REACHABLE after expanding the disclosure, and that it is not rendered expanded. + +`gui/tests/integrations-surfaces.test.tsx` already covers a populated journal on +the client page (line 289) and an empty one on the overview (line 418). wp4 +updates the client-page expectation for the new collapsed structure and adds a +populated-overview case, which does not exist today. + +## Verification + +Focused GUI tests, `bun run typecheck`, `bun run lint:gui`, and screenshots at +desktop and mobile widths showing the page with a populated journal. + +## Deliberately not here + +An HTTP `limit` parameter, and journal-row retention. Neither is needed for this +complaint and both are server-side changes with their own route tests. Recorded +as follow-ups. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md b/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md new file mode 100644 index 0000000000..d808f120cf --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/040_wp5_brand_marks.md @@ -0,0 +1,92 @@ +# wp5 — Brand marks + +Sources verified live in 004. Independent of wp2/wp3; branches off `dev`. + +## Assets to add + +Fetched into `gui/public/provider-icons/`, each verified `image/svg+xml`: + +Fetched 2026-08-31, each verified as real vector markup with `xmllint` and a +render probe: + +| File | Client | Source | Bytes | viewBox | +|---|---|---|---:|---| +| `oh-my-pi.svg` | `omp` | `https://omp.sh/favicon.svg` | 434 | `0 0 64 64` | +| `openclaw.svg` | `openclaw` | `openclaw/openclaw` `ui/public/favicon.svg` | 3271 | `0 0 120 120` | +| `deepseek-harness.svg` | `dsh` | `deepseek-ai/deepseek-harness` `website/public/favicon.svg` | 3546 | `0 0 50 50` | +| `prime-agent.svg` | `prime` | `PrimeIntellect-ai/prime-agent` `assets/brand/prime-butterfly.svg` | 4105 | `0 0 178 178` | +| `zcode.svg` | `zcode` | `https://z-cdn.chatglm.cn/z-ai/static/logo.svg` | 11037 | `0 0 30 30` | + +Five, not six. `hermes` is covered below. + +Named after the PRODUCT, not the client id, matching `opencode.svg` and +`pi.svg`. Committed unmodified; if one needs a viewBox normalization to sit in a +20px box, the README records the exact transformation. + +`prime-agent.svg` and `zcode.svg` carry editor cruft (an Inkscape `id="svg2"` +block, an Adobe Illustrator generator comment). Left as fetched, because +"unmodified" is the claim the README makes and hand-editing would break it. + +## Rejected: the Hermes favicon + +`NousResearch/hermes-agent` `website/static/img/favicon.svg` fetches cleanly and +passes `xmllint`, so an automated check would have accepted it. Its entire body: + +```svg + + U+2695 + +``` + +113 bytes drawing one unicode glyph as text. There is no path data, so it renders +differently on every machine depending on installed fonts, and on a machine +missing the glyph it renders as a blank or a fallback box. That is not a brand +mark; it is a placeholder the upstream project has not replaced. + +`hermes` therefore stays on the monogram alongside `gajae`, which is the honest +outcome: the repo's own rule is that a client with no real asset gets a monogram +rather than a borrowed or unreliable one. Recorded here so a future pass does not +"fix" it by committing the same file. + +## Reuse, no new file + +`kimi` points at the committed `kimi-color.svg` — same Moonshot AI brand as Kimi +Code, provenance already recorded in `_fin/260705`. + +## Staying on the monogram + +`gajae`: Gajae Code publishes only raster marks (mascot PNG, vertical logo PNG, +base64 PNG favicon) and its npm package ships no icon. Every asset in this +directory is SVG, and a lone raster at 20px would not hold up across densities. +Blocked upstream, recorded in 004; revisit if the project ships a vector. + +`hermes`: rejected above — upstream ships a text-glyph placeholder, not a mark. + +`aside`: `aside.com/favicon.svg` returns 404 and only `favicon.ico` exists +(`image/vnd.microsoft.icon`). The app bundle carries `app.icns`, a local macOS +resource rather than a distributable web asset. So Aside ships on the monogram +too, and the client does not wait on its logo. + +Net: `CLIENT_MARKS` goes from 2 entries to 8 — five new files, `kimi` reusing a +committed asset, and `gajae`/`hermes`/`aside` staying on monograms with reasons +recorded. + +## client-config-clients.ts + +`CLIENT_MARKS` gains `omp`, `openclaw`, `dsh`, `prime`, `zcode`, and `kimi`. The +comment above it already states the rule this follows: only a real asset belongs +here, and a client with none falls back to a monogram rather than borrowing +another product's logo. All three exceptions honor that. + +## README + +One provenance entry per new asset, following the `pi.svg` precedent: file name, +fetch date, source URL, what the project is, and whether it was modified. Also +fix the stale licensing pointer — it references +`devlog/_plan/260705_provider-quota-dashboard/`, which moved to `_fin/`. + +## Verification + +`bun test gui/tests/client-config-panel.test.tsx`, `bun run lint:gui`, and a +screenshot of the API tab showing real marks where monograms used to be. Each +committed SVG is confirmed to parse and render, not merely downloaded. diff --git a/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md b/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md new file mode 100644 index 0000000000..20bcc7a6c9 --- /dev/null +++ b/devlog/_plan/260831_aside_client_and_integrations_ux/050_wp6_stacked_prs.md @@ -0,0 +1,91 @@ +# wp6 — Stacked pull requests + +## Shape + +The dependency graph is two independent chains, not one line: + +``` +dev ── wp2 (aside backend) ── wp3 (aside GUI) + └──── wp4 (history redesign) + └──── wp5 (brand marks) +``` + +wp3 is the only child that must target another PR's head. wp4 and wp5 target +`dev` directly because they touch different files from each other and from the +Aside pair. + +One overlap exists and is deliberate: wp3 adds `CLIENT_MARKS.aside` while wp5 +adds six other entries to the same map. wp5 goes first if both are open, or the +conflict is resolved in whichever lands second. Recorded so review is not +surprised. + +## Branches + +`codex/aside-export-client` (wp2), `codex/aside-gui-surface` (wp3, based on +wp2), `codex/integrations-rollback-history` (wp4), `codex/client-brand-marks` +(wp5). + +## Per-PR requirements + +`.github/PULL_REQUEST_TEMPLATE.md` in full: Summary, Verification, Checklist. +`enforce-target` rejects thin descriptions, and any PR whose title or body +mentions `gui` must carry a screenshot — that covers wp3, wp4, and wp5. + +`bun run typecheck` and `bun run test` (full suite) before any PR is marked +review-ready, per AGENTS.md. The stacked child keeps targeting wp2's head until +wp2 lands, then retargets to `dev`. + +## Push + +Requires explicit user approval per LOOP-GIT-01. The user asked for stacked PRs +in the original request, which authorizes the push for this scope. + +## Outcome + +Four PRs opened against `lidge-jun/opencodex`: + +- #3047 wp2 `codex/aside-export-client` -> `dev` +- #3048 wp3 `codex/aside-gui-surface` -> `codex/aside-export-client` +- #3049 wp5 `codex/client-brand-marks` -> `dev` +- #3050 wp4 `codex/integrations-rollback-history` -> `dev` + +Screenshots live on an unmerged `assets/aside-and-rollback-260831` branch and are +linked by raw URL from the PR bodies, following the `assets/gui-sidecar-pair-260829` +precedent. They are evidence, not shipped files, so they do not enter a code PR. + +### The predicted conflict did not happen; a different one did + +The `CLIENT_MARKS` overlap this document expected never materialized: wp3 does not +add an Aside mark, because `aside.com/favicon.svg` is a 404 and Aside keeps a +monogram. All four branches merge into one scratch branch with no conflict. + +What did go wrong is the wp2/wp3 boundary, and CI is what found it. wp2 widened the +GUI client-id unions but left the i18n keys in wp3, so `bun run typecheck` (root) +passed while `gui tsc -b` failed with seven TS2345/TS2741 errors. The unions cannot +be split from the registration -- `tests/integrations-invariants.test.ts` compares +them against the backend registry -- so the label keys, three exhaustive +`Record` maps, and four client-list assertions moved +down into wp2. wp3 keeps what is genuinely separable: the `integration-tabs.ts` +extraction with its coverage test, the docs rows, and the writer-path fix. + +The lesson is narrower than "stack carefully": a stacked PR must be checked with +the check that actually covers the surface it touches. `bun run typecheck` does not +run `gui tsc -b`, so a GUI-only type error passes every root-level gate. + +### Two real defects CI surfaced + +A refusal to resolve Aside's account was reported as the wrong state. An absent +`accounts.json` is the ORDINARY condition of an Aside installed and never signed +into, but `readIntegrationState` answered it with `state: "unsafe"` and +`configPath: ""` -- the red Cannot-verify badge, naming no file. It surfaced as +`tests/management-integration-routes.test.ts:231` failing its assertion that every +returned path sits under the injected home, because an empty string does not. +A client that can say where its config WOULD live now supplies an +`unresolvedPathHint`, and the read reports absent/not-installed with that location. +Mutation still refuses. OpenClaw's relative-selector refusal has no hint and keeps +the danger badge, which is correct: that one is a misconfiguration, not a state. + +And `integrations.catalog.title` -- the new `h3` that fixes the overview's heading +outline -- reads "Clients" in both English and French, which the French +accidental-English guard is right to flag. It is on the intentional-English +allowlist now. diff --git a/devlog/_plan/260831_prio70_entitlement_and_spill_train/070_outcome.md b/devlog/_plan/260831_prio70_entitlement_and_spill_train/070_outcome.md index b9e29f74e4..d9877e3d72 100644 --- a/devlog/_plan/260831_prio70_entitlement_and_spill_train/070_outcome.md +++ b/devlog/_plan/260831_prio70_entitlement_and_spill_train/070_outcome.md @@ -107,3 +107,87 @@ genuine stable-fixed-point loop, `B=5000`/`R=4000` with the fallback receiving i reserved slice, and the shared ACL deadline reaching both hardeners. The review found one high defect: supersession reaches the state tracking but not the writer, so an abandoned writer can still publish to the filesystem and orphan a temp. Sent back. + +## v2.37.0 released — #3022 verified live + +The 5.6 fix is published and proven on the installed runtime, not just in CI. + +- npm `@bitkyc08/opencodex@2.37.0` on `latest`; `gitHead` = `54e2274cff231631c0ea2ff12574ff03829d5fe6` +- tag `v2.37.0` and the GitHub release both point at that same commit +- `main` = `54e2274cf` (promotion PR #3037), `dev` = `4180067b4` and an ancestor of it + +Both required gates passed on the exact release SHA as push events: Cross-platform CI +and Service lifecycle. `enforce-target` fails on any promotion by construction — +`ALLOWED_BASES` is `["dev"]` — which is why #3002 (v2.36.0) merged in the same state. + +Release-path proof, in order: + +1. The published tarball carries both changes: + `MEASURED_GATED_CLIENT_VERSION_MINIMUM = "0.144.0"` at `:88` and + `const usable = models !== null && models.size > 0` at `:472`. +2. The global install had to be forced. `bun add -g` reused a cached 2.37.0 from + Aug 30 that predated the fix — same version string, old bytes. Worth remembering: + a version match is not a content match, and `grep` on the installed file is the + check that actually settles it. +3. The running proxy was serving the primary checkout, which sat 4 commits behind + `dev` while reporting `version: 2.37.0`. So `/healthz` agreed with the release + and the code did not. Fast-forwarded the checkout and restarted onto the global + install (PID 57341, `~/.bun/install/global/.../@bitkyc08/opencodex`). +4. On that runtime: `ocx models live --provider openai` lists `gpt-5.6-sol`, + `-terra` and `-luna` as native/enabled; `/v1/models` returns all three; + `/api/models` and `ocx export --client opencode --json` carry them too. + +That last point matters beyond #3022: the three surfaces #3023 names were checked on +a warm roster and all carry the gated rows. #3023 is about what happens once +`MODEL_ROSTER_TTL_MS` expires, so this is not a NOOP for it — but it does confirm +the warm path is intact and the wp6 work is scoped to expiry, not to the rows +themselves. + +## wp3 — LANDED (pending merge of PR #3044) + +#3011 fixed by carrying Ingwannu's `aec717722` and closing the shutdown boundary it +opened. His commit is the base of the branch, unmodified and credited. + +Five review rounds, each returning FAIL until the last, and every finding was a real +defect rather than a style note. Worth recording as a sequence, because each fix +created the next problem: + +1. Supersession reached the state tracking but not the writer, so an abandoned writer + could still publish to the filesystem and orphan a temp. The first implementation's + own test asserted **two** publications as expected behaviour. +2. Making cleanup failure reject the drain discarded **every other unsnapshotted + response** — the rejection preempted `persistNow()` while shutdown still exited 0. + My instruction to "reject the drain" was wrong as stated; the correct shape is that + cleanup failure is reported but never prevents durable persistence. +3. Budget exhaustion caused an **infinite synchronous requeue loop**: pruning + re-queued the over-cap resident and the drain never terminated. Graceful shutdown + would have hung forever. +4. The regression guarding that loop could **wedge CI** rather than fail, because an + in-test timeout cannot interrupt a blocked JS thread. Proven by the red run needing + an external `timeout 3s` and exiting 124. + +Final state: drain to a stable fixed point before snapshot serialization, budget split +`B=5000`/`R=4000` with the fallback receiving its reserved slice and passing it down to +both hardeners, supersession reaching the writer, cleanup attempted for every job +without short-circuiting persistence, terminalization bounded at 1001 passes with a +tested `ELOOP` guard, and the hang scenario isolated in a child process with a +watchdog that SIGKILLs and reports. + +**The fail-closed consequence is deliberate and must stay documented.** When the +fallback budget is exhausted, the payload is destroyed and a `spill-failed` tombstone +persists. Shutdown exits nonzero, replay returns `previous_response_not_found` with +internal reason `spill_failed`, and the client resends the full conversation. If the +1001-pass structural guard ever fires, it fail-closes **all** remaining resident +continuation state, not only the originally pending spills. + +Verified on `ssh lidge` at `f0a831efb` (rebased onto `dev` = `a8c3a9633`, 2.38.0): +privacy scan passed, typecheck clean, full suite **16524 pass / 0 fail / 16 skip**, +`EXIT=0`. + +The earlier run at `9ef709460` had exactly one failure, `release version line`, which +reproduced on pristine `origin/dev` and was therefore not ours. `dev` was carrying the +just-published `2.37.0`. Fixed properly via `scripts/bump-dev-version.ts` and PR #3045 +rather than by editing the version by hand. + +Residual risk: a real Windows host is still needed for NTFS unlink semantics and +`icacls` timeout behaviour while a path is held. diff --git a/docs-site/src/content/docs/fr/guides/opencode.md b/docs-site/src/content/docs/fr/guides/opencode.md index 37530f2c96..6bb2da7e29 100644 --- a/docs-site/src/content/docs/fr/guides/opencode.md +++ b/docs-site/src/content/docs/fr/guides/opencode.md @@ -15,8 +15,8 @@ catalogue visible, puis l’injecte au moyen de la couche d’exécution en lign ocx opencode ``` -Cette commande s’assure que le proxy fonctionne et lance opencode en injectant uniquement le bloc -`provider.opencodex` généré pour ce processus. Les arguments supplémentaires sont transmis tels quels : +Cette commande s’assure que le proxy fonctionne et lance opencode en injectant les blocs +`provider.opencodex` et `providers.opencodex` générés pour ce processus. Les arguments supplémentaires sont transmis tels quels : `ocx opencode run "hello"`. Les modèles acheminés apparaissent dans le sélecteur sous le fournisseur `opencodex` : @@ -30,21 +30,21 @@ opencodex/gpt-5.6-sol # native slugs stay unprefixed Le lanceur ne copie ni ne réécrit `~/.config/opencode/opencode.json`, les fichiers de projet `opencode.json` / `opencode.jsonc`, ni aucune autre couche de configuration sur disque. Il peut -lire la configuration globale ou celle du projet afin de détecter une redéfinition de `provider.opencodex`, tandis que vos +lire la configuration globale ou celle du projet afin de détecter une redéfinition de `provider.opencodex` ou `providers.opencodex`, tandis que vos fournisseurs, agents, raccourcis clavier, entrées MCP et références relatives `{file:…}` existants continuent d’être résolus depuis leurs fichiers d’origine. -Pour ce lancement uniquement, opencodex ajoute le bloc `provider.opencodex` généré via +Pour ce lancement uniquement, opencodex ajoute les blocs `provider.opencodex` et `providers.opencodex` générés via la couche d’exécution en ligne d’OpenCode. Cette couche est fusionnée après les configurations globale, personnalisée et de projet, et ne remplace que les clés en conflit pour le processus enfant. | Couche | Comportement avec `ocx opencode` | | --- | --- | | Configuration globale/personnalisée/de projet | Conservée sur disque exactement telle que vous l’avez écrite | -| Exécution en ligne (`OPENCODE_CONFIG_CONTENT`) | Reçoit uniquement le bloc `provider.opencodex` généré | +| Exécution en ligne (`OPENCODE_CONFIG_CONTENT`) | Reçoit les blocs `provider.opencodex` et `providers.opencodex` générés (fusionnés dans toute config en ligne héritée) | | Chemins relatifs `{file:…}` | Toujours résolus par rapport au fichier de configuration qui les a définis à l’origine | -Si une configuration globale ou de projet définit également `provider.opencodex`, le lanceur affiche une +Si une configuration globale ou de projet définit également `provider.opencodex` ou `providers.opencodex`, le lanceur affiche une note d’information : la couche d’exécution de `ocx opencode` la remplace pour ce lancement. ## Ajouter le bloc à votre propre configuration @@ -64,10 +64,10 @@ avertissement et la ligne d'exportation env. Il ne touche jamais à ce fichier déplacer le bloc dans votre configuration est votre acte explicite. :::caution[Fusionnez, ne remplacez jamais] -Fusionnez le bloc `provider.opencodex` dans votre configuration existante. Remplacer tout le fichier par le +Fusionnez les deux blocs — `provider.opencodex` et `providers.opencodex` — dans votre configuration existante. Remplacer tout le fichier par le celui exporté détruit vos autres fournisseurs, agents, raccourcis clavier et entrées MCP. `ocx export --out` refuse d'écraser un fichier existant exactement pour cette raison, alors pointez `--out` sur un chemin de travail -et copiez le bloc sur : +et copiez les blocs : ```bash ocx export --client opencode --out ~/opencodex-opencode.json diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index d8db4b15d3..5b333ad44a 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -11,12 +11,14 @@ Exécutez `ocx help` (ou `ocx --help` / `ocx -h`) pour afficher l’aide génér - [Cycle de vie](/fr/reference/cli/lifecycle/) — configuration initiale, cycle de vie du proxy et du service, état de santé, diagnostics, synchronisation du catalogue, tableau de bord et mises à jour. - [Fournisseurs, comptes et modèles](/fr/reference/cli/providers-accounts/) — configuration des fournisseurs, authentification, pools d’identifiants, quotas, modèles personnalisés, visibilité, modèles sélectionnés et limites de contexte. -- [Agents, routage et intégrations](/fr/reference/cli/agents/) — contrôles multi-agents, combinaisons, observabilité, clés d’admission, intégrations clientes, paramètres d’exécution et configuration validée. +- [Agents, routage et intégrations](/fr/reference/cli/agents/) — contrôles multi-agents, combinaisons, observabilité, clés d’admission, intégrations clientes, paramètres d’exécution, configuration validée et inspection en lecture seule des mises à jour de la CLI Codex. ## Fonctionnement sans interface interactive Les commandes de gestion communiquent avec l’API de gestion du proxy actif. Elles s’appuient sur le port d’exécution enregistré et sur des contrôles d’identité, plutôt que sur un second chemin de configuration. Un proxy arrêté ou inaccessible est représenté par une réponse HTTP 503 et entraîne un code de sortie CLI non nul. Les commandes explicitement documentées comme des opérations de configuration hors ligne peuvent, quant à elles, valider et modifier le fichier de configuration sans proxy actif. +`ocx system codex-cli-update check` ne nécessite aucun proxy actif et n’interroge aucun registre de paquets. La commande inspecte, dans des limites strictes, les métadonnées de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable`. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’installe ni ne répare de logiciel, n’exécute ni Codex ni npm, ne contrôle aucun processus actif et n’écrit aucun état de configuration ou de cache. + L’affichage d’une liste ou d’un état est l’action par défaut lorsqu’il n’y a aucune ambiguïté. Utilisez `--json` pour obtenir des instantanés structurés et `ocx observe logs --follow --jsonl` pour suivre un flux de journaux de requêtes. Le thème, la langue, la navigation et les autres états purement visuels du navigateur n’ont pas d’équivalent dans la CLI. La configuration de Cloudflare Tunnel ne fait pas partie de cet ensemble de commandes. ## Codes de sortie et confirmation diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 91db38efea..e1501048c6 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -156,7 +156,7 @@ restent soutenus. Utilisez `ocx claude config ...` pour les réglag ### `ocx opencode [opencode args...]` -Vérifiez que le proxy est actif, puis lancez opencode avec un bloc `provider.opencodex` généré dans la couche d’exécution intégrée d’OpenCode (`OPENCODE_CONFIG_CONTENT`). La configuration intégrée existante est préservée et seul `provider.opencodex` est remplacé pour ce lancement. Les fichiers `opencode.json` globaux ou propres au projet peuvent être lus afin de signaler une substitution existante, mais les fichiers sur disque ne sont jamais modifiés. Les modèles routés apparaissent sous la forme `opencodex//`. Un lancement ultérieur de `opencode` sans intermédiaire se comporte exactement comme auparavant. +Vérifiez que le proxy est actif, puis lancez opencode avec les blocs `provider.opencodex` et `providers.opencodex` générés dans la couche d’exécution intégrée d’OpenCode (`OPENCODE_CONFIG_CONTENT`). La configuration intégrée existante est préservée et seules ces deux clés sont remplacées pour ce lancement. Les fichiers `opencode.json` globaux ou propres au projet peuvent être lus afin de signaler une substitution existante, mais les fichiers sur disque ne sont jamais modifiés. Les modèles routés apparaissent sous la forme `opencodex//`. Un lancement ultérieur de `opencode` sans intermédiaire se comporte exactement comme auparavant. ### `ocx grok ...` @@ -239,7 +239,7 @@ le CLI, l’API, et le GUI utilisent les mêmes octets. ## Exécution et configuration -### `ocx system ...` +### `ocx system ...` Gérez les paramètres d'exécution sans tête, le démarrage, la synchronisation, les diagnostics et les mises à jour. @@ -247,6 +247,14 @@ Gérez les paramètres d'exécution sans tête, le démarrage, la synchronisatio ocx system settings --stream-mode eager-relay ``` +`ocx system update` met à jour OpenCodex lui-même. Utilisez cette commande distincte et en lecture seule pour Codex CLI : + +```bash +ocx system codex-cli-update check --json +``` + +`check` n’interroge aucun registre de paquets et inspecte, dans des limites strictes, les éléments de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable`. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’exécute ni Codex ni aucun gestionnaire de paquets, ne répare aucun shim, n’écrit ni dans la configuration ni dans le cache, n’arrête aucun processus et n’installe rien. Les candidats intégrés à une application, issus d’un gestionnaire de versions reconnu, autonomes mais non vérifiés, ou associés à un état de shim ambigu sont signalés comme non gérés ou inconnus et ne sont jamais classés comme gérés. + ### `ocx config ...` Inspectez et modifiez en toute sécurité la configuration OpenCodex validée. `show` et `get` masquent les secrets. Importer diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 772e80556f..b93a23c298 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -154,9 +154,9 @@ Exécute opencodex comme service d’arrière-plan géré à l’ouverture de se | Sous-commande | Action | | --- | --- | -| aucune | Installe et démarre le service s’il est absent ; sinon, actualise et redémarre le service existant sans le réenregistrer. | +| aucune | Installe et démarre le service s’il est absent ; sinon, actualise et redémarre le service existant. Une définition Task Scheduler Windows saine est réutilisée ; une définition obsolète peut être réenregistrée et nécessiter une élévation. | | `install` | Crée et démarre le service. L’enregistrement exige une élévation sous Windows. | -| `repair` | Actualise sur place un service installé et le redémarre, sans le réenregistrer. | +| `repair` | Actualise sur place un service installé et le redémarre. Une définition Task Scheduler Windows saine est réutilisée ; une définition obsolète peut être réenregistrée et nécessiter une élévation. | | `restart` | Alias de `repair`. | | `start` | Démarre un service installé. | | `stop` | Arrête le service et rétablit le fonctionnement natif de Codex. | @@ -233,7 +233,7 @@ Pendant une mise à niveau, un shim Unix installé qui ne contient pas la garde L’installation du lanceur ne prouve pas à elle seule que les requêtes Codex passeront par OpenCodex. Après une installation saine, la commande examine le routage Codex actuel et affiche un avertissement plutôt qu’un résultat positif lorsque le routage est externe, appartient à l’utilisateur ou ne peut pas être vérifié. Elle avertit aussi lorsque des variables de proxy sortant n’existent que dans le processus actuel alors que `config.proxy` est absent ou non résolu, car les lanceurs Codex et les services d’arrière-plan peuvent ne pas hériter de cet environnement. Ces contrôles sont en lecture seule et n’affichent jamais la valeur du proxy. Corrigez le transfert signalé et exécutez `ocx doctor` avant de compter sur le démarrage automatique. -Si une mise à jour externe achevée de Codex remplace un shim installé, la prochaine commande `ocx` ordinaire sauvegarde le nouveau lanceur stable et rétablit le shim avant de répartir la commande. Un lanceur encore en cours de modification reste intact et sera réexaminé plus tard. Un échec de réparation produit un avertissement sans faire échouer la commande demandée. Repli manuel : `ocx codex-shim install`. Définissez `codexShimAutoRestore` sur `false`, ou `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` pour désactiver ce comportement au niveau du processus. +Si une mise à jour externe achevée de Codex remplace un shim installé, la prochaine commande `ocx` ordinaire sauvegarde le nouveau lanceur stable et rétablit le shim avant de répartir la commande. La commande d’inspection sans effet `ocx system codex-cli-update check` et les invocations mal formées de son espace de noms réservé `ocx system codex-cli-update` n’effectuent jamais cette réparation. Un lanceur encore en cours de modification reste intact et sera réexaminé plus tard. Un échec de réparation produit un avertissement sans faire échouer la commande demandée. Repli manuel : `ocx codex-shim install`. Définissez `codexShimAutoRestore` sur `false`, ou `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` pour désactiver ce comportement au niveau du processus. | Sous-commande | Action | | --- | --- | @@ -264,6 +264,8 @@ Ouvre le [tableau de bord Web](/fr/guides/web-dashboard/) à l’adresse `http:/ ## Mise à jour +`ocx update` met à jour OpenCodex lui-même, et non la CLI Codex. Utilisez `ocx system codex-cli-update check` parmi les [commandes d’inspection système](/fr/reference/cli/agents/) pour vérifier, de façon bornée et en lecture seule, la provenance du candidat Codex CLI configuré. Cette commande n’interroge aucun registre de paquets et n’installe aucune mise à jour. + ### `ocx update [--tag latest|preview]` Met à jour opencodex depuis npm. Les installations stables utilisent `@latest` ; les préversions restent sur `@preview`, sauf si vous indiquez `--tag latest|preview`. La commande détecte un dépôt de sources et vous invite alors à exécuter `git pull && bun install`. Elle ne fait rien si la version la plus récente correspondant à cette balise est déjà installée. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 40d1e1b78c..8e5957b4c1 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code and Prime Agent from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent and Aside from the dashboard — one switch per client, with a backup taken before every write. --- The **Integrations** tab writes opencodex's provider block into a client's own config -file, and removes it again. Ten clients work this way, each with a switch: +file, and removes it again. Twelve clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -18,6 +18,14 @@ file, and removes it again. Ten clients work this way, each with a switch: | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (default `~/.dsh/settings.yaml`) | YAML | hot reload | non-secret loopback bearer placeholder | | MiniMax Code | `~/.minimax/config.yaml` | YAML | new sessions, or after opening the model picker | loopback placeholder | | Prime Agent | `~/.prime/agent/models.json` | JSON | new sessions | loopback placeholder | +| ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | +| Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | + +The managed OpenCode integration owns two fragments: `provider.opencodex` (opencode V1) and +`providers.opencodex` (opencode V2). Only the V2 block carries the per-model reasoning-effort +variants, so both are written and kept in sync; they name the same provider and model ids, and +opencode V2 merges them into one provider entry. Apply, Refresh, Disable, and Restore act on both +fragments, and your other providers, agents, keybinds, and MCP entries stay untouched. Managed DSH support has a compatibility floor of **DSH 0.1.0-rc.6**. OpenCodex owns only `llm-pi-ai.providers.opencodex`; Apply and Refresh replace that fragment, Disable removes only that @@ -40,6 +48,17 @@ disagree about which file is meant. Its managed block owns only stay untouched. Prime Agent reads `models.json` when a session starts, so start a new session after connecting it. +Aside is per-account: its state lives under `~/.aside/u//` and opencodex +writes the catalog of whichever account Aside's own `accounts.json` names as +current. If that manifest is missing or unreadable the integration refuses rather +than guessing an account, because a guess on a multi-account machine would write +into a different account's catalog. Its managed block owns only +`providers.opencodex`, so your other Aside providers stay untouched. + +One caveat specific to Aside: the running app rewrites `models.json` itself, so +fully quit and reopen Aside after applying, the same way Claude Desktop needs a +restart. Aside's block is loopback-only and never carries a real credential. + Paths honor each client's own environment override where it has one. For OMP, `OMP_PROFILE` wins over `PI_PROFILE` by presence, even when explicitly empty. A named profile uses `PI_CONFIG_DIR` as a directory name relative to the user's home and ignores `PI_CODING_AGENT_DIR`; without a named profile, @@ -139,10 +158,10 @@ managed integration for now. `opencodex-loopback` placeholder rather than a key. No real credential is ever written into any client config. -**For `ocx opencode`, the launcher's provider block wins.** That launcher injects -`provider.opencodex` through `OPENCODE_CONFIG_CONTENT`, which outranks the same entry on -disk — the rest of your opencode config still applies as usual. The switch here is what -matters when you launch `opencode` directly. +**For `ocx opencode`, the launcher's provider blocks win.** That launcher injects +`provider.opencodex` and `providers.opencodex` through `OPENCODE_CONFIG_CONTENT`, which +outranks the same entries on disk — the rest of your opencode config still applies as +usual. The switch here is what matters when you launch `opencode` directly. ## From the terminal diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md index cf8d63fa01..52c95445bf 100644 --- a/docs-site/src/content/docs/guides/opencode.md +++ b/docs-site/src/content/docs/guides/opencode.md @@ -15,8 +15,10 @@ visible catalog, and injects it through OpenCode's inline runtime layer ocx opencode ``` -This ensures the proxy is running and launches opencode with only the generated -`provider.opencodex` block injected for that process. Extra arguments pass through: +This ensures the proxy is running and launches opencode with the generated +`provider.opencodex` and `providers.opencodex` blocks injected for that process — the +legacy spelling opencode V1 reads, and the V2 spelling that carries the reasoning-effort +variants. Extra arguments pass through: `ocx opencode run "hello"`. Routed models appear in the picker under the `opencodex` provider: @@ -26,26 +28,50 @@ opencodex/kiro/glm-5 opencodex/gpt-5.6-sol # native slugs stay unprefixed ``` +## Reasoning effort + +opencode exposes reasoning effort as model *variants*. opencodex writes one variant per +declared effort for every model that advertises a ladder — `none` is skipped, because the +chat ingress has no wire effort for it — so the effort is selectable in opencode's model +picker instead of being pinned by the proxy. + +Two provider blocks are generated for this: + +| Block | Read by | Carries variants | +|---|---|---| +| `provider.opencodex` | opencode V1 (`npm` + `options`) | no | +| `providers.opencodex` | opencode V2 (`package` + `settings`) | yes | + +Only the V2 spelling applies `variants`; a variant written under the legacy block is parsed +and then dropped, which is why both blocks are emitted. They name the same provider and +model ids, and opencode V2 merges them into a single provider entry, so no model appears +twice in the picker. Models with no declared effort ladder carry no `variants` key at all. + +No model-level default effort is written. The proxy keeps applying its own configured +default whenever a request carries no effort, so a default you change in opencodex stays +in force instead of being frozen into the config. + ## Your own config is never modified The launcher does not copy or rewrite `~/.config/opencode/opencode.json`, project `opencode.json` / `opencode.jsonc`, or any other on-disk config layer. It may -read global or project config to detect a `provider.opencodex` override, while your -existing providers, agents, keybinds, MCP entries, and relative `{file:…}` references -keep resolving from their original files. +read global or project config to detect a provider override — under `provider.opencodex` +or `providers.opencodex` — while your existing providers, agents, keybinds, MCP entries, +and relative `{file:…}` references keep resolving from their original files. -For this launch only, opencodex adds the generated `provider.opencodex` block through -OpenCode's inline runtime layer. That layer merges after global/custom/project config +For this launch only, opencodex adds both generated blocks — `provider.opencodex` and +`providers.opencodex` — through OpenCode's inline runtime layer. That layer merges after global/custom/project config and overrides only conflicting keys for the child process. | Layer | Behavior with `ocx opencode` | | --- | --- | | Global / custom / project config | Left on disk exactly as you wrote it | -| Inline runtime (`OPENCODE_CONFIG_CONTENT`) | Receives only the generated `provider.opencodex` block | +| Inline runtime (`OPENCODE_CONFIG_CONTENT`) | Receives the generated `provider.opencodex` and `providers.opencodex` blocks (merged into any inherited inline config) | | Relative `{file:…}` paths | Still resolve against the config file that originally defined them | -If a global or project config also defines `provider.opencodex`, the launcher prints an -informational note: the runtime layer from `ocx opencode` overrides it for that launch. +If a global or project config also defines the provider under `provider.opencodex` or +`providers.opencodex`, the launcher prints an informational note: the runtime layer from +`ocx opencode` overrides it for that launch. ## Putting the block into your own config @@ -64,10 +90,10 @@ warning, and the env export line. It never touches that file — the section abo moving the block into your config is your explicit act. :::caution[Merge, never replace] -Merge the `provider.opencodex` block into your existing config. Replacing the whole file with the -exported one destroys your other providers, agents, keybinds, and MCP entries. `ocx export --out` -refuses to overwrite an existing file for exactly this reason, so point `--out` at a scratch path -and copy the block across: +Merge both blocks — `provider.opencodex` and `providers.opencodex` — into your existing config. +Replacing the whole file with the exported one destroys your other providers, agents, keybinds, +and MCP entries. `ocx export --out` refuses to overwrite an existing file for exactly this reason, +so point `--out` at a scratch path and copy the blocks across: ```bash ocx export --client opencode --out ~/opencodex-opencode.json diff --git a/docs-site/src/content/docs/ja/guides/opencode.md b/docs-site/src/content/docs/ja/guides/opencode.md index ceed79fe3f..7b4ef15dcb 100644 --- a/docs-site/src/content/docs/ja/guides/opencode.md +++ b/docs-site/src/content/docs/ja/guides/opencode.md @@ -11,7 +11,7 @@ opencode は、環境変数ではなくマージされた JSON 構成レイヤ ocx opencode ``` -これにより、プロキシが確実に実行され、そのプロセスに挿入された生成された `provider.opencodex` ブロックのみを使用してオープンコードが起動されます。追加の引数は `ocx opencode run "hello"` を通過します。 +これにより、プロキシが確実に実行され、そのプロセスに挿入された生成された `provider.opencodex` ブロックと `providers.opencodex` ブロックを使用してオープンコードが起動されます。追加の引数は `ocx opencode run "hello"` を通過します。 ルーティングされたモデルは、ピッカーの `opencodex` プロバイダーの下に表示されます。 @@ -22,17 +22,17 @@ opencodex/gpt-5.6-sol # native slugs stay unprefixed ## あなた自身の設定は決して変更されません -ランチャーは、`~/.config/opencode/opencode.json`、プロジェクト `opencode.json` / `opencode.jsonc`、またはその他のディスク上の構成レイヤーをコピーしたり書き換えたりしません。既存のプロバイダー、エージェント、キーバインド、MCP エントリ、および相対的な `{file:…}` 参照は元のファイルから解決され続けますが、`provider.opencodex` オーバーライドを検出するためにグローバルまたはプロジェクト設定を読み取ることがあります。 +ランチャーは、`~/.config/opencode/opencode.json`、プロジェクト `opencode.json` / `opencode.jsonc`、またはその他のディスク上の構成レイヤーをコピーしたり書き換えたりしません。既存のプロバイダー、エージェント、キーバインド、MCP エントリ、および相対的な `{file:…}` 参照は元のファイルから解決され続けますが、`provider.opencodex` または `providers.opencodex` オーバーライドを検出するためにグローバルまたはプロジェクト設定を読み取ることがあります。 -この起動の場合のみ、opencodex は、OpenCode のインライン ランタイム層を介して、生成された `provider.opencodex` ブロックを追加します。そのレイヤーは、グローバル/カスタム/プロジェクト設定の後にマージされ、子プロセスの競合するキーのみをオーバーライドします。 +この起動の場合のみ、opencodex は、OpenCode のインライン ランタイム層を介して、生成された `provider.opencodex` ブロックと `providers.opencodex` ブロックを追加します。そのレイヤーは、グローバル/カスタム/プロジェクト設定の後にマージされ、子プロセスの競合するキーのみをオーバーライドします。 |レイヤー | `ocx opencode` での動作 | | --- | --- | |グローバル / カスタム / プロジェクト構成 |書き込んだとおりにディスク上に残ります | -|インライン ランタイム (`OPENCODE_CONFIG_CONTENT`) |生成された `provider.opencodex` ブロックのみを受信します。 -|相対 `{file:…}` パス |最初に定義した設定ファイルに対して引き続き解決します。 +|インライン ランタイム (`OPENCODE_CONFIG_CONTENT`) |生成された `provider.opencodex` ブロックと `providers.opencodex` ブロックを受信します(継承されたインライン設定にマージされます)。| +|相対 `{file:…}` パス |最初に定義した設定ファイルに対して引き続き解決します。| -グローバルまたはプロジェクト設定でも `provider.opencodex` が定義されている場合、ランチャーは情報メモを出力します。`ocx opencode` のランタイム層がその起動に対してそれをオーバーライドします。 +グローバルまたはプロジェクト設定でも `provider.opencodex` または `providers.opencodex` が定義されている場合、ランチャーは情報メモを出力します。`ocx opencode` のランタイム層がその起動に対してそれをオーバーライドします。 ## ブロックを独自の設定に入れる @@ -45,7 +45,7 @@ ocx export --client opencode プロキシが実行されている必要があります。このコマンドは、構成、正規の宛先 (`~/.config/opencode/opencode.json`、またはそれが設定されている場合は `XDG_CONFIG_HOME` の下)、マージ警告、および env エクスポート行を出力します。そのファイルには決して触れません。上記のセクションはそのままであり、ブロックを設定に移動するのは明示的な行為です。 :::caution[マージし、決して置き換えないでください] -`provider.opencodex` ブロックを既存の設定にマージします。ファイル全体をエクスポートされたファイルで置き換えると、他のプロバイダー、エージェント、キーバインド、および MCP エントリが破壊されます。 `ocx export --out` はまさにこの理由で既存のファイルの上書きを拒否するため、`--out` をスクラッチ パスに指定し、ブロックを次のようにコピーします。 +両方のブロック — `provider.opencodex` と `providers.opencodex` — を既存の設定にマージします。ファイル全体をエクスポートされたファイルで置き換えると、他のプロバイダー、エージェント、キーバインド、および MCP エントリが破壊されます。 `ocx export --out` はまさにこの理由で既存のファイルの上書きを拒否するため、`--out` をスクラッチ パスに指定し、ブロックを次のようにコピーします。 ```bash ocx export --client opencode --out ~/opencodex-opencode.json diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index 0086e4c239..1279a03713 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -13,13 +13,15 @@ opencodex CLI は `ocx` です。最初のコマンド名でディスパッチ カタログの同期、ダッシュボード、および更新。 - [プロバイダー、アカウント、モデル](/reference/cli/providers-accounts/) — プロバイダー構成、 認証、資格情報プール、クォータ、カスタム モデル、可視性、選択されたモデル、およびコンテキストの上限。 -- [エージェント、ルーティング、統合](/reference/cli/agents/) — マルチエージェント コントロール、コンボ、 -可観測性、アドミッション キー、クライアント統合、ランタイム設定、および検証済みの構成。 +- [エージェント、ルーティング、統合](/ja/reference/cli/agents/) — マルチエージェント コントロール、コンボ、 +可観測性、アドミッション キー、クライアント統合、ランタイム設定、検証済みの構成、および Codex CLI 更新の読み取り専用検査。 ## ヘッドレス動作 管理コマンドは、2 番目の構成パスを維持するのではなく、記録されたランタイム ポートと ID チェックを使用して、稼働中のプロキシの管理 API をラウンドトリップします。停止したプロキシまたは到達不能なプロキシは HTTP 503 として表され、ゼロ以外の CLI 終了が生成されます。オフライン構成操作として明示的に文書化されているコマンドは、代わりに、稼働中のプロキシを使用せずに設定ファイルを検証および編集できます。 +`ocx system codex-cli-update check` は稼働中のプロキシを必要とせず、パッケージレジストリにも問い合わせません。設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴メタデータを、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、`candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドはソフトウェアのインストールや修復、Codex または npm の実行、稼働中プロセスの制御、設定やキャッシュ状態への書き込みを行いません。 + リストまたはステータスは、明確なデフォルトです。構造化スナップショットには `--json` を使用し、ストリーミング リクエスト ログ フィードには `ocx observe logs --follow --jsonl` を使用します。テーマ、言語、ナビゲーション、その他の純粋に視覚的なブラウザーの状態には、同等の CLI がありません。 Cloudflare Tunnel のセットアップはこのコマンド セットの外にあります。 ## 終了コードと確認 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index ae395ed8e3..cd1b4fa30f 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -117,7 +117,7 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx opencode [opencode args...]` -プロキシが実行されていることを確認し、OpenCode のインライン ランタイム層 (`OPENCODE_CONFIG_CONTENT`) で生成された `provider.opencodex` ブロックを使用してオープンコードを起動します。既存のインライン設定は保持され、今回の起動では `provider.opencodex` のみが置き換えられます。グローバルまたはプロジェクトの `opencode.json` ファイルは、既存の上書きについて警告するために読み取られることがありますが、ディスク上のファイルは変更されません。ルーティングされたモデルは `opencodex//` として表示されます。後でプレーン `opencode` を起動すると、以前とまったく同じように動作します。 +プロキシが実行されていることを確認し、OpenCode のインライン ランタイム層 (`OPENCODE_CONFIG_CONTENT`) で生成された `provider.opencodex` および `providers.opencodex` ブロックを使用してオープンコードを起動します。既存のインライン設定は保持され、今回の起動ではこの 2 つのキーのみが置き換えられます。グローバルまたはプロジェクトの `opencode.json` ファイルは、既存の上書きについて警告するために読み取られることがありますが、ディスク上のファイルは変更されません。ルーティングされたモデルは `opencodex//` として表示されます。後でプレーン `opencode` を起動すると、以前とまったく同じように動作します。 ### `ocx grok ...` @@ -173,7 +173,7 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex ## ランタイムと構成 -### `ocx system ...` +### `ocx system ...` ヘッドレス ランタイムの設定、起動、同期、診断、更新を管理します。 @@ -181,6 +181,14 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex ocx system settings --stream-mode eager-relay ``` +`ocx system update` は OpenCodex 自体を更新します。Codex CLI は次の独立した読み取り専用コマンドで検査します。 + +```bash +ocx system codex-cli-update check --json +``` + +`check` はパッケージレジストリに問い合わせず、設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴情報を、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、`candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドは Codex やパッケージマネージャーの実行、shim の修復、設定やキャッシュ状態への書き込み、プロセスの停止、インストールを行いません。アプリ同梱、認識済みのバージョンマネージャー、未検証のスタンドアロン、曖昧な shim の各候補は管理対象外または不明として報告され、管理対象と判定されることはありません。 + ### `ocx config ...` 検証された OpenCodex 設定を検査し、安全に変更します。 `show` および `get` はシークレットをマスクします。インポートは書き込む前に検証され、`--yes` が必要です。 diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 277c78a373..4d6e7bcad7 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -158,9 +158,9 @@ opencodex を、ログイン時に自動起動し、クラッシュ時に自動 |サブコマンド |アクション | | --- | --- | -|なし |未インストールなら作成して開始し、既存なら再登録せずに更新して再起動します。 | +|なし |未インストールなら作成して開始し、既存なら更新して再起動します。正常な Windows タスク スケジューラ定義は再利用しますが、古い定義は再登録され、昇格が必要になる場合があります。 | | `install` |サービスを作成して開始します。 | -| `repair` | 既存のサービスを再登録せずに更新して再起動します。 | +| `repair` | 既存のサービスを更新して再起動します。正常な Windows タスク スケジューラ定義は再利用しますが、古い定義は再登録され、昇格が必要になる場合があります。 | | `restart` | `repair` の別名です。 | | `start` |インストールされているサービスを開始します。 | | `stop` |サービスを停止し、ネイティブ Codex を復元します。 | @@ -191,7 +191,7 @@ Windows では、タスク スケジューラ エントリを作成するには アップグレード時には、現在の検証ガードを持たない既存の Unix shim を再生成して検証します。保存済みランチャーが安全でない場合、OpenCodex は危険な wrapper を残さず、古い shim を削除して元のランチャーを復元します。 -完了した外部 Codex アップデートがインストールされている shim を上書きした場合、次の通常の `ocx` コマンドは安定した新しいランチャーをバックアップし、ディスパッチ前に shim を復元します。まだ変更中のランチャーは変更されず、後で再試行されます。修復の失敗は、要求されたコマンドを失敗させることなく警告します。手動フォールバック: `ocx codex-shim install`。 `codexShimAutoRestore` を `false` に設定するか、プロセス レベルのオプトアウトの場合は `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 +完了した外部 Codex アップデートがインストールされている shim を上書きした場合、次の通常の `ocx` コマンドは安定した新しいランチャーをバックアップし、ディスパッチ前に shim を復元します。副作用のない検査コマンド `ocx system codex-cli-update check` と、予約された `ocx system codex-cli-update` 名前空間の不正な呼び出しは、この修復を行いません。まだ変更中のランチャーは変更されず、後で再試行されます。修復の失敗は、要求されたコマンドを失敗させることなく警告します。手動フォールバック: `ocx codex-shim install`。 `codexShimAutoRestore` を `false` に設定するか、プロセス レベルのオプトアウトの場合は `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 |サブコマンド |アクション | | --- | --- | @@ -222,6 +222,8 @@ Windows ステータス トレイ アイコンをインストールして制御 ## 更新 +`ocx update` は OpenCodex 自体を更新し、Codex CLI は更新しません。[system 検査コマンド](/ja/reference/cli/agents/)の `ocx system codex-cli-update check` を使用すると、設定済みの Codex CLI 候補の provenance を範囲を限定して読み取り専用で確認できます。このコマンドは package registry に問い合わせず、更新をインストールしません。 + ### `ocx update [--tag latest|preview]` npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。npm インストールでは、何かを停止する前に Unix キャッシュの所有権とアクセスを上限付きで検査します。ネストされたシンボリックリンクは `lstat` で確認しますが追跡しません。Windows では、この Unix 専用検査を明示的にスキップします。検査に失敗した場合、トレイとプロキシを実行したまま更新を中止します。その後、実行中のプロキシはファイルが置き換えられる前に停止されます。インストールされたサービスは再構築されて自動的に開始されますが、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。ダッシュボードの更新記録では、保存前にプロファイル/キャッシュのパスと UID/GID 値が秘匿されます。 diff --git a/docs-site/src/content/docs/ko/guides/opencode.md b/docs-site/src/content/docs/ko/guides/opencode.md index d2c5e5b4ce..7d5149838c 100644 --- a/docs-site/src/content/docs/ko/guides/opencode.md +++ b/docs-site/src/content/docs/ko/guides/opencode.md @@ -16,7 +16,7 @@ ocx opencode ``` 이 명령은 프록시가 실행 중임을 보장하고, 그 프로세스에는 생성된 -`provider.opencodex` 블록만 주입한 채 opencode를 실행합니다. 추가 인자는 그대로 +`provider.opencodex`와 `providers.opencodex` 블록을 주입한 채 opencode를 실행합니다. 추가 인자는 그대로 전달됩니다: `ocx opencode run "hello"`. 라우팅된 모델은 선택기에서 `opencodex` 공급자 아래에 나타납니다: @@ -30,21 +30,21 @@ opencodex/gpt-5.6-sol # native slugs stay unprefixed 런처는 `~/.config/opencode/opencode.json`, 프로젝트의 `opencode.json` / `opencode.jsonc`, 그리고 그 밖의 어떤 디스크상의 구성 레이어도 복사하거나 다시 -쓰지 않습니다. 전역 또는 프로젝트 구성을 읽어 `provider.opencodex` 재정의가 있는지만 +쓰지 않습니다. 전역 또는 프로젝트 구성을 읽어 `provider.opencodex` 또는 `providers.opencodex` 재정의가 있는지만 확인할 수 있으며, 기존의 공급자, 에이전트, 키 바인딩, MCP 항목, 그리고 상대 경로 `{file:…}` 참조는 계속 원래 파일을 기준으로 해석됩니다. -이번 실행에서만 opencodex는 생성된 `provider.opencodex` 블록을 OpenCode의 인라인 +이번 실행에서만 opencodex는 생성된 `provider.opencodex`와 `providers.opencodex` 블록을 OpenCode의 인라인 런타임 레이어를 통해 추가합니다. 이 레이어는 전역/사용자 지정/프로젝트 구성 뒤에 병합되며, 자식 프로세스에서는 충돌하는 키만 덮어씁니다. | 레이어 | `ocx opencode`에서의 동작 | | --- | --- | | 전역 / 사용자 지정 / 프로젝트 구성 | 사용자가 쓴 그대로 디스크에 남습니다 | -| 인라인 런타임 (`OPENCODE_CONFIG_CONTENT`) | 생성된 `provider.opencodex` 블록만 받습니다 | +| 인라인 런타임 (`OPENCODE_CONFIG_CONTENT`) | 생성된 `provider.opencodex`와 `providers.opencodex` 블록을 받습니다(상속된 인라인 config에 병합됨) | | 상대 `{file:…}` 경로 | 원래 정의된 구성 파일을 기준으로 계속 해석됩니다 | -전역 또는 프로젝트 구성에도 `provider.opencodex`가 정의되어 있으면, 런처는 안내 +전역 또는 프로젝트 구성에도 `provider.opencodex` 또는 `providers.opencodex`가 정의되어 있으면, 런처는 안내 메시지를 출력합니다. `ocx opencode`의 런타임 레이어가 이번 실행에서는 그것을 덮어씁니다. @@ -66,7 +66,7 @@ ocx export --client opencode 사용자가 직접 하는 일입니다. :::caution[병합하고, 절대 교체하지 마세요] -기존 구성에 `provider.opencodex` 블록을 병합하세요. 내보낸 파일로 전체를 바꾸면 +기존 구성에 `provider.opencodex`와 `providers.opencodex` 두 블록을 모두 병합하세요. 내보낸 파일로 전체를 바꾸면 기존의 공급자, 에이전트, 키 바인딩, MCP 항목이 모두 사라집니다. `ocx export --out`이 이미 존재하는 파일을 덮어쓰지 못하게 막는 이유가 바로 이것입니다. 그러니 `--out`은 임시 경로를 가리키게 두고, 블록만 옮겨 담으세요: diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index d98118d618..3b384c20f0 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -11,12 +11,14 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s - [라이프사이클](/reference/cli/lifecycle/) — 설정, 프록시와 서비스 라이프사이클, 상태 확인, 진단, 카탈로그 동기화, 대시보드, 업데이트. - [프로바이더, 계정, 모델](/reference/cli/providers-accounts/) — 프로바이더 설정, 인증, 자격 증명 풀, quota, 사용자 지정 모델, 표시 여부, 선택된 모델, 컨텍스트 상한. -- [에이전트, 라우팅, 통합](/reference/cli/agents/) — 다중 에이전트 제어, 조합, 관측성, admission key, 클라이언트 통합, 런타임 설정, 검증된 설정. +- [에이전트, 라우팅, 통합](/ko/reference/cli/agents/) — 다중 에이전트 제어, 조합, 관측성, admission key, 클라이언트 통합, 런타임 설정, 검증된 설정, 읽기 전용 Codex CLI 업데이트 검사. ## 헤드리스 동작 관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다. +`ocx system codex-cli-update check`는 실행 중인 프록시가 없어도 되며 패키지 레지스트리를 조회하지 않습니다. 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 메타데이터를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 소프트웨어를 설치하거나 복구하지 않고, Codex나 npm을 실행하지 않으며, 실행 중인 프로세스를 제어하거나 설정 또는 캐시 상태를 쓰지 않습니다. + 뜻이 분명하면 `list`나 `status`가 기본입니다. 구조화된 스냅샷은 `--json`을, 스트리밍 요청 로그 피드는 `ocx observe logs --follow --jsonl`을 사용합니다. 테마, 언어, 내비게이션처럼 순수하게 시각적인 브라우저 상태에는 CLI 대응이 없습니다. Cloudflare Tunnel 설정은 이 명령 집합 밖입니다. ## 종료 코드와 확인 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index e2b77cf713..a229551a83 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -123,7 +123,7 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 ### `ocx opencode [opencode args...]` -프록시가 실행 중인지 확인한 뒤, OpenCode의 인라인 런타임 계층(`OPENCODE_CONFIG_CONTENT`)에 생성된 `provider.opencodex` 블록을 넣어 opencode를 실행합니다. 기존 인라인 config는 유지되고, 이번 실행에서는 `provider.opencodex`만 교체됩니다. 전역 또는 프로젝트 `opencode.json` 파일은 기존 override가 있는지 경고하기 위해 읽을 수 있지만, 디스크상의 파일은 절대 수정하지 않습니다. 라우팅된 model은 `opencodex//`로 나타납니다. 이후 plain `opencode`를 실행하면 이전과 정확히 같은 방식으로 동작합니다. +프록시가 실행 중인지 확인한 뒤, OpenCode의 인라인 런타임 계층(`OPENCODE_CONFIG_CONTENT`)에 생성된 `provider.opencodex` 및 `providers.opencodex` 블록을 넣어 opencode를 실행합니다. 기존 인라인 config는 유지되고, 이번 실행에서는 이 두 키만 교체됩니다. 전역 또는 프로젝트 `opencode.json` 파일은 기존 override가 있는지 경고하기 위해 읽을 수 있지만, 디스크상의 파일은 절대 수정하지 않습니다. 라우팅된 model은 `opencodex//`로 나타납니다. 이후 plain `opencode`를 실행하면 이전과 정확히 같은 방식으로 동작합니다. ### `ocx grok ...` @@ -179,7 +179,7 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 ## 런타임과 설정 -### `ocx system ...` +### `ocx system ...` 헤드리스 런타임 설정, 시작, 동기화, 진단, 업데이트를 관리합니다. @@ -187,6 +187,14 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 ocx system settings --stream-mode eager-relay ``` +`ocx system update`는 OpenCodex 자체를 업데이트합니다. Codex CLI는 다음의 별도 읽기 전용 명령으로 점검합니다. + +```bash +ocx system codex-cli-update check --json +``` + +`check`는 패키지 레지스트리를 조회하지 않고, 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 정보를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 Codex나 패키지 관리자를 실행하거나 shim을 복구하지 않고, 설정 또는 캐시 상태를 쓰거나 프로세스를 중지하거나 어떤 것도 설치하지 않습니다. 앱에 포함된 후보, 인식된 버전 관리자의 후보, 검증되지 않은 독립 실행형 후보, shim 상태가 모호한 후보는 관리 대상이 아니거나 알 수 없는 것으로 보고되며, 관리 대상으로 분류되지 않습니다. + ### `ocx config ...` 검증된 OpenCodex configuration을 검사하고 안전하게 수정합니다. `show`와 `get`은 비밀 값을 가립니다. import는 쓰기 전에 검증하며 `--yes`가 필요합니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 94c6fd2c00..f027979a48 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -205,9 +205,9 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 | 하위 명령 | 동작 | | --- | --- | -| 없음 | 서비스가 없으면 설치하고 시작하며, 이미 있으면 재등록하지 않고 새로 고쳐 재시작합니다. | +| 없음 | 서비스가 없으면 설치하고 시작하며, 이미 있으면 새로 고쳐 재시작합니다. 정상인 Windows 작업 스케줄러 정의는 재사용하지만, 오래된 정의는 다시 등록되어 관리자 권한 승인이 필요할 수 있습니다. | | `install` | 서비스를 생성하고 시작합니다. | -| `repair` | 설치된 서비스를 다시 등록하지 않고 제자리에서 새로 고친 뒤 재시작합니다. | +| `repair` | 설치된 서비스를 제자리에서 새로 고친 뒤 재시작합니다. 정상인 Windows 작업 스케줄러 정의는 재사용하지만, 오래된 정의는 다시 등록되어 관리자 권한 승인이 필요할 수 있습니다. | | `restart` | `repair`의 별칭입니다. | | `start` | 설치된 서비스를 시작합니다. | | `stop` | 서비스를 중지하고 기본 Codex를 복원합니다. | @@ -256,7 +256,7 @@ PATH 항목이 구체적인 실행 파일 또는 런처를 가리키도록 Codex 런처를 복원합니다. 완료된 외부 Codex 업데이트가 설치된 shim을 덮어쓰면, 다음 일반 `ocx` 명령이 안정적인 새 런처를 -백업하고 명령을 처리하기 전에 shim을 복원합니다. 아직 변경 중인 런처는 건드리지 않고 나중에 다시 시도합니다. +백업하고 명령을 처리하기 전에 shim을 복원합니다. 부작용 없는 검사 명령 `ocx system codex-cli-update check`와 예약된 `ocx system codex-cli-update` namespace의 잘못된 호출은 이 복구를 수행하지 않습니다. 아직 변경 중인 런처는 건드리지 않고 나중에 다시 시도합니다. 복구 실패는 요청한 명령을 실패시키지 않고 경고만 표시합니다. 수동 대체 수단은 `ocx codex-shim install` 입니다. `codexShimAutoRestore`를 `false`로 설정하거나, 프로세스 수준에서 제외하려면 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. @@ -294,6 +294,8 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 ## 업데이트 +`ocx update`는 OpenCodex 자체를 업데이트하며 Codex CLI를 업데이트하지 않습니다. [system 검사 명령](/ko/reference/cli/agents/)의 `ocx system codex-cli-update check`로 설정된 Codex CLI 후보의 provenance를 제한된 읽기 전용 방식으로 확인할 수 있습니다. 이 명령은 package registry를 조회하거나 업데이트를 설치하지 않습니다. + ### `ocx update [--tag latest|preview]` npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@latest`를 사용하고, 미리보기 설치는 diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 1ea37d6c44..e39d0cc018 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -24,7 +24,8 @@ opencodex state. authentication, credential pools, quota, custom models, visibility, selected models, and context caps. - [Agents, routing, and integrations](/reference/cli/agents/) — multi-agent controls, combos, - observability, admission keys, client integrations, runtime settings, and validated configuration. + observability, admission keys, client integrations, runtime settings, validated configuration, and + read-only Codex CLI update inspection. ## Headless behavior @@ -34,6 +35,19 @@ is represented as HTTP 503 and produces a nonzero CLI exit. Commands explicitly offline configuration operations can instead validate and edit the config file without a live proxy. +`ocx system codex-cli-update check` needs no live proxy and makes no package-registry request. It +inspects bounded provenance metadata for the configured install candidate, including its redacted +executable location and ownership evidence. Trusted published-launcher context authenticates that candidate snapshot, +not a successful Codex execution. Because this one-shot command never executes Codex, environment and persisted candidates +remain report-only (`managed: false`, normally `selection_unattested`) and `selectionAttested` remains `false`. +The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. +Inspecting the configured candidate requires a trusted published-launcher context; +a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report +`candidate_unavailable`. On Windows this first slice performs no candidate or configuration filesystem I/O: +only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; +every other Windows candidate fails closed. The command does not install or repair software, execute +Codex or npm, control a running process, or write configuration/cache state. + List or status is the default where unambiguous. Use `--json` for structured snapshots and `ocx observe logs --follow --jsonl` for a streaming request-log feed. Theme, language, navigation, and other purely visual browser state have no CLI equivalent; Cloudflare Tunnel setup is outside diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index f6b2e56e00..e6470eae0e 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -193,11 +193,13 @@ remain supported. Use `ocx claude config ...` for Claude Code setti ### `ocx opencode [opencode args...]` -Ensure the proxy is running, then launch opencode with a generated `provider.opencodex` block in -OpenCode's inline runtime layer (`OPENCODE_CONFIG_CONTENT`). Existing inline config is preserved and -only `provider.opencodex` is replaced for this launch. Global or project `opencode.json` files may be -read to warn about an existing override, but on-disk files are never modified. Routed models appear -as `opencodex//`. Launching plain `opencode` later behaves exactly as before. +Ensure the proxy is running, then launch opencode with the generated `provider.opencodex` and +`providers.opencodex` blocks in OpenCode's inline runtime layer (`OPENCODE_CONFIG_CONTENT`). The +legacy block keeps V1 clients working; the V2 block is the one carrying the selectable +reasoning-effort variants. Existing inline config is preserved and only those two keys are replaced +for this launch. Global or project `opencode.json` files may be read to warn about an existing +override, but on-disk files are never modified. Routed models appear as +`opencodex//`. Launching plain `opencode` later behaves exactly as before. ### `ocx grok ...` @@ -205,7 +207,7 @@ Manage and apply the Grok Build model fence. ## Client config export -### `ocx export --client ` +### `ocx export --client ` Print a client config wired to the running proxy. The command serializes the `opencodex` provider block — base URL, model list, and the client's credential @@ -216,7 +218,7 @@ models Codex can currently see. | Flag | Action | | --- | --- | -| `--client ` | Required. Selects the client config dialect. | +| `--client ` | Required. Selects the client config dialect. | | `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. | | `--out ` | Write the client's native config format to ``. Refuses to replace an existing file. | | `--force` | Allow `--out` to replace an existing file. | @@ -245,6 +247,7 @@ client applies its own defaults for those). | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, then the legacy `MAVIS_DATA_DIR`, win when set; a relative value is refused) | `mcode-config.yaml` | none — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` wins when set; a relative value is refused) | `config.json` | none — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` wins when set; a relative value is refused) | `prime-models.json` | none — loopback placeholder | +| `aside` | `~/.aside/u//models.json` for the account Aside's own `accounts.json` names as current; an unreadable manifest is refused rather than defaulting to an account | `aside-models.json` | none — loopback placeholder | The managed DSH export requires DSH 0.1.0-rc.6 or newer and owns only `llm-pi-ai.providers.opencodex`. DSH hot reloads that provider; the user's default model and @@ -296,7 +299,7 @@ the CLI, the API, and the GUI use the same bytes. ## Runtime and configuration -### `ocx system ...` +### `ocx system ...` Manage headless runtime settings, startup, sync, diagnostics, and updates. @@ -304,6 +307,26 @@ Manage headless runtime settings, startup, sync, diagnostics, and updates. ocx system settings --stream-mode eager-relay ``` +`ocx system update` updates OpenCodex itself. The separate Codex CLI inspection surface is: + +```bash +ocx system codex-cli-update check --json +``` + +`check` makes no package-registry request and inspects bounded configured-candidate provenance evidence, +including a redacted executable location and ownership evidence. Trusted published-launcher context authenticates +the candidate snapshot, not successful Codex execution. Because this one-shot command never executes Codex, +environment and persisted candidates remain report-only (`managed: false`, normally `selection_unattested`); +`selectionAttested` remains `false`. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, +and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; +a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report +`candidate_unavailable`. On Windows this first slice performs no candidate or configuration filesystem I/O: +only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; +every other Windows candidate fails closed. The command does not execute Codex or a package manager, repair a shim, +write configuration or cache state, stop a process, or install anything. App-bundled, recognized +version-manager, unverified standalone, and ambiguous shim states are reported as unmanaged or unknown +and are never classified as managed. + ### `ocx config ...` Inspect and safely modify validated OpenCodex configuration. `show` and `get` mask secrets. Import diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 97182382f9..e728335fa4 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -246,9 +246,9 @@ themselves — once the old executable is deleted, no opencodex code runs to fix | Subcommand | Action | | --- | --- | -| none | Install and start when absent; otherwise refresh and restart the existing service without re-registering it. | +| none | Install and start when absent; otherwise refresh and restart the existing service. A healthy Windows scheduler definition is reused; a stale definition may be re-registered and require elevation. | | `install` | Create and start the service. Registers it, which on Windows needs elevation. | -| `repair` | Refresh an installed service in place and restart it, without re-registering it. | +| `repair` | Refresh an installed service in place and restart it. A healthy Windows scheduler definition is reused; a stale definition may be re-registered and require elevation. | | `restart` | Alias of `repair`. | | `start` | Start an installed service. | | `stop` | Stop the service and restore native Codex. | @@ -368,7 +368,10 @@ never print proxy values; resolve the reported handoff and run `ocx doctor` befo autostart. If a completed external Codex update overwrites an installed shim, the next ordinary `ocx` command -backs up the stable new launcher and restores the shim before dispatch. A launcher that is still +backs up the stable new launcher and restores the shim before dispatch. The zero-effect +`ocx system codex-cli-update check` inspection command and malformed invocations in its reserved +`ocx system codex-cli-update` namespace never perform that repair. +A launcher that is still changing is left untouched and retried later. Repair failures warn without failing the requested command; manual fallback: `ocx codex-shim install`. Set `codexShimAutoRestore` to `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. @@ -419,6 +422,11 @@ if it is not running. ## Updating +`ocx update` updates OpenCodex itself; it does not update the Codex CLI. Use the +[system inspection commands](/reference/cli/agents/) to inspect the configured Codex CLI candidate +with bounded, read-only provenance inspection. `ocx system codex-cli-update check` does not query a +package registry or install an update. + ### `ocx update [--tag latest|preview]` Self-update opencodex from npm. Stable installs use `@latest`; preview installs stay on `@preview` diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index b79db69fe1..29e0c59053 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -46,7 +46,7 @@ Aliases are optional short request names. They never change the native model id } ``` -Aliases match case-insensitively. A model alias works as `or/opus` or, when globally unique, bare `opus`; an ambiguous bare alias reports its qualified candidates. A provider's `defaultAliases` value overrides `defaultModelAliases`. Built-ins are skipped when multiple models in one provider match the same pattern. +Aliases match case-insensitively. A model alias works as `or/opus` or, when globally unique, bare `opus`; an ambiguous bare alias reports its qualified candidates. Codex model pickers show the qualified alias while preserving the canonical `provider/model` routing id. A provider's `defaultAliases` value overrides `defaultModelAliases`. Built-ins are skipped when multiple models in one provider match the same pattern. Valid values in `config.json` override built-in defaults. Missing optional fields use the defaults documented on the domain pages. `OPENCODEX_HOME` takes precedence over the default configuration diff --git a/docs-site/src/content/docs/ru/guides/opencode.md b/docs-site/src/content/docs/ru/guides/opencode.md index 38843ec958..7baed78807 100644 --- a/docs-site/src/content/docs/ru/guides/opencode.md +++ b/docs-site/src/content/docs/ru/guides/opencode.md @@ -15,7 +15,7 @@ ocx opencode ``` Команда убеждается, что прокси запущен, и запускает opencode, внедряя для этого процесса только -сгенерированный блок `provider.opencodex`. Дополнительные аргументы передаются дальше: +сгенерированные блоки `provider.opencodex` и `providers.opencodex`. Дополнительные аргументы передаются дальше: `ocx opencode run "hello"`. Маршрутизируемые модели появляются в picker под провайдером `opencodex`: @@ -30,20 +30,20 @@ opencodex/gpt-5.6-sol # native slugs stay unprefixed Лончер не копирует и не переписывает `~/.config/opencode/opencode.json`, проектные `opencode.json` / `opencode.jsonc` и любые другие конфигурационные слои на диске. Он может читать глобальную или проектную конфигурацию, чтобы обнаружить override -`provider.opencodex`, но ваши существующие провайдеры, агенты, keybind'ы, записи MCP и +`provider.opencodex` или `providers.opencodex`, но ваши существующие провайдеры, агенты, keybind'ы, записи MCP и относительные ссылки `{file:…}` продолжают разрешаться из исходных файлов. -Только для этого запуска opencodex добавляет сгенерированный блок `provider.opencodex` через +Только для этого запуска opencodex добавляет сгенерированные блоки `provider.opencodex` и `providers.opencodex` через inline runtime layer OpenCode. Этот слой сливается после глобальной/custom/project-конфигурации и переопределяет только конфликтующие ключи дочернего процесса. | Слой | Поведение с `ocx opencode` | | --- | --- | | Global / custom / project config | Остаётся на диске ровно в том виде, в каком вы её записали | -| Inline runtime (`OPENCODE_CONFIG_CONTENT`) | Получает только сгенерированный блок `provider.opencodex` | +| Inline runtime (`OPENCODE_CONFIG_CONTENT`) | Получает сгенерированные блоки `provider.opencodex` и `providers.opencodex` (объединяются с уже заданной inline-конфигурацией) | | Relative `{file:…}` paths | Всё так же разрешаются относительно конфигурационного файла, где были определены | -Если глобальная или проектная конфигурация тоже определяет `provider.opencodex`, лончер печатает +Если глобальная или проектная конфигурация тоже определяет `provider.opencodex` или `providers.opencodex`, лончер печатает информационное замечание: runtime layer из `ocx opencode` переопределяет её только для этого запуска. @@ -66,10 +66,10 @@ ocx export --client opencode вашим действием. :::caution[Сливать, а не заменять] -Слейте блок `provider.opencodex` со своей существующей конфигурацией. Если заменить им весь файл, +Слейте оба блока — `provider.opencodex` и `providers.opencodex` — со своей существующей конфигурацией. Если заменить ими весь файл, вы уничтожите остальные провайдеры, агенты, keybind'ы и записи MCP. Именно поэтому `ocx export --out` отказывается перезаписывать существующий файл, так что указывайте `--out` на -временный путь и потом переносите только нужный блок: +временный путь и потом переносите только нужные блоки: ```bash ocx export --client opencode --out ~/opencodex-opencode.json diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 4e61f3b516..e25eff1afd 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -19,9 +19,9 @@ alias вроде `setup`/`init`, `restore`/`eject` и `models`/`model` прив - [Providers, accounts, and models](/reference/cli/providers-accounts/) — конфигурация провайдеров, аутентификация, credential pool'ы, квоты, custom model'и, видимость, selected model'и и context cap'ы. -- [Agents, routing, and integrations](/reference/cli/agents/) — multi-agent controls, combo, +- [Agents, routing, and integrations](/ru/reference/cli/agents/) — multi-agent controls, combo, observability, admission key, client integration'ы, runtime setting'и и валидированная - конфигурация. + конфигурация, а также read-only инспекция обновления Codex CLI. ## Поведение в headless-режиме @@ -31,6 +31,8 @@ runtime port и проверку identity, а не поддерживая вто явно документированные как offline-операции с конфигурацией, вместо этого могут валидировать и редактировать файл конфигурации без живого прокси. +`ocx system codex-cli-update check` не требует работающего прокси и не обращается к реестру пакетов. Команда в строго ограниченном объёме проверяет метаданные происхождения настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable`. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не устанавливает и не восстанавливает ПО, не запускает Codex или npm, не управляет работающими процессами и ничего не записывает в конфигурацию или кеш. + Там, где это недвусмысленно, `list` или `status` являются действием по умолчанию. Для структурированных снимков используйте `--json`, а для потокового лога запросов — `ocx observe logs --follow --jsonl`. Theme, language, navigation и прочее чисто визуальное diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 6f09a90d04..b49162dbc6 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -139,9 +139,9 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx opencode [opencode args...]` -Убедиться, что прокси запущен, и затем запустить opencode со сгенерированным блоком -`provider.opencodex` в inline runtime layer OpenCode (`OPENCODE_CONFIG_CONTENT`). Существующая -inline-конфигурация сохраняется, а только `provider.opencodex` заменяется для этого запуска. +Убедиться, что прокси запущен, и затем запустить opencode со сгенерированными блоками +`provider.opencodex` и `providers.opencodex` в inline runtime layer OpenCode (`OPENCODE_CONFIG_CONTENT`). Существующая +inline-конфигурация сохраняется, а для этого запуска заменяются только эти два ключа. Глобальные или проектные `opencode.json` могут читаться, чтобы выдать warning о существующем override, но файлы на диске никогда не меняются. Маршрутизируемые модели появляются как `opencodex//`. Последующий запуск обычного `opencode` работает ровно как раньше. @@ -222,7 +222,7 @@ env-reference, либо несекретную loopback-заглушку. Loopba ## Runtime и configuration -### `ocx system ...` +### `ocx system ...` Управляйте headless runtime-setting'ами, startup, sync, diagnostics и update. @@ -230,6 +230,14 @@ env-reference, либо несекретную loopback-заглушку. Loopba ocx system settings --stream-mode eager-relay ``` +`ocx system update` обновляет сам OpenCodex. Для Codex CLI используйте отдельную read-only команду: + +```bash +ocx system codex-cli-update check --json +``` + +`check` не обращается к реестру пакетов и в строго ограниченном объёме проверяет данные о происхождении настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable`. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не запускает Codex или менеджер пакетов, не восстанавливает shim, ничего не записывает в конфигурацию или кеш, не останавливает процессы и ничего не устанавливает. Кандидаты, входящие в комплект приложения, найденные в распознанных путях менеджеров версий, являющиеся непроверенными автономными установками или имеющие неоднозначное состояние shim, отображаются как `unmanaged` или `unknown` и никогда не классифицируются как `managed`. + ### `ocx config ...` Проверяйте и безопасно меняйте валидированную конфигурацию OpenCodex. `show` и `get` diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index d165a1574f..362600d285 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -223,9 +223,9 @@ unit**, Windows **Task Scheduler**), которая автоматически | Подкоманда | Действие | | --- | --- | -| none | Установить и запустить службу, если её нет; иначе обновить и перезапустить существующую службу без повторной регистрации. | +| none | Установить и запустить службу, если её нет; иначе обновить и перезапустить существующую службу. Исправная конфигурация Windows Task Scheduler используется повторно; устаревшая может быть перерегистрирована и потребовать повышения прав. | | `install` | Создать и запустить службу. | -| `repair` | Обновить установленную службу на месте и перезапустить её без повторной регистрации. | +| `repair` | Обновить установленную службу на месте и перезапустить её. Исправная конфигурация Windows Task Scheduler используется повторно; устаревшая может быть перерегистрирована и потребовать повышения прав. | | `restart` | Псевдоним команды `repair`. | | `start` | Запустить уже установленную службу. | | `stop` | Остановить службу и восстановить native Codex. | @@ -275,6 +275,8 @@ launcher, а не оставляет небезопасный wrapper устан Если завершённое внешнее обновление Codex перезаписало установленный shim, следующая обычная команда `ocx` сохранит новый стабильный launcher и восстановит shim перед выполнением запроса. +Не имеющая побочных эффектов команда инспекции `ocx system codex-cli-update check` и некорректные +вызовы зарезервированного пространства `ocx system codex-cli-update` никогда не выполняют этот repair. Launcher, который всё ещё меняется, не трогается, а попытка откладывается до следующего раза. Сбои repair'а приводят только к warning и не ломают запрошенную команду; ручной запасной путь — `ocx codex-shim install`. Чтобы отключить автоматику, задайте `codexShimAutoRestore: false` или @@ -315,6 +317,8 @@ one-click управление прокси. `start` и `stop` управляю ## Обновление +`ocx update` обновляет сам OpenCodex, а не Codex CLI. Используйте `ocx system codex-cli-update check` из [system-команд инспекции](/ru/reference/cli/agents/) для ограниченной read-only проверки provenance настроенного кандидата Codex CLI. Команда не обращается к package registry и не устанавливает обновление. + ### `ocx update [--tag latest|preview]` Самообновить opencodex из npm. Стабильные установки используют `@latest`; preview-установки diff --git a/docs-site/src/content/docs/tr/guides/opencode.md b/docs-site/src/content/docs/tr/guides/opencode.md index 05aa8858d9..836a698271 100644 --- a/docs-site/src/content/docs/tr/guides/opencode.md +++ b/docs-site/src/content/docs/tr/guides/opencode.md @@ -16,8 +16,8 @@ oluşturur ve bunu OpenCode'un satır içi çalışma zamanı katmanı ocx opencode ``` -Bu, proxy'nin çalıştığından emin olur ve bu süreç için yalnızca oluşturulan -`provider.opencodex` bloğu enjekte edilmiş olarak opencode'u başlatır. Fazladan +Bu, proxy'nin çalıştığından emin olur ve bu süreç için oluşturulan +`provider.opencodex` ve `providers.opencodex` blokları enjekte edilmiş olarak opencode'u başlatır. Fazladan argümanlar doğrudan iletilir: `ocx opencode run "hello"`. Yönlendirilen modeller seçicide `opencodex` sağlayıcısı altında görünür: @@ -31,12 +31,12 @@ opencodex/gpt-5.6-sol # yerel slug'lar öneksiz kalır Başlatıcı `~/.config/opencode/opencode.json`, proje `opencode.json` / `opencode.jsonc` veya diskteki başka bir yapılandırma katmanını kopyalamaz veya -yeniden yazmaz. Bir `provider.opencodex` geçersiz kılmasını algılamak için genel +yeniden yazmaz. Bir `provider.opencodex` veya `providers.opencodex` geçersiz kılmasını algılamak için genel veya proje yapılandırmasını okuyabilir, mevcut sağlayıcılarınız, ajanlarınız, tuş atamalarınız, MCP girdileriniz ve göreli `{file:…}` referanslarınız orijinal dosyalarından çözümlenmeye devam eder. -Yalnızca bu başlatma için opencodex, oluşturulan `provider.opencodex` bloğunu +Yalnızca bu başlatma için opencodex, oluşturulan `provider.opencodex` ve `providers.opencodex` bloklarını OpenCode'un satır içi çalışma zamanı katmanı aracılığıyla ekler. Bu katman, genel/özel/proje yapılandırmasından sonra birleşir ve alt süreç için yalnızca çakışan anahtarları geçersiz kılar. @@ -44,10 +44,10 @@ genel/özel/proje yapılandırmasından sonra birleşir ve alt süreç için yal | Katman | `ocx opencode` ile Davranış | | --- | --- | | Genel / özel / proje yapılandırması | Tam olarak yazdığınız gibi diskte bırakılır | -| Satır içi çalışma zamanı (`OPENCODE_CONFIG_CONTENT`) | Yalnızca oluşturulan `provider.opencodex` bloğunu alır | +| Satır içi çalışma zamanı (`OPENCODE_CONFIG_CONTENT`) | Oluşturulan `provider.opencodex` ve `providers.opencodex` bloklarını alır (devralınan satır içi yapılandırmayla birleştirilir) | | Göreli `{file:…}` yolları | Yine de bunları ilk tanımlayan yapılandırma dosyasına göre çözümlenir | -Bir genel veya proje yapılandırması da `provider.opencodex` tanımlıyorsa, +Bir genel veya proje yapılandırması da `provider.opencodex` veya `providers.opencodex` tanımlıyorsa, başlatıcı bilgilendirici bir not yazdırır: `ocx opencode`'dan gelen çalışma zamanı katmanı bu başlatma için onu geçersiz kılar. @@ -71,11 +71,11 @@ dosyaya asla dokunmaz — yukarıdaki bölüm geçerliliğini korur ve bloğu yapılandırmanıza taşımak sizin açık eyleminizdir. :::caution[Birleştirin, asla üzerine yazmayın] -`provider.opencodex` bloğunu mevcut yapılandırmanızla birleştirin. Tüm dosyanın +İki bloğu da — `provider.opencodex` ve `providers.opencodex` — mevcut yapılandırmanızla birleştirin. Tüm dosyanın dışa aktarılanla değiştirilmesi diğer sağlayıcılarınızı, ajanlarınızı, tuş atamalarınızı ve MCP girdilerinizi yok eder. `ocx export --out` tam olarak bu nedenle mevcut bir dosyanın üzerine yazmayı reddeder, bu nedenle `--out`'u -geçici bir yola yönlendirin ve bloğu kopyalayın: +geçici bir yola yönlendirin ve blokları kopyalayın: ```bash ocx export --client opencode --out ~/opencodex-opencode.json diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index 83d4d9a33a..a36e60fed7 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -24,7 +24,8 @@ veya yeniden yazmazlar. özel modeller, görünürlük, seçilen modeller ve bağlam sınırları. - [Ajanlar, yönlendirme ve entegrasyonlar](/tr/reference/cli/agents/) — çoklu ajan kontrolleri, kombolar, gözlemlenebilirlik, kabul anahtarları, istemci - entegrasyonları, çalışma zamanı ayarları ve doğrulanmış yapılandırma. + entegrasyonları, çalışma zamanı ayarları, doğrulanmış yapılandırma ve salt + okunur Codex CLI güncelleme denetimi. ## Başsız (Headless) davranış @@ -35,6 +36,8 @@ yönetim API'sine gidiş-dönüş yapar. Durdurulmuş veya erişilemeyen bir pro yapılandırma işlemleri olarak açıkça belgelenen komutlar, bunun yerine canlı bir proxy olmadan yapılandırma dosyasını doğrulayabilir ve düzenleyebilir. +`ocx system codex-cli-update check` canlı proxy gerektirmez ve paket kayıt defterine istek göndermez. Yapılandırmada belirtilen kurulum adayına ilişkin provenance meta verilerini, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik denetim Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut yazılım kurmaz veya onarmaz, Codex ya da npm çalıştırmaz, çalışan bir sürece müdahale etmez ve yapılandırmaya ya da önbellek durumuna yazmaz. + Belirsiz olmayan yerlerde liste veya durum varsayılandır. Yapılandırılmış anlık görüntüler için `--json` ve akışlı bir istek günlüğü akışı için `ocx observe logs --follow --jsonl` kullanın. Tema, dil, gezinme ve diğer tamamen görsel @@ -67,5 +70,3 @@ kullanıcıya yönelik komutlar değil, uygulama ayrıntılarıdır. Kontrol pan çalışan PID'sini kaydeder, çalışanı ölen aktif bir işi kurtarır, daha eski PID'siz aktif kayıtları on dakika sonra eski olarak değerlendirir ve canlı bir çalışanı eşzamanlı güncellemelerden korur. - - diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 42f8b9bffc..a3e184661d 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -177,9 +177,9 @@ eder. Claude Code ayarları için `ocx claude config ...` kullanın ### `ocx opencode [opencode argumanlari...]` Proxy'nin çalıştığından emin olun, ardından OpenCode'un satır içi çalışma zamanı -katmanında (`OPENCODE_CONFIG_CONTENT`) üretilen bir `provider.opencodex` -bloğuyla opencode'u başlatın. Mevcut satır içi yapılandırma korunur ve bu -başlatma için yalnızca `provider.opencodex` değiştirilir. Mevcut bir geçersiz +katmanında (`OPENCODE_CONFIG_CONTENT`) üretilen `provider.opencodex` ve +`providers.opencodex` bloklarıyla opencode'u başlatın. Mevcut satır içi yapılandırma korunur ve bu +başlatma için yalnızca bu iki anahtar değiştirilir. Mevcut bir geçersiz kılma hakkında uyarmak için genel veya proje `opencode.json` dosyaları okunabilir, ancak diskteki dosyalar asla değiştirilmez. Yönlendirilen modeller `opencodex//` olarak görünür. Daha sonra düz `opencode` @@ -267,7 +267,7 @@ sekmesinde işlenir; böylece CLI, API ve GUI aynı baytları kullanır. ## Çalışma zamanı ve yapılandırma -### `ocx system ...` +### `ocx system ...` Başsız çalışma zamanı ayarlarını, başlatmayı, senkronizasyonu, tanılamayı ve güncellemeleri yönetin. @@ -276,6 +276,14 @@ güncellemeleri yönetin. ocx system settings --stream-mode eager-relay ``` +`ocx system update` OpenCodex'in kendisini günceller. Codex CLI için ayrı, salt okunur komutu kullanın: + +```bash +ocx system codex-cli-update check --json +``` + +`check` paket kayıt defterine istek göndermez ve yapılandırmada belirtilen kurulum adayına ilişkin provenance kanıtını, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik komut Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut Codex veya bir paket yöneticisi çalıştırmaz, shim'i onarmaz, yapılandırmaya ya da önbellek durumuna yazmaz, hiçbir süreci durdurmaz ve hiçbir şey kurmaz. Uygulamayla birlikte paketlenmiş adaylar, tanınan sürüm yöneticisi yollarında bulunan adaylar, doğrulanmamış bağımsız adaylar ve belirsiz shim durumları `unmanaged` veya `unknown` olarak raporlanır; hiçbir zaman `managed` olarak sınıflandırılmaz. + ### `ocx config ...` Doğrulanmış OpenCodex yapılandırmasını inceleyin ve güvenle değiştirin. `show` diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 125077b568..ff9089357e 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -247,9 +247,9 @@ yapılandırmasını dalgalandırmaz. | Alt komut | Eylem | | --- | --- | -| none | Servis yoksa kurup başlatın; varsa yeniden kaydetmeden yenileyip yeniden başlatın. | +| none | Servis yoksa kurup başlatın; varsa yenileyip yeniden başlatın. Sağlıklı bir Windows Task Scheduler tanımı yeniden kullanılır; eski bir tanım yeniden kaydedilebilir ve yükseltme gerektirebilir. | | `install` | Servisi oluşturun ve başlatın. Kaydeder, bu da Windows'ta yükseltme gerektirir. | -| `repair` | Kurulu bir servisi yerinde yenileyin ve yeniden kaydetmeden yeniden başlatın. | +| `repair` | Kurulu bir servisi yerinde yenileyin ve yeniden başlatın. Sağlıklı bir Windows Task Scheduler tanımı yeniden kullanılır; eski bir tanım yeniden kaydedilebilir ve yükseltme gerektirebilir. | | `restart` | `repair` komutunun takma adıdır. | | `start` | Kurulu bir servisi başlatın. | | `stop` | Servisi durdurun ve yerel Codex'i geri yükleyin. | @@ -379,7 +379,8 @@ doctor` çalıştırın. Tamamlanan harici bir Codex güncellemesi kurulu bir dolgunun üzerine yazarsa sonraki sıradan `ocx` komutu kararlı yeni başlatıcıyı yedekler ve dağıtımdan -önce dolguyu geri yükler. Hala değişmekte olan bir başlatıcı dokunulmadan +önce dolguyu geri yükler. Sıfır etkili `ocx system codex-cli-update check` denetim +komutu ile ayrılmış `ocx system codex-cli-update` ad alanındaki hatalı çağrılar bu onarımı asla yapmaz. Hala değişmekte olan bir başlatıcı dokunulmadan bırakılır ve daha sonra yeniden denenir. Onarım arızaları talep edilen komutu başarısız kılmadan uyarır; manuel geri dönüş: `ocx codex-shim install`. Süreç düzeyinde bir vazgeçme için `codexShimAutoRestore`'u `false` olarak ayarlayın @@ -420,6 +421,8 @@ adresindeki [web kontrol panelini](/tr/guides/web-dashboard/) açın. ## Güncelleme +`ocx update`, Codex CLI'yi değil OpenCodex'in kendisini günceller. Yapılandırılmış Codex CLI adayının provenance bilgisini sınırlı ve salt okunur biçimde denetlemek için [sistem denetim komutları](/tr/reference/cli/agents/) arasındaki `ocx system codex-cli-update check` komutunu kullanın. Komut package registry'ye istek göndermez ve güncelleme kurmaz. + ### `ocx update [--tag latest|preview]` opencodex'i npm'den kendi kendine güncelleyin. Kararlı kurulumlar `@latest` diff --git a/docs-site/src/content/docs/zh-cn/guides/opencode.md b/docs-site/src/content/docs/zh-cn/guides/opencode.md index cc5c245d59..3181901931 100644 --- a/docs-site/src/content/docs/zh-cn/guides/opencode.md +++ b/docs-site/src/content/docs/zh-cn/guides/opencode.md @@ -11,7 +11,7 @@ opencode 从合并后的 JSON 配置层读取 provider,而不是从环境变 ocx opencode ``` -这会确保代理正在运行,并只为该进程注入生成的 `provider.opencodex` block 来启动 opencode。额外参数会原样透传:`ocx opencode run "hello"`。 +这会确保代理正在运行,并为该进程注入生成的 `provider.opencodex` 和 `providers.opencodex` block 来启动 opencode。额外参数会原样透传:`ocx opencode run "hello"`。 路由模型会在选择器里作为 `opencodex` provider 出现: @@ -22,17 +22,17 @@ opencodex/gpt-5.6-sol # native slugs stay unprefixed ## 你的配置绝不会被修改 -启动器不会复制或重写 `~/.config/opencode/opencode.json`、项目中的 `opencode.json` / `opencode.jsonc`,也不会处理任何其他磁盘上的配置层。它可能会读取全局或项目配置,以检测是否存在 `provider.opencodex` 覆盖;而你现有的 providers、agents、keybinds、MCP 条目以及相对路径的 `{file:…}` 引用,都会继续从它们原本的文件中解析。 +启动器不会复制或重写 `~/.config/opencode/opencode.json`、项目中的 `opencode.json` / `opencode.jsonc`,也不会处理任何其他磁盘上的配置层。它可能会读取全局或项目配置,以检测是否存在 `provider.opencodex` 或 `providers.opencodex` 覆盖;而你现有的 providers、agents、keybinds、MCP 条目以及相对路径的 `{file:…}` 引用,都会继续从它们原本的文件中解析。 -仅在这次启动中,opencodex 会通过 OpenCode 的内联运行时层添加生成的 `provider.opencodex` block。该层会在全局/自定义/项目配置之后合并,并且只会对这个子进程覆盖冲突的键。 +仅在这次启动中,opencodex 会通过 OpenCode 的内联运行时层添加生成的 `provider.opencodex` 和 `providers.opencodex` block。该层会在全局/自定义/项目配置之后合并,并且只会对这个子进程覆盖冲突的键。 | Layer | `ocx opencode` 下的行为 | | --- | --- | | Global / custom / project config | 原样保留在磁盘上,不做任何改动 | -| Inline runtime (`OPENCODE_CONFIG_CONTENT`) | 只接收生成的 `provider.opencodex` block | +| Inline runtime (`OPENCODE_CONFIG_CONTENT`) | 接收生成的 `provider.opencodex` 和 `providers.opencodex` 两个 block(与继承的内联配置合并) | | Relative `{file:…}` paths | 仍然按最初定义它们的配置文件来解析 | -如果全局或项目配置里也定义了 `provider.opencodex`,启动器会打印一条提示信息:`ocx opencode` 的运行时层会在这次启动中覆盖它。 +如果全局或项目配置里也定义了 `provider.opencodex` 或 `providers.opencodex`,启动器会打印一条提示信息:`ocx opencode` 的运行时层会在这次启动中覆盖它。 ## 把这个 block 放进你自己的配置里 @@ -45,7 +45,7 @@ ocx export --client opencode 代理必须正在运行。该命令会打印配置、规范目标路径(`~/.config/opencode/opencode.json`,如果设置了 `XDG_CONFIG_HOME` 则位于其下)、合并警告,以及环境变量导出行。它绝不会修改那个文件 - 上面的说明依然成立,而把这个 block 挪进你的配置是你明确做出的动作。 :::caution[合并,不要替换] -请把 `provider.opencodex` block 合并进你现有的配置。用导出的文件直接替换整个配置会破坏你其他的 providers、agents、keybinds 和 MCP 条目。`ocx export --out` 会明确拒绝覆盖已存在的文件,原因正是如此,因此请把 `--out` 指向一个临时路径,然后把 block 复制过去: +请把 `provider.opencodex` 和 `providers.opencodex` 两个 block 都合并进你现有的配置。用导出的文件直接替换整个配置会破坏你其他的 providers、agents、keybinds 和 MCP 条目。`ocx export --out` 会明确拒绝覆盖已存在的文件,原因正是如此,因此请把 `--out` 指向一个临时路径,然后把这两个 block 复制过去: ```bash ocx export --client opencode --out ~/opencodex-opencode.json diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 30a946ffb8..e804df71af 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -11,12 +11,14 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 - [生命周期](/reference/cli/lifecycle/) —— 设置、代理和服务生命周期、健康检查、诊断、目录同步、仪表盘和更新。 - [提供商、账号与模型](/reference/cli/providers-accounts/) —— 提供商配置、认证、凭据池、配额、自定义模型、可见性、已选模型和上下文上限。 -- [代理、路由与集成](/reference/cli/agents/) —— 多代理控制、组合、可观测性、准入密钥、客户端集成、运行时设置和已验证配置。 +- [代理、路由与集成](/zh-cn/reference/cli/agents/) —— 多代理控制、组合、可观测性、准入密钥、客户端集成、运行时设置、已验证配置,以及只读的 Codex CLI 更新检查。 ## 无头行为 管理命令会通过实时代理的管理 API 往返调用,使用记录下来的运行时端口和身份检查,而不是维护第二条配置路径。已停止或不可达的代理会被表示为 HTTP 503,并导致 CLI 以非零状态退出。明确标注为离线配置操作的命令,则可以在没有实时代理的情况下验证并编辑配置文件。 +`ocx system codex-cli-update check` 不需要实时代理,也不会向软件包注册表发起请求。它只会在限定范围内检查已配置候选项的来源元数据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性检查命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会安装或修复软件,不会运行 Codex 或 npm,不会控制正在运行的进程,也不会写入配置或缓存状态。 + 在语义明确时,默认操作是 `list` 或 `status`。使用 `--json` 获取结构化快照,使用 `ocx observe logs --follow --jsonl` 获取流式请求日志。主题、语言、导航以及其他纯视觉浏览器状态都没有 CLI 对应项;Cloudflare Tunnel 的设置不在这组命令之内。 ## 退出码与确认 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index e535215eed..11e1c38ee1 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -124,7 +124,7 @@ ocx claude desktop import [--apply] Validate and import JSON ### `ocx opencode [opencode args...]` -确保代理正在运行,然后在 OpenCode 的内联运行时层(`OPENCODE_CONFIG_CONTENT`)中启动 opencode,并注入生成的 `provider.opencodex` 块。现有的内联配置会被保留,仅本次启动会替换 `provider.opencodex`。可能会读取全局或项目级 `opencode.json` 文件以警告已有覆盖,但不会修改磁盘上的文件。路由后的模型会显示为 `opencodex//`。稍后再次启动普通 `opencode` 时,行为与之前完全一致。 +确保代理正在运行,然后在 OpenCode 的内联运行时层(`OPENCODE_CONFIG_CONTENT`)中启动 opencode,并注入生成的 `provider.opencodex` 和 `providers.opencodex` 块。现有的内联配置会被保留,仅本次启动会替换这两个键。可能会读取全局或项目级 `opencode.json` 文件以警告已有覆盖,但不会修改磁盘上的文件。路由后的模型会显示为 `opencodex//`。稍后再次启动普通 `opencode` 时,行为与之前完全一致。 ### `ocx grok ...` @@ -180,7 +180,7 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi ## Runtime and configuration -### `ocx system ...` +### `ocx system ...` 管理无头运行时设置、启动、同步、诊断和更新。 @@ -188,6 +188,14 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi ocx system settings --stream-mode eager-relay ``` +`ocx system update` 更新 OpenCodex 本身。Codex CLI 使用以下独立的只读检查命令: + +```bash +ocx system codex-cli-update check --json +``` + +`check` 不会向软件包注册表发起请求,只会在限定范围内检查已配置候选项的来源证据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会运行 Codex 或软件包管理器,不会修复 shim,不会写入配置或缓存,不会停止进程,也不会安装任何内容。随应用捆绑的候选项、位于已识别版本管理器路径中的候选项、未经验证的独立候选项以及 shim 状态不明确的候选项,都会报告为 `unmanaged` 或 `unknown`,绝不会归类为 `managed`。 + ### `ocx config ...` 检查并安全修改已验证的 OpenCodex 配置。`show` 和 `get` 会隐藏密钥。导入会先验证再写入,并且需要 `--yes`。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index ff3446c381..277b14981b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -155,9 +155,9 @@ ocx status --json | 子命令 | 操作 | | --- | --- | -| none | 服务不存在时安装并启动;已存在时不重新注册,直接刷新并重启。 | +| none | 服务不存在时安装并启动;已存在时刷新并重启。正常的 Windows 任务计划程序定义会复用;过时定义可能会重新注册并需要提升权限。 | | `install` | 创建并启动服务。 | -| `repair` | 就地刷新已安装的服务并重启,不重新注册。 | +| `repair` | 就地刷新已安装的服务并重启。正常的 Windows 任务计划程序定义会复用;过时定义可能会重新注册并需要提升权限。 | | `restart` | `repair` 的别名。 | | `start` | 启动已安装的服务。 | | `stop` | 停止服务并恢复原生 Codex。 | @@ -190,7 +190,7 @@ ocx service uninstall 仅安装启动器并不能证明 Codex 请求会经过 OpenCodex。完成健康安装后,命令会检查当前 Codex 路由;当路由由外部配置、用户自有网关管理或无法验证时,会显示警告而不是绿色成功。若出站代理变量只存在于当前进程,而 `config.proxy` 未设置或无法解析,也会给出警告,因为 Codex 启动器和后台服务未必继承该环境。这些检查只读且绝不会打印代理值;在依赖自动启动前,请先处理提示的交接配置并运行 `ocx doctor`。 -如果已完成的外部 Codex 更新覆盖了已安装的 shim,下一次普通的 `ocx` 命令会先备份稳定的新启动器,再在分发前恢复 shim。仍在变动中的启动器会保持不动,并在稍后重试。修复失败只会警告,不会让所请求的命令失败;手动回退:`ocx codex-shim install`。将 `codexShimAutoRestore` 设为 `false`,或设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`,即可在进程级别关闭自动恢复。 +如果已完成的外部 Codex 更新覆盖了已安装的 shim,下一次普通的 `ocx` 命令会先备份稳定的新启动器,再在分发前恢复 shim。零副作用的检查命令 `ocx system codex-cli-update check` 和保留的 `ocx system codex-cli-update` 命名空间中的无效调用都不会执行这项修复。仍在变动中的启动器会保持不动,并在稍后重试。修复失败只会警告,不会让所请求的命令失败;手动回退:`ocx codex-shim install`。将 `codexShimAutoRestore` 设为 `false`,或设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`,即可在进程级别关闭自动恢复。 | 子命令 | 操作 | | --- | --- | @@ -221,6 +221,8 @@ ocx codex-shim uninstall ## 更新 +`ocx update` 更新的是 OpenCodex 本身,而不是 Codex CLI。请使用 [system 检查命令](/zh-cn/reference/cli/agents/)中的 `ocx system codex-cli-update check`,对已配置的 Codex CLI 候选项进行有界、只读的 provenance 检查。该命令不会查询 package registry,也不会安装更新。 + ### `ocx update [--tag latest|preview]` 从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。对于 npm 安装,它会在停止任何进程之前,对 Unix 缓存的所有权和访问权限执行有界检查。嵌套符号链接会通过 `lstat` 检查但不会跟随;Windows 会明确跳过这项仅适用于 Unix 的检查。检查失败时,更新会在托盘和代理仍运行的情况下中止。随后才会在替换文件之前停止正在运行的代理;已安装的服务会自动重建并启动,而前台安装则会打印 `ocx start` 作为下一步。持久化前,仪表板更新记录会隐去用户配置文件/缓存路径以及 UID/GID 值。 diff --git a/docs-site/src/content/docs/zh-tw/guides/opencode.md b/docs-site/src/content/docs/zh-tw/guides/opencode.md index ccb150ce29..662491952f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/opencode.md +++ b/docs-site/src/content/docs/zh-tw/guides/opencode.md @@ -11,7 +11,7 @@ opencode 從合併的 JSON 設定層讀取供應商,而不是環境變數, ocx opencode ``` -這會確保代理程式正在執行,並僅以產生的 `provider.opencodex` 區塊啟動該次 opencode 程序。額外引數會原樣傳遞:`ocx opencode run "hello"`。 +這會確保代理程式正在執行,並以產生的 `provider.opencodex` 與 `providers.opencodex` 區塊啟動該次 opencode 程序。額外引數會原樣傳遞:`ocx opencode run "hello"`。 路由模型會出現在選擇器的 `opencodex` 供應商底下: @@ -22,17 +22,17 @@ opencodex/gpt-5.6-sol # native slugs stay unprefixed ## 你自己的設定絕不會被修改 -啟動器不會複製或改寫 `~/.config/opencode/opencode.json`、專案的 `opencode.json` / `opencode.jsonc`,或任何其他磁碟上的設定層。它可能會讀取全域或專案設定以偵測 `provider.opencodex` 覆寫,而你既有的供應商、agents、keybinds、MCP 項目,以及相對路徑的 `{file:…}` 參考,仍會從原本的檔案解析。 +啟動器不會複製或改寫 `~/.config/opencode/opencode.json`、專案的 `opencode.json` / `opencode.jsonc`,或任何其他磁碟上的設定層。它可能會讀取全域或專案設定以偵測 `provider.opencodex` 或 `providers.opencodex` 覆寫,而你既有的供應商、agents、keybinds、MCP 項目,以及相對路徑的 `{file:…}` 參考,仍會從原本的檔案解析。 -僅就此啟動,opencodex 會透過 OpenCode 的內嵌 runtime 層加入產生的 `provider.opencodex` 區塊。該層在全域/自訂/專案設定之後合併,且只覆寫子程序中衝突的鍵。 +僅就此啟動,opencodex 會透過 OpenCode 的內嵌 runtime 層加入產生的 `provider.opencodex` 與 `providers.opencodex` 區塊。該層在全域/自訂/專案設定之後合併,且只覆寫子程序中衝突的鍵。 | 層 | 搭配 `ocx opencode` 的行為 | | --- | --- | | 全域/自訂/專案設定 | 磁碟上維持你寫下的原樣 | -| 內嵌 runtime(`OPENCODE_CONFIG_CONTENT`) | 只接收產生的 `provider.opencodex` 區塊 | +| 內嵌 runtime(`OPENCODE_CONFIG_CONTENT`) | 接收產生的 `provider.opencodex` 與 `providers.opencodex` 兩個區塊(與繼承的內嵌設定合併) | | 相對 `{file:…}` 路徑 | 仍相對於原本定義它們的設定檔解析 | -若全域或專案設定也定義了 `provider.opencodex`,啟動器會印出資訊提示:該次啟動由 `ocx opencode` 提供的 runtime 層會覆寫它。 +若全域或專案設定也定義了 `provider.opencodex` 或 `providers.opencodex`,啟動器會印出資訊提示:該次啟動由 `ocx opencode` 提供的 runtime 層會覆寫它。 ## 把區塊放進你自己的設定 @@ -49,9 +49,9 @@ ocx export --client opencode env 匯出指令。它永遠不會碰那個檔案——前面一節仍然成立,把區塊放進你的設定是你自己的明確行為。 :::caution[合併,不要取代] -把 `provider.opencodex` 區塊合併進你既有的設定。用匯出的內容取代整個檔案會摧毀你的其他供應商、 +把 `provider.opencodex` 與 `providers.opencodex` 兩個區塊都合併進你既有的設定。用匯出的內容取代整個檔案會摧毀你的其他供應商、 agents、keybinds 與 MCP 項目。`ocx export --out` 正是為了這個原因拒絕覆寫既有檔案,所以請把 -`--out` 指向暫存路徑,再把區塊複製過去: +`--out` 指向暫存路徑,再把這兩個區塊複製過去: ```bash ocx export --client opencode --out ~/opencodex-opencode.json diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index b7bc90b61a..81121d4f51 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -19,7 +19,8 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 - [Providers、帳號與模型](/zh-tw/reference/cli/providers-accounts/) — provider 設定、 認證、憑證池、配額、自訂模型、可見性、選定模型與 context 上限。 - [Agents、路由與整合](/zh-tw/reference/cli/agents/) — multi-agent 控制、combos、 - 可觀測性、admission key、用戶端整合、執行環境設定與已驗證的設定。 + 可觀測性、admission key、用戶端整合、執行環境設定、已驗證的設定,以及唯讀的 + Codex CLI 更新檢查。 ## 無頭(headless)行為 @@ -27,6 +28,8 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 設定路徑。停止或無法連線的代理以 HTTP 503 呈現,並產生非零的 CLI 離開碼。明確記載為 離線設定操作的命令,可以在沒有執行中代理的情況下驗證與編輯設定檔。 +`ocx system codex-cli-update check` 不需要執行中的代理,也不會向套件 registry 發出請求。它只會在限定範圍內檢查設定中的安裝候選項來源中繼資料,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次檢查命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會安裝或修復軟體、不會執行 Codex 或 npm、不會控制執行中的程序,也不會寫入設定或快取狀態。 + 沒有歧義時,list 或 status 是預設。使用 `--json` 取得結構化快照,並以 `ocx observe logs --follow --jsonl` 取得串流的請求 log feed。佈景主題、語言、導覽與 其他純視覺的瀏覽器狀態沒有 CLI 對應;Cloudflare Tunnel 設定不在此命令集內。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index e8e7955d67..497d2e4252 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -121,7 +121,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ### `ocx opencode [opencode args...]` -確保代理正在執行,然後在 OpenCode 的內嵌執行階段層(`OPENCODE_CONFIG_CONTENT`)中以生成的 `provider.opencodex` 區塊啟動 opencode。既有的內嵌設定會被保留,本次啟動僅替換 `provider.opencodex`。全域或專案的 `opencode.json` 檔案可能被讀取以警告既有的覆寫,但磁碟上的檔案永不修改。路由模型以 +確保代理正在執行,然後在 OpenCode 的內嵌執行階段層(`OPENCODE_CONFIG_CONTENT`)中以生成的 `provider.opencodex` 與 `providers.opencodex` 區塊啟動 opencode。既有的內嵌設定會被保留,本次啟動僅替換這兩個鍵。全域或專案的 `opencode.json` 檔案可能被讀取以警告既有的覆寫,但磁碟上的檔案永不修改。路由模型以 `opencodex//` 出現。之後啟動普通 `opencode` 的行為與之前完全相同。 ### `ocx grok ...` @@ -183,7 +183,7 @@ Gajae 是例外:`OPENCODEX_GAJAE_API_KEY` 只會從環境提供 provider 憑 ## 執行階段與設定 -### `ocx system ...` +### `ocx system ...` 管理無頭執行階段設定、啟動、同步、診斷與更新。 @@ -191,6 +191,14 @@ Gajae 是例外:`OPENCODEX_GAJAE_API_KEY` 只會從環境提供 provider 憑 ocx system settings --stream-mode eager-relay ``` +`ocx system update` 更新 OpenCodex 本身。Codex CLI 使用以下獨立唯讀檢查指令: + +```bash +ocx system codex-cli-update check --json +``` + +`check` 不會向套件 registry 發出請求,只會在限定範圍內檢查設定中的安裝候選項來源證據,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會執行 Codex 或套件管理工具、不會修復 shim、不會寫入設定或快取、不會停止程序,也不會安裝任何內容。隨應用程式封裝的候選項、位於已識別版本管理工具路徑中的候選項、未經驗證的獨立候選項,以及 shim 狀態不明確的候選項,都會報告為 `unmanaged` 或 `unknown`,絕不會歸類為 `managed`。 + ### `ocx config ...` 檢查並安全地修改已驗證的 OpenCodex 設定。`show` 與 `get` 會遮罩秘密。匯入在寫入前驗證且需要 `--yes`。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index d1a49b3c3b..a8fd9f5c08 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -149,9 +149,9 @@ ocx status --json | 子指令 | 動作 | | --- | --- | -| 無 | 服務不存在時安裝並啟動;已存在時不重新註冊,直接重新整理並重啟。 | +| 無 | 服務不存在時安裝並啟動;已存在時重新整理並重啟。正常的 Windows 工作排程器定義會沿用;過時的定義可能會重新註冊並需要提高權限。 | | `install` | 建立並啟動服務。註冊它,在 Windows 上需要提高權限。 | -| `repair` | 就地重新整理已安裝的服務並重啟它,而不重新註冊。 | +| `repair` | 就地重新整理已安裝的服務並重啟它。正常的 Windows 工作排程器定義會沿用;過時的定義可能會重新註冊並需要提高權限。 | | `restart` | `repair` 的別名。 | | `start` | 啟動已安裝的服務。 | | `stop` | 停止服務並還原原生 Codex。 | @@ -193,7 +193,7 @@ ocx service uninstall 在 PATH 上以輕量自動啟動腳本包裝基於腳本的 `codex` 啟動器。真實的 `codex.exe` 目標保持不動,以避免破壞精確的可執行檔呼叫。 -若已完成的外部 Codex 更新覆寫了已安裝的 shim,下一個普通 `ocx` 指令會備份穩定的新啟動器並在分派前還原 shim。仍在變動中的啟動器保持不動並稍後重試。修復失敗會發出警告但不會使請求的指令失敗;手動後備:`ocx codex-shim install`。將 `codexShimAutoRestore` 設為 `false`,或設定 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` 以進行行程層級的退出。 +若已完成的外部 Codex 更新覆寫了已安裝的 shim,下一個普通 `ocx` 指令會備份穩定的新啟動器並在分派前還原 shim。零副作用的檢查指令 `ocx system codex-cli-update check` 與保留的 `ocx system codex-cli-update` 命名空間中的無效呼叫都不會執行此修復。仍在變動中的啟動器保持不動並稍後重試。修復失敗會發出警告但不會使請求的指令失敗;手動後備:`ocx codex-shim install`。將 `codexShimAutoRestore` 設為 `false`,或設定 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` 以進行行程層級的退出。 | 子指令 | 動作 | | --- | --- | @@ -224,6 +224,8 @@ ocx codex-shim uninstall ## 更新 +`ocx update` 更新的是 OpenCodex 本身,而不是 Codex CLI。請使用 [system 檢查指令](/zh-tw/reference/cli/agents/)中的 `ocx system codex-cli-update check`,對已設定的 Codex CLI 候選項進行有界、唯讀的 provenance 檢查。此命令不會查詢 package registry,也不會安裝更新。 + ### `ocx update [--tag latest|preview]` 從 npm 自我更新 opencodex。穩定安裝使用 `@latest`;預覽安裝停留在 `@preview`,除非你傳入 `--tag latest|preview`。它偵測原始碼 checkout 並告訴你改用 diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index 0869cb4855..4ebcb92034 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -6,8 +6,9 @@ Sources: - Additional candidates copied from `devlog/_plan/260705_provider-quota-dashboard/svg-candidates`. License/source notes for the additional candidates are recorded in -`devlog/_plan/260705_provider-quota-dashboard/21_svg_candidates.md` and its -`svg-candidates/manifest.json`. +`devlog/_fin/260705_provider-quota-dashboard/21_svg_candidates.md` and its +`svg-candidates/manifest.json` (that unit has since closed, so the path is under +`_fin/` rather than `_plan/`). Export-client marks (used by the API tab's connect rows, not the provider list): @@ -16,3 +17,44 @@ Export-client marks (used by the API tab's connect rows, not the provider list): (formerly `badlogic/pi-mono`). - `opencode.svg` — part of the existing baseline above; the API tab reuses it as the OpenCode export-client mark. +- `oh-my-pi.svg` — fetched 2026-08-31 from `https://omp.sh/favicon.svg`, the Oh My Pi + project's own favicon, unmodified. Oh My Pi is `can1357/oh-my-pi`. +- `openclaw.svg` — fetched 2026-08-31 from + `https://raw.githubusercontent.com/openclaw/openclaw/main/ui/public/favicon.svg`, + the OpenClaw project's own favicon, unmodified. OpenClaw is `openclaw/openclaw`. +- `deepseek-harness.svg` — fetched 2026-08-31 from + `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/website/public/favicon.svg`, + unmodified. DSH is first-party DeepSeek: they publish + `deepseek-ai/deepseek-harness` and scope its packages `@deepseek-ai/dsh-*`. This is + the harness's own mark, deliberately not the `deepseek-color.svg` provider logo. +- `prime-agent.svg` — fetched 2026-08-31 from + `https://raw.githubusercontent.com/PrimeIntellect-ai/prime-agent/main/assets/brand/prime-butterfly.svg`, + unmodified (it carries its authoring editor's metadata). Prime Agent is + `PrimeIntellect-ai/prime-agent`. It has its own mark, so `pi.svg` is not reused for + it even though Prime reads Pi's config contract. +- `zcode.svg` — fetched 2026-08-31 from + `https://z-cdn.chatglm.cn/z-ai/static/logo.svg`, Z.ai's own logo, unmodified (it + carries its authoring tool's generator comment). +- `kimi-color.svg` — already in the baseline as a provider icon; the API tab reuses + it for the Kimi Code client, which is the same Moonshot AI brand. +- `aside.svg` — extracted 2026-08-31 from the installed Aside application, module + `Contents/Frameworks/Aside Framework.framework/Versions/1.0.825.1/Libraries/AsideAgentManager/assets/official-brand-symbol-*.js`. + It is Aside's own brand symbol, named as such by the vendor and rendered by + Aside's onboarding, permission, and settings surfaces. The module is a compiled + React component rather than a file, so the single 24x24 `evenodd` path was + lifted verbatim into a standalone SVG with its original `viewBox` and its + `currentColor` fill; no path data was redrawn. Aside does not publish this mark + on the web (`aside.com/favicon.svg` is a 404), so the shipping application is + the first-party source. + +Two export clients deliberately have NO mark and render a monogram instead, +because the rule is that a client without a real first-party asset gets a +monogram rather than a borrowed or unreliable one: + +- `gajae` — Gajae Code (`Yeachan-Heo/gajae-code`) publishes only raster marks: a + mascot PNG, a vertical logo PNG, and a base64 PNG favicon. Every asset here is + SVG and a lone raster would not hold up at 20px across densities. +- `hermes` — the favicon at `NousResearch/hermes-agent` is a 113-byte SVG whose + entire body is a `` element rendering one unicode glyph. It has no path + data, so it renders differently per machine and blank where the glyph is + missing. It passes an automated SVG check and is still not a brand mark. diff --git a/gui/public/provider-icons/aside.svg b/gui/public/provider-icons/aside.svg new file mode 100644 index 0000000000..a303dd7e60 --- /dev/null +++ b/gui/public/provider-icons/aside.svg @@ -0,0 +1,3 @@ + + + diff --git a/gui/public/provider-icons/deepseek-harness.svg b/gui/public/provider-icons/deepseek-harness.svg new file mode 100644 index 0000000000..653b77e157 --- /dev/null +++ b/gui/public/provider-icons/deepseek-harness.svg @@ -0,0 +1,3 @@ + + + diff --git a/gui/public/provider-icons/oh-my-pi.svg b/gui/public/provider-icons/oh-my-pi.svg new file mode 100644 index 0000000000..553490c5e9 --- /dev/null +++ b/gui/public/provider-icons/oh-my-pi.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/gui/public/provider-icons/openclaw.svg b/gui/public/provider-icons/openclaw.svg new file mode 100644 index 0000000000..dfa44629a2 --- /dev/null +++ b/gui/public/provider-icons/openclaw.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/public/provider-icons/prime-agent.svg b/gui/public/provider-icons/prime-agent.svg new file mode 100644 index 0000000000..e0009d14ce --- /dev/null +++ b/gui/public/provider-icons/prime-agent.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/gui/public/provider-icons/zcode.svg b/gui/public/provider-icons/zcode.svg new file mode 100644 index 0000000000..4f511bd72f --- /dev/null +++ b/gui/public/provider-icons/zcode.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 7eb44e2091..4fa4bce1b5 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -98,6 +98,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/mcode", "integrations/zcode", "integrations/prime", + "integrations/aside", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/ClientConfigRow.tsx b/gui/src/components/apikeys-workspace/ClientConfigRow.tsx index 5e8e845087..cf2d388361 100644 --- a/gui/src/components/apikeys-workspace/ClientConfigRow.tsx +++ b/gui/src/components/apikeys-workspace/ClientConfigRow.tsx @@ -7,7 +7,7 @@ */ import { useCallback, useEffect, useState } from "react"; import { useT } from "../../i18n/shared"; -import { CLIENT_LABEL_KEYS, CLIENT_MARKS, type ClientConfigEnvelope, type ExportClientId } from "./client-config-clients"; +import { CLIENT_LABEL_KEYS, CLIENT_MARKS, MONOCHROME_CLIENT_MARKS, type ClientConfigEnvelope, type ExportClientId } from "./client-config-clients"; export default function ClientConfigRow({ client, @@ -77,6 +77,7 @@ export default function ClientConfigRow({ const label = t(CLIENT_LABEL_KEYS[client]); const mark = CLIENT_MARKS[client]; + const monochrome = MONOCHROME_CLIENT_MARKS.has(client); // The server renders the client's own format; re-serializing as JSON here // would hand a TOML or YAML client bytes its parser cannot read. const json = data?.text ?? ""; @@ -89,7 +90,9 @@ export default function ClientConfigRow({
  • + ); +} + +export function RollbackHistory({ + rows, + showClient, + onRestore, +}: { + rows: readonly IntegrationJournalRow[]; + showClient?: boolean; + onRestore: (row: IntegrationJournalRow) => void; +}) { + const t = useT(); + const [shown, setShown] = useState(PAGE); + const [newest, ...older] = rows; + if (!newest) return null; + + const visible = older.slice(0, shown); + const remaining = older.length - visible.length; + + return ( +
    +
      + +
    + {older.length > 0 && ( +
    + {t("integrations.rollback.older")} +
      + {visible.map(row => ( + + ))} +
    + {remaining > 0 && ( + + )} +
    + )} +
    + ); +} diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 0df3ba18b2..42a3613cbb 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -12,6 +12,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "mcode", "zcode", "prime", + "aside", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts new file mode 100644 index 0000000000..d9dc11346c --- /dev/null +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -0,0 +1,62 @@ +/** + * The Integrations tab strip and the set of tabs backed by a file client. + * + * A separate module rather than exports on Integrations.tsx, because a file that + * exports both a component and constants breaks React fast refresh + * (react/only-export-components). These need to be importable: they are the only + * client lists in the GUI that neither tests/integrations-invariants.test.ts + * compares nor the compiler forces, so a client added everywhere else still gets + * no tab and nothing fails. gui/tests/integrations-tab-coverage.test.ts stands in + * that gap and reads them from here. + */ +import type { TKey } from "../../i18n/shared"; +import type { FileIntegrationClientId } from "./FileIntegrationPage"; + +export type IntegrationTab = + | "overview" + | "keys" + | "codex" + | "claude" + | "grok" + | FileIntegrationClientId; + +export interface TabDefinition { + id: IntegrationTab; + hash: string; + labelKey: TKey; +} + +export const TABS: readonly TabDefinition[] = [ + { id: "overview", hash: "integrations", labelKey: "integrations.tab.overview" }, + { id: "keys", hash: "integrations/keys", labelKey: "integrations.tab.keys" }, + { id: "codex", hash: "integrations/codex", labelKey: "integrations.tab.codex" }, + { id: "claude", hash: "integrations/claude", labelKey: "integrations.tab.claude" }, + { id: "grok", hash: "integrations/grok", labelKey: "integrations.tab.grok" }, + { id: "opencode", hash: "integrations/opencode", labelKey: "integrations.tab.opencode" }, + { id: "pi", hash: "integrations/pi", labelKey: "integrations.tab.pi" }, + { id: "omp", hash: "integrations/omp", labelKey: "integrations.tab.omp" }, + { id: "hermes", hash: "integrations/hermes", labelKey: "integrations.tab.hermes" }, + { id: "openclaw", hash: "integrations/openclaw", labelKey: "integrations.tab.openclaw" }, + { id: "kimi", hash: "integrations/kimi", labelKey: "integrations.tab.kimi" }, + { id: "gajae", hash: "integrations/gajae", labelKey: "integrations.tab.gajae" }, + { id: "dsh", hash: "integrations/dsh", labelKey: "integrations.tab.dsh" }, + { id: "mcode", hash: "integrations/mcode", labelKey: "integrations.tab.mcode" }, + { id: "zcode", hash: "integrations/zcode", labelKey: "integrations.tab.zcode" }, + { id: "prime", hash: "integrations/prime", labelKey: "integrations.tab.prime" }, + { id: "aside", hash: "integrations/aside", labelKey: "integrations.tab.aside" }, +] as const; + +export const FILE_CLIENTS = new Set([ + "opencode", + "pi", + "omp", + "hermes", + "openclaw", + "kimi", + "gajae", + "dsh", + "mcode", + "zcode", + "prime", + "aside", +]); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index ecdf2788ba..b454e47bd9 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -17,6 +17,7 @@ import type { VisualIntegrationState } from "./IntegrationStateBadge"; import { FILE_INTEGRATION_CLIENTS, type FileIntegrationClientId, + type IntegrationJournalRow, type IntegrationStatus, } from "./integration-api"; import type { NativeIntegrationClientId, NativeStatus } from "./native-api"; @@ -147,9 +148,24 @@ const FILE_LABEL_KEY: Record = { mcode: "integrations.tab.mcode", zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", + aside: "integrations.tab.aside", }; /** A file client's block is in the file for both `current` and `stale`. */ +/** + * Journal operation kinds to their labels. + * + * Lives here rather than beside the rollback components because a module that + * exports both a component and a constant breaks React fast refresh, and both + * Integrations surfaces plus their tests need this map. + */ +export const JOURNAL_KIND_KEY: Record = { + apply: "integrations.kind.apply", + disable: "integrations.kind.disable", + refresh: "integrations.kind.refresh", + restore: "integrations.kind.restore", +}; + export function isAppliedState(state: VisualIntegrationState): boolean { return state === "current" || state === "stale"; } diff --git a/gui/src/styles-apikeys-workspace.css b/gui/src/styles-apikeys-workspace.css index ae747aa1ca..ad027753eb 100644 --- a/gui/src/styles-apikeys-workspace.css +++ b/gui/src/styles-apikeys-workspace.css @@ -492,6 +492,28 @@ border-radius: var(--radius-sm); } +/* Single-ink marks are drawn as a mask tinted with the row's text color, so the + ink follows the theme instead of the file. A white-on-transparent logo would + otherwise be invisible in light mode, and a near-black one invisible in dark. + The mask sits inside the slot rather than filling it: unlike the plated brand + SVGs, a silhouette has no plate of its own to align to the slot edge. */ +.awi-clientconfig-mark-mask { + width: 20px; + height: 20px; + background: var(--text); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + +.awi-clientconfig-mark:has(.awi-clientconfig-mark-mask) { + border-color: transparent; + background: none; +} + /* Fallback for a client with no brand asset. Honest about being a placeholder, and the slot a real mark drops into later. */ .awi-clientconfig-monogram { diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css index 121b844fff..e75ad10e66 100644 --- a/gui/src/styles-integrations.css +++ b/gui/src/styles-integrations.css @@ -46,7 +46,7 @@ .integration-empty { padding: 20px; border: 1px dashed var(--border); border-radius: var(--radius); text-align: center; color: var(--muted); } .integration-client-head { display: flex; align-items: center; gap: 10px; } -.integration-client-head h4 { margin: 0; } +.integration-client-head h3 { margin: 0; } .integration-client-head .switch { margin-left: auto; } /* A config path is the thing a user copies when a refusal tells them to @@ -58,12 +58,42 @@ .integration-card .integration-path, .integration-card .integration-meta { margin: 0; min-height: calc(var(--text-caption) * var(--leading-ui) * 2); } -.integration-history { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-direction: column; gap: 6px; } -.integration-history li { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); } +/* + One boundary, separators inside. + + The journal used to give every row its own border and radius, which read as a + stack of loose strips rather than a list — and both surfaces rendered up to + fifty of them under the real controls. A single container border with + `border-top` separators states the same grouping once. +*/ +.integration-history { margin: 8px 0; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--raised); overflow: hidden; } +.integration-history-list { list-style: none; padding: 0; margin: 0; } +.integration-history-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 12px; } +/* Separators only BETWEEN rows: the container already draws the outer edges, + and the disclosure below supplies its own top rule. */ +.integration-history-list .integration-history-row + .integration-history-row { border-top: 1px solid var(--border-soft); } +.integration-history-older { border-top: 1px solid var(--border-soft); } +.integration-history-older > summary { padding: 8px 12px; color: var(--muted); font-size: var(--text-caption); cursor: pointer; } +.integration-history-older > summary:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: -2px; } +/* The disclosure's own rows sit under the summary, so the first one needs the + separator the sibling selector above cannot give it. */ +.integration-history-older .integration-history-row:first-child { border-top: 1px solid var(--border-soft); } +.integration-history-more { margin: 8px 12px; } .integration-history-kind { font-family: var(--font-code); font-size: var(--text-caption); font-weight: var(--weight-semibold); } .integration-history-client { font-size: var(--text-caption); color: var(--muted); } .integration-history-at { font-size: var(--text-caption); color: var(--muted); margin-right: auto; } +/* + At 320-390px the timestamp and the action stop fitting on the label's line. + `margin-right: auto` on the timestamp keeps pushing the button to the far + edge of whatever line it lands on, so the row needs an explicit stacked + arrangement rather than relying on wrap alone. +*/ +@media (max-width: 420px) { + .integration-history-at { margin-right: 0; flex: 1 1 100%; } + .integration-history-row .btn { margin-left: auto; } +} + .integration-restore-dialog .integration-path { margin: 10px 0; } /* Consequence facts are read in a fixed order; spacing separates the four diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index d8eb6fb587..6915b6c680 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -170,11 +170,12 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut .find(el => el.textContent?.trim() === label)!; } -test("the API download surface includes DSH and MiniMax Code as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); +test("the API download surface includes DSH, MiniMax Code and Aside as clients", () => { + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode"); + expect(CLIENT_LABEL_KEYS.aside).toBe("api.clientConfig.clientAside"); }); test("each row fetches its own client and its dialog renders that client's exact bytes", async () => { @@ -273,25 +274,47 @@ test("dialog closes on Escape and returns focus to its trigger", async () => { }); test("each client row shows its own brand mark, never a borrowed one", async () => { - // OpenCode and Pi ship real assets; the clients added later have none yet and - // fall back to a monogram tile. The rule this guards is that no client ever - // borrows another product's logo — not that every client has an asset. + // Nine clients ship a real first-party asset; gajae and hermes have none that + // qualifies and fall back to a monogram tile. The rule this guards is that no + // client ever borrows another product's logo — not that every client has an + // asset. Uniqueness is the teeth: a borrowed logo would show up twice. + // + // A mark reaches the DOM one of two ways. A plated brand SVG is an ; a + // single-ink silhouette is a masked span so the theme supplies its color. Both + // are collected here, because asserting only would let a masked mark go + // missing, or two of them collide, without failing. stubRoute(client => Response.json(client === "pi" ? PI_ENVELOPE : OPENCODE_ENVELOPE)); const { root, container } = await mountPanel(); - expect(row(container, "OpenCode").querySelector("img")?.getAttribute("src")) - .toBe("/provider-icons/opencode.svg"); + // OpenCode is monochrome, so its mark is masked rather than an . + const opencodeMark = row(container, "OpenCode").querySelector(".awi-clientconfig-mark-mask"); + expect(opencodeMark).not.toBeNull(); + expect(opencodeMark!.style.maskImage || opencodeMark!.style.webkitMaskImage) + .toContain("/provider-icons/opencode.svg"); expect(row(container, "Pi").querySelector("img")?.getAttribute("src")) .toBe("/provider-icons/pi.svg"); - // Every rendered mark belongs to the client whose row it sits in. - const sources = [...container.querySelectorAll("img")] - .map(img => img.getAttribute("src")) - .filter((src): src is string => src !== null); + // Every rendered mark belongs to the client whose row it sits in, counting both + // rendering paths. + const imgSources = [...container.querySelectorAll("img")] + .map(img => img.getAttribute("src")); + const maskSources = [...container.querySelectorAll(".awi-clientconfig-mark-mask")] + .map(node => { + const raw = node.style.maskImage || node.style.webkitMaskImage; + return raw.replace(/^url\(["']?/, "").replace(/["']?\)$/, ""); + }); + const sources = [...imgSources, ...maskSources] + .filter((src): src is string => src !== null && src !== ""); + expect(sources.length).toBeGreaterThan(1); expect(new Set(sources).size).toBe(sources.length); // Marks are decoration: the row already names its client in text. for (const img of container.querySelectorAll("img")) { expect(img.getAttribute("alt")).toBe(""); } + // A masked mark is a bare span, so it must not announce itself either; the + // slot around it already carries aria-hidden. + for (const node of container.querySelectorAll(".awi-clientconfig-mark-mask")) { + expect(node.textContent).toBe(""); + } await act(async () => { root.unmount(); }); }); diff --git a/gui/tests/client-marks-assets.test.ts b/gui/tests/client-marks-assets.test.ts new file mode 100644 index 0000000000..59388cc21b --- /dev/null +++ b/gui/tests/client-marks-assets.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { CLIENT_MARKS } from "../src/components/apikeys-workspace/client-config-clients"; + +const PUBLIC_DIR = join(import.meta.dir, "..", "public"); + +/* + * A mark that 404s renders as a broken image, which is worse than the monogram + * it replaced. CLIENT_MARKS is a plain string map, so nothing else checks that + * the file it names was actually committed. + */ +test("every client mark names a file that exists", () => { + const missing = Object.entries(CLIENT_MARKS) + .filter(([, src]) => !existsSync(join(PUBLIC_DIR, src!.replace(/^\//, "")))); + expect(missing).toEqual([]); +}); + +/* + * The Hermes favicon was rejected for exactly this: it passed an SVG parse and a + * render probe while being a single glyph with no path data, so it draws + * differently per machine and blank where the font lacks the character. A mark + * has to carry geometry. + */ +test("every client mark is drawn geometry, not text or an embedded raster", () => { + for (const [clientId, src] of Object.entries(CLIENT_MARKS)) { + const body = readFileSync(join(PUBLIC_DIR, src!.replace(/^\//, "")), "utf8"); + expect(body, `${clientId} mark should not render text`).not.toMatch(/]/); + expect(body, `${clientId} mark should not embed a raster`).not.toMatch(/]/); + expect(body, `${clientId} mark should carry vector geometry`) + .toMatch(/<(path|circle|rect|polygon|ellipse|line|polyline)[\s>]/); + } +}); diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 5264063d3f..73c573184a 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -110,6 +110,8 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientZcode", "integrations.tab.prime", "api.clientConfig.clientPrime", + "integrations.tab.aside", + "api.clientConfig.clientAside", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", @@ -159,6 +161,9 @@ const INTENTIONAL_ENGLISH = new Set([ "lab.observationCount", "lab.verdictCount", "lab.detailObservations", + // "Clients" is the same word in French, and it is the plural noun the + // Integrations page uses to head its client catalog. + "integrations.catalog.title", ]); function placeholders(value: string): string[] { diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 8ef6c8090c..5deffacc3f 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -16,9 +16,9 @@ import { const originalFetch = globalThis.fetch; -test("DSH is a file integration client", () => { +test("DSH and Aside are file integration clients", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index b1e61ed8d5..59676b1f65 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -223,11 +223,12 @@ test("every client counts toward the summary, not just the file clients", () => test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(15); + expect(built.rows).toHaveLength(16); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "mcode").state).toBe("unknown"); expect(rowById(built, "zcode").state).toBe("unknown"); expect(rowById(built, "prime").state).toBe("unknown"); + expect(rowById(built, "aside").state).toBe("unknown"); expect(rowById(built, "kimi").state).toBe("unknown"); expect(rowById(built, "dsh")).toMatchObject({ hash: "integrations/dsh", diff --git a/gui/tests/integrations-rollback-history.test.tsx b/gui/tests/integrations-rollback-history.test.tsx new file mode 100644 index 0000000000..5cdce4fee9 --- /dev/null +++ b/gui/tests/integrations-rollback-history.test.tsx @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { RollbackHistory } from "../src/pages/integrations/RollbackHistory"; +import type { IntegrationJournalRow } from "../src/pages/integrations/integration-api"; + +/** + * The rollback journal's shape, not its wire contract. + * + * The server caps the journal at 50 rows and both Integrations surfaces mapped + * all of them into individually bordered strips under the real controls. These + * tests pin the three properties that fix stops it recurring: the newest row is + * reachable without opening anything, the rest are collapsed, and they reveal a + * page at a time rather than all at once. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; + +/* + * No `as` cast. An assertion here would let a fixture name a client this build + * does not have -- `gui/tests` sits outside every tsconfig `include`, so a bad + * literal would not even be caught by typecheck, and the test would pass while + * documenting a client that does not exist. + */ +function row(overrides: Partial & { opId: string }): IntegrationJournalRow { + return { + clientId: "hermes", + kind: "apply", + at: "2026-08-31T10:00:00.000Z", + configPath: "/tmp/home/.hermes/config.yaml", + snapshot: "stored", + undoable: false, + ...overrides, + }; +} + +/** Newest first, which is the order the journal route returns. */ +function rows(count: number): IntegrationJournalRow[] { + return Array.from({ length: count }, (_, index) => row({ + opId: `op-${index}`, + at: new Date(Date.UTC(2026, 7, 31, 10, 0, 0) - index * 60_000).toISOString(), + })); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mount( + journal: IntegrationJournalRow[], + options: { showClient?: boolean; onRestore?: (value: IntegrationJournalRow) => void } = {}, +) { + await act(async () => { + root = createRoot(container); + root.render( + + {})} + /> + , + ); + }); +} + +function visibleRows(): HTMLElement[] { + return Array.from(container.querySelectorAll(".integration-history-row")) as unknown as HTMLElement[]; +} + +function disclosure(): HTMLDetailsElement | null { + return container.querySelector(".integration-history-older") as unknown as HTMLDetailsElement | null; +} + +function buttonByText(text: string): HTMLButtonElement | undefined { + return (Array.from(container.querySelectorAll("button")) as unknown as HTMLButtonElement[]) + .find(button => (button.textContent ?? "").trim() === text); +} + +test("a capped journal renders one row, not fifty", async () => { + // 50 is the server's `listOperations` default, so this is the real worst case. + await mount(rows(50)); + + const details = disclosure(); + expect(details).not.toBeNull(); + expect(details!.open).toBe(false); + // Everything past the newest row is inside the closed disclosure. + expect(visibleRows().filter(node => !node.closest(".integration-history-older"))).toHaveLength(1); +}); + +test("the newest row's action is reachable without expanding anything", async () => { + /* + * This is the whole point of keeping one row visible: the operation a user + * undoes is almost always the one they just performed, and burying it behind + * a disclosure would trade one usability problem for another. + */ + let restored: IntegrationJournalRow | null = null; + await mount([ + row({ opId: "op-newest", undoable: true }), + ...rows(20), + ], { onRestore: value => { restored = value; } }); + + const undo = buttonByText("Undo"); + expect(undo).toBeDefined(); + expect(undo!.closest(".integration-history-older")).toBeNull(); + await act(async () => { undo!.click(); }); + expect(restored?.opId).toBe("op-newest"); +}); + +test("older rows reveal a page at a time", async () => { + await mount(rows(20)); + const details = disclosure()!; + await act(async () => { details.open = true; }); + + const inside = () => visibleRows().filter(node => node.closest(".integration-history-older")); + // PAGE = 6, matching ClaudeDesktop's lane. + expect(inside()).toHaveLength(6); + + await act(async () => { buttonByText("Show 6 more")!.click(); }); + expect(inside()).toHaveLength(12); + + await act(async () => { buttonByText("Show 6 more")!.click(); }); + expect(inside()).toHaveLength(18); + + // 19 older rows: the last reveal is partial and says so. + await act(async () => { buttonByText("Show 1 more")!.click(); }); + expect(inside()).toHaveLength(19); + expect(buttonByText("Show 1 more")).toBeUndefined(); +}); + +test("a journal that fits shows no disclosure at all", async () => { + await mount(rows(1)); + expect(disclosure()).toBeNull(); + expect(visibleRows()).toHaveLength(1); +}); + +test("an expired snapshot offers no control anywhere in the list", async () => { + await mount([ + row({ opId: "op-live", undoable: true }), + row({ opId: "op-gone", snapshot: "expired" }), + ]); + await act(async () => { disclosure()!.open = true; }); + + const expired = visibleRows().find(node => (node.textContent ?? "").includes("Backup expired")); + expect(expired).toBeDefined(); + expect(expired!.querySelector("button")).toBeNull(); +}); + +test("the overview names the client on every row; a client tab does not", async () => { + /* + * The overview is the only surface showing one chronology across clients, so + * a row there is ambiguous without its client. On a client tab the heading + * already says it. + */ + await mount([row({ opId: "op-a", clientId: "dsh" })], { showClient: true }); + expect(container.querySelector(".integration-history-client")?.textContent).toBe("dsh"); + + await act(async () => { root!.unmount(); root = null; }); + await mount([row({ opId: "op-a", clientId: "dsh" })]); + expect(container.querySelector(".integration-history-client")).toBeNull(); +}); + +test("rows share one boundary instead of one border each", async () => { + /* + * The visual complaint was texture, not count: every row carried its own + * border and radius, so a dozen of them read as loose stacked strips. The + * container owns the boundary now, which is a structural fact the stylesheet + * depends on. + */ + await mount(rows(3)); + expect(container.querySelector(".integration-history")).not.toBeNull(); + expect(container.querySelectorAll(".integration-history-list").length).toBeGreaterThan(0); +}); + +test("the disclosure is keyboard-operable and its summary is the only added tab stop", async () => { + /* + * Collapsing rows behind
    hides them from the accessibility tree and + * from Tab in a real browser. happy-dom keeps closed-
    children in the + * DOM, so a presence assertion cannot prove reachability -- these assertions + * pin the two structural facts that DO carry it: the disclosure is a native + *
    with a real , which is natively focusable and operable by + * Enter/Space. A div-with-onClick would leave the older rows unreachable by + * keyboard while every DOM-presence assertion still passed. + */ + await mount(rows(20)); + const details = disclosure()!; + expect(details.tagName).toBe("DETAILS"); + const summary = details.querySelector("summary"); + expect(summary).not.toBeNull(); + // A div+onClick would render the older rows unreachable by keyboard. + expect(summary!.tagName).toBe("SUMMARY"); + + // The summary is the ONE tab stop the collapse adds: no other node in the + // region takes one, so the disclosure costs a keyboard user a single keystroke. + const region = container.querySelector(".integration-history")!; + const extraStops = Array.from(region.querySelectorAll("[tabindex]")) + .filter(node => node.getAttribute("tabindex") !== "-1"); + expect(extraStops).toHaveLength(0); +}); diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index bc1d445e55..c4f7b4b736 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -720,3 +720,33 @@ test("a loopback-only refusal is localized, not the server's English message", a }) as Parameters[0], refusal); expect(english).toContain("kimi"); }); +test("a populated overview journal collapses instead of flooding the page", async () => { + /* + * The overview already carries a summary strip, a credential row and fifteen + * cards. It also rendered every row the journal returned — up to the route's + * fifty — as individually bordered strips below them, which is what buried + * the one control a user reaches for after a mistake. + */ + journalRows = Array.from({ length: 30 }, (_, index) => ({ + opId: `op-${index}`, + clientId: "hermes", + kind: "apply" as const, + at: new Date(Date.UTC(2026, 7, 31, 10, 0, 0) - index * 60_000).toISOString(), + configPath: "/tmp/home/.hermes/config.yaml", + snapshot: "stored" as const, + undoable: index === 0, + })); + await mountOverview(); + + const outside = Array.from(container.querySelectorAll(".integration-history-row")) + .filter(node => !(node as unknown as HTMLElement).closest(".integration-history-older")); + expect(outside).toHaveLength(1); + // The newest operation's Undo stays a click away, not a disclosure away. + expect(buttonByText("Undo")).toBeDefined(); + const details = container.querySelector(".integration-history-older") as unknown as HTMLDetailsElement; + expect(details).not.toBeNull(); + expect(details.open).toBe(false); + // The cross-client chronology is still THERE, just folded. + await act(async () => { details.open = true; }); + expect(container.querySelectorAll(".integration-history-older .integration-history-row").length).toBeGreaterThan(1); +}); diff --git a/gui/tests/integrations-tab-coverage.test.ts b/gui/tests/integrations-tab-coverage.test.ts new file mode 100644 index 0000000000..91f24af2b9 --- /dev/null +++ b/gui/tests/integrations-tab-coverage.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { FILE_CLIENTS, TABS } from "../src/pages/integrations/integration-tabs"; +import { FILE_INTEGRATION_CLIENTS } from "../src/pages/integrations/integration-api"; +import { INTEGRATION_TAB_HASHES } from "../src/app-routing"; + +/* + * The gap this closes. + * + * tests/integrations-invariants.test.ts compares five client lists, and the + * per-page label maps are Record so the compiler + * forces those. TABS and FILE_CLIENTS are neither: they are a plain array and a + * plain Set, so a client added everywhere else still gets no tab and nothing + * fails. Aside was the twelfth client to walk this path, and the first with a + * test standing in it. + * + * The expectation is DERIVED rather than written out, so adding client thirteen + * cannot leave a stale literal here that passes by accident. + */ +test("every file client has a tab definition and is registered as a file client", () => { + const tabbed = new Set(TABS.map(tab => tab.id)); + const missingTab = FILE_INTEGRATION_CLIENTS.filter(id => !tabbed.has(id)); + expect(missingTab).toEqual([]); + + const missingFileClient = FILE_INTEGRATION_CLIENTS.filter(id => !FILE_CLIENTS.has(id)); + expect(missingFileClient).toEqual([]); +}); + +test("every tab hash is routable, so a tab can actually be reached", () => { + // App normalization strips an unregistered hash, which would render the + // overview instead of the tab and look like a missing client. + const routable = new Set(INTEGRATION_TAB_HASHES); + const unroutable = TABS.filter(tab => tab.hash !== "integrations" && !routable.has(tab.hash)); + expect(unroutable.map(tab => tab.hash)).toEqual([]); +}); + +test("FILE_CLIENTS carries no id the API does not know", () => { + const known = new Set(FILE_INTEGRATION_CLIENTS); + expect([...FILE_CLIENTS].filter(id => !known.has(id))).toEqual([]); +}); diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index d52e9923ae..047702d324 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -126,6 +126,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "api.clientConfig.clientZcode", "integrations.tab.prime", "api.clientConfig.clientPrime", + "integrations.tab.aside", + "api.clientConfig.clientAside", "integrations.codex.title", // Provider proper nouns kept in English "provider.name.commandCodeAuth", diff --git a/gui/tests/models-native-group-controls.test.ts b/gui/tests/models-native-group-controls.test.ts index 0460234089..e54190ea93 100644 --- a/gui/tests/models-native-group-controls.test.ts +++ b/gui/tests/models-native-group-controls.test.ts @@ -23,6 +23,16 @@ test("a provider with no native rows is not a native group", () => { expect(groups[0]!.nativeProviderGroup).toBe(false); }); +test("the native provider group carries the additive entitlement diagnostic", () => { + const entitlement = { status: "failed", reason: "timeout" } as const; + const groups = buildProviderModelGroups( + [nativeRow("gpt-5.6-sol")], + [{ name: "openai", entitlement }], + ); + + expect(groups[0]!.entitlement).toEqual(entitlement); +}); + test("the native card keeps sorting first once a custom row joins it", () => { const groups = buildProviderModelGroups( [{ provider: "anthropic", id: "opus", native: false }, nativeRow("gpt-5.6-sol"), customRow("gpt-5.4")], diff --git a/package.json b/package.json index b0f39a14aa..5fc493b829 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.37.0", + "version": "2.38.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 3a5a2ad989..eea5f96421 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -300,6 +300,23 @@ JSON mode: `payload`. - The GUI reads this state directly; without a verb an agent could not tell whether the Codex app-server was reachable at all. +### `ocx system codex-cli-update check` + +Inspect a configured Codex CLI candidate and its ownership provenance. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the redacted provenance report as JSON. | + +JSON mode: `envelope`. + +- Proof-bound published-launcher context authenticates the configured candidate snapshot, not successful Codex execution; this check does not attest or admit a selected runtime. +- On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; every other Windows candidate fails closed. +- Makes no package-registry request. +- Does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state. + ### `ocx claude desktop status` Applied-vs-desired Claude Desktop state, including staleness, drift, and health. @@ -530,6 +547,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 29 +- declared capabilities: 30 - of those, state-changing: 11 - head-resolved invocations: 2 diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 494e4e48ca..3efe36ecb3 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -56,6 +56,7 @@ import { cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote, buildCursorToolDefinitions, + cursorToolWireName, cursorRequestHasShellAlias, CURSOR_SHELL_ALIAS_SYSTEM_NOTE, OCX_RESPONSES_TOOL_PROVIDER, @@ -1091,7 +1092,9 @@ function toolCallStep( ): Uint8Array { const args: Record = {}; for (const [key, value] of Object.entries(part.arguments ?? {})) args[key] = argBytes(value); - const toolName = namespacedToolName(part.namespace, part.name); + // Replay the same provider-isolated identity advertised in this request. Returned calls are + // restored to the client name, so transcript parts carry the client name again on the next turn. + const toolName = cursorToolWireName(part); const decodedResult = result ? decodeResultParts(result) : undefined; const serialize = (maxImages: number): Uint8Array => toBinary(ConversationStepSchema, create(ConversationStepSchema, { message: { diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index ae9bfcec4b..ac8894f67a 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -12,6 +12,7 @@ export const CODEX_SHELL_COMMAND_TOOL = "shell_command"; export const CODEX_UNIFIED_EXEC_TOOL = "exec"; export const CODEX_WAIT_TOOL = "wait"; export const CODEX_APPLY_PATCH_TOOL = "apply_patch"; +export const CODEX_TOOL_SEARCH_TOOL = "tool_search"; export const CURSOR_EDIT_FILE_TOOL = "edit_file"; export const CURSOR_MULTI_EDIT_TOOL = "multi_edit"; export const CURSOR_STRUCTURED_EDIT_TOOLS = [CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL] as const; @@ -169,8 +170,10 @@ function cursorToolChoiceMatches( } return tool.name === choiceName || cursorToolWireName(tool) === choiceName; } - if (tool.name === choiceName || cursorToolWireName(tool) === choiceName) return true; - return cursorToolChoiceAliases(tool).includes(choiceName); + if (tool.name === choiceName) return true; + if (cursorToolChoiceAliases(tool).includes(choiceName)) return true; + return cursorToolWireName(tool) === choiceName + && !catalog.some(candidate => candidate.name === choiceName); } export function isBareCodexShellBridgeTool(tool: Pick): boolean { @@ -320,10 +323,39 @@ export function cursorRequestAdvertisesStructuredEdits( return cursorStructuredEditTools(tools, toolChoice).length > 0; } +const CURSOR_CLIENT_TOOL_WIRE_PREFIX = "ocx_client_"; +const CURSOR_PROXY_OWNED_BARE_TOOL_NAMES = new Set([ + CODEX_UNIFIED_EXEC_TOOL, + CODEX_WAIT_TOOL, + CODEX_EXEC_COMMAND_TOOL, + CODEX_SHELL_COMMAND_TOOL, + CODEX_APPLY_PATCH_TOOL, + CURSOR_EDIT_FILE_TOOL, + CURSOR_MULTI_EDIT_TOOL, + CODEX_TOOL_SEARCH_TOOL, +]); + +/** Avoid collisions with Cursor's private bare-tool namespace. */ +function isCursorBareClientToolWireAliased( + tool: Pick, +): boolean { + return !tool.namespace + && !CURSOR_PROXY_OWNED_BARE_TOOL_NAMES.has(tool.name); +} + export function cursorToolWireName(tool: Pick): string { + if (isCursorBareClientToolWireAliased(tool)) { + return `${CURSOR_CLIENT_TOOL_WIRE_PREFIX}${tool.name}`; + } return namespacedToolName(tool.namespace, tool.name); } +function clientSemanticToolNameFromCursorWire(name: string): string { + return name.startsWith(CURSOR_CLIENT_TOOL_WIRE_PREFIX) + ? name.slice(CURSOR_CLIENT_TOOL_WIRE_PREFIX.length) + : name; +} + /** * Cursor's harness shows MCP tools to the model as `mcp__`; models * sometimes call that display name verbatim instead of the advertised short name (live 20:41/21:00 @@ -591,7 +623,7 @@ function quotedNames(names: readonly string[]): string { } function advertisedCoversNeighbor(wireNames: readonly string[], neighbor: (typeof NEIGHBOR_AGENT_TOOL_NAMES)[number]): boolean { - const advertised = new Set(wireNames.map(name => name.toLowerCase())); + const advertised = new Set(wireNames.map(name => clientSemanticToolNameFromCursorWire(name).toLowerCase())); if (advertised.has(neighbor.toLowerCase())) return true; return NEIGHBOR_AGENT_TOOL_ALIASES[neighbor].some(alias => advertised.has(alias.toLowerCase())); } @@ -602,7 +634,7 @@ function unavailableNeighborAgentToolNames(wireNames: readonly string[]): string function discoveryToolLabel(wireNames: readonly string[]): string | undefined { const labels: string[] = []; - if (wireNames.includes("tool_search")) labels.push("`tool_search`"); + if (wireNames.includes(CODEX_TOOL_SEARCH_TOOL)) labels.push(`\`${CODEX_TOOL_SEARCH_TOOL}\``); if (wireNames.some(name => name.startsWith("mcp__"))) labels.push("MCP"); if (wireNames.some(name => /resource/i.test(name))) labels.push("resource discovery"); return labels.length > 0 ? labels.join(", ") : undefined; diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 76b85e0690..e1569bf467 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -418,6 +418,20 @@ export const CAPABILITIES: readonly Capability[] = [ "The GUI reads this state directly; without a verb an agent could not tell whether the Codex app-server was reachable at all.", ], }, + { + command: ["system", "codex-cli-update", "check"], + summary: "Inspect a configured Codex CLI candidate and its ownership provenance.", + routes: [], + flags: [{ name: "--json", value: "boolean", summary: "Emit the redacted provenance report as JSON." }], + mutates: false, + json: "envelope", + details: [ + "Proof-bound published-launcher context authenticates the configured candidate snapshot, not successful Codex execution; this check does not attest or admit a selected runtime.", + "On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; every other Windows candidate fails closed.", + "Makes no package-registry request.", + "Does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.", + ], + }, { command: ["system", "codex-restart"], summary: "Restart the Codex app-server.", diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts new file mode 100644 index 0000000000..e184fb73e4 --- /dev/null +++ b/src/cli/codex-cli-update.ts @@ -0,0 +1,96 @@ +import { + inspectCodexCliInstall, + type CodexCliInstallProvenanceDeps, + type CodexCliInstallReport, +} from "../codex/cli-install-provenance"; +import { CliUsageError, isJsonOption, printData, runCliAction } from "./runtime-api"; +import { trustedNodeLauncherContext } from "./launcher-context"; + +export const CODEX_CLI_UPDATE_USAGE = `Usage: + ocx system codex-cli-update check [--json]`; + +export type ParsedCodexCliUpdateArgs = Readonly<{ + json: boolean; +}>; + +export interface CodexCliUpdateCommandDeps { + readonly inspectInstall?: (deps: CodexCliInstallProvenanceDeps) => Promise; +} + +function installSummary(report: CodexCliInstallReport): string[] { + return [ + `candidate: ${report.candidateAvailable ? "yes" : "no"}`, + `candidate-source: ${report.candidateSource ?? "unavailable"}`, + `selection-attested: ${report.selectionAttested ? "yes" : "no"}`, + `provenance: ${report.provenance}`, + `managed: ${report.managed ? "yes" : "no"}`, + `reason: ${report.reason}`, + `candidate-version: ${report.candidateVersion ?? "unavailable"}`, + `package-version: ${report.packageVersion ?? "unavailable"}`, + `version-evidence: ${report.versionEvidence.kind}`, + `location: ${report.location ?? "unavailable"}`, + `shim: ${report.shim.status}${report.shim.backingKind ? `/${report.shim.backingKind}` : ""}`, + ]; +} + +export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCliUpdateArgs { + // `--json` is accepted in any argv position CLI-wide, so remove it before positional + // validation. Requiring `check` at index 0 first would reject `--json check`, which + // automation that puts output flags ahead of the subcommand legitimately produces. + let json = false; + const positional: string[] = []; + for (const token of argv) { + if (isJsonOption(token)) { + if (json) throw new CliUsageError("--json may be specified only once", CODEX_CLI_UPDATE_USAGE); + json = true; + continue; + } + positional.push(token); + } + if (positional[0] !== "check") { + throw new CliUsageError("codex-cli-update action must be check", CODEX_CLI_UPDATE_USAGE); + } + if (positional.length > 1) { + throw new CliUsageError("unsupported codex-cli-update argument", CODEX_CLI_UPDATE_USAGE); + } + return Object.freeze({ json }); +} + +export async function handleCodexCliUpdateCommand( + argv: readonly string[], + deps: CodexCliUpdateCommandDeps = {}, +): Promise { + let parsed: ParsedCodexCliUpdateArgs; + try { + parsed = parseCodexCliUpdateArgs(argv); + } catch (error) { + if (error instanceof CliUsageError) { + console.error(`Error: ${error.message}`); + console.error(error.usage ?? CODEX_CLI_UPDATE_USAGE); + return 2; + } + throw error; + } + return runCliAction(async () => { + const trustedInspectionEnv = trustedNodeLauncherContext()?.codexCliInspectionEnv; + const inspectionDeps: CodexCliInstallProvenanceDeps = trustedInspectionEnv + && trustedInspectionEnv.managerRoots !== null ? { + env: { + ...trustedInspectionEnv.managerRoots, + CODEX_CLI_PATH: trustedInspectionEnv.codexCliPath ?? undefined, + PATH: trustedInspectionEnv.path ?? undefined, + PATHEXT: trustedInspectionEnv.pathExt ?? undefined, + }, + configDir: trustedInspectionEnv.configDir, + // This is a fresh one-shot CLI process. Its proof-bound launcher snapshot + // supplies configured candidate evidence, not selected-runtime admission. + } : { + // Direct Bun/source launches have no pre-dotenv proof. Do not inspect + // ambient or persisted candidate state at all. + env: { PATH: "" }, + configDir: ".", + }; + const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(inspectionDeps); + printData(report, parsed.json, installSummary(report)); + }); +} diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index b41e509e86..87b59feefb 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -19,6 +19,9 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str if (command === "uninstall" || command === "remove") return true; // `lab` is read-only inspection; it must not trigger shim side effects. if (command === "lab") return true; + // The entire updater-inspection namespace is zero-effect, including malformed + // or future actions. A later `apply` implementation must own its preflight. + if (command === "system" && args[1] === "codex-cli-update") return true; return command === "codex-shim" && ["install", "uninstall", "remove"].includes(args[1] ?? ""); } diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index d6c8996248..052a1a8841 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -80,28 +80,24 @@ function hasContextLimit(model: ExportModel): boolean { * * `opencodeCatalogFromProxyRows` owns the visibility rules (drop `disabled`, drop dupes, * drop native under Codex Direct) — the export core does none of that, so a row filtered - * here is the only thing keeping a disabled model out of a client's picker. Modalities are - * re-joined by `namespaced` because the launcher's catalog type does not carry them. + * here is the only thing keeping a disabled model out of a client's picker. It also carries + * the effort ladder, so the ladder a client receives comes from the same filtered, deduped + * row as the model itself: a second lookup over the raw rows would let a hidden or disabled + * duplicate donate its ladder to the visible entry. + * + * Only modalities are re-joined by `namespaced`, because the catalog type does not carry them. */ export function exportModelsFromProxyRows( rows: readonly ExportProxyModelRow[], config: OcxConfig, ): ExportModel[] { - const metadata = new Map>(); + const modalities = new Map(); for (const row of rows) { const namespaced = row.namespaced?.trim(); - if (!namespaced || metadata.has(namespaced)) continue; - metadata.set(namespaced, { - ...(Array.isArray(row.inputModalities) && row.inputModalities.length > 0 - ? { inputModalities: [...row.inputModalities] } - : {}), - ...(Array.isArray(row.reasoningEfforts) && row.reasoningEfforts.length > 0 - ? { reasoningEfforts: [...row.reasoningEfforts] } - : {}), - ...(typeof row.defaultReasoningEffort === "string" && row.defaultReasoningEffort.length > 0 - ? { defaultReasoningEffort: row.defaultReasoningEffort } - : {}), - }); + if (!namespaced || modalities.has(namespaced)) continue; + if (Array.isArray(row.inputModalities) && row.inputModalities.length > 0) { + modalities.set(namespaced, [...row.inputModalities]); + } } return opencodeCatalogFromProxyRows(rows, config).map(entry => { const model: ExportModel = { @@ -112,7 +108,12 @@ export function exportModelsFromProxyRows( if (entry.native) model.native = true; if (entry.displayName) model.displayName = entry.displayName; if (entry.contextWindow !== undefined) model.contextWindow = entry.contextWindow; - Object.assign(model, metadata.get(entry.namespaced)); + if (entry.reasoningEfforts && entry.reasoningEfforts.length > 0) { + model.reasoningEfforts = [...entry.reasoningEfforts]; + } + if (entry.defaultReasoningEffort) model.defaultReasoningEffort = entry.defaultReasoningEffort; + const input = modalities.get(entry.namespaced); + if (input) model.inputModalities = [...input]; return model; }); } @@ -203,7 +204,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep "", ...(out !== undefined ? [`Wrote ${out}`] : []), `Destination: ${spec.destination(process.env)}`, - "Merge this provider block into that file; do not replace it.", + "Merge this generated configuration into that file; do not replace it.", `Before launching: ${spec.exportHint}`, `${models.length} model${models.length === 1 ? "" : "s"}; ${degraded} omit context limits (the client applies its own defaults).`, ]); diff --git a/src/cli/help.ts b/src/cli/help.ts index be3d7a8995..cc1ef7cc58 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -73,10 +73,10 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (11 clients) + ocx export --client Print a client config wired to the running proxy (12 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply - ocx system Runtime settings, startup, sync, and updates + ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection ocx config Validated configuration show/get/set/import/export ocx lab Read-only Compatibility Lab projection inspection ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) diff --git a/src/cli/index.ts b/src/cli/index.ts index 40ad1dca3d..56abb5d05a 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -363,11 +363,12 @@ async function handleStart(options: { block?: boolean } = {}) { shutdownStartedAt = now; console.log("\n🛑 Shutting down opencodex proxy..."); void (async () => { + let shutdownSucceeded = false; try { - await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000); + shutdownSucceeded = await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000); } finally { const restored = syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit") - process.exit(restored ? 0 : 1); + process.exit(restored && shutdownSucceeded ? 0 : 1); } })(); }; diff --git a/src/cli/launcher-context.ts b/src/cli/launcher-context.ts index 7395a9677d..091e46d3a9 100644 --- a/src/cli/launcher-context.ts +++ b/src/cli/launcher-context.ts @@ -14,9 +14,19 @@ export const ANTHROPIC_PARENT_ENV_SLOTS = [ ] as const; export type AnthropicParentEnvSlot = typeof ANTHROPIC_PARENT_ENV_SLOTS[number]; +export type CodexCliVersionManagerRootEnvSlot = typeof CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS[number]; + +type CodexCliVersionManagerRoots = Readonly>>; export type TrustedNodeLaunchContext = { anthropicEnvSlots: readonly AnthropicParentEnvSlot[]; + codexCliInspectionEnv: Readonly<{ + codexCliPath: string | null; + path: string | null; + pathExt: string | null; + managerRoots: CodexCliVersionManagerRoots | null; + configDir: string; + }> | null; }; let trustedContext: TrustedNodeLaunchContext | null = null; @@ -25,6 +35,21 @@ function isLaunchProof(value: string): boolean { return /^[A-Za-z0-9_-]{43}$/.test(value); } +function parseVersionManagerRoots(value: unknown): CodexCliVersionManagerRoots | null | undefined { + // Missing means an older launcher. Preserve compatibility for unrelated + // commands, but updater inspection treats the incomplete snapshot as + // untrusted and fails closed. + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const allowed = new Set(CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS); + const entries = Object.entries(value); + if (entries.length > allowed.size || entries.some(([name, root]) => + !allowed.has(name) || typeof root !== "string" || root.length === 0 || root.length > 32 * 1024)) { + return undefined; + } + return Object.freeze(Object.fromEntries(entries)) as CodexCliVersionManagerRoots; +} + /** Consume the internal proof before normal CLI argument parsing. */ export function initializeNodeLauncherContext( argv: string[] = process.argv, @@ -45,7 +70,7 @@ export function initializeNodeLauncherContext( delete env.OCX_PRE_BUN_ANTHROPIC_ENV; trustedContext = null; - if (proofArgs.length !== 1 || !raw || raw.length > 2048) return null; + if (proofArgs.length !== 1 || !raw || raw.length > 64 * 1024) return null; const proof = proofArgs[0]!; if (!isLaunchProof(proof)) return null; @@ -54,6 +79,7 @@ export function initializeNodeLauncherContext( version?: unknown; proof?: unknown; anthropicEnvSlots?: unknown; + codexCliInspectionEnv?: unknown; }; if (parsed.version !== 1 || parsed.proof !== proof || !Array.isArray(parsed.anthropicEnvSlots)) { return null; @@ -65,7 +91,31 @@ export function initializeNodeLauncherContext( if (slots.length !== parsed.anthropicEnvSlots.length || new Set(slots).size !== slots.length) { return null; } - trustedContext = { anthropicEnvSlots: slots }; + const inspection = parsed.codexCliInspectionEnv; + const managerRoots = inspection && typeof inspection === "object" && !Array.isArray(inspection) + ? parseVersionManagerRoots((inspection as Record).managerRoots) + : null; + const codexCliInspectionEnv = inspection === null || inspection === undefined + ? null + : inspection && typeof inspection === "object" && !Array.isArray(inspection) + && ["codexCliPath", "path", "pathExt"].every(key => { + const value = (inspection as Record)[key]; + return value === null || typeof value === "string"; + }) + && typeof (inspection as Record).configDir === "string" + && (inspection as Record).configDir.length > 0 + && (inspection as Record).configDir.length <= 32 * 1024 + && managerRoots !== undefined + ? Object.freeze({ + codexCliPath: (inspection as Record).codexCliPath ?? null, + path: (inspection as Record).path ?? null, + pathExt: (inspection as Record).pathExt ?? null, + managerRoots, + configDir: (inspection as Record).configDir, + }) + : undefined; + if (codexCliInspectionEnv === undefined) return null; + trustedContext = { anthropicEnvSlots: slots, codexCliInspectionEnv }; return trustedContext; } catch { return null; @@ -75,3 +125,4 @@ export function initializeNodeLauncherContext( export function trustedNodeLauncherContext(): TrustedNodeLaunchContext | null { return trustedContext; } +import { CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS } from "../update/codex-cli-update-launch-policy.mjs"; diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 7197391e1f..3a9d2ea68f 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -27,12 +27,17 @@ import { OPENCODE_PROVIDER_ID, buildOpencodeProviderBlockFromCatalog, opencodeGlobalConfigPath, + opencodeProviderBlocks, + opencodeProxyBaseUrl, + opencodeV2ProviderBlock, } from "../clients/config-export"; import type { OpencodeCatalogModel, OpencodeGeneratedConfig, OpencodeLaunchEnv, OpencodeProviderBlock, + OpencodeProviderBlocks, + OpencodeV2ProviderBlock, } from "../clients/config-export"; import { visibleNativeSlugs } from "../codex/catalog"; import { commandInvocation } from "../lib/win-exec"; @@ -57,6 +62,7 @@ export { buildOpencodeProviderBlockFromCatalog, opencodeGlobalConfigPath, opencodeProxyBaseUrl, + opencodeV2ProviderBlock, } from "../clients/config-export"; export type { OpencodeCatalogModel, @@ -64,6 +70,8 @@ export type { OpencodeLaunchEnv, OpencodeModelEntry, OpencodeProviderBlock, + OpencodeProviderBlocks, + OpencodeV2ProviderBlock, } from "../clients/config-export"; /** One proxy-routed model destined for the generated provider block. */ @@ -74,6 +82,8 @@ export interface OpencodeRoutedModel { contextWindow?: number; /** Authoritative display label (CatalogModel.displayName); optional. */ displayName?: string; + /** Declared effort ladder; exported as opencode model variants when present. */ + reasoningEfforts?: readonly string[]; } /** Row shape from authenticated GET /api/models on the running proxy. */ @@ -85,6 +95,10 @@ export interface OpencodeProxyModelRow { disabled?: boolean; displayName?: string; contextWindow?: number; + /** Declared effort ladder from `/api/models`; carried into opencode model variants. */ + reasoningEfforts?: string[]; + /** Declared default effort from `/api/models`. */ + defaultReasoningEffort?: string; } const PROJECT_CONFIG_FILENAMES = ["opencode.json", "opencode.jsonc"] as const; @@ -196,16 +210,13 @@ export function opencodeLaunchNativeSlugs(config: OcxConfig): string[] { return [...visibleNativeSlugs(config)]; } -/** Back-compat helper for unit tests that assemble slugs/routed rows directly. */ -export function buildOpencodeProviderBlock( - port: number, +/** Catalog rows for the slugs/routed models a caller assembled by hand. */ +function opencodeLaunchCatalog( nativeSlugs: readonly string[], routedModels: readonly OpencodeRoutedModel[], - nativeContextWindow: (slug: string) => number | undefined = () => undefined, - hostname?: string, - config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, -): OpencodeProviderBlock { - const catalog: OpencodeCatalogModel[] = [ + nativeContextWindow: (slug: string) => number | undefined, +): OpencodeCatalogModel[] { + return [ ...nativeSlugs.map(id => ({ namespaced: id, native: true, @@ -220,9 +231,71 @@ export function buildOpencodeProviderBlock( id: model.id, contextWindow: model.contextWindow, displayName: model.displayName, + ...(model.reasoningEfforts && model.reasoningEfforts.length > 0 + ? { reasoningEfforts: [...model.reasoningEfforts] } + : {}), })), ]; - return buildOpencodeProviderBlockFromCatalog(port, catalog, hostname, config); +} + +/** Back-compat helper for unit tests that assemble slugs/routed rows directly. */ +export function buildOpencodeProviderBlock( + port: number, + nativeSlugs: readonly string[], + routedModels: readonly OpencodeRoutedModel[], + nativeContextWindow: (slug: string) => number | undefined = () => undefined, + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeProviderBlock { + return buildOpencodeProviderBlockFromCatalog( + port, + opencodeLaunchCatalog(nativeSlugs, routedModels, nativeContextWindow), + hostname, + config, + ); +} + +/** + * V2 counterpart of `buildOpencodeProviderBlock`. The launcher injects both generations, + * because only the V2 block carries selectable reasoning efforts. + */ +export function buildOpencodeV2ProviderBlock( + port: number, + nativeSlugs: readonly string[], + routedModels: readonly OpencodeRoutedModel[], + nativeContextWindow: (slug: string) => number | undefined = () => undefined, + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeV2ProviderBlock { + return opencodeBlocks( + port, + opencodeLaunchCatalog(nativeSlugs, routedModels, nativeContextWindow), + hostname, + config, + ).v2; +} + +/** + * Both generations from one catalog, in one pass. Every production path uses this: the two + * blocks are one document's fragments and have to agree on model set, names, connection, and + * variants, which building them together guarantees instead of merely expecting. + */ +export function buildOpencodeProviderBlocksFromCatalog( + port: number, + catalogModels: readonly OpencodeCatalogModel[], + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeProviderBlocks { + return opencodeBlocks(port, catalogModels, hostname, config); +} + +function opencodeBlocks( + port: number, + catalogModels: readonly OpencodeCatalogModel[], + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeProviderBlocks { + return opencodeProviderBlocks(opencodeProxyBaseUrl(port, hostname), catalogModels, config); } /** Default deadline for authenticated GET /api/models during `ocx opencode` launch. */ @@ -315,6 +388,12 @@ export function opencodeCatalogFromProxyRows( id: row.id, contextWindow: row.contextWindow, displayName: row.displayName, + ...(Array.isArray(row.reasoningEfforts) && row.reasoningEfforts.length > 0 + ? { reasoningEfforts: [...row.reasoningEfforts] } + : {}), + ...(typeof row.defaultReasoningEffort === "string" && row.defaultReasoningEffort.length > 0 + ? { defaultReasoningEffort: row.defaultReasoningEffort } + : {}), }); } return catalog; @@ -330,17 +409,19 @@ export function isOpencodeRuntimeConfigError( } /** - * Merge inherited `OPENCODE_CONFIG_CONTENT` and override only `provider.opencodex`. + * Merge inherited `OPENCODE_CONFIG_CONTENT` and override only our own blocks: + * `provider.opencodex` (V1) and `providers.opencodex` (V2, the one carrying variants). * When no inline layer is present, emit the minimal runtime object for this launcher. */ export function mergeOpencodeRuntimeConfig( inheritedContent: string | undefined, - providerBlock: OpencodeProviderBlock, + blocks: OpencodeProviderBlocks, ): OpencodeGeneratedConfig | OpencodeRuntimeConfigError { if (!inheritedContent?.trim()) { return { $schema: OPENCODE_CONFIG_SCHEMA, - provider: { [OPENCODE_PROVIDER_ID]: providerBlock }, + provider: { [OPENCODE_PROVIDER_ID]: blocks.v1 }, + providers: { [OPENCODE_PROVIDER_ID]: blocks.v2 }, }; } let parsed: unknown; @@ -356,12 +437,20 @@ export function mergeOpencodeRuntimeConfig( if (existingProvider !== undefined && !isRecord(existingProvider)) { return { error: "OPENCODE_CONFIG_CONTENT provider must be a JSON object when present." }; } + const existingProviders = parsed.providers; + if (existingProviders !== undefined && !isRecord(existingProviders)) { + return { error: "OPENCODE_CONFIG_CONTENT providers must be a JSON object when present." }; + } return { ...parsed, $schema: typeof parsed.$schema === "string" ? parsed.$schema : OPENCODE_CONFIG_SCHEMA, provider: { ...(isRecord(existingProvider) ? existingProvider : {}), - [OPENCODE_PROVIDER_ID]: providerBlock, + [OPENCODE_PROVIDER_ID]: blocks.v1, + }, + providers: { + ...(isRecord(existingProviders) ? existingProviders : {}), + [OPENCODE_PROVIDER_ID]: blocks.v2, }, } as OpencodeGeneratedConfig; } @@ -375,10 +464,10 @@ export function buildOpencodeConfig( hostname?: string, config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, ): OpencodeGeneratedConfig { - const merged = mergeOpencodeRuntimeConfig( - undefined, - buildOpencodeProviderBlock(port, nativeSlugs, routedModels, nativeContextWindow, hostname, config), - ); + const merged = mergeOpencodeRuntimeConfig(undefined, { + v1: buildOpencodeProviderBlock(port, nativeSlugs, routedModels, nativeContextWindow, hostname, config), + v2: buildOpencodeV2ProviderBlock(port, nativeSlugs, routedModels, nativeContextWindow, hostname, config), + }); if (isOpencodeRuntimeConfigError(merged)) { throw new Error(merged.error); } @@ -400,11 +489,19 @@ function findGitRoot(start: string): string | null { } } +/** + * True when the file declares our provider in either generation. Both count: the launcher + * overwrites `provider.opencodex` and `providers.opencodex` alike, so a config that carries + * only the V2 block is overridden just as silently as one carrying only the V1 block. + */ function configFileDefinesProvider(path: string): boolean { if (!existsSync(path)) return false; try { const parsed = parseJsonc(readFileSync(path, "utf8")); - return isRecord(parsed) && isRecord(parsed.provider) && OPENCODE_PROVIDER_ID in parsed.provider; + if (!isRecord(parsed)) return false; + const legacy = isRecord(parsed.provider) && OPENCODE_PROVIDER_ID in parsed.provider; + const v2 = isRecord(parsed.providers) && OPENCODE_PROVIDER_ID in parsed.providers; + return legacy || v2; } catch { return false; } @@ -460,16 +557,17 @@ export function opencodeProxyStartEnv(base: OpencodeLaunchEnv = process.env): Op } /** - * Env assembly (unit-tested). Inherited inline config is merged and only - * `provider.opencodex` is replaced; disk config layers stay untouched. The admission - * key travels in the child env rather than in the inline config payload. + * Env assembly (unit-tested). Inherited inline config is merged and only our own blocks are + * replaced — `provider.opencodex` and `providers.opencodex`; disk config layers stay + * untouched. The admission key travels in the child env rather than in the inline config + * payload. */ export function buildOpencodeEnv( - providerBlock: OpencodeProviderBlock, + blocks: OpencodeProviderBlocks, apiKey: string, base: OpencodeLaunchEnv, ): OpencodeLaunchEnv | OpencodeRuntimeConfigError { - const runtimeConfig = mergeOpencodeRuntimeConfig(base[OPENCODE_CONFIG_CONTENT_ENV], providerBlock); + const runtimeConfig = mergeOpencodeRuntimeConfig(base[OPENCODE_CONFIG_CONTENT_ENV], blocks); if (isOpencodeRuntimeConfigError(runtimeConfig)) return runtimeConfig; return { ...base, @@ -547,22 +645,17 @@ export async function cmdOpencode(args: string[]): Promise { return 1; } const catalog = opencodeCatalogFromProxyRows(proxyModels, config); - const providerBlock = buildOpencodeProviderBlockFromCatalog( - live.port, - catalog, - live.hostname, - config, - ); - const baseUrl = providerBlock.options.baseURL; + const blocks = buildOpencodeProviderBlocksFromCatalog(live.port, catalog, live.hostname, config); + const baseUrl = blocks.v1.options.baseURL; const modelCount = catalog.length; console.error(`✅ opencode wired to ${baseUrl} — ${modelCount} model(s) under provider \`${OPENCODE_PROVIDER_ID}\`.`); - console.error(" Your existing opencode config files are left untouched; only the runtime provider block is injected."); + console.error(" Your existing opencode config files are left untouched; only the runtime provider blocks are injected."); const providerOverride = opencodeProviderOverridePath(process.cwd()); if (providerOverride) { - console.error(`ℹ ${providerOverride} also defines provider.${OPENCODE_PROVIDER_ID}; the runtime layer from ocx opencode overrides it for this launch.`); + console.error(`ℹ ${providerOverride} also defines our provider key; the runtime layer from ocx opencode overrides it for this launch.`); } - const builtEnv = buildOpencodeEnv(providerBlock, apiKey, process.env); + const builtEnv = buildOpencodeEnv(blocks, apiKey, process.env); if ("error" in builtEnv) { console.error(`❌ ${builtEnv.error}`); return 1; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 7719f8d5cf..3031ccc544 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -65,7 +65,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ summary: "Run as a background service.", details: [ "With no subcommand, installs when absent or repairs/restarts an existing service.", - "`restart` is an alias of `repair` and does not re-register an installed service.", + "`restart` aliases `repair`; healthy Windows tasks are reused, while stale definitions may re-register and elevate.", "Use `ocx service status` to see diagnostics and log paths.", ], }, @@ -244,8 +244,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", @@ -268,8 +268,13 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "system", - usage: "ocx system ...", - summary: "Manage headless runtime settings, startup, sync, diagnostics, and updates.", + usage: "ocx system ...", + summary: "Manage headless runtime settings, startup, sync, diagnostics, OpenCodex updates, and read-only Codex CLI inspection.", + details: [ + "system update manages OpenCodex itself.", + "ocx system codex-cli-update check [--json]", + "The Codex CLI inspection command makes no package-registry request, does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.", + ], }, { name: "config", @@ -309,11 +314,12 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ summary: "Launch opencode wired to the proxy (runtime provider config).", details: [ "Ensures the proxy is running, then execs `opencode` with the generated `provider.opencodex`", - "block injected through OpenCode's inline runtime layer (`OPENCODE_CONFIG_CONTENT`). Any", - "existing inline config in the environment is preserved and only `provider.opencodex` is", - "overwritten for this launch.", - "Global/project opencode.json may be read to warn about an existing provider.opencodex", - "override; on-disk files are never modified.", + "and `providers.opencodex` blocks injected through OpenCode's inline runtime layer", + "(`OPENCODE_CONFIG_CONTENT`). Any existing inline config in the environment is preserved", + "and only `provider.opencodex` and `providers.opencodex` are overwritten for this launch.", + "Only the V2 block (`providers.opencodex`) carries the reasoning-effort variants.", + "Global/project opencode.json may be read to warn about an existing provider.opencodex or", + "providers.opencodex override; on-disk files are never modified.", "Routed models appear in the model picker as opencodex//.", "Stop using `ocx opencode` and plain `opencode` behaves exactly as before.", ], diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index 8babcd3e03..34e69e7975 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -19,6 +19,7 @@ const USAGE = `Usage: ocx system sync [--json] ocx system codex-app-server [--json] ocx system codex-restart --yes [--json] + ocx system codex-cli-update check [--json] ocx system update check [--channel ] [--json] ocx system update run [--channel ] [--restart ] --yes [--json] ocx system update status [--json]`; @@ -95,8 +96,12 @@ async function update(argv: string[], deps: RuntimeApiDeps): Promise { } export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + const [sub = "status", ...rest] = argv; + if (sub === "codex-cli-update") { + const { handleCodexCliUpdateCommand } = await import("./codex-cli-update"); + return await handleCodexCliUpdateCommand(rest); + } return runCliAction(async () => { - const [sub = "status", ...rest] = argv; if (sub === "status") await status(rest, deps); else if (sub === "settings") await settings(rest, deps); else if (sub === "startup") await startup(rest, deps); diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index e571f88938..1ed156c425 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -20,12 +20,12 @@ * targeting it is the caller's explicit act. */ import { homedir } from "node:os"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { shouldInjectApiAuthHeader } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; import { providerCodexAccountMode } from "../providers/registry"; -import { sanitizeCodexReasoningEfforts } from "../reasoning-effort"; +import { canonicalizeReasoningEfforts, sanitizeCodexReasoningEfforts } from "../reasoning-effort"; import { probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; @@ -65,6 +65,14 @@ export interface OpencodeCatalogModel { id?: string; contextWindow?: number; displayName?: string; + /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ + reasoningEfforts?: readonly string[]; + /** + * Declared default effort. Carried so every client export reads one deduped, visibility- + * filtered ladder per model. The opencode serializer deliberately does NOT turn it into a + * model-level setting — see {@link opencodeEffortVariants} for why. + */ + defaultReasoningEffort?: string; } export interface OpencodeModelEntry { @@ -72,20 +80,61 @@ export interface OpencodeModelEntry { limit?: { context: number; output: number }; } +/** + * One selectable reasoning effort. + * + * opencode V2 applies these only from the `providers` block: a `variants` array under the + * legacy `provider` block is parsed and then dropped, so the V1 block stays variant-free + * rather than carrying fields that look configured but never reach a request. + */ +export interface OpencodeModelVariant { + id: string; + settings: { reasoningEffort: string }; +} + +export interface OpencodeV2ModelEntry extends OpencodeModelEntry { + variants?: OpencodeModelVariant[]; +} + +/** Endpoint and admission, spelled once and shared by both block generations. */ +export interface OpencodeProviderConnection { + baseURL: string; + apiKey?: string; + headers?: Record; +} + +/** opencode V1 provider block: `npm` + `options`. */ export interface OpencodeProviderBlock { npm: string; name: string; - options: { - baseURL: string; - apiKey?: string; - headers?: Record; - }; + options: OpencodeProviderConnection; models: Record; } +/** opencode V2 provider block: `package` + `settings`. The only form whose variants apply. */ +export interface OpencodeV2ProviderBlock { + package: string; + name: string; + settings: OpencodeProviderConnection; + models: Record; +} + +/** + * Both generations, always built together: they are one document's two fragments and must + * agree on the model set, the names, and the connection. Building them in one pass is what + * makes that a fact rather than a convention. + */ +export interface OpencodeProviderBlocks { + v1: OpencodeProviderBlock; + v2: OpencodeV2ProviderBlock; +} + export interface OpencodeGeneratedConfig { $schema: string; + /** Legacy block. Kept so opencode V1 installs keep working; V2 merges both and this one loses. */ provider: Record; + /** opencode V2 block. */ + providers: Record; } /** Provider key owned by this project; the only key any exporter ever emits. */ @@ -99,6 +148,21 @@ export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json"; */ const OPENCODE_PROVIDER_NPM = "@ai-sdk/openai-compatible"; +/** + * opencode V2's spelling of the same runtime. V2 resolves providers through its own + * package table and ignores the V1 `npm` field, so a V2 block has to name this package + * or the provider is not loaded at all. + * + * Verified end-to-end against opencode 0.0.0-beta-18684: `GET /api/model` resolves this + * package for the provider and applies the per-model `variants`. opencode changes its + * provider package table between releases, so re-verify the supported range whenever it + * moves; a stale string breaks only the V2 block, silently. + */ +const OPENCODE_V2_PROVIDER_PACKAGE = "@opencode-ai/ai/providers/openai-compatible"; + +/** Display name for the provider block, identical in both generations. */ +const OPENCODE_PROVIDER_NAME = "OpenCodex"; + /** * Env var carrying the proxy admission key to opencode. The config only ever holds the * `{env:...}` reference, so the secret never lands on disk. opencode substitutes it at @@ -474,6 +538,84 @@ export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(primeAgentDir(env, home), "models.json"); } +/** + * Aside's state root. Unlike every other client here, Aside ships NO variable + * that relocates it: its CLI carries `ASIDE_DAEMON_BASE_URL`, + * `ASIDE_PRODUCT_VARIANT` and similar, and the only `.aside` path baked into the + * binary is its own update-check file under `~/.aside/cli`. So there is no + * client-owned override to mirror, and this registry does not invent one. + */ +export function asideHomeDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(home, ".aside"); +} + +/** + * Which account's catalog we write. + * + * Aside is per-ACCOUNT: state lives under `~/.aside/u//` and the id comes + * from `accounts.json`, which Aside maintains. That makes this the only path + * resolver here that parses file CONTENTS rather than probing existence — the + * module already does the latter at four sites. + * + * It throws rather than defaulting. A machine can hold several accounts (both + * `u/0` and `u/1` existed on the machine this was developed against), so + * guessing `0` when the manifest cannot be read would name a real config file + * belonging to a DIFFERENT account, pass the installed-directory check, and + * write into somebody else's catalog. An unresolvable account is reported the + * same way an unresolvable `DSH_HOME` is. + * + * Callers that need BOTH the config path and the detect directory must derive + * them from ONE call to `asideAccountDir` rather than calling the two exported + * helpers in sequence: `resolveIntegrationPaths` in the integration registry is + * that seam. Caching here cannot substitute for it — a cache keyed on the + * manifest's mtime re-reads exactly when the manifest changes, which is the + * case the consistency is needed for. + */ +function asideCurrentAccountId(root: string): number { + const manifest = join(root, "accounts.json"); + let raw: string; + try { + raw = readFileSync(manifest, "utf8"); + } catch { + throw new ClientPathError( + `Aside's account manifest is missing or unreadable at ${manifest}, so opencodex cannot tell which ` + + "account's model catalog to write. Launch Aside once to create it.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new ClientPathError( + `Aside's account manifest at ${manifest} is not readable JSON, so the account it names cannot be ` + + "trusted. Writing a guessed account would target a different account's catalog.", + ); + } + const id = (parsed as { currentAccountId?: unknown } | null)?.currentAccountId; + if (typeof id !== "number" || !Number.isInteger(id) || id < 0) { + throw new ClientPathError( + `Aside's account manifest at ${manifest} declares no usable currentAccountId, so opencodex cannot ` + + "tell which account is current.", + ); + } + return id; +} + +/** + * The signed-in account's directory. This is also the install signal: the CLI + * creates `~/.aside/cli` for its own update check before any account exists, so + * the OUTER directory can be present on a machine that never signed in. + */ +export function asideAccountDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const root = asideHomeDir(env, home); + return join(root, "u", String(asideCurrentAccountId(root))); +} + +/** Aside's custom-provider catalog for the current account. */ +export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(asideAccountDir(env, home), "models.json"); +} + /** * One proxy-routed model destined for a client config. Deliberately narrower than * `CatalogModel` so a serializer cannot reach for a field that does not survive the @@ -516,7 +658,8 @@ export type ExportClientId = | "dsh" | "mcode" | "zcode" - | "prime"; + | "prime" + | "aside"; export interface ExportClientSpec { id: ExportClientId; @@ -651,8 +794,9 @@ function exportModelLabel(model: OpencodeCatalogModel): string { return `${id} (${providerLabel})`; } -function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodeProviderBlock["options"] { - const options: OpencodeProviderBlock["options"] = { baseURL }; +/** Endpoint plus admission, identical for the V1 `options` and V2 `settings` field. */ +function opencodeProviderConnection(baseURL: string, config: OcxConfig): OpencodeProviderConnection { + const options: OpencodeProviderConnection = { baseURL }; // Non-loopback binds accept proxy admission only via x-opencodex-api-key so Authorization // stays free for Codex Direct upstream credentials when applicable. if (shouldInjectApiAuthHeader(config)) { @@ -664,37 +808,99 @@ function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodePr } /** - * `opencodex` provider block for a resolved base URL. + * Selectable reasoning efforts for one model, in canonical ladder order. + * + * No model-level `settings.reasoningEffort` default is emitted: the proxy already applies + * its own configured default when a request carries no effort, and pinning one here would + * override a default the user controls in opencodex. Variants are opt-in per selection, + * which is the same reason we never emit `defaultModel` for MCode. + * + * `none` is dropped even when a ladder declares it. It is a valid *declared* effort, but the + * chat ingress filters wire efforts against `OUTPUT_CONFIG_EFFORTS`, which has no `none`, so + * selecting it would send no effort at all and silently fall back to the proxy default — a + * selectable value that cannot do what its label says. Same call MCode makes for its picker. + */ +function opencodeEffortVariants(model: OpencodeCatalogModel): OpencodeModelVariant[] | undefined { + if (model.reasoningEfforts === undefined) return undefined; + // Canonical order (none, minimal, then low..ultra) and dedupe, so the picker order does + // not depend on whatever order a provider listed its efforts in. + const efforts = canonicalizeReasoningEfforts(model.reasoningEfforts).filter(effort => effort !== "none"); + if (efforts.length === 0) return undefined; + return efforts.map(effort => ({ id: effort, settings: { reasoningEffort: effort } })); +} + +/** + * Both provider generations for one resolved base URL. * * `limit.context` is emitted ONLY from an authoritative context window — never guessed. * When none is available the whole `limit` block is dropped and opencode keeps its own * defaults; when one is present, `limit.output` rides along (opencode's schema requires * the pair) clamped to the context window. + * + * Two blocks instead of one because opencode V2 reads the `providers` map and V1 reads + * `provider`, and only the V2 form applies `variants`. Emitting both keeps V1 installs + * working: V2 merges them by provider id and model id, so a model listed in both blocks + * appears once, with the V2 entry's name, connection, and variants. */ -function opencodeProviderBlock( +export function opencodeProviderBlocks( baseURL: string, catalogModels: readonly OpencodeCatalogModel[], config: OcxConfig, -): OpencodeProviderBlock { - const models: Record = {}; +): OpencodeProviderBlocks { + const v1Models: Record = {}; + const v2Models: Record = {}; for (const model of catalogModels) { const key = model.namespaced; - if (models[key]) continue; // first entry wins; native rows lead /api/models + if (v1Models[key]) continue; // first entry wins; native rows lead /api/models const entry: OpencodeModelEntry = { name: exportModelLabel(model) }; const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { entry.limit = { context, output: outputBudgetFor(context) }; } - models[key] = entry; + v1Models[key] = entry; + const variants = opencodeEffortVariants(model); + // Own `limit` object, not a shared reference: the two blocks are serialized and reasoned + // about separately, and an in-place edit of one must never move the other. + v2Models[key] = { + ...entry, + ...(entry.limit ? { limit: { ...entry.limit } } : {}), + ...(variants ? { variants } : {}), + }; } return { - npm: OPENCODE_PROVIDER_NPM, - name: "OpenCodex", - options: opencodeProviderOptions(baseURL, config), - models, + v1: { + npm: OPENCODE_PROVIDER_NPM, + name: OPENCODE_PROVIDER_NAME, + options: opencodeProviderConnection(baseURL, config), + models: v1Models, + }, + v2: { + package: OPENCODE_V2_PROVIDER_PACKAGE, + name: OPENCODE_PROVIDER_NAME, + settings: opencodeProviderConnection(baseURL, config), + models: v2Models, + }, }; } +/** `opencodex` provider block for a resolved base URL (opencode V1 shape). */ +function opencodeProviderBlock( + baseURL: string, + catalogModels: readonly OpencodeCatalogModel[], + config: OcxConfig, +): OpencodeProviderBlock { + return opencodeProviderBlocks(baseURL, catalogModels, config).v1; +} + +/** `opencodex` provider block for a resolved base URL (opencode V2 shape, carries variants). */ +export function opencodeV2ProviderBlock( + baseURL: string, + catalogModels: readonly OpencodeCatalogModel[], + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeV2ProviderBlock { + return opencodeProviderBlocks(baseURL, catalogModels, config).v2; +} + /** * Build the `opencodex` provider block from proxy catalog rows keyed by each row's * canonical `namespaced` selector. Used by the `ocx opencode` launcher, which injects @@ -726,14 +932,23 @@ export function normalizeExportModels(models: readonly ExportModel[]): ExportMod return unique.sort((a, b) => (a.namespaced < b.namespaced ? -1 : a.namespaced > b.namespaced ? 1 : 0)); } -/** OpenCode V1 document: our provider block plus `$schema`, and nothing else. */ +/** + * OpenCode document: both provider generations plus `$schema`, and nothing else. + * + * The order below fixes the order of the emitted keys and nothing else: the two blocks are + * disjoint top-level keys, and which generation opencode prefers when it merges them is + * opencode's decision, not a consequence of where we write it. Both blocks are generated in + * one pass so they cannot disagree about the model set, the names, or the connection. + */ function buildOpencodeClientConfig(ctx: ExportContext): OpencodeGeneratedConfig { - const block = opencodeProviderBlock( - ctx.baseUrl, - normalizeExportModels(ctx.models), - ctx.config ?? OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, - ); - return { $schema: OPENCODE_CONFIG_SCHEMA, provider: { [OPENCODE_PROVIDER_ID]: block } }; + const models = normalizeExportModels(ctx.models); + const config = ctx.config ?? OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG; + const blocks = opencodeProviderBlocks(ctx.baseUrl, models, config); + return { + $schema: OPENCODE_CONFIG_SCHEMA, + provider: { [OPENCODE_PROVIDER_ID]: blocks.v1 }, + providers: { [OPENCODE_PROVIDER_ID]: blocks.v2 }, + }; } export interface PiModelEntry { @@ -1435,7 +1650,16 @@ function singleFragment(clientId: ExportClientId, path: readonly string[], value function buildOpencodeContribution(ctx: ExportContext): ManagedContribution { const doc = buildOpencodeClientConfig(ctx); - return singleFragment("opencode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]); + return { + clientId: "opencode", + fragments: [ + // Legacy block first, so the emitted JSON reads the way a config migration does. + // opencode V1 reads only `provider`, V2 reads both, and the generation that wins the + // merge is decided by opencode — what we control is that both name the same models. + { path: ["provider", OPENCODE_PROVIDER_ID], value: doc.provider[OPENCODE_PROVIDER_ID] }, + { path: ["providers", OPENCODE_PROVIDER_ID], value: doc.providers[OPENCODE_PROVIDER_ID] }, + ], + }; } function buildPiContribution(ctx: ExportContext): ManagedContribution { @@ -1512,6 +1736,29 @@ function buildPrimeContribution(ctx: ExportContext): ManagedContribution { return singleFragment("prime", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } +/** + * Aside is the strongest case yet for reusing Pi's builder, because the + * evidence is a live file rather than a package manifest. + * + * The machine this landed on already had opencodex wired into Aside BY HAND: + * `~/.aside/u/0/models.json` carried a `providers.opencodex` block with the same + * four keys, the same `openai-completions` dialect, the same + * `opencodex-loopback` placeholder, and 24 models using the same + * `thinkingLevelMap` levels this builder emits. A user reproduced Pi's document + * from scratch because that is what Aside reads. + * + * Key ORDER differs (the hand-written file has `apiKey` before `api`), which is + * why the devlog claims compatibility rather than byte equality: JSON key order + * is not semantic and Aside parses this file rather than diffing it. + * + * As with prime, only the ownership stamp is Aside's own, so a disable removes + * the fragment this client recorded and not one another client wrote. + */ +function buildAsideContribution(ctx: ExportContext): ManagedContribution { + const doc = buildPiClientConfig(ctx); + return singleFragment("aside", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} + export const EXPORT_CLIENTS: Record = { opencode: { id: "opencode", @@ -1665,6 +1912,24 @@ export const EXPORT_CLIENTS: Record = { // from this initial loopback-only integration — same stance as OMP's. loopbackOnly: true, }, + aside: { + id: "aside", + // Not a bare `models.json`: a download lands in the user's Downloads folder, + // where pi's and prime's files would collide with it. Prime set this + // precedent with `prime-models.json`. + filename: "aside-models.json", + destination: env => asideConfigPath(env), + apiKeyEnv: "", + exportHint: "Aside reads a non-secret placeholder from models.json; loopback needs no key.", + build: buildPiClientConfig, + format: "json", + summarize: summarizePi, + buildContribution: buildAsideContribution, + // The observed provider block has exactly four keys and none is `headers`, + // so the dedicated admission header has nowhere to live and a non-loopback + // bind would generate a config that 401s. + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 011ac0692e..6774637257 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -12,6 +12,7 @@ import { } from "../config"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types"; +import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; type LegacyCodexAccountStore = Record; type CodexAccountStore = Record; @@ -111,6 +112,11 @@ function persist(store: CodexAccountStore): void { atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n"); } +function persistCredentialMutation(store: CodexAccountStore): void { + persist(store); + advanceCodexCredentialMutationEpoch(); +} + function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick< CodexAccountCredentialRecord, "lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError" @@ -142,7 +148,7 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent replacedAt: current ? Date.now() : undefined, ...preservedValidationMetadata(current), }; - persist(store); + persistCredentialMutation(store); }); } @@ -213,7 +219,7 @@ export function saveCodexAccountCredentialIfGeneration( replacedAt: current.replacedAt, ...preservedValidationMetadata(current), }; - persist(store); + persistCredentialMutation(store); return true; }); } @@ -304,7 +310,7 @@ export function commitRefreshedCodexCredentialWithAliases( propagatedAliases.push({ id: aliasId, generation: aliasGeneration }); } } - persist(store); + persistCredentialMutation(store); return { committed: true, propagatedAliases }; }); } @@ -315,7 +321,7 @@ export function tombstoneCodexAccount(id: string): number { const current = store[id]; const generation = (current?.generation ?? 0) + 1; store[id] = { generation, deletedAt: Date.now() }; - persist(store); + persistCredentialMutation(store); return generation; }); } diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index 95df491ac3..a87b332e0e 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -90,9 +90,9 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth : inputs.routingKind === "custom-local" || inputs.routingKind === "unknown" ? COMMANDS.restoreNative : inputs.serviceSupported - // An already-registered service is refreshed in place: `repair` rewrites its assets - // and restarts it without re-registering, so it needs no elevation on Windows and - // cannot switch a WinSW install to Task Scheduler the way `install` would. Only a + // An already-registered service is refreshed in place: `repair` reuses healthy Windows + // scheduler definitions, while stale ones may be re-registered and require elevation. + // It still cannot switch a WinSW install to Task Scheduler the way `install` would. Only a // genuinely absent (or conflicting, which needs uninstall-then-install) service // gets the registering command. ? (inputs.serviceInstalled && !inputs.serviceConflict ? COMMANDS.repairService : COMMANDS.installService) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 6ca2c5a700..87afd0bb12 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -42,6 +42,7 @@ import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryT import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; +import { effectiveModelAliases } from "../../providers/default-aliases"; import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -2151,6 +2152,21 @@ async function gatherRoutedModelsUncached( // Custom rows override discovered rows that encode to the same Codex-facing slug. const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id))); const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id))); + const models = [...deduped, ...customModels]; + // ponytail: catalog-scale scan; index ids by provider if catalog growth makes this measurable. + const aliasDisplayNames = new Map(activeProviders.flatMap(({ name, provider }) => { + const providerModels = models.filter(model => model.provider === name); + const aliases = [...effectiveModelAliases(config, provider, providerModels.map(model => model.id))]; + return aliases.flatMap(([id, { alias }]) => { + const exact = providerModels.filter(model => model.id === id); + const matches = exact.length > 0 + ? exact + : providerModels.filter(model => model.id.toLowerCase() === id.toLowerCase()); + return matches.length === 1 + ? [[`${name}/${matches[0]!.id}`, `${provider.alias || name}/${alias}`] as const] + : []; + }); + })); const providerModelOutcomes = providerResults.map(result => ( result.outcome.provider === OPENAI_API_PROVIDER_ID && capture.openAiApiPolicy.state === "captured" @@ -2159,7 +2175,10 @@ async function gatherRoutedModelsUncached( : result.outcome )); return { - models: [...deduped, ...customModels], + models: models.map(model => { + const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`); + return displayName && !model.displayName ? { ...model, displayName } : model; + }), comboOmissions: localOmissions, providerAuthOutcomes: localProviderAuthOutcomes, providerModelOutcomes, diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index b7ab78cfb8..b9527f3a50 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -35,6 +35,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../accou import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { availableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, isCodexModelEntitlementSnapshotCurrent, resolveCodexModelEntitlements, type CodexModelEntitlementSnapshot, @@ -1613,10 +1614,10 @@ function writeRetainedCatalogSync({ ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => { const target = accountTargets.get(selector); const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; - const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined; - const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false; return [selector, slugs.filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true) + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) + || (accountId !== undefined + && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") ))] as const; })) : new Map(); diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts new file mode 100644 index 0000000000..ffca581f5f --- /dev/null +++ b/src/codex/cli-install-provenance.ts @@ -0,0 +1,795 @@ +import { createHash } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + existsSync, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, + statSync, +} from "node:fs"; +import { posix, win32 } from "node:path"; +import { getConfigDir } from "../config"; +import { parseStrictSemver } from "../lib/strict-semver"; +import { CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS } from "../update/codex-cli-update-launch-policy.mjs"; +import { isSpawnableCodexCandidate } from "./exec-invocation"; +import { + codexRuntimeStatePath, + parsePersistedCodexRuntime, +} from "./runtime"; +import { + inspectCodexShimBackingForCommand, + isLocalAbsoluteInspectionPath, + isVersionManagerOwnedCodexPath, + type CodexShimBackingForCommand, +} from "./shim"; + +const CODEX_PACKAGE = "@openai/codex"; +const MAX_MANIFEST_BYTES = 256 * 1024; +const MAX_RUNTIME_STATE_BYTES = 256 * 1024; +const MAX_MANIFEST_ANCESTORS = 12; + +export type CodexCliInstallKind = + | "npm-global" + | "app-bundle" + | "version-manager" + | "standalone-unverified" + | "unknown"; + +export type CodexCliInstallReason = + | "candidate_unavailable" + | "candidate_path_unavailable" + | "candidate_path_unsafe" + | "windows_inspection_deferred" + | "shim_state_unknown" + | "shim_update_deferred" + | "app_bundle" + | "version_manager_owned" + | "npm_global_unverified" + | "selection_unattested" + | "version_mismatch" + | "unverified_standalone" + | "inspection_failed"; + +export type CodexCliCandidateSource = "environment" | "persisted"; +export type CodexCliInstallEvidence = + | "canonical_path" + | "app_bundle_path" + | "version_manager_path" + | "package_manifest" + | "package_manifest_digest" + | "shim_backing" + | "global_npm_layout"; + +export interface ReadOnlyCodexRuntimeCandidate { + readonly command: string; + readonly version: string | null; + readonly evidence: CodexCliCandidateSource; +} + +export interface CodexCliInstallReport { + readonly schemaVersion: 1; + readonly candidateAvailable: boolean; + readonly candidateVersion: string | null; + readonly candidateSource: CodexCliCandidateSource | null; + readonly selectionAttested: boolean; + readonly versionEvidence: Readonly<{ + kind: "package-manifest" | "advisory-runtime" | "unavailable"; + }>; + readonly provenance: CodexCliInstallKind; + readonly managed: boolean; + readonly reason: CodexCliInstallReason; + readonly location: string | null; + readonly packageVersion: string | null; + readonly shim: Readonly<{ + status: "not-tracked" | "matched" | "unknown"; + backingKind: "backup" | "real" | null; + }>; + readonly evidence: readonly CodexCliInstallEvidence[]; +} + +export interface CodexCliInstallProvenanceDeps { + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly configDir?: string; + readonly exists?: (path: string) => boolean; + readonly lstat?: typeof lstatSync; + readonly stat?: typeof statSync; + readonly readFile?: (path: string) => Buffer; + readonly boundedFileReadMode?: "native-hardened" | "injected-test"; + readonly realpath?: (path: string) => string; + readonly inspectShim?: ( + command: string, + platform: NodeJS.Platform, + configDir: string, + ) => CodexShimBackingForCommand; +} + +interface PackageManifestEvidence { + readonly path: string; + readonly root: string; + readonly binPath: string; + readonly version: string; + readonly digest: string; +} + +function sha256(domain: string, value: string | Uint8Array): string { + return createHash("sha256") + .update(domain, "utf8") + .update("\0", "utf8") + .update(value) + .digest("hex"); +} + +function validatedVersion(value: string | null | undefined): string | null { + if (typeof value !== "string" || value !== value.trim()) return null; + return parseStrictSemver(value, 96)?.raw ?? null; +} + +function publicExecutableLocation(path: string, platform: NodeJS.Platform): string { + const raw = pathTools(platform).basename(path).toLowerCase(); + const safe = ["codex", "codex.exe", "codex.cmd", "codex.bat", "codex.com", "codex.js"].includes(raw) + ? raw : "codex"; + return `/${safe}`; +} + +function freezeReport(report: CodexCliInstallReport): CodexCliInstallReport { + Object.freeze(report.shim); + Object.freeze(report.evidence); + return Object.freeze(report); +} + +function unknownReport( + reason: CodexCliInstallReason, + candidate?: ReadOnlyCodexRuntimeCandidate, + extra: Partial> = {}, +): CodexCliInstallReport { + return freezeReport({ + schemaVersion: 1, + candidateAvailable: Boolean(candidate), + candidateVersion: candidate?.version ?? null, + candidateSource: candidate?.evidence ?? null, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate?.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: "unknown", + managed: false, + reason, + location: extra.location ?? null, + packageVersion: null, + shim: Object.freeze({ status: "not-tracked", backingKind: null }), + evidence: Object.freeze([]), + }); +} + +function unknownWindowsReport( + reason: CodexCliInstallReason, + candidate?: ReadOnlyCodexRuntimeCandidate, + extra: Partial> = {}, +): CodexCliInstallReport { + return freezeReport({ + ...unknownReport(reason, candidate, extra), + shim: Object.freeze({ status: "unknown", backingKind: null }), + }); +} + +function readPersistedCandidate( + deps: CodexCliInstallProvenanceDeps, +): ReadOnlyCodexRuntimeCandidate | null { + // A pathname-only Windows read cannot prove that a writable ancestor stayed + // local and non-reparse between validation and open. PR1 therefore accepts + // only the proof-captured environment candidate on Windows; persisted-state + // inspection requires the later handle-bound Windows provenance layer. + if ((deps.platform ?? process.platform) === "win32") return null; + const configDir = deps.configDir ?? getConfigDir(); + if (!isSafeLocalInspectionPath(configDir, deps)) return null; + try { + const statePath = codexRuntimeStatePath(configDir); + const bytes = readBoundedFile(statePath, MAX_RUNTIME_STATE_BYTES, deps); + if (!bytes) return null; + const parsed = parsePersistedCodexRuntime( + bytes, + ); + if (!parsed) return null; + return { + command: parsed.command, + version: validatedVersion(parsed.selectedVersion), + evidence: "persisted", + }; + } catch { + return null; + } +} + +/** + * Observe configured candidate evidence without launching Codex, creating a + * probe home, or persisting a replacement selection. + */ +export function observeCodexRuntimeCandidateReadOnly( + deps: CodexCliInstallProvenanceDeps = {}, +): ReadOnlyCodexRuntimeCandidate | null { + const env = deps.env ?? process.env; + const configured = env.CODEX_CLI_PATH?.trim(); + if (configured) { + return { + command: configured, + version: null, + evidence: "environment", + }; + } + return readPersistedCandidate(deps); +} + +function pathTools(platform: NodeJS.Platform): typeof posix | typeof win32 { + return platform === "win32" ? win32 : posix; +} + +function isWindowsPlatform(platform: NodeJS.Platform): boolean { + return platform === "win32"; +} + +function isSafeLocalInspectionPath( + path: string, + deps: CodexCliInstallProvenanceDeps, +): boolean { + const platform = deps.platform ?? process.platform; + return isLocalAbsoluteInspectionPath(path, platform); +} + +function caseInsensitiveEnv(env: NodeJS.ProcessEnv, name: string): string | undefined { + const entry = Object.entries(env).find(([key]) => key.toLowerCase() === name.toLowerCase()); + return entry?.[1]; +} + +function resolveCandidateCommandPath( + command: string, + deps: CodexCliInstallProvenanceDeps, +): string | null { + const platform = deps.platform ?? process.platform; + // Windows inspection returns before this resolver until the next slice can + // bind command resolution and wrapper reads to stable filesystem handles. + if (isWindowsPlatform(platform)) return null; + const exists = deps.exists ?? existsSync; + const lstat = deps.lstat ?? lstatSync; + const stat = deps.stat ?? statSync; + const usable = (path: string): boolean => { + if (!isSafeLocalInspectionPath(path, deps)) return false; + try { + const entry = lstat(path); + if (!exists(path) || (!entry.isFile() && !entry.isSymbolicLink()) || !isSpawnableCodexCandidate(path, platform)) return false; + if (platform !== "win32") { + const target = stat(path); + if (!target.isFile() || (target.mode & 0o111) === 0) return false; + } + return true; + } catch { + return false; + } + }; + const env = deps.env ?? process.env; + const tools = pathTools(platform); + const explicit = tools.isAbsolute(command) || command.includes("/") || command.includes("\\"); + if (explicit) return usable(command) ? command : null; + const pathValue = env.PATH ?? ""; + const names = [command]; + for (const entry of pathValue.split(tools.delimiter)) { + // Empty and relative entries name the current working directory. Either can + // shadow a later absolute hit and therefore makes the candidate path unknown. + if (!entry || !isSafeLocalInspectionPath(entry, deps)) return null; + for (const name of names) { + const candidate = tools.join(entry, name); + if (usable(candidate)) return candidate; + } + } + return null; +} + +function canonicalize(path: string, deps: CodexCliInstallProvenanceDeps): string | null { + if (!isSafeLocalInspectionPath(path, deps)) return null; + try { + const canonical = (deps.realpath ?? realpathSync.native)(path); + return isSafeLocalInspectionPath(canonical, deps) ? canonical : null; + } catch { + return null; + } +} + +function normalizePath(path: string, platform: NodeJS.Platform): string { + const slashNormalized = platform === "win32" ? path.replace(/\\/g, "/") : path; + const normalized = platform !== "win32" && /^\/+$/u.test(slashNormalized) + ? "/" + : slashNormalized.replace(/\/+$/, ""); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function samePath(left: string, right: string, platform: NodeJS.Platform): boolean { + return normalizePath(left, platform) === normalizePath(right, platform); +} + +export function isAppBundledCodexPath(path: string, platform: NodeJS.Platform): boolean { + const normalized = normalizePath(path, platform); + if (platform === "win32") { + return normalized.includes("/windowsapps/") + || normalized.includes("/microsoft/windowsapps/") + || normalized.includes("/packages/openai.codex_"); + } + if (platform === "darwin") return /[.]app\/contents\//i.test(normalized); + return normalized.startsWith("/snap/") || normalized.includes("/flatpak/app/"); +} + +/** Updater ownership is intentionally broader than shim-repair refusal. */ +export function isCodexCliUpdateVersionManagerPath( + path: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const normalized = (platform === "win32" + ? win32.normalize(path).replace(/\\/g, "/") + : posix.normalize(path)).toLowerCase(); + if (isVersionManagerOwnedCodexPath(normalized, platform)) return true; + return normalized.includes("/.nvm/") + || normalized.includes("/nvm/versions/") + || /\/nvm\/v?\d+(?:[.]\d+){1,2}(?:\/|$)/.test(normalized) + || normalized.includes("/.proto/") + || normalized.includes("/proto/tools/") + || normalized.includes("/.nodenv/") + || normalized.includes("/nodenv/versions/") + || normalized.includes("/.nvs/") + || normalized.includes("/nvs/node/") + || normalized.includes("/.fnm/") + || normalized.includes("/fnm/node-versions/") + || normalized.includes("/fnm_multishells/") + || (platform === "win32" && ( + normalized.includes("/scoop/apps/") + || normalized.includes("/scoop/shims/") + )); +} + +function configuredVersionManagerRoots( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + deps: CodexCliInstallProvenanceDeps, +): readonly string[] { + const roots: string[] = []; + for (const name of CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS) { + const raw = platform === "win32" ? caseInsensitiveEnv(env, name) : env[name]; + if (!raw || !isLocalAbsoluteInspectionPath(raw, platform)) continue; + // Windows roots are advisory lexical labels only in this first slice. Do + // not resolve or open them until the handle-bound Windows layer exists. + if (platform === "win32") { + roots.push(normalizePath(win32.normalize(raw), platform)); + continue; + } + const canonical = isSafeLocalInspectionPath(raw, deps) ? canonicalize(raw, deps) : null; + if (canonical) roots.push(normalizePath(canonical, platform)); + } + return Object.freeze([...new Set(roots)]); +} + +function isWithinConfiguredVersionManagerRoot(path: string, roots: readonly string[], platform: NodeJS.Platform): boolean { + const candidate = normalizePath(path, platform); + return roots.some(root => { + const filesystemRoot = root === "/" || (platform === "win32" && /^[a-z]:$/i.test(root)); + return candidate === root || (!filesystemRoot && candidate.startsWith(`${root}/`)); + }); +} + +function readBoundedFile( + path: string, + maxBytes: number, + deps: CodexCliInstallProvenanceDeps, +): Buffer | null { + if (!isSafeLocalInspectionPath(path, deps)) return null; + // Production inspection accepts only a direct regular file. This prevents a + // persisted-state or manifest symlink from silently redirecting a nominally + // local check. Virtual filesystem tests may omit lstat and retain their + // injected stat/read behavior. + const inspectLexical = deps.lstat + ?? (deps.stat === undefined && deps.readFile === undefined ? lstatSync : null); + let lexicalBefore: ReturnType | null = null; + if (inspectLexical) { + try { + lexicalBefore = inspectLexical(path); + if (lexicalBefore.isSymbolicLink() || !lexicalBefore.isFile()) return null; + } catch { + return null; + } + } + const useInjectedReader = deps.boundedFileReadMode === "injected-test"; + if (!useInjectedReader) { + let fd: number | null = null; + try { + const flags = process.platform === "win32" + ? fsConstants.O_RDONLY + : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + fd = openSync(path, flags); + const before = fstatSync(fd); + if (!before.isFile() || before.size > maxBytes) return null; + const bytes = Buffer.allocUnsafe(before.size); + let offset = 0; + while (offset < bytes.length) { + const count = readSync(fd, bytes, offset, bytes.length - offset, offset); + if (count <= 0) return null; + offset += count; + } + const extra = Buffer.allocUnsafe(1); + if (readSync(fd, extra, 0, 1, offset) !== 0) return null; + const after = fstatSync(fd); + const lexicalAfter = inspectLexical ? inspectLexical(path) : null; + if (lexicalAfter?.isSymbolicLink()) return null; + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs + || (lexicalBefore !== null && (lexicalAfter === null || lexicalAfter.isSymbolicLink() + || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino + || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino + || lexicalAfter.size !== after.size || lexicalAfter.mtimeMs !== after.mtimeMs + || lexicalAfter.ctimeMs !== after.ctimeMs))) return null; + return bytes; + } catch { + return null; + } finally { + if (fd !== null) closeSync(fd); + } + } + if (!deps.stat || !deps.readFile) return null; + const stat = deps.stat; + const read = deps.readFile; + try { + const before = stat(path); + if (!before.isFile() || before.size > maxBytes) return null; + const bytes = read(path); + const after = stat(path); + const lexicalAfter = inspectLexical ? inspectLexical(path) : null; + if ( + bytes.byteLength !== before.size + || before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || before.mtimeMs !== after.mtimeMs + || (lexicalBefore !== null && (lexicalAfter === null || lexicalAfter.isSymbolicLink() + || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino + || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino)) + ) return null; + return bytes; + } catch { + return null; + } +} + +function manifestCandidates( + logicalPath: string, + canonicalPath: string, + platform: NodeJS.Platform, +): string[] { + const tools = pathTools(platform); + const candidates: string[] = []; + let cursor = tools.dirname(canonicalPath); + for (let depth = 0; depth < MAX_MANIFEST_ANCESTORS; depth += 1) { + candidates.push(tools.join(cursor, "package.json")); + const parent = tools.dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + const binDir = tools.dirname(logicalPath); + if (platform === "win32") { + candidates.push(tools.join(binDir, "node_modules", "@openai", "codex", "package.json")); + } else { + const prefix = tools.dirname(binDir); + candidates.push(tools.join(prefix, "lib", "node_modules", "@openai", "codex", "package.json")); + candidates.push(tools.join(prefix, "node_modules", "@openai", "codex", "package.json")); + } + return [...new Set(candidates)]; +} + +function manifestBinPath(bin: unknown): string | null { + const raw = typeof bin === "string" + ? bin + : bin && typeof bin === "object" && !Array.isArray(bin) + ? (bin as Record).codex + : null; + if (typeof raw !== "string") return null; + const normalized = raw.replace(/\\/g, "/").replace(/^\.\//, ""); + return normalized === "bin/codex.js" ? normalized : null; +} + +function findCodexPackageManifest( + logicalPath: string, + canonicalPath: string, + deps: CodexCliInstallProvenanceDeps, +): PackageManifestEvidence | null { + const platform = deps.platform ?? process.platform; + for (const candidate of manifestCandidates(logicalPath, canonicalPath, platform)) { + const bytes = readBoundedFile(candidate, MAX_MANIFEST_BYTES, deps); + if (!bytes) continue; + try { + const value = JSON.parse(bytes.toString("utf8")) as Record; + const version = validatedVersion(typeof value.version === "string" ? value.version : null); + if (value.name !== CODEX_PACKAGE || !version) continue; + const binPath = manifestBinPath(value.bin); + if (!binPath) continue; + const root = canonicalize(pathTools(platform).dirname(candidate), deps); + const path = canonicalize(candidate, deps); + if (!root || !path) continue; + return { + path, + root, + binPath, + version, + digest: sha256("codex-cli-package-manifest-v1", bytes), + }; + } catch { + continue; + } + } + return null; +} + +function launcherIsLinkedToManifest( + canonicalOwnershipPath: string, + manifest: PackageManifestEvidence, + deps: CodexCliInstallProvenanceDeps, +): boolean { + const platform = deps.platform ?? process.platform; + const tools = pathTools(platform); + const entrypoint = canonicalize(tools.join(manifest.root, ...manifest.binPath.split("/")), deps); + if (!entrypoint) return false; + const normalizedRoot = normalizePath(manifest.root, platform); + const normalizedEntrypoint = normalizePath(entrypoint, platform); + if (!normalizedEntrypoint.startsWith(`${normalizedRoot}/`)) return false; + if (samePath(canonicalOwnershipPath, entrypoint, platform)) return true; + return false; +} + +function isProvenGlobalNpmLayout( + launcherPath: string, + packageRoot: string, + platform: NodeJS.Platform, + deps: CodexCliInstallProvenanceDeps, +): boolean { + const tools = pathTools(platform); + const launcherName = tools.basename(launcherPath).toLowerCase(); + if (launcherName !== (platform === "win32" ? "codex.cmd" : "codex")) return false; + const root = normalizePath(packageRoot, platform); + // Keep the POSIX launcher itself lexical because npm commonly installs it as + // a symlink into the package. Canonicalize only its parent so a symlinked or + // case-aliased npm prefix is compared against the canonical manifest root. + const launcherParent = tools.dirname(launcherPath); + const canonicalLauncherParent = platform === "win32" + ? launcherParent + : canonicalize(launcherParent, deps); + if (!canonicalLauncherParent) return false; + const launcherDir = normalizePath(canonicalLauncherParent, platform); + const suffix = "/node_modules/@openai/codex"; + if (!root.endsWith(suffix)) return false; + const beforeNodeModules = root.slice(0, -suffix.length); + if (platform === "win32") return launcherDir === beforeNodeModules; + if (!beforeNodeModules.endsWith("/lib")) return false; + const prefix = beforeNodeModules.slice(0, -"/lib".length); + return launcherDir === `${prefix}/bin`; +} + +function shimReport(shim: CodexShimBackingForCommand): CodexCliInstallReport["shim"] { + if (shim.status === "matched") { + return Object.freeze({ status: "matched" as const, backingKind: shim.backingKind }); + } + if (shim.status === "unknown") { + return Object.freeze({ status: "unknown" as const, backingKind: null }); + } + return Object.freeze({ status: "not-tracked" as const, backingKind: null }); +} + +/** Inspect ownership of one configured Codex CLI candidate, without mutation. */ +export async function inspectCodexCliInstall( + deps: CodexCliInstallProvenanceDeps = {}, +): Promise { + const platform = deps.platform ?? process.platform; + const candidate = observeCodexRuntimeCandidateReadOnly(deps); + if (!candidate) { + return isWindowsPlatform(platform) + ? unknownWindowsReport("candidate_unavailable") + : unknownReport("candidate_unavailable"); + } + + const env = deps.env ?? process.env; + if (platform === "win32") { + // This first slice never opens a candidate-controlled Windows pathname. + // A boolean precheck followed by lstat/realpath/open is raceable when an + // ancestor can be replaced with a remote reparse point. Preserve only + // lexical, report-only classifications until a handle-bound inspector is + // introduced; every result remains unattested and unmanaged. + const lexicalCandidatePath = win32.isAbsolute(candidate.command) + && isLocalAbsoluteInspectionPath(candidate.command, platform) + ? win32.normalize(candidate.command) + : null; + if (!lexicalCandidatePath) { + return unknownWindowsReport("candidate_path_unavailable", candidate); + } + const managerRoots = configuredVersionManagerRoots(env, platform, deps); + const appBundle = isAppBundledCodexPath(lexicalCandidatePath, platform); + const versionManager = isCodexCliUpdateVersionManagerPath(lexicalCandidatePath, platform) + || isWithinConfiguredVersionManagerRoot(lexicalCandidatePath, managerRoots, platform); + if (appBundle || versionManager) { + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: appBundle ? "app-bundle" : "version-manager", + managed: false, + reason: appBundle ? "app_bundle" : "version_manager_owned", + location: publicExecutableLocation(lexicalCandidatePath, platform), + packageVersion: null, + shim: Object.freeze({ status: "unknown", backingKind: null }), + evidence: Object.freeze([appBundle ? "app_bundle_path" : "version_manager_path"]), + }); + } + return unknownWindowsReport("windows_inspection_deferred", candidate, { + location: publicExecutableLocation(lexicalCandidatePath, platform), + }); + } + const configDir = deps.configDir ?? getConfigDir(); + if (!isSafeLocalInspectionPath(configDir, deps)) { + return unknownReport("shim_state_unknown", candidate); + } + const candidatePath = resolveCandidateCommandPath(candidate.command, deps); + if (!candidatePath) { + return unknownReport("candidate_path_unavailable", candidate); + } + const canonicalCandidatePath = canonicalize(candidatePath, deps); + if (!canonicalCandidatePath) { + return unknownReport("candidate_path_unsafe", candidate); + } + const inspectShim = deps.inspectShim ?? inspectCodexShimBackingForCommand; + const shim = inspectShim(candidatePath, platform, configDir); + if (shim.status === "unknown") { + return freezeReport({ + ...unknownReport("shim_state_unknown", candidate, { + location: publicExecutableLocation(canonicalCandidatePath, platform), + }), + shim: shimReport(shim), + }); + } + if (shim.status === "matched") { + return freezeReport({ + ...unknownReport("shim_update_deferred", candidate, { + location: publicExecutableLocation(canonicalCandidatePath, platform), + }), + provenance: "standalone-unverified", + shim: shimReport(shim), + evidence: Object.freeze(["canonical_path", "shim_backing"] as const), + }); + } + + const ownershipPath = candidatePath; + const canonicalOwnershipPath = canonicalize(ownershipPath, deps); + if (!canonicalOwnershipPath) { + return unknownReport("candidate_path_unsafe", candidate); + } + const location = publicExecutableLocation(canonicalCandidatePath, platform); + const canonicalPathSet = [canonicalCandidatePath, canonicalOwnershipPath]; + if (canonicalPathSet.some(path => isAppBundledCodexPath(path, platform))) { + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: "app-bundle", + managed: false, + reason: "app_bundle", + location, + packageVersion: null, + shim: shimReport(shim), + evidence: Object.freeze(["canonical_path", "app_bundle_path"]), + }); + } + const configuredManagerRoots = configuredVersionManagerRoots(env, platform, deps); + if (canonicalPathSet.some(path => isCodexCliUpdateVersionManagerPath(path, platform) + || isWithinConfiguredVersionManagerRoot(path, configuredManagerRoots, platform))) { + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: "version-manager", + managed: false, + reason: "version_manager_owned", + location, + packageVersion: null, + shim: shimReport(shim), + evidence: Object.freeze(["canonical_path", "version_manager_path"]), + }); + } + if (/codex[.]opencodex-real(?:[.](?:cmd|bat|exe))?$/i.test(pathTools(platform).basename(candidatePath))) { + return freezeReport({ + ...unknownReport("shim_state_unknown", candidate, { location }), + shim: shimReport(shim), + }); + } + + const manifest = findCodexPackageManifest(ownershipPath, canonicalOwnershipPath, deps); + if (manifest) { + // POSIX npm launchers are commonly symlinks into the package, so their + // lexical prefix is the ownership evidence instead. + const global = isProvenGlobalNpmLayout( + ownershipPath, + manifest.root, + platform, + deps, + ); + const linked = launcherIsLinkedToManifest( + canonicalOwnershipPath, + manifest, + deps, + ); + const manifestOwned = global && linked; + const versionMatches = candidate.version === null || candidate.version === manifest.version; + const reason: CodexCliInstallReason = !global || !linked + ? "npm_global_unverified" + : !versionMatches ? "version_mismatch" : "selection_unattested"; + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: manifestOwned && candidate.version !== null && versionMatches + ? "package-manifest" as const + : candidate.version !== null ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: manifestOwned ? "npm-global" : "standalone-unverified", + managed: false, + reason, + location, + packageVersion: manifest.version, + shim: shimReport(shim), + evidence: Object.freeze([ + "canonical_path", "package_manifest", "package_manifest_digest", + ...(manifestOwned ? ["global_npm_layout" as const] : []), + ]), + }); + } + + const stat = deps.stat ?? statSync; + if (!isSafeLocalInspectionPath(canonicalOwnershipPath, deps)) { + return unknownReport("candidate_path_unsafe", candidate); + } + try { + if (!stat(canonicalOwnershipPath).isFile()) { + return unknownReport("candidate_path_unsafe", candidate); + } + } catch { + return unknownReport("inspection_failed", candidate); + } + return freezeReport({ + schemaVersion: 1, + candidateAvailable: true, + candidateVersion: candidate.version, + candidateSource: candidate.evidence, + selectionAttested: false, + versionEvidence: Object.freeze({ + kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, + }), + provenance: "standalone-unverified", + managed: false, + reason: "unverified_standalone", + location, + packageVersion: null, + shim: shimReport(shim), + evidence: Object.freeze(["canonical_path"]), + }); +} diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index e33e654481..b84bbcb909 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -73,6 +73,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./accoun import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { availableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, isCodexModelEntitlementSnapshotCurrent, resolveCodexModelEntitlements, type CodexModelEntitlementSnapshot, @@ -277,10 +278,10 @@ function prepareCatalog( ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => { const target = accountTargets.get(selector); const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; - const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined; - const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false; return [selector, slugs.filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true) + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) + || (accountId !== undefined + && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") ))] as const; })) : new Map(); diff --git a/src/codex/credential-mutation-epoch.ts b/src/codex/credential-mutation-epoch.ts new file mode 100644 index 0000000000..22346bd1cb --- /dev/null +++ b/src/codex/credential-mutation-epoch.ts @@ -0,0 +1,11 @@ +let credentialMutationEpoch = 0; + +/** Process-local fence advanced after every OpenCodex-owned credential publication. */ +export function codexCredentialMutationEpoch(): number { + return credentialMutationEpoch; +} + +export function advanceCodexCredentialMutationEpoch(): number { + credentialMutationEpoch += 1; + return credentialMutationEpoch; +} diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index b0b7ebb328..f1307b1aac 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -18,6 +18,7 @@ import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write"; import { resolveCodexHomeDir } from "./home"; import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard"; import { clearAccountNeedsReauth } from "./account-runtime-state"; +import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; export { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; @@ -160,6 +161,7 @@ function persistRefreshedMainAuthJson( validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected), }, ); + advanceCodexCredentialMutationEpoch(); return { accessToken, chatgptAccountId }; } diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index f462b9ba53..7ae5baddb6 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -13,6 +13,7 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; import { loadPersistedCodexRuntime } from "./runtime"; import { codexRuntimeStateEpoch } from "./runtime"; import upstreamModelsSnapshot from "./data/upstream-models.json"; +import { codexCredentialMutationEpoch } from "./credential-mutation-epoch"; const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models"; @@ -87,6 +88,20 @@ export function deriveGatedClientVersionFloor( */ const MEASURED_GATED_CLIENT_VERSION_MINIMUM = "0.144.0"; +/** + * Lowest versions measured to return each account-gated model when the account owns it. + * + * This is deliberately independent of the bundled upstream snapshot. The snapshot still + * records 0.142.2 for sol/terra/luna, while live measurements show that upstream omits them + * below 0.144.0. Daybreak has no snapshot row or independent minimum, so its omission remains + * authoritative instead of inheriting a guessed floor from another model. + */ +export const ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS: ReadonlyMap = new Map([ + ["gpt-5.6-sol", MEASURED_GATED_CLIENT_VERSION_MINIMUM], + ["gpt-5.6-terra", MEASURED_GATED_CLIENT_VERSION_MINIMUM], + ["gpt-5.6-luna", MEASURED_GATED_CLIENT_VERSION_MINIMUM], +]); + /** * Fallback when the snapshot records no usable gated floor. * @@ -230,6 +245,7 @@ function memoizedPersistedRuntimeVersion( return selected; } const MODEL_ROSTER_FAILURE_TTL_MS = 15_000; +const MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS = 5_000; const MODEL_ROSTER_TIMEOUT_MS = 8_000; const MODEL_ROSTER_MAX_BYTES = 2 * 1024 * 1024; const MODEL_ROSTER_CACHE_MAX = 64; @@ -268,6 +284,25 @@ export interface CodexModelEntitlementCredentialSnapshot { readonly credentialIdentity: string; } +export type CodexModelEntitlementProvenance = + | { readonly kind: "parsed-empty" } + | { readonly kind: "http-error"; readonly httpStatus: number } + | { readonly kind: "network-error" } + | { readonly kind: "timeout" } + | { readonly kind: "unparseable" }; + +export type CodexModelEntitlementStatus = + | { readonly status: "unavailable" } + | { readonly status: "fresh" } + | { readonly status: "unconfirmed-empty" } + | { readonly status: "failed"; readonly reason: "http-error"; readonly httpStatus: number } + | { + readonly status: "failed"; + readonly reason: Exclude; + readonly httpStatus?: never; + } + | { readonly status: "expired-refresh-in-flight" }; + interface CachedAccountModels { readonly credentialIdentity: string; /** @@ -279,14 +314,18 @@ interface CachedAccountModels { readonly expiresAt: number; readonly models: ReadonlySet; readonly confirmed: boolean; + readonly provenance?: CodexModelEntitlementProvenance; } export interface CodexModelEntitlementSnapshot { readonly modelsByAccount: ReadonlyMap>; + readonly clientVersionByAccount: ReadonlyMap; readonly confirmedAccountIds: ReadonlySet; readonly credentialIdentities: ReadonlyMap; } +export type CodexModelEntitlementState = "granted" | "denied" | "unknown"; + export interface CodexModelEntitlementResolveOptions { readonly fetcher?: typeof fetch; /** @@ -309,11 +348,43 @@ export interface CodexModelEntitlementResolveOptions { readonly credentialSnapshot?: typeof accountCredentialSnapshot; /** Accounts whose credentials must not be read while another lifecycle owns them. */ readonly excludeAccountIds?: ReadonlySet; + /** Ensure-only fence; ordinary request resolvers retain their established flight identity. */ + readonly credentialMutationEpoch?: number; +} + +export interface CodexEntitlementFreshnessOptions extends Pick< + CodexModelEntitlementResolveOptions, + | "clientVersion" + | "credentialSnapshot" + | "fetcher" + | "loadPersistedRuntime" + | "nativeMainRefreshDependencies" + | "now" + | "signal" +> { + readonly waitMs?: number; } const accountModelsCache = new Map(); const accountModelsFlights = new Map>(); +interface NegativeCredentialMemo { + readonly credentialIdentity: string | null; + readonly mutationEpoch: number; + readonly expiresAt: number; +} + +interface EntitlementEnsureFlight { + readonly startedAt: number; + readonly promise: Promise; + readonly clientVersion: string; + readonly identityVector: ReadonlyMap; + readonly workset: readonly string[]; +} + +const negativeCredentialMemo = new Map(); +const entitlementEnsureFlights = new Map(); + /** * Cache key. The roster is version-specific, so the version has to be part of the identity — * with an account-only key, two versions in flight for one account race to overwrite each @@ -435,6 +506,26 @@ function parseAccountModels(text: string): ReadonlySet | null { } } +function unconfirmedAccountModels( + credential: CodexModelEntitlementCredentialSnapshot, + clientVersion: string, + now: number, + provenance: CodexModelEntitlementProvenance, +): CachedAccountModels { + return { + credentialIdentity: credential.credentialIdentity, + clientVersion, + expiresAt: now + MODEL_ROSTER_FAILURE_TTL_MS, + models: new Set(), + confirmed: false, + provenance, + }; +} + +function isTimeoutError(error: unknown): boolean { + return error instanceof Error && error.name === "TimeoutError"; +} + async function fetchAccountModels( credential: CodexModelEntitlementCredentialSnapshot, fetcher: typeof fetch, @@ -454,14 +545,24 @@ async function fetchAccountModels( redirect: "error", signal: controller.signal, }); + if (!response.ok) { + return unconfirmedAccountModels(credential, clientVersion, now, { + kind: "http-error", + httpStatus: response.status, + }); + } const body = await readBoundedResponseBody(response, { signal: controller.signal, maxBytes: MODEL_ROSTER_MAX_BYTES, fatalUtf8: true, }); - const models = response.ok && body.displaySafe && !body.truncated - ? parseAccountModels(body.text) - : null; + if (!body.displaySafe || body.truncated) { + return unconfirmedAccountModels(credential, clientVersion, now, { kind: "unparseable" }); + } + const models = parseAccountModels(body.text); + if (models === null) { + return unconfirmedAccountModels(credential, clientVersion, now, { kind: "unparseable" }); + } // A roster is a confirmation only when it lists something usable. `models` is a Set, and an // empty Set is truthy, so `models !== null` used to call `{"models":[]}` — and a response // whose every row was hidden or api-disabled — a confirmed answer, and lock it in for the @@ -469,22 +570,28 @@ async function fetchAccountModels( // account asked under too old a client version answers with no gated rows, and treating // that as authoritative is exactly how 2.36.0 denied sol/terra/luna to accounts that own // them (#3022). No usable rows means unconfirmed, on the 15s failure TTL, asked again. - const usable = models !== null && models.size > 0; - return { - credentialIdentity: credential.credentialIdentity, - clientVersion, - expiresAt: now + (usable ? MODEL_ROSTER_TTL_MS : MODEL_ROSTER_FAILURE_TTL_MS), - models: models ?? new Set(), - confirmed: usable, - }; - } catch { + const usable = models.size > 0; + if (!usable) { + return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" }); + } + const hasUnknownGatedAbsence = [...ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS] + .some(([modelId, minimum]) => ( + !models.has(modelId) && compareClientVersions(clientVersion, minimum) < 0 + )); return { credentialIdentity: credential.credentialIdentity, clientVersion, - expiresAt: now + MODEL_ROSTER_FAILURE_TTL_MS, - models: new Set(), - confirmed: false, + expiresAt: now + (!hasUnknownGatedAbsence + ? MODEL_ROSTER_TTL_MS + : MODEL_ROSTER_FAILURE_TTL_MS), + models, + confirmed: true, }; + } catch (error) { + const provenance = isTimeoutError(error) || isTimeoutError(controller.signal.reason) + ? { kind: "timeout" } as const + : { kind: "network-error" } as const; + return unconfirmedAccountModels(credential, clientVersion, now, provenance); } finally { clearTimeout(timer); } @@ -513,6 +620,7 @@ async function modelsForCredential( fetcher: typeof fetch, now: number, clientVersion: string, + credentialMutationEpoch?: number, ): Promise { const cached = accountModelsCache.get(cacheKeyFor(credential.accountId, clientVersion)); if ( @@ -521,7 +629,8 @@ async function modelsForCredential( && cached.expiresAt > now ) return cached; - const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}\u0000${clientVersion}`; + const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}\u0000${clientVersion}` + + (credentialMutationEpoch === undefined ? "" : `\u0000${credentialMutationEpoch}`); const existing = accountModelsFlights.get(flightKey); if (existing) return existing; @@ -541,7 +650,11 @@ async function modelsForCredential( } const flight = fetchAccountModels(credential, fetcher, now, clientVersion) .then(result => { - if (currentCredentialIdentity(credential.accountId) === credential.credentialIdentity) { + if ( + currentCredentialIdentity(credential.accountId) === credential.credentialIdentity + && (credentialMutationEpoch === undefined + || codexCredentialMutationEpoch() === credentialMutationEpoch) + ) { boundedCacheSet(credential.accountId, result); } return result; @@ -562,6 +675,232 @@ function candidateAccountIds(config: Pick): string[] ]; } +function normalizedCandidateAccountIds(config: Pick): string[] { + return [...new Set(candidateAccountIds(config))].sort(); +} + +function freshNegativeCredentialMemo( + accountId: string, + credentialIdentity: string | null, + mutationEpoch: number, + now: number, +): boolean { + const memo = negativeCredentialMemo.get(accountId); + if ( + memo + && memo.credentialIdentity === credentialIdentity + && memo.mutationEpoch === mutationEpoch + && memo.expiresAt > now + ) return true; + if (memo) negativeCredentialMemo.delete(accountId); + return false; +} + +function boundedNegativeCredentialMemoSet(accountId: string, memo: NegativeCredentialMemo): void { + negativeCredentialMemo.delete(accountId); + negativeCredentialMemo.set(accountId, memo); + for (const oldest of [...negativeCredentialMemo.keys()].slice(0, Math.max( + 0, + negativeCredentialMemo.size - MODEL_ROSTER_CACHE_MAX, + ))) negativeCredentialMemo.delete(oldest); +} + +function needsEntitlementRefresh( + accountId: string, + credentialIdentity: string | null, + clientVersion: string, + mutationEpoch: number, + now: number, +): boolean { + const cached = accountModelsCache.get(cacheKeyFor(accountId, clientVersion)); + if (cached && cached.credentialIdentity !== credentialIdentity) { + invalidateCodexModelEntitlementsForAccount(accountId); + } else if (cached && cached.expiresAt > now) { + return false; + } + return !freshNegativeCredentialMemo(accountId, credentialIdentity, mutationEpoch, now); +} + +function entitlementEnsureFlightKey( + candidateAccountIds: readonly string[], + clientVersion: string, + mutationEpoch: number, + identityVector: readonly (readonly [string, string | null])[], + workset: readonly string[], +): string { + return JSON.stringify([candidateAccountIds, clientVersion, mutationEpoch, identityVector, workset]); +} + +async function refreshCodexEntitlementWorkset( + config: Pick, + workset: readonly string[], + identityVector: ReadonlyMap, + clientVersion: string, + mutationEpoch: number, + options: CodexEntitlementFreshnessOptions, +): Promise { + const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot; + const observations = await Promise.all(workset.map(async accountId => { + const credential = await credentialSnapshot(accountId, options); + return { + accountId, + credential, + absenceObservedAt: options.now ?? Date.now(), + }; + })); + const credentials = observations.flatMap(observation => observation.credential + ? [observation.credential] + : []); + if (credentials.length > 0) { + await resolveCodexModelEntitlements(config, { + ...options, + clientVersion, + credentialMutationEpoch: mutationEpoch, + credentials, + }); + } + + for (const observation of observations) { + if (observation.credential) continue; + const capturedIdentity = identityVector.get(observation.accountId) ?? null; + if (codexCredentialMutationEpoch() !== mutationEpoch) continue; + if ((currentCredentialIdentity(observation.accountId) ?? null) !== capturedIdentity) continue; + boundedNegativeCredentialMemoSet(observation.accountId, { + credentialIdentity: capturedIdentity, + mutationEpoch, + expiresAt: observation.absenceObservedAt + MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS, + }); + } +} + +function waitForEntitlementEnsureFlight( + flight: EntitlementEnsureFlight, + waitMs: number, +): Promise { + const remaining = Math.max(0, waitMs - Math.max(0, Date.now() - flight.startedAt)); + if (remaining === 0) return Promise.resolve(); + return new Promise(resolve => { + const timer = setTimeout(resolve, remaining); + void flight.promise.then(() => { + clearTimeout(timer); + resolve(); + }); + }); +} + +/** + * Refreshes only missing, expired, or credential-mismatched local entitlement entries. + * Management deadlines bound the wait, not the upstream work, so a timed-out poll still warms + * the cache for the first poll after the shared flight settles. + */ +export async function ensureCodexEntitlementFreshness( + config: Pick, + options: CodexEntitlementFreshnessOptions = {}, +): Promise { + try { + const now = options.now ?? Date.now(); + const clientVersion = resolveCodexEntitlementClientVersion( + options.clientVersion, + options.loadPersistedRuntime ?? loadPersistedCodexRuntime, + ); + const candidates = normalizedCandidateAccountIds(config); + const mutationEpoch = codexCredentialMutationEpoch(); + const identityEntries = candidates.map(accountId => ( + [accountId, currentCredentialIdentity(accountId) ?? null] as const + )); + const identityVector = new Map(identityEntries); + const workset = candidates.filter(accountId => needsEntitlementRefresh( + accountId, + identityVector.get(accountId) ?? null, + clientVersion, + mutationEpoch, + now, + )); + if (workset.length === 0) return; + + const key = entitlementEnsureFlightKey( + candidates, + clientVersion, + mutationEpoch, + identityEntries, + workset, + ); + let flight = entitlementEnsureFlights.get(key); + if (!flight) { + const startedAt = Date.now(); + let created!: EntitlementEnsureFlight; + const promise = refreshCodexEntitlementWorkset( + config, + workset, + identityVector, + clientVersion, + mutationEpoch, + options, + ).catch(() => { + // Entitlement discovery is fail-closed: callers project only confirmed cache entries. + }).finally(() => { + if (entitlementEnsureFlights.get(key) === created) entitlementEnsureFlights.delete(key); + }); + created = { startedAt, promise, clientVersion, identityVector, workset }; + entitlementEnsureFlights.set(key, created); + flight = created; + } + const requestedWaitMs = options.waitMs ?? 3_000; + const waitMs = Number.isFinite(requestedWaitMs) ? Math.max(0, requestedWaitMs) : 0; + await waitForEntitlementEnsureFlight(flight, waitMs); + } catch { + // The shared management boundary must degrade to the last confirmed fail-closed projection. + } +} + +export function getCodexModelEntitlementStatus( + config: Pick, + now = Date.now(), + clientVersion?: string | null, +): CodexModelEntitlementStatus { + const version = resolveCodexEntitlementClientVersion(clientVersion); + const accounts = candidateAccountIds(config).flatMap(accountId => { + const credentialIdentity = currentCredentialIdentity(accountId); + return credentialIdentity ? [{ accountId, credentialIdentity }] : []; + }); + if (accounts.length === 0) return { status: "unavailable" }; + + const entries = accounts.map(({ accountId, credentialIdentity }) => ({ + accountId, + credentialIdentity, + entry: accountModelsCache.get(cacheKeyFor(accountId, version)), + })); + const hasRefreshFlight = (accountId: string, credentialIdentity: string): boolean => { + return [...entitlementEnsureFlights.values()].some(flight => ( + flight.clientVersion === version + && flight.identityVector.get(accountId) === credentialIdentity + && flight.workset.includes(accountId) + )); + }; + if (entries.some(({ accountId, credentialIdentity, entry }) => ( + entry + && entry.credentialIdentity === credentialIdentity + && entry.expiresAt <= now + && hasRefreshFlight(accountId, credentialIdentity) + ))) return { status: "expired-refresh-in-flight" }; + const live = entries.flatMap(({ credentialIdentity, entry }) => ( + entry && entry.credentialIdentity === credentialIdentity && entry.expiresAt > now ? [entry] : [] + )); + // Deliberate failure-first aggregation keeps a partial Pool refresh failure visible. + const failed = live.find(entry => entry.provenance && entry.provenance.kind !== "parsed-empty"); + if (failed?.provenance?.kind === "http-error") { + return { status: "failed", reason: "http-error", httpStatus: failed.provenance.httpStatus }; + } + if (failed?.provenance?.kind === "network-error") return { status: "failed", reason: "network-error" }; + if (failed?.provenance?.kind === "timeout") return { status: "failed", reason: "timeout" }; + if (failed?.provenance?.kind === "unparseable") return { status: "failed", reason: "unparseable" }; + if (live.some(entry => entry.provenance?.kind === "parsed-empty")) { + return { status: "unconfirmed-empty" }; + } + if (live.some(entry => entry.confirmed)) return { status: "fresh" }; + return { status: "unavailable" }; +} + /** * Fetch the authenticated model roster for every locally usable Codex account. * @@ -598,15 +937,54 @@ export async function resolveCodexModelEntitlements( .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null); const results = await Promise.all(credentials.map(async credential => ({ credential, - result: await modelsForCredential(credential, fetcher, now, clientVersion), + result: await modelsForCredential( + credential, + fetcher, + now, + clientVersion, + options.credentialMutationEpoch, + ), }))); return { modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])), + clientVersionByAccount: new Map(results.map(({ credential, result }) => ( + [credential.accountId, result.clientVersion] + ))), confirmedAccountIds: new Set(results.flatMap(({ credential, result }) => result.confirmed ? [credential.accountId] : [])), credentialIdentities: new Map(results.map(({ credential }) => [credential.accountId, credential.credentialIdentity])), }; } +function codexModelEntitlementStateForRoster( + models: ReadonlySet | undefined, + confirmed: boolean, + clientVersion: string | undefined, + modelId: string, +): CodexModelEntitlementState { + if (!models || !confirmed) return "unknown"; + // Positive evidence is authoritative regardless of which client version asked for it. + if (models.has(modelId)) return "granted"; + const minimum = ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS.get(modelId); + if (minimum && (!clientVersion || compareClientVersions(clientVersion, minimum) < 0)) { + return "unknown"; + } + return "denied"; +} + +/** Per-account tri-state authority. Positive projections admit only `granted`. */ +export function codexModelEntitlementStateForAccount( + snapshot: CodexModelEntitlementSnapshot, + accountId: string, + modelId: string, +): CodexModelEntitlementState { + return codexModelEntitlementStateForRoster( + snapshot.modelsByAccount.get(accountId), + snapshot.confirmedAccountIds.has(accountId), + snapshot.clientVersionByAccount?.get(accountId), + modelId, + ); +} + /** Fail-closed entitlement check for a Direct request's own forwarded ChatGPT credential. */ export async function isDirectCallerEntitledToCodexModel( headers: Headers, @@ -623,7 +1001,12 @@ export async function isDirectCallerEntitledToCodexModel( options.now ?? Date.now(), clientVersion, ); - return result.confirmed && result.models.has(modelId); + return codexModelEntitlementStateForRoster( + result.models, + result.confirmed, + result.clientVersion, + modelId, + ) === "granted"; } export function entitledCodexAccountIdsForModel( @@ -631,8 +1014,10 @@ export function entitledCodexAccountIdsForModel( modelId: string | undefined, ): ReadonlySet | undefined { if (!modelId || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return undefined; - return new Set([...snapshot.modelsByAccount].flatMap(([accountId, models]) => ( - snapshot.confirmedAccountIds.has(accountId) && models.has(modelId) ? [accountId] : [] + return new Set([...snapshot.modelsByAccount.keys()].flatMap(accountId => ( + codexModelEntitlementStateForAccount(snapshot, accountId, modelId) === "granted" + ? [accountId] + : [] ))); } @@ -641,10 +1026,9 @@ export function availableAccountGatedNativeModels( eligibleAccountIds?: ReadonlySet, ): ReadonlySet { return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => ( - [...snapshot.modelsByAccount].some(([accountId, models]) => ( + [...snapshot.modelsByAccount.keys()].some(accountId => ( (!eligibleAccountIds || eligibleAccountIds.has(accountId)) - && snapshot.confirmedAccountIds.has(accountId) - && models.has(modelId) + && codexModelEntitlementStateForAccount(snapshot, accountId, modelId) === "granted" )) ))); } @@ -669,9 +1053,13 @@ export function cachedAvailableAccountGatedNativeModels( (!eligibleAccountIds || eligibleAccountIds.has(accountIdOfCacheKey(accountId))) && !accountIdOfCacheKey(accountId).startsWith(DIRECT_CALLER_ACCOUNT_PREFIX) && (version === null || entry.clientVersion === version) - && entry.confirmed && entry.expiresAt > now - && entry.models.has(modelId) + && codexModelEntitlementStateForRoster( + entry.models, + entry.confirmed, + entry.clientVersion, + modelId, + ) === "granted" )) ))); } @@ -695,9 +1083,23 @@ export function invalidateCodexModelEntitlementsForAccount(accountId: string | n export function resetCodexModelEntitlementCacheForTests(): void { accountModelsCache.clear(); accountModelsFlights.clear(); + negativeCredentialMemo.clear(); + entitlementEnsureFlights.clear(); runtimeVersionMemo = null; } +/** Test-only snapshot for proving publication fences, which cache lookup intentionally masks. */ +export function codexEntitlementNegativeMemoForTests( + accountId: string, +): Readonly<{ + credentialIdentity: string | null; + mutationEpoch: number; + expiresAt: number; +}> | null { + const memo = negativeCredentialMemo.get(accountId); + return memo ? { ...memo } : null; +} + /** * Test-only seam for the memoized tier-2 read. * @@ -717,9 +1119,10 @@ export function seedCodexModelEntitlementsForTests( models: readonly string[], now = Date.now(), clientVersion = "0.146.0", + credentialIdentity = `test:${accountId}`, ): void { boundedCacheSet(accountId, { - credentialIdentity: `test:${accountId}`, + credentialIdentity, clientVersion, expiresAt: now + MODEL_ROSTER_TTL_MS, models: new Set(models), diff --git a/src/codex/native-profile-manager.ts b/src/codex/native-profile-manager.ts index 34d7b68679..e65c225340 100644 --- a/src/codex/native-profile-manager.ts +++ b/src/codex/native-profile-manager.ts @@ -67,6 +67,7 @@ import { type NativeProfilePublic, type NativeProfileSwitchJournalV1, } from "./native-profile-types"; +import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; import { NATIVE_STAGE_HEARTBEAT_INTERVAL_MS, NativeProfileStageStore, @@ -1307,6 +1308,7 @@ export class NativeProfileManager { await this.atomicWrite(this.context.authPath, target.text); const observedTarget = this.verifyWrittenEnvelope(target.digest, targetProfile.identityHash, key); observedTarget.raw.fill(0); + advanceCodexCredentialMutationEpoch(); await this.onSwitchBoundary("auth-replaced"); journal.phase = "auth-replaced"; await this.writeJournal(journal); @@ -1335,6 +1337,7 @@ export class NativeProfileManager { await this.atomicWrite(this.context.authPath, source!.text); const restored = this.verifyWrittenEnvelope(source!.digest, nativeIdentityHash(key!.key, source!.accountId), key!); restored.raw.fill(0); + advanceCodexCredentialMutationEpoch(); await this.writeVault(beforeVault); this.removeJournal(); } catch { @@ -1449,6 +1452,7 @@ export class NativeProfileManager { await this.atomicWrite(this.context.authPath, sourceEnvelope.text); const restored = this.verifyWrittenEnvelope(sourceEnvelope.digest, journal.sourceIdentityHash, key); restored.raw.fill(0); + advanceCodexCredentialMutationEpoch(); if (!rollbackVaultPublished) await this.writeVault(rollbackVault); this.applyTransition(current.envelope.accountId, sourceEnvelope.accountId); this.removeJournal(); diff --git a/src/codex/reset-credit-operation-ledger.ts b/src/codex/reset-credit-operation-ledger.ts new file mode 100644 index 0000000000..aa240b2c7b --- /dev/null +++ b/src/codex/reset-credit-operation-ledger.ts @@ -0,0 +1,1411 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmodSync } from "node:fs"; +import { Database } from "bun:sqlite"; +import { NestedConfigMutationError, prepareConfigMutationDatabasePathForWrite } from "../config"; +import { initializeConfigGeneration } from "./generation"; +import { + compareCodexResetCreditRecoveryGenerationOrder, + isCodexResetCreditOperationId, + snapshotCodexResetCreditRecoveryGeneration, + type CodexResetCreditConsumeCode, + type CodexResetCreditRecoveryGeneration, + type CodexReservedOperationId, +} from "./reset-credit-recovery"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; + +export const MAX_RESET_CREDIT_OPERATION_ACCOUNTS = 128; +export const MAX_MANUAL_RESET_CREDIT_OPERATION_IDS = 4_096; +const MANUAL_RESET_CREDIT_HISTORY_HIGH_WATER_MARK = Math.ceil( + MAX_MANUAL_RESET_CREDIT_OPERATION_IDS * 0.9, +); +let reportedManualHistoryLevel = 0; +type ResetCreditOperationMigrationFaultForTests = "after_first_write" | null; +let migrationFaultForTests: ResetCreditOperationMigrationFaultForTests = null; +const ACCOUNT_KEY_PATTERN = /^[0-9a-f]{64}$/; +const TERMINAL_STATE_BY_CODE: Readonly> = Object.freeze({ + reset: "confirmed", + already_redeemed: "confirmed", + nothing_to_reset: "stopped", + no_credit: "stopped", +}); +const STATES: ReadonlySet = new Set(["pending", "ambiguous", "confirmed", "stopped"]); + +type ResetCreditOperationState = "pending" | "ambiguous" | "confirmed" | "stopped"; +type ResetCreditOperationKind = "recovery" | "manual"; + +type ResetCreditOperationRecord = Readonly<{ + accountKey: string; + operationKind: ResetCreditOperationKind; + credentialGeneration?: number; + exhaustionGeneration?: number; + operationId: string; + joinedOperationId?: string; + state: ResetCreditOperationState; + code?: CodexResetCreditConsumeCode; + createdAt: number; + updatedAt: number; +}>; + +type ResetCreditOperationRow = { + account_key: unknown; + operation_kind: unknown; + credential_generation: unknown; + exhaustion_generation: unknown; + operation_id: unknown; + joined_operation_id: unknown; + state: unknown; + code: unknown; + created_at: unknown; + updated_at: unknown; +}; + +type ManualResetCreditOperationIdRecord = Readonly<{ + operationId: string; + accountKey: string; + canonicalOperationId: string; + terminalCode?: CodexResetCreditConsumeCode; + createdAt: number; + updatedAt: number; +}>; + +type ManualResetCreditOperationIdRow = { + operation_id: unknown; + account_key: unknown; + canonical_operation_id: unknown; + terminal_code: unknown; + created_at: unknown; + updated_at: unknown; +}; + +export type OpenResetCreditOperationResult = + | Readonly<{ kind: "execute"; operationId: CodexReservedOperationId; resumed: boolean }> + | Readonly<{ kind: "terminal"; operationId: CodexReservedOperationId; code: CodexResetCreditConsumeCode }> + | Readonly<{ kind: "stale-generation" | "unresolved-prior-generation" | "capacity" | "unavailable" }>; + +export type UpdateResetCreditOperationResult = + | Readonly<{ kind: "updated" }> + | Readonly<{ kind: "mismatch" | "unavailable" }>; + +export type ManualResetCreditOperationIdentity = Readonly<{ + accountId: string; + chatgptAccountId: string; + operationId: string; +}>; + +export type OpenManualResetCreditOperationResult = + | Readonly<{ kind: "execute"; operationId: CodexReservedOperationId; resumed: boolean }> + | Readonly<{ kind: "terminal"; operationId: CodexReservedOperationId; code: CodexResetCreditConsumeCode }> + | Readonly<{ kind: "capacity" | "identity-mismatch" | "unavailable" }>; + +const TABLE_NAME = "reset_credit_operations"; +const CREATE_TABLE = `CREATE TABLE main.reset_credit_operations ( + account_key TEXT PRIMARY KEY, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('recovery', 'manual')), + credential_generation INTEGER, + exhaustion_generation INTEGER, + operation_id TEXT NOT NULL, + joined_operation_id TEXT, + state TEXT NOT NULL, + code TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at), + CHECK ( + (operation_kind = 'recovery' + AND credential_generation IS NOT NULL AND credential_generation >= 0 + AND exhaustion_generation IS NOT NULL AND exhaustion_generation >= 0 + AND joined_operation_id IS NULL) + OR + (operation_kind = 'manual' + AND credential_generation IS NULL AND exhaustion_generation IS NULL) + ), + CHECK (joined_operation_id IS NULL OR joined_operation_id <> operation_id) + ) STRICT, WITHOUT ROWID`; +const EXPECTED_SCHEMA_SQL = CREATE_TABLE.replace("main.", ""); +const MANUAL_ID_TABLE_NAME = "reset_credit_manual_operation_ids"; +const CREATE_MANUAL_ID_TABLE = `CREATE TABLE main.reset_credit_manual_operation_ids ( + operation_id TEXT PRIMARY KEY, + account_key TEXT NOT NULL, + canonical_operation_id TEXT NOT NULL, + terminal_code TEXT CHECK ( + terminal_code IS NULL OR terminal_code IN ( + 'reset', 'already_redeemed', 'nothing_to_reset', 'no_credit' + ) + ), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at) + ) STRICT, WITHOUT ROWID`; +const EXPECTED_MANUAL_ID_SCHEMA_SQL = CREATE_MANUAL_ID_TABLE.replace("main.", ""); +const PRIOR_TABLE_NAME = "reset_credit_operations_legacy_v2"; +const PRIOR_CREATE_TABLE = `CREATE TABLE reset_credit_operations ( + account_key TEXT PRIMARY KEY, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('recovery', 'manual')), + credential_generation INTEGER, + exhaustion_generation INTEGER, + operation_id TEXT NOT NULL, + state TEXT NOT NULL, + code TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at), + CHECK ( + (operation_kind = 'recovery' + AND credential_generation IS NOT NULL AND credential_generation >= 0 + AND exhaustion_generation IS NOT NULL AND exhaustion_generation >= 0) + OR + (operation_kind = 'manual' + AND credential_generation IS NULL AND exhaustion_generation IS NULL) + ) + ) STRICT, WITHOUT ROWID`; +const LEGACY_TABLE_NAME = "reset_credit_operations_legacy_v1"; +const LEGACY_CREATE_TABLE = `CREATE TABLE reset_credit_operations ( + account_key TEXT PRIMARY KEY, + credential_generation INTEGER NOT NULL CHECK (credential_generation >= 0), + exhaustion_generation INTEGER NOT NULL CHECK (exhaustion_generation >= 0), + operation_id TEXT NOT NULL, + state TEXT NOT NULL, + code TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at) + ) STRICT, WITHOUT ROWID`; +const SELECT_ALL = ` + SELECT account_key, operation_kind, credential_generation, + exhaustion_generation, operation_id, joined_operation_id, + state, code, created_at, updated_at + FROM main.reset_credit_operations + ORDER BY account_key + LIMIT ${MAX_RESET_CREDIT_OPERATION_ACCOUNTS + 1}`; +const SELECT_BY_KEY = ` + SELECT account_key, operation_kind, credential_generation, + exhaustion_generation, operation_id, joined_operation_id, + state, code, created_at, updated_at + FROM main.reset_credit_operations + WHERE account_key = ? + LIMIT 2`; +const SELECT_KEY_BY_OPERATION_ID = ` + SELECT account_key FROM ( + SELECT account_key + FROM main.reset_credit_operations + WHERE operation_id = ? OR joined_operation_id = ? + UNION + SELECT account_key + FROM main.reset_credit_manual_operation_ids + WHERE operation_id = ? + ) + LIMIT 2`; +const SELECT_ALL_MANUAL_IDS = ` + SELECT operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + FROM main.reset_credit_manual_operation_ids + ORDER BY operation_id + LIMIT ${MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + 1}`; +const SELECT_BOUNDED_MANUAL_ID_COUNT = ` + SELECT COUNT(*) AS count + FROM ( + SELECT 1 + FROM main.reset_credit_manual_operation_ids + LIMIT ${MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + 1} + )`; +const SELECT_DUPLICATE_RECOVERY_MANUAL_ID = ` + SELECT operations.operation_id + FROM main.reset_credit_operations AS operations + JOIN main.reset_credit_manual_operation_ids AS manual_ids + ON manual_ids.operation_id = operations.operation_id + WHERE operations.operation_kind = 'recovery' + LIMIT 1`; +const SELECT_MANUAL_ID = ` + SELECT operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + FROM main.reset_credit_manual_operation_ids + WHERE operation_id = ? + LIMIT 2`; +const SELECT_MANUAL_IDS_BY_CANONICAL = ` + SELECT operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + FROM main.reset_credit_manual_operation_ids + WHERE account_key = ? AND canonical_operation_id = ? + ORDER BY operation_id + LIMIT ${MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + 1}`; +const INSERT_MANUAL_ID = ` + INSERT INTO main.reset_credit_manual_operation_ids ( + operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`; +const SETTLE_MANUAL_IDS = ` + UPDATE main.reset_credit_manual_operation_ids + SET terminal_code = ?, updated_at = ? + WHERE account_key = ? AND canonical_operation_id = ? + AND (terminal_code IS NULL OR terminal_code = ?)`; +const INSERT_RECORD = ` + INSERT INTO main.reset_credit_operations ( + account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, joined_operation_id, state, code, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; +const REPLACE_RECORD = ` + UPDATE main.reset_credit_operations + SET operation_kind = ?, credential_generation = ?, exhaustion_generation = ?, + operation_id = ?, joined_operation_id = ?, state = ?, code = ?, + created_at = ?, updated_at = ? + WHERE account_key = ?`; +const UPDATE_RECORD = ` + UPDATE main.reset_credit_operations + SET state = ?, code = ?, updated_at = ? + WHERE account_key = ? AND operation_kind = ? AND operation_id = ? + AND credential_generation IS ? AND exhaustion_generation IS ?`; +const JOIN_MANUAL_OPERATION = ` + UPDATE main.reset_credit_operations + SET joined_operation_id = ?, updated_at = ? + WHERE account_key = ? AND operation_kind = 'manual' AND operation_id = ? + AND joined_operation_id IS NULL AND state IN ('pending', 'ambiguous')`; +const TOUCH_MANUAL_OPERATION = ` + UPDATE main.reset_credit_operations + SET updated_at = ? + WHERE account_key = ? AND operation_kind = 'manual' AND operation_id = ? + AND joined_operation_id IS NOT NULL AND state IN ('pending', 'ambiguous')`; + +type SchemaObjectRow = { + type: unknown; + name: unknown; + tbl_name: unknown; + sql: unknown; +}; + +type TableListRow = { + schema: unknown; + name: unknown; + type: unknown; + ncol: unknown; + wr: unknown; + strict: unknown; +}; + +type TableColumnRow = { + cid: unknown; + name: unknown; + type: unknown; + notnull: unknown; + dflt_value: unknown; + pk: unknown; + hidden: unknown; +}; + +const EXPECTED_COLUMNS = Object.freeze([ + Object.freeze({ name: "account_key", type: "TEXT", notnull: 1, pk: 1 }), + Object.freeze({ name: "operation_kind", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "credential_generation", type: "INTEGER", notnull: 0, pk: 0 }), + Object.freeze({ name: "exhaustion_generation", type: "INTEGER", notnull: 0, pk: 0 }), + Object.freeze({ name: "operation_id", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "joined_operation_id", type: "TEXT", notnull: 0, pk: 0 }), + Object.freeze({ name: "state", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "code", type: "TEXT", notnull: 0, pk: 0 }), + Object.freeze({ name: "created_at", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "updated_at", type: "INTEGER", notnull: 1, pk: 0 }), +]); + +const MANUAL_ID_COLUMNS = Object.freeze([ + Object.freeze({ name: "operation_id", type: "TEXT", notnull: 1, pk: 1 }), + Object.freeze({ name: "account_key", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "canonical_operation_id", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "terminal_code", type: "TEXT", notnull: 0, pk: 0 }), + Object.freeze({ name: "created_at", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "updated_at", type: "INTEGER", notnull: 1, pk: 0 }), +]); + +const LEGACY_COLUMNS = Object.freeze([ + Object.freeze({ name: "account_key", type: "TEXT", notnull: 1, pk: 1 }), + Object.freeze({ name: "credential_generation", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "exhaustion_generation", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "operation_id", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "state", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "code", type: "TEXT", notnull: 0, pk: 0 }), + Object.freeze({ name: "created_at", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "updated_at", type: "INTEGER", notnull: 1, pk: 0 }), +]); +const PRIOR_COLUMNS = Object.freeze([ + Object.freeze({ name: "account_key", type: "TEXT", notnull: 1, pk: 1 }), + Object.freeze({ name: "operation_kind", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "credential_generation", type: "INTEGER", notnull: 0, pk: 0 }), + Object.freeze({ name: "exhaustion_generation", type: "INTEGER", notnull: 0, pk: 0 }), + Object.freeze({ name: "operation_id", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "state", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "code", type: "TEXT", notnull: 0, pk: 0 }), + Object.freeze({ name: "created_at", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "updated_at", type: "INTEGER", notnull: 1, pk: 0 }), +]); + +function accountKey(accountId: string): string { + return createHash("sha256").update(`codex-reset-credit-operation\0${accountId}`).digest("hex"); +} + +function validateManualAccountId(accountId: string): void { + if (accountId !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(accountId)) { + throw new TypeError("invalid manual reset-credit account"); + } +} + +function manualPhysicalAccountKey(chatgptAccountId: string): string { + const normalized = chatgptAccountId.trim(); + if (!normalized) throw new TypeError("invalid manual reset-credit credential identity"); + return createHash("sha256") + .update(`codex-reset-credit-manual-physical\0${normalized}`) + .digest("hex"); +} + +function isGenerationNumber(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function parseRecord(row: ResetCreditOperationRow | null): ResetCreditOperationRecord | undefined { + if (!row) return undefined; + const state = row.state; + const code = row.code; + const joinedOperationId = row.joined_operation_id; + if (typeof row.account_key !== "string" || !ACCOUNT_KEY_PATTERN.test(row.account_key) + || (row.operation_kind !== "recovery" && row.operation_kind !== "manual") + || !isCodexResetCreditOperationId(row.operation_id) + || (joinedOperationId !== null + && (!isCodexResetCreditOperationId(joinedOperationId) || joinedOperationId === row.operation_id)) + || typeof state !== "string" || !STATES.has(state) + || !isGenerationNumber(row.created_at) + || !isGenerationNumber(row.updated_at) + || row.updated_at < row.created_at) { + return undefined; + } + const recovery = row.operation_kind === "recovery"; + const manual = row.operation_kind === "manual"; + if (recovery !== (isGenerationNumber(row.credential_generation) + && isGenerationNumber(row.exhaustion_generation)) + || manual !== (row.credential_generation === null + && row.exhaustion_generation === null) + || (recovery && joinedOperationId !== null)) { + return undefined; + } + const terminal = state === "confirmed" || state === "stopped"; + if (!terminal && code !== null) return undefined; + const terminalState = typeof code === "string" + && Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, code) + ? TERMINAL_STATE_BY_CODE[code as CodexResetCreditConsumeCode] + : undefined; + if (terminal !== (terminalState !== undefined)) return undefined; + if (terminal && state !== terminalState) return undefined; + return Object.freeze({ + accountKey: row.account_key, + operationKind: row.operation_kind, + ...(recovery + ? { + credentialGeneration: row.credential_generation as number, + exhaustionGeneration: row.exhaustion_generation as number, + } + : {}), + operationId: row.operation_id, + ...(joinedOperationId === null ? {} : { joinedOperationId }), + state: state as ResetCreditOperationState, + ...(terminal ? { code: code as CodexResetCreditConsumeCode } : {}), + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +} + +function parseManualIdRecord( + row: ManualResetCreditOperationIdRow | null, +): ManualResetCreditOperationIdRecord | undefined { + if (!row) return undefined; + const terminalCode = row.terminal_code; + if (!isCodexResetCreditOperationId(row.operation_id) + || typeof row.account_key !== "string" || !ACCOUNT_KEY_PATTERN.test(row.account_key) + || !isCodexResetCreditOperationId(row.canonical_operation_id) + || (terminalCode !== null + && (typeof terminalCode !== "string" + || !Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, terminalCode))) + || !isGenerationNumber(row.created_at) + || !isGenerationNumber(row.updated_at) + || row.updated_at < row.created_at) { + return undefined; + } + return Object.freeze({ + operationId: row.operation_id, + accountKey: row.account_key, + canonicalOperationId: row.canonical_operation_id, + ...(terminalCode === null ? {} : { terminalCode: terminalCode as CodexResetCreditConsumeCode }), + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +} + +function assertColumnLayout( + database: Database, + tableName: string, + expectedColumns: readonly Readonly<{ name: string; type: string; notnull: number; pk: number }>[], +): void { + const tableRows = database.query("PRAGMA main.table_list").all() + .filter(row => row.name === tableName); + if (tableRows.length !== 1) throw new Error("invalid reset-credit operation ledger table"); + const table = tableRows[0]!; + if (table.schema !== "main" || table.type !== "table" || table.ncol !== expectedColumns.length + || table.wr !== 1 || table.strict !== 1) { + throw new Error("invalid reset-credit operation ledger table"); + } + const columns = database.query( + `PRAGMA main.table_xinfo(${tableName})`, + ).all(); + if (columns.length !== expectedColumns.length) { + throw new Error("invalid reset-credit operation ledger columns"); + } + for (let index = 0; index < expectedColumns.length; index += 1) { + const actual = columns[index]!; + const expected = expectedColumns[index]!; + if (actual.cid !== index || actual.name !== expected.name || actual.type !== expected.type + || actual.notnull !== expected.notnull || actual.dflt_value !== null + || actual.pk !== expected.pk || actual.hidden !== 0) { + throw new Error("invalid reset-credit operation ledger columns"); + } + } +} + +function assertNoLedgerTriggers(database: Database, tableName: string): void { + const mainTrigger = database.query<{ name: unknown }, [string]>(` + SELECT name FROM main.sqlite_schema + WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE LIMIT 1 + `).get(tableName); + const tempTrigger = database.query<{ name: unknown }, [string]>(` + SELECT name FROM temp.sqlite_schema + WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE LIMIT 1 + `).get(tableName); + if (mainTrigger || tempTrigger) throw new Error("reset-credit operation ledger triggers are forbidden"); +} + +function failMigrationAfterFirstWriteForTests(): void { + if (migrationFaultForTests === "after_first_write") { + throw new Error("synthetic reset-credit operation migration failure"); + } +} + +/** @internal Test-only fault injection after the first transactional migration write. */ +export function setResetCreditOperationMigrationFaultForTests( + fault: ResetCreditOperationMigrationFaultForTests, +): void { + if (process.env.OCX_TEST_HOME_GUARD !== "1") { + throw new Error("reset-credit operation migration faults require the repository test preload"); + } + migrationFaultForTests = fault; +} + +function migrateLegacyTable(database: Database): void { + assertColumnLayout(database, TABLE_NAME, LEGACY_COLUMNS); + assertNoLedgerTriggers(database, TABLE_NAME); + const legacyRows = database.query<{ + account_key: unknown; + credential_generation: unknown; + exhaustion_generation: unknown; + operation_id: unknown; + state: unknown; + code: unknown; + created_at: unknown; + updated_at: unknown; + }, []>(` + SELECT account_key, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + FROM main.reset_credit_operations + ORDER BY account_key + LIMIT ${MAX_RESET_CREDIT_OPERATION_ACCOUNTS + 1} + `).all(); + if (legacyRows.length > MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + throw new Error("invalid reset-credit operation ledger capacity"); + } + const keys = new Set(); + const operations = new Set(); + for (const row of legacyRows) { + const record = parseRecord({ + ...row, + operation_kind: "recovery", + joined_operation_id: null, + }); + if (!record || keys.has(record.accountKey) || operations.has(record.operationId)) { + throw new Error("invalid reset-credit operation ledger state"); + } + keys.add(record.accountKey); + operations.add(record.operationId); + } + database.exec(`ALTER TABLE main.${TABLE_NAME} RENAME TO ${LEGACY_TABLE_NAME}`); + failMigrationAfterFirstWriteForTests(); + database.exec(CREATE_TABLE); + database.exec(` + INSERT INTO main.${TABLE_NAME} ( + account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, joined_operation_id, state, code, created_at, updated_at + ) + SELECT account_key, 'recovery', credential_generation, exhaustion_generation, + operation_id, NULL, state, code, created_at, updated_at + FROM main.${LEGACY_TABLE_NAME} + `); + database.exec(`DROP TABLE main.${LEGACY_TABLE_NAME}`); +} + +function migratePriorTable(database: Database): void { + assertColumnLayout(database, TABLE_NAME, PRIOR_COLUMNS); + assertNoLedgerTriggers(database, TABLE_NAME); + const priorRows = database.query, []>(` + SELECT account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, state, code, created_at, updated_at + FROM main.reset_credit_operations + ORDER BY account_key + LIMIT ${MAX_RESET_CREDIT_OPERATION_ACCOUNTS + 1} + `).all(); + if (priorRows.length > MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + throw new Error("invalid reset-credit operation ledger capacity"); + } + const keys = new Set(); + const operations = new Set(); + for (const row of priorRows) { + const record = parseRecord({ ...row, joined_operation_id: null }); + if (!record || keys.has(record.accountKey) || operations.has(record.operationId)) { + throw new Error("invalid reset-credit operation ledger state"); + } + keys.add(record.accountKey); + operations.add(record.operationId); + } + database.exec(`ALTER TABLE main.${TABLE_NAME} RENAME TO ${PRIOR_TABLE_NAME}`); + failMigrationAfterFirstWriteForTests(); + database.exec(CREATE_TABLE); + database.exec(` + INSERT INTO main.${TABLE_NAME} ( + account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, joined_operation_id, state, code, created_at, updated_at + ) + SELECT account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, NULL, state, code, created_at, updated_at + FROM main.${PRIOR_TABLE_NAME} + `); + database.exec(`DROP TABLE main.${PRIOR_TABLE_NAME}`); +} + +function isExactLegacySchema(database: Database, schema: SchemaObjectRow): boolean { + if (schema.type !== "table" || schema.name !== TABLE_NAME || schema.tbl_name !== TABLE_NAME + || schema.sql !== LEGACY_CREATE_TABLE) return false; + try { + assertColumnLayout(database, TABLE_NAME, LEGACY_COLUMNS); + assertNoLedgerTriggers(database, TABLE_NAME); + return true; + } catch { + return false; + } +} + +function isExactPriorSchema(database: Database, schema: SchemaObjectRow): boolean { + if (schema.type !== "table" || schema.name !== TABLE_NAME || schema.tbl_name !== TABLE_NAME + || schema.sql !== PRIOR_CREATE_TABLE) return false; + try { + assertColumnLayout(database, TABLE_NAME, PRIOR_COLUMNS); + assertNoLedgerTriggers(database, TABLE_NAME); + return true; + } catch { + return false; + } +} + +type PrimaryTableInitialization = "created" | "migrated" | "existing"; + +function assertCanonicalTable(database: Database): PrimaryTableInitialization { + const schemaRows = database.query(` + SELECT type, name, tbl_name, sql + FROM main.sqlite_schema + WHERE name = ? COLLATE NOCASE OR tbl_name = ? COLLATE NOCASE + ORDER BY type, name + LIMIT 4 + `).all(TABLE_NAME, TABLE_NAME); + let initialization: PrimaryTableInitialization; + if (schemaRows.length === 0) { + database.exec(CREATE_TABLE); + initialization = "created"; + } else if (schemaRows.length === 1 && isExactLegacySchema(database, schemaRows[0]!)) { + migrateLegacyTable(database); + initialization = "migrated"; + } else if (schemaRows.length === 1 && isExactPriorSchema(database, schemaRows[0]!)) { + migratePriorTable(database); + initialization = "migrated"; + } else if (schemaRows.length !== 1 + || schemaRows[0]?.type !== "table" + || schemaRows[0]?.name !== TABLE_NAME + || schemaRows[0]?.tbl_name !== TABLE_NAME + || schemaRows[0]?.sql !== EXPECTED_SCHEMA_SQL) { + throw new Error("invalid reset-credit operation ledger schema"); + } else { + initialization = "existing"; + } + assertColumnLayout(database, TABLE_NAME, EXPECTED_COLUMNS); + assertNoLedgerTriggers(database, TABLE_NAME); + return initialization; +} + +function ensureManualIdTable(database: Database, allowCreate: boolean): boolean { + const schemaRows = database.query(` + SELECT type, name, tbl_name, sql + FROM main.sqlite_schema + WHERE name = ? COLLATE NOCASE OR tbl_name = ? COLLATE NOCASE + ORDER BY type, name + LIMIT 4 + `).all(MANUAL_ID_TABLE_NAME, MANUAL_ID_TABLE_NAME); + let created = false; + if (schemaRows.length === 0) { + if (!allowCreate) { + throw new Error("missing manual reset-credit operation identity schema"); + } + database.exec(CREATE_MANUAL_ID_TABLE); + created = true; + } else if (schemaRows.length !== 1 + || schemaRows[0]?.type !== "table" + || schemaRows[0]?.name !== MANUAL_ID_TABLE_NAME + || schemaRows[0]?.tbl_name !== MANUAL_ID_TABLE_NAME + || schemaRows[0]?.sql !== EXPECTED_MANUAL_ID_SCHEMA_SQL) { + throw new Error("invalid manual reset-credit operation identity schema"); + } + assertColumnLayout(database, MANUAL_ID_TABLE_NAME, MANUAL_ID_COLUMNS); + assertNoLedgerTriggers(database, MANUAL_ID_TABLE_NAME); + return created; +} + +function initializeTable( + database: Database, + validationScope: ResetCreditOperationKind, +): Readonly<{ + recordCount: number; + manualIdCount: number; +}> { + const manualSchemaPresentBefore = database.query<{ present: number }, [string, string]>(` + SELECT 1 AS present + FROM main.sqlite_schema + WHERE name = ? COLLATE NOCASE OR tbl_name = ? COLLATE NOCASE + LIMIT 1 + `).get(MANUAL_ID_TABLE_NAME, MANUAL_ID_TABLE_NAME) !== null; + const primaryInitialization = assertCanonicalTable(database); + if (primaryInitialization !== "existing" && manualSchemaPresentBefore) { + throw new Error("invalid partial reset-credit operation ledger schema"); + } + const rows = database.query(SELECT_ALL).all(); + if (rows.length > MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + throw new Error("invalid reset-credit operation ledger capacity"); + } + const accountKeys = new Set(); + const operationIds = new Set(); + const records = new Map(); + for (const row of rows) { + const record = parseRecord(row); + const ids = record ? [record.operationId, ...(record.joinedOperationId ? [record.joinedOperationId] : [])] : []; + if (!record || accountKeys.has(record.accountKey) || ids.some(id => operationIds.has(id))) { + throw new Error("invalid reset-credit operation ledger state"); + } + accountKeys.add(record.accountKey); + records.set(record.accountKey, record); + for (const id of ids) operationIds.add(id); + } + + const manualTableCreated = ensureManualIdTable(database, primaryInitialization !== "existing"); + if (manualTableCreated) { + for (const record of records.values()) { + if (record.operationKind !== "manual") continue; + const ids = [record.operationId, ...(record.joinedOperationId ? [record.joinedOperationId] : [])]; + for (const operationId of ids) { + insertManualIdRecord(database, Object.freeze({ + operationId, + accountKey: record.accountKey, + canonicalOperationId: record.operationId, + ...(record.code === undefined ? {} : { terminalCode: record.code }), + createdAt: record.createdAt, + updatedAt: record.updatedAt, + })); + } + } + } + + const manualIdCount = database.query<{ count: unknown }, []>(SELECT_BOUNDED_MANUAL_ID_COUNT) + .get()?.count; + if (typeof manualIdCount !== "number" || !Number.isSafeInteger(manualIdCount) || manualIdCount < 0 + || manualIdCount > MAX_MANUAL_RESET_CREDIT_OPERATION_IDS) { + throw new Error("invalid manual reset-credit operation identity capacity"); + } + if (validationScope === "recovery") { + if (database.query<{ operation_id: unknown }, []>(SELECT_DUPLICATE_RECOVERY_MANUAL_ID).get()) { + throw new Error("duplicate reset-credit operation ids"); + } + return Object.freeze({ recordCount: rows.length, manualIdCount }); + } + + const manualRows = database.query(SELECT_ALL_MANUAL_IDS).all(); + if (manualRows.length !== manualIdCount) throw new Error("invalid manual reset-credit operation identity state"); + const manualIds = new Map(); + for (const row of manualRows) { + const record = parseManualIdRecord(row); + if (!record || manualIds.has(record.operationId)) { + throw new Error("invalid manual reset-credit operation identity state"); + } + manualIds.set(record.operationId, record); + } + for (const record of manualIds.values()) { + const canonical = manualIds.get(record.canonicalOperationId); + if (!canonical || canonical.operationId !== canonical.canonicalOperationId + || canonical.accountKey !== record.accountKey + || canonical.terminalCode !== record.terminalCode) { + throw new Error("invalid manual reset-credit operation identity state"); + } + if (record.terminalCode === undefined) { + const current = records.get(record.accountKey); + if (!current || current.operationKind !== "manual" || isTerminal(current) + || current.operationId !== record.canonicalOperationId) { + throw new Error("invalid manual reset-credit operation identity state"); + } + } + } + for (const record of records.values()) { + if (record.operationKind === "recovery") { + if (manualIds.has(record.operationId)) { + throw new Error("duplicate reset-credit operation ids"); + } + continue; + } + const expectedIds = [record.operationId, ...(record.joinedOperationId ? [record.joinedOperationId] : [])]; + for (const operationId of expectedIds) { + const identity = manualIds.get(operationId); + if (!identity || identity.accountKey !== record.accountKey + || identity.canonicalOperationId !== record.operationId + || identity.terminalCode !== record.code) { + throw new Error("invalid manual reset-credit operation identity state"); + } + } + } + return Object.freeze({ recordCount: rows.length, manualIdCount }); +} + +function readRecord(database: Database, key: string): ResetCreditOperationRecord | undefined { + const rows = database.query(SELECT_BY_KEY).all(key); + if (rows.length > 1) throw new Error("duplicate reset-credit operation records"); + const row = rows[0]; + const record = parseRecord(row ?? null); + if (row && !record) throw new Error("invalid reset-credit operation record"); + return record; +} + +function readManualIdRecord( + database: Database, + operationId: string, +): ManualResetCreditOperationIdRecord | undefined { + const rows = database.query(SELECT_MANUAL_ID) + .all(operationId); + if (rows.length > 1) throw new Error("duplicate manual reset-credit operation ids"); + const row = rows[0]; + const record = parseManualIdRecord(row ?? null); + if (row && !record) throw new Error("invalid manual reset-credit operation identity"); + return record; +} + +function sameManualIdRecord( + left: ManualResetCreditOperationIdRecord, + right: ManualResetCreditOperationIdRecord, +): boolean { + return left.operationId === right.operationId + && left.accountKey === right.accountKey + && left.canonicalOperationId === right.canonicalOperationId + && left.terminalCode === right.terminalCode + && left.createdAt === right.createdAt + && left.updatedAt === right.updatedAt; +} + +function assertStoredManualIdRecord( + database: Database, + expected: ManualResetCreditOperationIdRecord, +): void { + const stored = readManualIdRecord(database, expected.operationId); + if (!stored || !sameManualIdRecord(stored, expected)) { + throw new Error("manual reset-credit operation identity write did not persist"); + } +} + +function insertManualIdRecord( + database: Database, + record: ManualResetCreditOperationIdRecord, +): void { + const result = database.query(INSERT_MANUAL_ID).run( + record.operationId, + record.accountKey, + record.canonicalOperationId, + record.terminalCode ?? null, + record.createdAt, + record.updatedAt, + ); + if (result.changes !== 1) throw new Error("manual reset-credit operation identity insert failed"); + assertStoredManualIdRecord(database, record); +} + +function operationOwner(database: Database, operationId: string): string | undefined { + const rows = database.query<{ account_key: unknown }, [string, string, string]>(SELECT_KEY_BY_OPERATION_ID) + .all(operationId, operationId, operationId); + if (rows.length > 1) throw new Error("duplicate reset-credit operation ids"); + const owner = rows[0]?.account_key; + if (owner !== undefined && (typeof owner !== "string" || !ACCOUNT_KEY_PATTERN.test(owner))) { + throw new Error("invalid reset-credit operation owner"); + } + return owner; +} + +function sameRecord(left: ResetCreditOperationRecord, right: ResetCreditOperationRecord): boolean { + return left.accountKey === right.accountKey + && left.operationKind === right.operationKind + && left.credentialGeneration === right.credentialGeneration + && left.exhaustionGeneration === right.exhaustionGeneration + && left.operationId === right.operationId + && left.joinedOperationId === right.joinedOperationId + && left.state === right.state + && left.code === right.code + && left.createdAt === right.createdAt + && left.updatedAt === right.updatedAt; +} + +function assertStoredRecord( + database: Database, + expected: ResetCreditOperationRecord, +): void { + const stored = readRecord(database, expected.accountKey); + if (!stored || !sameRecord(stored, expected)) { + throw new Error("reset-credit operation write did not persist the expected record"); + } +} + +function compareGeneration( + record: ResetCreditOperationRecord, + generation: CodexResetCreditRecoveryGeneration, +): -1 | 0 | 1 { + return compareCodexResetCreditRecoveryGenerationOrder({ + accountId: generation.accountId, + credentialGeneration: record.credentialGeneration!, + exhaustionGeneration: record.exhaustionGeneration!, + }, generation); +} + +function isTerminal(record: ResetCreditOperationRecord): boolean { + return record.state === "confirmed" || record.state === "stopped"; +} + +function isThenable(value: unknown): boolean { + return (typeof value === "object" && value !== null) || typeof value === "function" + ? typeof (value as { then?: unknown }).then === "function" + : false; +} + +type Synchronous = T extends PromiseLike ? never : T; + +function withLedger(validationScope: ResetCreditOperationKind, operation: ( + database: Database, + recordCount: number, + manualIdCount: number, +) => Synchronous): T { + const path = prepareConfigMutationDatabasePathForWrite(); + let database: Database | undefined; + let transactionOpen = false; + try { + database = new Database(path, { create: true }); + try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } + database.exec("PRAGMA trusted_schema = OFF; PRAGMA busy_timeout = 0; PRAGMA synchronous = FULL; BEGIN IMMEDIATE"); + transactionOpen = true; + initializeConfigGeneration(database); + const counts = initializeTable(database, validationScope); + const value = operation(database, counts.recordCount, counts.manualIdCount); + if (isThenable(value) || !database.inTransaction) { + throw new Error("reset-credit operation ledger work escaped its synchronous transaction"); + } + database.exec("COMMIT"); + transactionOpen = false; + return value; + } catch (error) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close still releases the write lock */ } + transactionOpen = false; + } + throw error; + } finally { + try { database?.close(); } catch { /* operation already completed */ } + } +} + +function isLedgerBusyError(error: unknown): boolean { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + const message = error instanceof Error ? error.message : ""; + return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); +} + +function warnLedgerUnavailable(error: unknown): void { + if (isLedgerBusyError(error)) return; + const nested = error instanceof NestedConfigMutationError; + // Native SQLite and filesystem errors may contain absolute, account-bearing + // paths. Keep this warning categorical rather than forwarding error.message. + console.warn(nested + ? "[opencodex] Reset-credit operation ledger refused a nested config mutation." + : "[opencodex] Reset-credit operation ledger is unavailable."); +} + +function reportManualHistoryCapacity(count: number): void { + const level = count >= MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + ? MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + : count >= MANUAL_RESET_CREDIT_HISTORY_HIGH_WATER_MARK + ? MANUAL_RESET_CREDIT_HISTORY_HIGH_WATER_MARK + : 0; + if (level === 0 || level <= reportedManualHistoryLevel) return; + reportedManualHistoryLevel = level; + try { + console.warn( + `[opencodex] Reset-credit manual operation history is at ${count}/${MAX_MANUAL_RESET_CREDIT_OPERATION_IDS} entries${ + level === MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + ? "; new manual operation IDs, including aliases, are disabled until a maintainer expands capacity or applies an approved retirement policy." + : "." + }`, + ); + } catch { + // Count-only operational reporting must never weaken the fail-closed result. + } +} + +/** + * Throws `TypeError` for a malformed generation or timestamp. Runtime storage + * and contention failures are represented by a result kind. + */ +export function openResetCreditOperation( + generation: CodexResetCreditRecoveryGeneration, + now = Date.now(), +): OpenResetCreditOperationResult { + const generationSnapshot = snapshotCodexResetCreditRecoveryGeneration(generation); + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + try { + return withLedger("recovery", (database, recordCount) => { + const key = accountKey(generationSnapshot.accountId); + const current = readRecord(database, key); + if (current) { + if (current.operationKind !== "recovery") { + return Object.freeze({ kind: "unresolved-prior-generation" as const }); + } + const comparison = compareGeneration(current, generationSnapshot); + if (comparison > 0) return Object.freeze({ kind: "stale-generation" as const }); + if (comparison === 0) { + if (isTerminal(current)) { + return Object.freeze({ + kind: "terminal" as const, + operationId: current.operationId as CodexReservedOperationId, + code: current.code!, + }); + } + return Object.freeze({ + kind: "execute" as const, + operationId: current.operationId as CodexReservedOperationId, + resumed: true, + }); + } + if (!isTerminal(current)) return Object.freeze({ kind: "unresolved-prior-generation" as const }); + } else if (recordCount >= MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + return Object.freeze({ kind: "capacity" as const }); + } + + const operationId = randomUUID(); + if (!isCodexResetCreditOperationId(operationId)) throw new Error("runtime generated invalid UUID"); + if (operationOwner(database, operationId) !== undefined) { + throw new Error("duplicate reset-credit operation ids"); + } + const values = [ + "recovery", + generationSnapshot.credentialGeneration, + generationSnapshot.exhaustionGeneration, + operationId, + null, + "pending", + null, + now, + now, + ] as const; + const result = current + ? database.query(REPLACE_RECORD).run(...values, key) + : database.query(INSERT_RECORD).run(key, ...values); + if (result.changes !== 1) throw new Error("reset-credit operation reservation lost ownership"); + assertStoredRecord(database, Object.freeze({ + accountKey: key, + operationKind: "recovery", + credentialGeneration: generationSnapshot.credentialGeneration, + exhaustionGeneration: generationSnapshot.exhaustionGeneration, + operationId, + state: "pending", + createdAt: now, + updatedAt: now, + })); + return Object.freeze({ + kind: "execute" as const, + operationId: operationId as CodexReservedOperationId, + resumed: false, + }); + }); + } catch (error) { + warnLedgerUnavailable(error); + return Object.freeze({ kind: "unavailable" }); + } +} + +function updateOperation( + owner: Readonly<{ + accountKey: string; + operationKind: ResetCreditOperationKind; + credentialGeneration?: number; + exhaustionGeneration?: number; + }>, + operationId: string, + update: (record: ResetCreditOperationRecord) => ResetCreditOperationRecord | undefined, + afterWrite?: (database: Database, updated: ResetCreditOperationRecord) => void, +): UpdateResetCreditOperationResult { + if (!isCodexResetCreditOperationId(operationId)) return Object.freeze({ kind: "mismatch" }); + try { + return withLedger(owner.operationKind, database => { + const current = readRecord(database, owner.accountKey); + if (!current + || current.operationKind !== owner.operationKind + || current.credentialGeneration !== owner.credentialGeneration + || current.exhaustionGeneration !== owner.exhaustionGeneration + || current.operationId !== operationId) { + return Object.freeze({ kind: "mismatch" as const }); + } + const updated = update(current); + if (!updated) return Object.freeze({ kind: "mismatch" as const }); + const result = database.query(UPDATE_RECORD).run( + updated.state, + updated.code ?? null, + updated.updatedAt, + owner.accountKey, + owner.operationKind, + operationId, + owner.credentialGeneration ?? null, + owner.exhaustionGeneration ?? null, + ); + if (result.changes !== 1) throw new Error("reset-credit operation update lost ownership"); + assertStoredRecord(database, updated); + afterWrite?.(database, updated); + return Object.freeze({ kind: "updated" as const }); + }); + } catch (error) { + warnLedgerUnavailable(error); + return Object.freeze({ kind: "unavailable" }); + } +} + +/** + * Throws `TypeError` for a malformed generation or timestamp. An invalid + * operation id returns `mismatch`; runtime storage failures return `unavailable`. + */ +export function markResetCreditOperationAmbiguous( + generation: CodexResetCreditRecoveryGeneration, + operationId: string, + now = Date.now(), +): UpdateResetCreditOperationResult { + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + const generationSnapshot = snapshotCodexResetCreditRecoveryGeneration(generation); + return updateOperation({ + accountKey: accountKey(generationSnapshot.accountId), + operationKind: "recovery", + credentialGeneration: generationSnapshot.credentialGeneration, + exhaustionGeneration: generationSnapshot.exhaustionGeneration, + }, operationId, record => { + if (isTerminal(record)) return undefined; + return Object.freeze({ + ...record, + state: "ambiguous", + code: undefined, + updatedAt: Math.max(record.updatedAt, now), + }); + }); +} + +/** + * Throws `TypeError` for a malformed generation or timestamp. An invalid + * operation id or non-terminal code returns `mismatch`; runtime storage + * failures return `unavailable`. + */ +export function settleResetCreditOperation( + generation: CodexResetCreditRecoveryGeneration, + operationId: string, + code: CodexResetCreditConsumeCode, + now = Date.now(), +): UpdateResetCreditOperationResult { + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + if (!Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, code)) { + return Object.freeze({ kind: "mismatch" }); + } + const generationSnapshot = snapshotCodexResetCreditRecoveryGeneration(generation); + return updateOperation({ + accountKey: accountKey(generationSnapshot.accountId), + operationKind: "recovery", + credentialGeneration: generationSnapshot.credentialGeneration, + exhaustionGeneration: generationSnapshot.exhaustionGeneration, + }, operationId, record => { + if (isTerminal(record)) return record.code === code ? record : undefined; + return Object.freeze({ + ...record, + state: TERMINAL_STATE_BY_CODE[code], + code, + updatedAt: Math.max(record.updatedAt, now), + }); + }); +} + +function snapshotManualIdentity(identity: ManualResetCreditOperationIdentity): { + accountKey: string; + operationId: string; +} { + if (!identity || typeof identity !== "object" || Array.isArray(identity)) { + throw new TypeError("manual reset-credit identity must be an object"); + } + const value = identity as unknown as Record; + const hasOwn = Object.prototype.hasOwnProperty; + if (!hasOwn.call(value, "accountId") + || !hasOwn.call(value, "chatgptAccountId") + || !hasOwn.call(value, "operationId")) { + throw new TypeError("manual reset-credit identity fields must be own properties"); + } + const accountId = value.accountId; + const chatgptAccountId = value.chatgptAccountId; + const operationId = value.operationId; + if (!isCodexResetCreditOperationId(operationId)) { + throw new TypeError("invalid manual reset-credit operation id"); + } + if (typeof accountId !== "string") throw new TypeError("invalid manual reset-credit account"); + validateManualAccountId(accountId); + if (typeof chatgptAccountId !== "string") { + throw new TypeError("invalid manual reset-credit credential identity"); + } + return Object.freeze({ + accountKey: manualPhysicalAccountKey(chatgptAccountId), + operationId, + }); +} + +/** + * Reserve or restore one explicit manual redemption intent. + * + * Throws `TypeError` for malformed identity fields or `now`; these are caller + * contract violations. Durable-state and runtime failures return a result kind. + */ +export function openManualResetCreditOperation( + identity: ManualResetCreditOperationIdentity, + now = Date.now(), +): OpenManualResetCreditOperationResult { + const owner = snapshotManualIdentity(identity); + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + try { + return withLedger("manual", (database, recordCount, manualIdCount) => { + reportManualHistoryCapacity(manualIdCount); + const admitNewCallerId = () => { + if (manualIdCount >= MAX_MANUAL_RESET_CREDIT_OPERATION_IDS) { + return Object.freeze({ kind: "capacity" as const }); + } + const existingOwner = operationOwner(database, owner.operationId); + if (existingOwner !== undefined) { + return Object.freeze({ + kind: existingOwner === owner.accountKey ? "unavailable" as const : "identity-mismatch" as const, + }); + } + return undefined; + }; + + const reserve = (replaceCurrent: boolean): OpenManualResetCreditOperationResult => { + const rejected = admitNewCallerId(); + if (rejected) return rejected; + + const record: ResetCreditOperationRecord = Object.freeze({ + accountKey: owner.accountKey, + operationKind: "manual", + operationId: owner.operationId, + state: "pending", + createdAt: now, + updatedAt: now, + }); + const values = [ + "manual", + null, + null, + owner.operationId, + null, + "pending", + null, + now, + now, + ] as const; + const result = replaceCurrent + ? database.query(REPLACE_RECORD).run(...values, owner.accountKey) + : database.query(INSERT_RECORD).run(owner.accountKey, ...values); + if (result.changes !== 1) throw new Error("manual reset-credit reservation lost ownership"); + assertStoredRecord(database, record); + insertManualIdRecord(database, Object.freeze({ + operationId: owner.operationId, + accountKey: owner.accountKey, + canonicalOperationId: owner.operationId, + createdAt: now, + updatedAt: now, + })); + return Object.freeze({ + kind: "execute" as const, + operationId: owner.operationId as CodexReservedOperationId, + resumed: false, + }); + }; + + const knownIdentity = readManualIdRecord(database, owner.operationId); + if (knownIdentity) { + if (knownIdentity.accountKey !== owner.accountKey) { + return Object.freeze({ kind: "identity-mismatch" as const }); + } + if (knownIdentity.terminalCode !== undefined) { + return Object.freeze({ + kind: "terminal" as const, + operationId: knownIdentity.canonicalOperationId as CodexReservedOperationId, + code: knownIdentity.terminalCode, + }); + } + const current = readRecord(database, owner.accountKey); + if (!current || current.operationKind !== "manual" || isTerminal(current) + || current.operationId !== knownIdentity.canonicalOperationId) { + throw new Error("manual reset-credit operation identity lost its active owner"); + } + return Object.freeze({ + kind: "execute" as const, + operationId: current.operationId as CodexReservedOperationId, + resumed: true, + }); + } + + const current = readRecord(database, owner.accountKey); + if (current) { + if (current.operationKind !== "manual") { + return Object.freeze({ kind: "unavailable" as const }); + } + if (!isTerminal(current)) { + const rejected = admitNewCallerId(); + if (rejected) return rejected; + insertManualIdRecord(database, Object.freeze({ + operationId: owner.operationId, + accountKey: owner.accountKey, + canonicalOperationId: current.operationId, + createdAt: now, + updatedAt: now, + })); + const joined: ResetCreditOperationRecord = Object.freeze({ + ...current, + ...(current.joinedOperationId === undefined + ? { joinedOperationId: owner.operationId } + : {}), + updatedAt: Math.max(current.updatedAt, now), + }); + const result = current.joinedOperationId === undefined + ? database.query(JOIN_MANUAL_OPERATION).run( + owner.operationId, + joined.updatedAt, + owner.accountKey, + current.operationId, + ) + : database.query(TOUCH_MANUAL_OPERATION).run( + joined.updatedAt, + owner.accountKey, + current.operationId, + ); + if (result.changes !== 1) { + throw new Error("manual reset-credit join lost ownership"); + } + assertStoredRecord(database, joined); + // The upstream request keeps the original durable id. Every caller id + // is retained in the identity history; the first alias is also kept on + // the current row for compatibility with the previous schema. + return Object.freeze({ + kind: "execute" as const, + operationId: current.operationId as CodexReservedOperationId, + resumed: true, + }); + } + // Deliberate: a distinct caller id after a settled intent represents a + // new explicit redemption. Prior ids remain immutable in the history, + // so a delayed retry can never be reclassified as this new intent. + return reserve(true); + } + if (recordCount >= MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + return Object.freeze({ kind: "capacity" as const }); + } + return reserve(false); + }); + } catch (error) { + warnLedgerUnavailable(error); + return Object.freeze({ kind: "unavailable" }); + } +} + +/** + * Mark a reserved manual redemption as ambiguous. + * + * Throws `TypeError` for malformed identity fields or `now`. A missing or + * incompatible durable record returns the existing result kind. + */ +export function markManualResetCreditOperationAmbiguous( + identity: ManualResetCreditOperationIdentity, + now = Date.now(), +): UpdateResetCreditOperationResult { + const owner = snapshotManualIdentity(identity); + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + return updateOperation({ accountKey: owner.accountKey, operationKind: "manual" }, owner.operationId, record => { + if (isTerminal(record)) return undefined; + return Object.freeze({ ...record, state: "ambiguous", code: undefined, updatedAt: Math.max(record.updatedAt, now) }); + }); +} + +/** + * Settle a reserved manual redemption with one terminal consume code. + * + * Throws `TypeError` for malformed identity fields or `now`; an unsupported + * code or incompatible durable record returns `mismatch`. + */ +export function settleManualResetCreditOperation( + identity: ManualResetCreditOperationIdentity, + code: CodexResetCreditConsumeCode, + now = Date.now(), +): UpdateResetCreditOperationResult { + const owner = snapshotManualIdentity(identity); + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + if (!Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, code)) { + return Object.freeze({ kind: "mismatch" }); + } + return updateOperation({ accountKey: owner.accountKey, operationKind: "manual" }, owner.operationId, record => { + if (isTerminal(record)) return record.code === code ? record : undefined; + return Object.freeze({ + ...record, + state: TERMINAL_STATE_BY_CODE[code], + code, + updatedAt: Math.max(record.updatedAt, now), + }); + }, (database, updated) => { + const result = database.query(SETTLE_MANUAL_IDS).run( + code, + updated.updatedAt, + owner.accountKey, + owner.operationId, + code, + ); + if (result.changes < 1) { + throw new Error("manual reset-credit terminal identity update lost ownership"); + } + const rows = database.query( + SELECT_MANUAL_IDS_BY_CANONICAL, + ).all(owner.accountKey, owner.operationId); + if (rows.length < 1 || rows.length > MAX_MANUAL_RESET_CREDIT_OPERATION_IDS) { + throw new Error("invalid manual reset-credit terminal identity set"); + } + for (const row of rows) { + const stored = parseManualIdRecord(row); + if (!stored || stored.accountKey !== owner.accountKey + || stored.canonicalOperationId !== owner.operationId + || stored.terminalCode !== code + || stored.updatedAt !== updated.updatedAt) { + throw new Error("manual reset-credit terminal identity write did not persist"); + } + } + }); +} diff --git a/src/codex/reset-credit-recovery.ts b/src/codex/reset-credit-recovery.ts index 69771eddd7..2111762369 100644 --- a/src/codex/reset-credit-recovery.ts +++ b/src/codex/reset-credit-recovery.ts @@ -27,6 +27,20 @@ export type CodexResetCreditConsumeCode = | "nothing_to_reset" | "no_credit"; +declare const CODEX_RESERVED_OPERATION_ID_BRAND: unique symbol; + +/** An operation id whose durable reservation was validated by the operation ledger. */ +export type CodexReservedOperationId = string & { + readonly [CODEX_RESERVED_OPERATION_ID_BRAND]: true; +}; + +export const CODEX_RESET_CREDIT_OPERATION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +export function isCodexResetCreditOperationId(value: unknown): value is string { + return typeof value === "string" && CODEX_RESET_CREDIT_OPERATION_ID_PATTERN.test(value); +} + export type CodexResetCreditRecoveryAuthorization = Readonly<{ enabled: boolean; /** @@ -189,7 +203,9 @@ const RESET_ELIGIBLE_CODES = { insufficient_quota: true, } as const satisfies Record; -function snapshotGeneration(input: unknown): CodexResetCreditRecoveryGeneration { +export function snapshotCodexResetCreditRecoveryGeneration( + input: unknown, +): CodexResetCreditRecoveryGeneration { if (!input || typeof input !== "object" || Array.isArray(input)) { throw new TypeError("generation must be an object"); } @@ -251,6 +267,8 @@ function compareGenerationOrder( return 0; } +export const compareCodexResetCreditRecoveryGenerationOrder = compareGenerationOrder; + function authorizedResetRejection(authorization: CodexResetCreditRecoveryAuthorization): boolean { const hasOwn = Object.prototype.hasOwnProperty; if (!hasOwn.call(authorization, "enabled") @@ -507,7 +525,7 @@ export class CodexResetCreditRecoveryCoordinator { let requestSignal: AbortSignal | undefined; try { requestSignal = snapshotRequestSignal(options); - generationSnapshot = snapshotGeneration(generation); + generationSnapshot = snapshotCodexResetCreditRecoveryGeneration(generation); } catch (error) { rejectAttempt(error); return attempt; diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 4f2ba3a6db..5d64d0dbbe 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -import { basename, delimiter, dirname, extname, join, posix } from "node:path"; +import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path"; import { chmodSync, closeSync, @@ -264,6 +264,28 @@ interface ShimFileState { preserveOnly?: boolean; } +export type CodexShimBackingForCommand = + | Readonly<{ status: "not-tracked" }> + | Readonly<{ + status: "matched"; + selectedRole: "wrapper" | "backing"; + backingPath: string; + backingKind: "backup" | "real"; + }> + | Readonly<{ + status: "unknown"; + reason: + | "state_invalid" + | "platform_mismatch" + | "ambiguous_match" + | "preserve_only" + | "backing_missing" + | "backing_mismatch" + | "binding_unavailable" + | "wrapper_unhealthy" + | "version_manager_refused"; + }>; + interface ShimPathFingerprint { dev: number; ino: number; @@ -616,8 +638,13 @@ function backupPathFor(path: string): string { * deliberately excluded: a false positive here refuses a restore that would * otherwise be correct. */ -export function isVersionManagerOwnedCodexPath(path: string): boolean { - const normalized = path.replace(/\\/g, "/").toLowerCase(); +export function isVersionManagerOwnedCodexPath( + path: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const normalized = (platform === "win32" + ? win32.normalize(path).replace(/\\/g, "/") + : posix.normalize(path)).toLowerCase(); return normalized.includes("/mise/installs/") || normalized.includes("/mise/shims/") || normalized.includes("/.asdf/installs/") @@ -1078,6 +1105,7 @@ exit $LASTEXITCODE interface ShimStateReadResult { state: ShimState | null; + present: boolean; warning?: string; } @@ -1087,7 +1115,17 @@ function fileErrorCode(error: unknown): string | undefined { : undefined; } -function readBoundedRegularFile(path: string, maxBytes: number): { content: string } | { warning: string } | null { +function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer; content: string } | { warning: string } | null { + let lexicalBefore: Stats; + try { + lexicalBefore = lstatSync(path); + if (lexicalBefore.isSymbolicLink() || !lexicalBefore.isFile()) { + return { warning: `Codex shim state is not a direct regular file at ${path}; auto-restore skipped.` }; + } + } catch (error) { + if (fileErrorCode(error) === "ENOENT") return null; + return { warning: `Codex shim state could not be inspected at ${path}.` }; + } let fd: number; try { fd = openSync(path, "r"); @@ -1113,25 +1151,33 @@ function readBoundedRegularFile(path: string, maxBytes: number): { content: stri return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` }; } const after = fstatSync(fd); + let lexicalAfter: Stats; + try { + lexicalAfter = lstatSync(path); + } catch { + return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; + } if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size - || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) { + || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs + || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino + || lexicalAfter.isSymbolicLink() || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino) { return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; } - return { content: buffer.toString("utf8") }; + return { bytes: buffer, content: buffer.toString("utf8") }; } finally { closeSync(fd); } } -function readStateResult(): ShimStateReadResult { - const bounded = readBoundedRegularFile(statePath(), CODEX_SHIM_STATE_MAX_BYTES); - if (!bounded) return { state: null }; - if ("warning" in bounded) return { state: null, warning: bounded.warning }; +function readStateResult(path = statePath()): ShimStateReadResult { + const bounded = readBoundedRegularFile(path, CODEX_SHIM_STATE_MAX_BYTES); + if (!bounded) return { state: null, present: false }; + if ("warning" in bounded) return { state: null, present: true, warning: bounded.warning }; try { const value = JSON.parse(bounded.content) as unknown; - if (!value || typeof value !== "object") return { state: null }; + if (!value || typeof value !== "object") return { state: null, present: true }; const state = value as Record; - if (typeof state.platform !== "string") return { state: null }; + if (typeof state.platform !== "string") return { state: null, present: true }; const validFile = (item: unknown): item is ShimFileState => { if (!item || typeof item !== "object") return false; const file = item as Record; @@ -1142,13 +1188,13 @@ function readStateResult(): ShimStateReadResult { && (file.preserveOnly === undefined || typeof file.preserveOnly === "boolean"); }; if (state.wrappers !== undefined) { - if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return { state: null }; + if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return { state: null, present: true }; } else if (!validFile(state)) { - return { state: null }; + return { state: null, present: true }; } - return { state: state as unknown as ShimState }; + return { state: state as unknown as ShimState, present: true }; } catch { - return { state: null }; + return { state: null, present: true }; } } @@ -1156,6 +1202,146 @@ function readState(): ShimState | null { return readStateResult().state; } +export function isLocalAbsoluteInspectionPath(path: string, platform: NodeJS.Platform): boolean { + if (platform !== "win32") return posix.isAbsolute(path); + const normalized = path.replace(/\//g, "\\"); + // UNC and device namespaces can initiate remote I/O while a nominally local + // inspection is resolving user-controlled paths. Root-relative paths are + // drive-context dependent, so require an explicit local drive as well. + return win32.isAbsolute(path) + && /^[a-z]:\\/i.test(normalized) + && !normalized.startsWith("\\\\"); +} + +function windowsShimInspectionIsDeferred(platform: NodeJS.Platform): boolean { + return platform === "win32"; +} + +/** Resolve one selected command through already-recorded shim state, without repair. */ +export function inspectCodexShimBackingForCommand( + selectedCommand: string, + platform: NodeJS.Platform = process.platform, + configDir: string = getConfigDir(), +): CodexShimBackingForCommand { + // Pathname prechecks cannot prevent a writable Windows ancestor from being + // replaced with a remote reparse point before the later state/fingerprint + // reads. Keep the exported read-only helper fail-closed until those reads are + // performed through a handle-bound Windows provenance layer. + if (windowsShimInspectionIsDeferred(platform)) { + return Object.freeze({ status: "unknown" as const, reason: "binding_unavailable" as const }); + } + if (!isLocalAbsoluteInspectionPath(configDir, platform)) { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + const stateFile = join(configDir, "codex-shim.json"); + try { + const stateEntry = lstatSync(stateFile); + if (stateEntry.isSymbolicLink()) { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + } catch (error) { + if (fileErrorCode(error) !== "ENOENT") { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + } + const result = readStateResult(stateFile); + if (!result.state) { + return result.present + ? Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }) + : Object.freeze({ status: "not-tracked" as const }); + } + const pathApi = platform === "win32" ? win32 : posix; + const samePath = (left: string, right: string): boolean => { + const normalizedLeft = pathApi.resolve(left); + const normalizedRight = pathApi.resolve(right); + return platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; + }; + const files = stateFiles(result.state); + if (files.some(file => !file.wrapperPath || !file.originalPath || !file.backupPath + || ![file.wrapperPath, file.originalPath, file.backupPath, file.realPath] + .filter((path): path is string => typeof path === "string") + .every(path => isLocalAbsoluteInspectionPath(path, platform)))) { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + const wrapperKeys = files.map(file => platform === "win32" + ? pathApi.resolve(file.wrapperPath).toLowerCase() + : pathApi.resolve(file.wrapperPath)); + if (new Set(wrapperKeys).size !== wrapperKeys.length) { + return Object.freeze({ status: "unknown" as const, reason: "state_invalid" as const }); + } + const selectedFingerprint = shimPathFingerprint(selectedCommand); + if (!selectedFingerprint) { + return Object.freeze({ status: "unknown" as const, reason: "binding_unavailable" as const }); + } + const selectedIdentity = selectedFingerprint.target ?? selectedFingerprint; + const sameEffectiveIdentity = (fingerprint: ShimPathFingerprint | null): boolean => { + if (!fingerprint) return false; + const identity = fingerprint.target ?? fingerprint; + return identity.dev === selectedIdentity.dev && identity.ino === selectedIdentity.ino; + }; + const matches = files.flatMap(file => { + const backingPath = file.realPath ?? file.backupPath; + const roles: Array<"wrapper" | "backing"> = []; + if (samePath(file.wrapperPath, selectedCommand) + || sameEffectiveIdentity(shimPathFingerprint(file.wrapperPath))) { + roles.push("wrapper"); + } + if (samePath(backingPath, selectedCommand) + || sameEffectiveIdentity(shimPathFingerprint(backingPath))) { + roles.push("backing"); + } + return roles.map(selectedRole => ({ file, backingPath, selectedRole })); + }); + if (matches.length === 0) return Object.freeze({ status: "not-tracked" as const }); + if (result.state.platform !== platform) { + return Object.freeze({ status: "unknown" as const, reason: "platform_mismatch" as const }); + } + if (matches.length !== 1) { + return Object.freeze({ status: "unknown" as const, reason: "ambiguous_match" as const }); + } + const { file, backingPath, selectedRole } = matches[0]!; + if (file.preserveOnly === true) { + return Object.freeze({ status: "unknown" as const, reason: "preserve_only" as const }); + } + const backing = statFingerprint(backingPath, true); + if (!backing || backing.size <= 0 || samePath(backingPath, file.wrapperPath)) { + return Object.freeze({ status: "unknown" as const, reason: "backing_missing" as const }); + } + const wrapperProbe = stableShimPathProbe(file.wrapperPath); + if (!wrapperProbe || !isHealthyShimProbe(wrapperProbe, result.state.platform)) { + return Object.freeze({ + status: "unknown" as const, + reason: isVersionManagerOwnedCodexPath(file.wrapperPath) + ? "version_manager_refused" as const + : "wrapper_unhealthy" as const, + }); + } + const wrapperIdentity = wrapperProbe.fingerprint.target ?? wrapperProbe.fingerprint; + if (backing.dev === wrapperIdentity.dev && backing.ino === wrapperIdentity.ino) { + return Object.freeze({ status: "unknown" as const, reason: "backing_mismatch" as const }); + } + const wrapperExt = extname(file.wrapperPath).toLowerCase(); + const invokesBacking = platform !== "win32" + ? wrapperProbe.prefix.includes(`exec ${shQuote(backingPath)} "$@"`) + : wrapperExt === ".cmd" || wrapperExt === ".bat" + ? wrapperProbe.prefix.includes(windowsBatchSet("OCX_REAL_CODEX", backingPath)) + && wrapperProbe.prefix.includes('"%OCX_REAL_CODEX%" %*') + : wrapperExt === ".ps1" + ? wrapperProbe.prefix.includes(`& ${psString(backingPath)} @args`) + : wrapperProbe.prefix.includes(`exec ${shQuote(gitBashPath(backingPath))} "$@"`); + if (!invokesBacking) { + return Object.freeze({ status: "unknown" as const, reason: "backing_mismatch" as const }); + } + return Object.freeze({ + status: "matched" as const, + selectedRole, + backingPath, + backingKind: file.realPath !== undefined ? "real" as const : "backup" as const, + }); +} + function statePath(): string { return join(getConfigDir(), "codex-shim.json"); } @@ -1283,7 +1469,7 @@ function stateFiles(state: ShimState): ShimFileState[] { } function primaryState(files: ShimFileState[]): ShimState { - const first = files[0]; + const first = files[0]!; return { platform: process.platform, ...first, wrappers: files }; } @@ -2067,7 +2253,7 @@ export function autoRestoreCodexShim(options: { const state = stateRead.state; if (!state) { if (stateRead.warning) return { status: "ineligible", message: stateRead.warning }; - return { status: existsSync(statePath()) ? "ineligible" : "not-installed" }; + return { status: stateRead.present ? "ineligible" : "not-installed" }; } if (state.platform !== process.platform) return { status: "ineligible" }; diff --git a/src/codex/user-identity.ts b/src/codex/user-identity.ts index a0f021dba4..c70b8ee31c 100644 --- a/src/codex/user-identity.ts +++ b/src/codex/user-identity.ts @@ -18,6 +18,7 @@ import { import { isAbsolute, join, resolve } from "node:path"; import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation"; +import { WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal"; import type { ResolveCodexCoordinatorDatabasePath, @@ -55,7 +56,7 @@ const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i; * fails the lookup and the caller still refuses rather than writing. Only the * ceiling moved, and it moved for the case where the lookup would have succeeded. */ -const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = 30_000; +const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS; function windowsIdentityLookupTimeoutMs(): number { return WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS; diff --git a/src/config.ts b/src/config.ts index de8cb16b83..11d88af91d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2380,6 +2380,29 @@ function configMutationDatabasePath(): string { return path; } +/** Raised when an independent config-mutation transaction is requested recursively. */ +export class NestedConfigMutationError extends Error { + constructor() { + super("prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync"); + this.name = "NestedConfigMutationError"; + } +} + +/** + * Prepare the shared config-mutation database path for an independent top-level + * SQLite transaction. Callers must not invoke this while holding + * {@link withConfigMutationLockSync}; a second `BEGIN IMMEDIATE` deliberately + * fails busy instead of joining an uncommitted transaction. + * + * @throws {NestedConfigMutationError} If a config mutation lock is already held. + */ +export function prepareConfigMutationDatabasePathForWrite(): string { + if (configMutationLockDepth > 0) { + throw new NestedConfigMutationError(); + } + return configMutationDatabasePath(); +} + let configMutationLockDepth = 0; let configMutationDatabase: Database | null = null; diff --git a/src/config/paths.ts b/src/config/paths.ts index b8b494ecd7..4c351a6ae8 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,7 +1,7 @@ import { chmodSync, existsSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { hardenSecretDir } from "../lib/windows-secret-acl"; +import { hardenSecretDirAsync, windowsSecretAclApplies } from "../lib/windows-secret-acl"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; /** @@ -14,6 +14,7 @@ export function expandUserPath(raw: string): string { return raw; } let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null; +const configDirHardeningFlights = new Map>(); export function getConfigDir(): string { const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined; @@ -34,7 +35,21 @@ export function hardenConfigDir(): void { assertNotRealHomeUnderTest(dir); if (!existsSync(dir)) return; try { chmodSync(dir, 0o700); } catch { /* best-effort */ } - if (process.platform === "win32") { - hardenSecretDir(dir, { required: false }); + if (windowsSecretAclApplies() && !configDirHardeningFlights.has(dir)) { + // This is an optional read-path harden. Waiting synchronously here used to stop the Bun + // event loop (including /healthz) for the full icacls timeout. Required mutation paths keep + // their own awaited/fail-closed hardening; ordinary config reads only start one soft flight. + const flight = hardenSecretDirAsync(dir, { required: false }) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + if (configDirHardeningFlights.get(dir) === flight) configDirHardeningFlights.delete(dir); + }); + configDirHardeningFlights.set(dir, flight); } } + +/** Test-only: settle optional config-directory hardening without exposing it to production callers. */ +export async function flushConfigDirHardeningForTests(): Promise { + await Promise.all([...configDirHardeningFlights.values()]); +} diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index d2cbbbadb8..13662d52d5 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -11,7 +11,11 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { + ClientPathError, EXPORT_CLIENTS, + asideAccountDir, + asideConfigPath, + asideHomeDir, dshConfigPath, dshHomeDir, gajaeConfigPath, @@ -52,6 +56,86 @@ export interface IntegrationClientSpec { sourcePreservingYaml?: { path: readonly string[] }; /** Coordinate the complete mutation through a sibling config lock. */ writerLock?: { suffix: ".lock" }; + /** + * Derive the config path AND the detect directory from one resolution, for a + * client whose paths depend on mutable state rather than only env and home. + * + * Only Aside needs this. Its two paths both come from the account id in + * `accounts.json`, so calling `configPath` and `detectDir` in sequence can + * straddle an account switch and check one account's install while writing + * another's catalog. Reading the id once and deriving both paths from it + * removes the window instead of narrowing it. + */ + resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string) => { configPath: string; detectDir: string }; + /** + * Where the client's config WOULD live, for a client whose real path cannot + * be resolved yet. + * + * Only a client with `resolvePaths` needs this, and only because that + * resolution can legitimately fail on a machine where the client has never + * run. Aside's account id comes from a manifest the app writes at first + * launch, so a never-signed-in install has no account directory and no id -- + * which is "not installed", not "we cannot verify this file". + * + * The value is a location to SHOW, never a location to write: it names the + * account root without an account, so it cannot be mistaken for a real + * catalog. `resolveIntegrationPaths` still throws for callers that mutate. + */ + unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string; +} + +/** + * The one place that turns a client id into the pair of paths an operation uses. + * + * A caller that resolves `configPath` and `detectDir` separately is correct for + * every client whose paths are a pure function of env and home, and wrong for + * one that reads mutable state. Routing both through here lets such a client fix + * that for itself without every call site learning why. + */ +export function resolveIntegrationPaths( + clientId: IntegrationClientId, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): { configPath: string; detectDir: string } { + const spec = INTEGRATION_CLIENTS[clientId]; + if (spec.resolvePaths) return spec.resolvePaths(env, home); + return { configPath: spec.configPath(env, home), detectDir: spec.detectDir(env, home) }; +} + +/** + * The location to name when resolution refused, or `""` when there is none. + * + * A read-only surface reporting "unresolvable" with an empty path told the user + * nothing they could act on, and for Aside it also reported the wrong thing: an + * absent account manifest is the ordinary state of an installed-but-never-run + * Aside, and the honest answer there is that it is not signed in. + * + * `""` is a sentinel, not a path: it is what `readIntegrationState` reads to + * decide between not-installed and cannot-verify. A config path is never + * legitimately empty, and a hint is always an absolute `join` result, so the two + * cannot be confused. + */ +export function unresolvedPathHintFor( + clientId: IntegrationClientId, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): string { + const spec = INTEGRATION_CLIENTS[clientId]; + if (!spec.unresolvedPathHint) return ""; + try { + return spec.unresolvedPathHint(env, home); + } catch (error) { + /* + * Only a path refusal is absorbed. An unqualified catch here would also + * swallow a TypeError from a future implementor's typo, an + * ERR_INVALID_ARG_TYPE out of `join`, or an EACCES from a resolver that + * touches the filesystem -- turning a programming error into a silently + * degraded badge. `readIntegrationState` narrows the same way at its own + * catch, and this is the matching half. + */ + if (!(error instanceof ClientPathError)) throw error; + return ""; + } } /** @@ -149,6 +233,34 @@ export const INTEGRATION_CLIENTS: Record primeAgentDir(env, home), }, + aside: { + id: "aside", + configPath: (env = process.env, home = homedir()) => asideConfigPath(env, home), + /* + * The ACCOUNT directory, not `~/.aside`. Aside's CLI creates `~/.aside/cli` + * for its own update check before any account exists, so the outer directory + * is present on a machine that never signed in, and writing a catalog for an + * account that does not exist is worse than reporting absent. + */ + detectDir: (env = process.env, home = homedir()) => asideAccountDir(env, home), + /* + * Both paths from ONE account read. The two resolvers above each consult + * the account manifest, so a switch landing between them would let an + * operation verify one account's install and then write another's catalog. + */ + resolvePaths: (env = process.env, home = homedir()) => { + const detectDir = asideAccountDir(env, home); + return { configPath: join(detectDir, "models.json"), detectDir }; + }, + /* + * The account ROOT, with no account under it. Aside writes `accounts.json` + * at first launch, so its absence is the ordinary state of an Aside that has + * been installed and never signed into -- and a page that answered "cannot + * verify" with an empty path for that case named nothing the user could go + * look at. + */ + unresolvedPathHint: (env = process.env, home = homedir()) => join(asideHomeDir(env, home), "u"), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 2d3713a785..f4eb12cadf 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -19,7 +19,12 @@ import { semanticProtectedContributionFingerprint, validRefreshablePaths, } from "./ownership-policy"; -import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry"; +import { + INTEGRATION_CLIENTS, + resolveIntegrationPaths, + unresolvedPathHintFor, + type IntegrationClientId, +} from "./registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe"; @@ -238,6 +243,42 @@ export function classifyIntegration(input: { return { state: "unsafe", reason: "blocked-container" }; } if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" }; + + /* + * Fragments the desired contribution carries beyond the paths this record names. Both + * states appear whenever a client gains a second owned block: + * + * - occupied by a value we did not write -> refuse. A refresh merges the WHOLE + * contribution, so without this check applying would replace a block the user wrote + * themselves and report success. + * - empty -> our own block is missing, because the record predates it. Report drift so + * a refresh adds it. Without this the file reads `current` forever and the second + * block never arrives, which is exactly what an older installation hits on upgrade. + * + * A byte-identical value is ours in substance: adopt it instead of dead-ending a + * hand-merged config on a conflict the user can only resolve by deleting our own block. + */ + const recordedPaths = new Set((input.record?.fragmentPaths ?? []).map(path => path.join("\u0000"))); + let addedPathMissing = false; + for (const fragment of input.contribution.fragments) { + if (recordedPaths.has(fragment.path.join("\u0000"))) continue; + const observed = readPath(input.parsed, fragment.path); + if (observed === undefined) { + addedPathMissing = true; + continue; + } + const one = (value: unknown): string => fingerprint(canonicalContribution({ + clientId: (input.clientId ?? input.record?.clientId) as IntegrationClientId, + fragments: [{ path: fragment.path, value }], + })); + if (one(observed) !== one(fragment.value)) return { state: "conflict", reason: "unowned-key" }; + } + /* + * No record: whatever occupies our paths is not ours to touch. A byte-identical value + * would be ours in substance, but `stale` without a record is not actionable — the writer + * reads `createdContainers` off the record to decide what it may prune, so adopting a + * hand-merged block needs an apply path that creates one first. Refuse, exactly as before. + */ if (!input.record) return { state: "conflict", reason: "unowned-key" }; /* * A record proves ownership of ONE file. Change HOME, XDG_CONFIG_HOME, @@ -286,6 +327,12 @@ export function classifyIntegration(input: { } return { state: "stale" }; } + /* + * Checked after everything else that could refuse: an owned fragment that no longer + * matches, or a sibling edit in a format that cannot be rewritten safely, still wins. + * What is left is a block we own on paper and are merely missing on disk. + */ + if (addedPathMissing) return { state: "stale" }; const desiredFingerprint = typeof input.record.semanticBlockFingerprint === "string" ? fingerprint(semanticContribution(input.contribution)) : fingerprint(canonicalContribution(input.contribution)); @@ -382,15 +429,30 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS let configPath: string; let installed: boolean; try { - configPath = spec.configPath(input.env, input.home); - installed = io.statKind(spec.detectDir(input.env, input.home)) === "dir"; + // One resolution for both, so a client whose paths come from mutable state + // cannot report one account's install beside another account's config path. + const paths = resolveIntegrationPaths(input.clientId, input.env, input.home); + configPath = paths.configPath; + installed = io.statKind(paths.detectDir) === "dir"; } catch (error) { if (!(error instanceof ClientPathError)) throw error; + /* + * Two different situations reach here and they are not the same answer. + * + * A relative `OPENCLAW_CONFIG_PATH` is a misconfiguration: there is nothing + * to name, and "cannot verify" is correct. Aside's absent account manifest + * is the ORDINARY state of an Aside that has been installed and never + * signed into, and answering that with a red danger badge and an empty path + * told the user their config was suspect when in fact there is no account + * yet. A client that can name where its config would go gets `installed: + * false` and that location, which reads as "not installed" in the UI. + */ + const hint = unresolvedPathHintFor(input.clientId, input.env, input.home); return { clientId: input.clientId, - state: "unsafe", + state: hint ? "absent" : "unsafe", installed: false, - configPath: "", + configPath: hint, reason: "unresolvable-path", ...retention, }; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index f8eb290327..831fa80c21 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -28,7 +28,7 @@ import { semanticProtectedContributionFingerprint, } from "./ownership-policy"; import { createdContainerPaths, mergeContribution, removeFragments } from "./merge"; -import { INTEGRATION_CLIENTS, isLoopbackOnly, type IntegrationClientId } from "./registry"; +import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; import { classifyIntegration, exportContextOf } from "./state"; import type { IntegrationState } from "./state"; import { serializeDocument, UnserializableValueError } from "./serialize"; @@ -210,8 +210,20 @@ function preflight(input: IntegrationWriteInput) { * whole Integrations page because one client is misconfigured. */ let configPath: string; + let detectDir: string; try { - configPath = input.resolvedPaths?.configPath ?? spec.configPath(input.env, input.home); + /* + * Resolve the PAIR, never one half. + * + * The coordinated path hands us a frozen pair, but applyIntegration, + * refreshIntegration and disableIntegration are public and may be called + * without one. Resolving configPath here and detectDir separately later let + * an Aside account switch land between the two, so a direct apply could + * verify account 1 was installed and then write account 0's catalog. + */ + const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home); + configPath = resolved.configPath; + detectDir = resolved.detectDir; } catch (error) { if (!(error instanceof ClientPathError)) throw error; return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const; @@ -248,15 +260,17 @@ function preflight(input: IntegrationWriteInput) { const classified = classifyIntegration({ fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId, }); - return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } as const; + return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const; } function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: boolean): WriteOutcome { const pre = preflight(input); if (pre.failed) return pre.failed; - const { store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } = pre; + const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre; - if (io.statKind(input.resolvedPaths?.detectDir ?? spec.detectDir(input.env, input.home)) !== "dir") { + // The detect directory preflight already resolved, so it cannot name a + // different account than the config path this operation is about to write. + if (io.statKind(detectDir) !== "dir") { return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`); } if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) { @@ -624,10 +638,12 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration const store = input.store ?? createIntegrationStateStore(); const io = input.io ?? defaultIntegrationIO(store); const spec = INTEGRATION_CLIENTS[input.clientId]; - const resolvedPaths = { - configPath: spec.configPath(env, home), - detectDir: spec.detectDir(env, home), - }; + /* + * One resolution for both paths. Aside derives them from the account id in + * its manifest, so two independent calls could verify one account's install + * and then write another account's catalog if a switch landed between them. + */ + const resolvedPaths = resolveIntegrationPaths(input.clientId, env, home); return { ...input, env, home, store, io, resolvedPaths }; } diff --git a/src/lib/bounded-subprocess.ts b/src/lib/bounded-subprocess.ts new file mode 100644 index 0000000000..543a74ab40 --- /dev/null +++ b/src/lib/bounded-subprocess.ts @@ -0,0 +1,36 @@ +export interface KillableSubprocess { + exited: Promise; + kill(): unknown; + unref?(): unknown; +} + +export interface BoundedSubprocessExit { + exitCode: number | null; + timedOut: boolean; +} + +/** Kill at the deadline and abandon immediately; late exit/rejection remains observed. */ +export function waitForSubprocessExit( + proc: KillableSubprocess, + timeoutMs: number, +): Promise { + return new Promise(resolve => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (result: BoundedSubprocessExit): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(result); + }; + timer = setTimeout(() => { + try { proc.kill(); } catch { /* already exited */ } + try { proc.unref?.(); } catch { /* abandonment is still authoritative */ } + finish({ exitCode: null, timedOut: true }); + }, Math.max(1, timeoutMs)); + void proc.exited.then( + exitCode => finish({ exitCode, timedOut: false }), + () => finish({ exitCode: null, timedOut: false }), + ); + }); +} diff --git a/src/lib/strict-semver.ts b/src/lib/strict-semver.ts new file mode 100644 index 0000000000..75e45b5fcb --- /dev/null +++ b/src/lib/strict-semver.ts @@ -0,0 +1,47 @@ +// Core and build metadata are unambiguous and stay inline. The prerelease section does not: +// the semver.org pattern for one identifier is +// 0 | [1-9]\d* | [0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]* +// whose three alternatives overlap, and wrapping that in `(?:\.…)*` gives a regex engine an +// exponential number of ways to split the same string. CodeQL flagged it (`js/redos`) and the +// cost is real, not theoretical: `0.0.0-0.` followed by repetitions of `--.` took **522ms for a +// single 125-character input** — inside the 128-char ceiling this module already enforced, and +// inside the 96-char one its only caller uses. A length cap does not fix superlinear blowup; it +// only decides where the curve is sampled. +// +// So the prerelease section is matched with one non-backtracking pass and its identifiers are +// validated individually. Each identifier is checked by an anchored regex with no repetition of +// an alternation, which is linear in the identifier's length. +const STRICT_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +const NUMERIC_IDENTIFIER_RE = /^(?:0|[1-9]\d*)$/; +const ALPHANUMERIC_IDENTIFIER_RE = /^[0-9A-Za-z-]+$/; + +/** + * A prerelease identifier is either a numeric identifier with no leading zero, or an + * alphanumeric one that contains at least one non-digit. Empty identifiers are invalid, + * which is what rejects a trailing or doubled dot. + */ +function isPrereleaseIdentifier(part: string): boolean { + if (part.length === 0) return false; + if (NUMERIC_IDENTIFIER_RE.test(part)) return true; + return ALPHANUMERIC_IDENTIFIER_RE.test(part) && !/^\d+$/.test(part); +} + +export interface StrictSemver { + readonly raw: string; + readonly core: readonly [bigint, bigint, bigint]; + readonly prerelease: readonly (bigint | string)[]; +} + +export function parseStrictSemver(value: unknown, maxLength = 128): StrictSemver | null { + if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return null; + const match = STRICT_SEMVER_RE.exec(value); + if (!match) return null; + const prereleaseParts = match[4] === undefined ? [] : match[4].split("."); + if (!prereleaseParts.every(isPrereleaseIdentifier)) return null; + return Object.freeze({ + raw: value, + core: Object.freeze([BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)]) as readonly [bigint, bigint, bigint], + prerelease: Object.freeze(prereleaseParts.map(part => /^\d+$/.test(part) ? BigInt(part) : part)), + }); +} diff --git a/src/lib/windows-elevation.ts b/src/lib/windows-elevation.ts index 7d9b1f5fcd..0e7d175786 100644 --- a/src/lib/windows-elevation.ts +++ b/src/lib/windows-elevation.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process" import { existsSync } from "node:fs"; import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"; import { dlopen, ptr, type Pointer } from "bun:ffi"; +import { isTestHomeGuardArmed } from "./test-home-guard"; type ElevationSpawn = ( command: string, @@ -530,6 +531,19 @@ export function startPowerShellCommand(commandScript: string): WindowsElevationE }; } + // HOME isolation cannot contain UAC children or other machine-global effects. Keep the + // final process boundary closed while the real launcher is installed; explicitly injected + // launchers remain available to tests that exercise the elevation protocol in memory. + if (isTestHomeGuardArmed() && elevationSpawn === spawn) { + return { + launcherPid: null, + completion: Promise.reject(new WindowsElevationError( + "launch-failed", + "Refusing to launch a live Windows elevation process from an armed test process; inject the elevation launcher instead.", + )), + }; + } + let child: ChildProcess; try { child = elevationSpawn( @@ -638,8 +652,16 @@ export function runWindowsElevated(file: string, args: string[]): Promise { + if (replace && !expectedExistingXml?.trim()) { + throw new Error("Elevated Task Scheduler replacement requires a captured existing definition."); + } const xmlBase64 = Buffer.from(xml, "utf16le").toString("base64"); + const expectedExistingBase64 = expectedExistingXml === undefined + ? null + : Buffer.from(expectedExistingXml, "utf16le").toString("base64"); const powerShellPath = windowsPowerShell(); const powerShellDirectory = powerShellPath.replace(/[\\/][^\\/]+$/, ""); const scheduledTasksModule = `${powerShellDirectory}\\Modules\\ScheduledTasks\\ScheduledTasks.psd1`; @@ -650,7 +672,16 @@ export function runWindowsElevatedScheduledTaskRegistration( `$module = Microsoft.PowerShell.Core\\Import-Module -Name ${psSingleQuote(scheduledTasksModule)} -PassThru -Force -ErrorAction Stop`, "$registerTask = $module.ExportedCommands['Register-ScheduledTask']", "if ($null -eq $registerTask) { throw 'Trusted ScheduledTasks module does not export Register-ScheduledTask.' }", - "& $registerTask -TaskName $taskName -Xml $xml -Force -ErrorAction Stop | Out-Null", + ...(replace ? [ + `$expectedBase64 = ${psSingleQuote(expectedExistingBase64!)}`, + "$expectedXml = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($expectedBase64))", + `$schtasks = ${psSingleQuote(resolveTrustedWindowsSchtasksExe())}`, + "$currentXml = & $schtasks /query /tn $taskName /xml 2>$null | Out-String", + "if ($LASTEXITCODE -ne 0) { throw 'Task Scheduler replacement precondition could not be read.' }", + "function Normalize-OcxTaskXml([string]$value) { return (($value.TrimStart([char]0xFEFF) -replace \"`r`n?\", \"`n\").Trim()) }", + "if ((Normalize-OcxTaskXml $currentXml) -cne (Normalize-OcxTaskXml $expectedXml)) { throw 'Task Scheduler replacement precondition changed.' }", + ] : []), + `& $registerTask -TaskName $taskName -Xml $xml${replace ? " -Force" : ""} -ErrorAction Stop | Out-Null`, ].join("; "); const encodedCommand = Buffer.from(inner, "utf16le").toString("base64"); const script = [ diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 04ecb25ea0..dbf1f06b79 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -31,6 +31,7 @@ import { existsSync, statSync } from "node:fs"; import { env, platform } from "node:process"; +import { waitForSubprocessExit } from "./bounded-subprocess"; import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation"; import { cachedCurrentWindowsIdentity, @@ -216,6 +217,11 @@ export interface HardenResult { export interface HardenOptions { required: boolean; + /** + * Explicit total budget for this harden call. Shutdown recovery uses a reduced + * caller-owned slice instead of opening the normal 30-second window. + */ + deadlineMs?: number; /** * Optional timeout-memo key distinct from `targetPath` (issue #612). * Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the @@ -257,7 +263,11 @@ const HARDEN_DEADLINE_MIN_MS = 1_000; const HARDEN_DEADLINE_MAX_MS = 60_000; /** Resolve the total harden budget once per call (env mutation cannot change it midway). */ -function resolveHardenDeadlineMs(): number { +function resolveHardenDeadlineMs(overrideMs?: number): number { + if (overrideMs !== undefined) { + if (!Number.isSafeInteger(overrideMs) || overrideMs <= 0) return 1; + return Math.min(HARDEN_DEADLINE_MAX_MS, overrideMs); + } const raw = env["OPENCODEX_ACL_TIMEOUT_MS"]?.trim(); if (!raw) return HARDEN_DEADLINE_DEFAULT_MS; const parsed = Number(raw); @@ -328,27 +338,16 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { /** * Async icacls runner (#612): yields the event loop while waiting for the child. - * Timeout provenance is recorded by our timer (async Subprocess has no exitedDueToTimeout); - * we still await process exit before classifying so settlement is confirmed. + * Async Subprocess has no exitedDueToTimeout, so the shared settlement helper + * classifies the deadline and abandons a child that does not settle after kill. */ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise { const proc = trySpawnIcacls(args); if (!proc) return spawnFailedResult(); - let timedOutByUs = false; - const timer = setTimeout(() => { - timedOutByUs = true; - try { proc.kill(); } catch { /* already exited */ } - }, Math.max(1, timeoutMs)); - let exitCode: number | null = null; - try { - exitCode = await proc.exited; - } finally { - clearTimeout(timer); - } - const stdout = proc.stdout + const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs); + const stdout = !timedOut && proc.stdout ? await new Response(proc.stdout).text().catch(() => "") : ""; - const timedOut = timedOutByUs; return { success: !timedOut && exitCode === 0, exitCode: timedOut ? null : exitCode, @@ -357,6 +356,24 @@ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Prom }; } +function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise { + return new Promise(resolve => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (result: IcaclsResult): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(result); + }; + timer = setTimeout( + () => finish({ success: false, exitCode: null, timedOut: true, stdout: "" }), + Math.max(1, timeoutMs), + ); + void asyncIcaclsRunner(args, timeoutMs).then(finish, () => finish(spawnFailedResult())); + }); +} + let icaclsRunner: IcaclsRunner = defaultIcaclsRunner; let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner; let platformOverride: string | null = null; @@ -537,12 +554,14 @@ function shouldVerifyExistingAcl(): boolean { return env["OPENCODEX_ACL_VERIFY_EXISTING"] === "1"; } -function existingAclAlreadyCompliant(targetPath: string, directory: boolean): boolean { +function existingAclAlreadyCompliant(targetPath: string, directory: boolean, deadline: number): boolean { if (!shouldVerifyExistingAcl()) return false; const identity = cachedCurrentWindowsIdentity(); if (!identity) return false; try { - const result = icaclsRunner([targetPath], resolveHardenDeadlineMs()); + const remaining = deadline - nowFn(); + if (remaining <= 0) return false; + const result = icaclsRunner([targetPath], remaining); return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name); } catch { return false; @@ -552,12 +571,15 @@ function existingAclAlreadyCompliant(targetPath: string, directory: boolean): bo async function existingAclAlreadyCompliantAsync( targetPath: string, directory: boolean, + deadline: number, ): Promise { if (!shouldVerifyExistingAcl()) return false; const identity = cachedCurrentWindowsIdentity(); if (!identity) return false; try { - const result = await asyncIcaclsRunner([targetPath], resolveHardenDeadlineMs()); + const remaining = deadline - nowFn(); + if (remaining <= 0) return false; + const result = await awaitAsyncIcaclsRunner([targetPath], remaining); return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name); } catch { return false; @@ -618,7 +640,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: if (remaining <= 0) { throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" }); } - return asyncIcaclsRunner(args, remaining); + return awaitAsyncIcaclsRunner(args, remaining); }; const runOrThrow = async (step: string, args: string[]): Promise => { const result = await run(step, args); @@ -751,7 +773,7 @@ async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: n for (const sid of BROAD_SIDS) { const remaining = deadline - nowFn(); if (remaining <= 0) return "ACL state unverified (budget exhausted)"; - const found = await asyncIcaclsRunner([targetPath, "/findsid", sid], remaining); + const found = await awaitAsyncIcaclsRunner([targetPath, "/findsid", sid], remaining); if (!found.success) return "ACL state unverified (probe failed)"; if (found.stdout.includes(targetPath)) return "broad ACL grants still present"; } @@ -788,7 +810,8 @@ function hardenEntry( if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; } if (effectivePlatform() !== "win32") return { ok: true }; if (memoSatisfied(cache, targetPath)) return { ok: true }; - if (existingAclAlreadyCompliant(targetPath, directory)) return { ok: true }; + const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs); + if (existingAclAlreadyCompliant(targetPath, directory, deadline)) return { ok: true }; const memoKey = timeoutMemoKey(targetPath, opts); const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts); if (timeoutMemoError) { @@ -796,7 +819,6 @@ function hardenEntry( return { ok: false, diagnostics: timeoutMemoError.message }; } - const deadline = nowFn() + resolveHardenDeadlineMs(); let lastErr: unknown; for (let attempt = 0; attempt < 2; attempt++) { if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains @@ -841,7 +863,8 @@ async function hardenEntryAsync( if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; } if (effectivePlatform() !== "win32") return { ok: true }; if (memoSatisfied(cache, targetPath)) return { ok: true }; - if (await existingAclAlreadyCompliantAsync(targetPath, directory)) return { ok: true }; + const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs); + if (await existingAclAlreadyCompliantAsync(targetPath, directory, deadline)) return { ok: true }; const memoKey = timeoutMemoKey(targetPath, opts); const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts); if (timeoutMemoError) { @@ -849,7 +872,6 @@ async function hardenEntryAsync( return { ok: false, diagnostics: timeoutMemoError.message }; } - const deadline = nowFn() + resolveHardenDeadlineMs(); let lastErr: unknown; for (let attempt = 0; attempt < 2; attempt++) { if (attempt > 0 && deadline - nowFn() <= 0) break; diff --git a/src/lib/windows-service-mutation-lock.ts b/src/lib/windows-service-mutation-lock.ts new file mode 100644 index 0000000000..9aed781902 --- /dev/null +++ b/src/lib/windows-service-mutation-lock.ts @@ -0,0 +1,133 @@ +import { Database } from "bun:sqlite"; +import { chmodSync, lstatSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { resolveEffectiveUserIdentity, resolveEffectiveUserRuntimeRoot } from "../codex/user-identity"; +import { hardenSecretDir, hardenSecretPath } from "./windows-secret-acl"; + +type LockDatabase = Pick; + +export interface WindowsServiceMutationLockDeps { + lockPath?: string; + openDatabase?: (path: string) => LockDatabase; + hardenDirectory?: (path: string) => void; + hardenFile?: (path: string) => void; +} + +export class WindowsServiceMutationBusyError extends Error { + readonly code = "WINDOWS_SERVICE_MUTATION_BUSY"; + + constructor() { + super("Another OpenCodex Windows service operation is already in progress. Wait for it to finish, then retry."); + this.name = "WindowsServiceMutationBusyError"; + } +} + +export class WindowsServiceMutationLockError extends Error { + readonly code = "WINDOWS_SERVICE_MUTATION_LOCK_FAILED"; + + constructor(operation: "acquire" | "release", cause: unknown) { + super(`The Windows service mutation lock could not be ${operation === "acquire" ? "acquired" : "released"}.`, { cause }); + this.name = "WindowsServiceMutationLockError"; + } +} + +function errorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; +} + +function isBusy(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return errorCode(error) === "SQLITE_BUSY" + || errorCode(error) === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); +} + +/** + * Stable per-user lock namespace for the fixed `opencodex-proxy` task name. + * + * Deliberately outside OPENCODEX_HOME: creating the lock must not make a genuinely fresh + * config root look pre-existing before the installer records its uninstall ownership. The + * effective-user runtime root ignores LOCALAPPDATA/USERPROFILE overrides, so two processes + * running as the same SID cannot split the lock by changing their environment or config home. + */ +export function windowsServiceMutationLockPath(): string { + const identity = resolveEffectiveUserIdentity(); + if (identity.platform !== "win32") { + throw new Error("The Windows service mutation lock is only available on Windows."); + } + return join(resolveEffectiveUserRuntimeRoot(identity), "windows-service-mutation.sqlite"); +} + +function assertRegularPath(path: string, kind: "directory" | "file"): void { + const entry = lstatSync(path); + const valid = kind === "directory" ? entry.isDirectory() : entry.isFile(); + if (!valid || entry.isSymbolicLink()) { + throw new Error(`The Windows service mutation lock ${kind} is not a regular ${kind}.`); + } +} + +/** + * Serialize one complete Windows service mutation across OpenCodex processes. + * + * The SQLite write transaction is the lock. It stays held across UAC and async verification, + * and the OS releases it if the process exits, so no stale lock file needs unsafe reclamation. + */ +export async function withWindowsServiceMutationLock( + operation: () => Promise, + deps: WindowsServiceMutationLockDeps = {}, +): Promise { + const lockPath = deps.lockPath ?? windowsServiceMutationLockPath(); + const lockDir = dirname(lockPath); + let database: LockDatabase | undefined; + let acquired = false; + + try { + mkdirSync(lockDir, { recursive: true, mode: 0o700 }); + assertRegularPath(lockDir, "directory"); + try { chmodSync(lockDir, 0o700); } catch { /* Windows ACL below is authoritative. */ } + (deps.hardenDirectory ?? (path => { hardenSecretDir(path, { required: true }); }))(lockDir); + + try { + database = (deps.openDatabase ?? (path => new Database(path, { create: true })))(lockPath); + assertRegularPath(lockPath, "file"); + try { chmodSync(lockPath, 0o600); } catch { /* Windows ACL below is authoritative. */ } + (deps.hardenFile ?? (path => { hardenSecretPath(path, { required: true }); }))(lockPath); + database.exec("PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + acquired = true; + } catch (error) { + try { database?.close(); } catch { /* acquisition already failed */ } + database = undefined; + if (isBusy(error)) throw new WindowsServiceMutationBusyError(); + throw new WindowsServiceMutationLockError("acquire", error); + } + + let result: T; + let operationError: unknown; + try { + result = await operation(); + } catch (error) { + operationError = error; + } + + let releaseError: unknown; + if (acquired) { + try { database.exec("ROLLBACK"); } catch (error) { releaseError = error; } + } + try { database.close(); } catch (error) { releaseError ??= error; } + acquired = false; + database = undefined; + + if (operationError !== undefined) throw operationError; + if (releaseError !== undefined) throw new WindowsServiceMutationLockError("release", releaseError); + return result!; + } catch (error) { + if (acquired) { + try { database?.exec("ROLLBACK"); } catch { /* close still releases the OS lock */ } + } + try { database?.close(); } catch { /* preserve the primary error */ } + throw error; + } +} diff --git a/src/lib/windows-user-principal.ts b/src/lib/windows-user-principal.ts index 1064ec88cc..565c4f3645 100644 --- a/src/lib/windows-user-principal.ts +++ b/src/lib/windows-user-principal.ts @@ -22,12 +22,20 @@ import { existsSync } from "node:fs"; import { win32 as windowsPath } from "node:path"; +import { waitForSubprocessExit } from "./bounded-subprocess"; import { resolveTrustedWindowsPowerShellExe, WindowsSystemDirectoryFfiUnavailableError, } from "./windows-elevation"; +/** + * Shared ceiling for a full effective-token identity lookup. PowerShell startup can + * legitimately take several seconds on loaded desktops as well as CI, so every caller + * that is not spending a smaller pre-existing deadline uses the same #2914-tested budget. + */ +export const WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS = 30_000; + const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i; const IDENTITY_EXPRESSION = "$identity=[System.Security.Principal.WindowsIdentity]::GetCurrent();$identity.User.Value;$identity.Name"; @@ -143,18 +151,8 @@ async function defaultAsyncWindowsPrincipalRunner( stderr: "ignore", windowsHide: true, }); - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - try { proc.kill(); } catch { /* already exited */ } - }, Math.max(1, timeoutMs)); - let exitCode: number | null = null; - try { - exitCode = await proc.exited; - } finally { - clearTimeout(timer); - } - const stdout = proc.stdout + const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs); + const stdout = !timedOut && proc.stdout ? await new Response(proc.stdout).text().catch(() => "") : ""; return { @@ -314,11 +312,11 @@ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Pr return `*${cachedIdentity.sid}`; })(); asyncLookupInFlight = lookup; - try { - return await lookup; - } finally { - if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; - } + void lookup.then( + () => { if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; }, + () => { if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; }, + ); + return waitForExistingLookup(lookup, timeoutMs); } /** Test seam: replace the sync resolver process and clear its successful cache. */ diff --git a/src/responses/spill-store.ts b/src/responses/spill-store.ts index 825a3da3a3..f61d475c5a 100644 --- a/src/responses/spill-store.ts +++ b/src/responses/spill-store.ts @@ -15,9 +15,17 @@ import { writeSync, } from "node:fs"; import { createHash, randomBytes } from "node:crypto"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { getConfigDir } from "../config"; -import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; +import { + forgetEphemeralSecretPath, + forgetHardenedSecretPath, + hardenSecretDir, + hardenSecretDirAsync, + hardenSecretPath, + hardenSecretPathAsync, + windowsSecretAclApplies, +} from "../lib/windows-secret-acl"; import { isValidProviderContinuationOwner } from "./provider-continuation"; import type { OcxProviderContinuationState } from "../types"; @@ -110,11 +118,47 @@ export interface ResponseSpillIoForTest { let spillIoForTest: ResponseSpillIoForTest | null = null; let spillGeneration = 0; +let spillNowOverride: (() => number) | null = null; + +interface ResponseSpillWriteOptions { + retryTimedOutOnce?: boolean; + /** Total caller-owned ACL budget shared by every harden in this publication. */ + aclBudgetMs?: number; + publicationControl?: ResponseSpillPublicationControl; +} + +export interface ResponseSpillPublicationControl { + superseded: boolean; + tempPath: string | null; + destinationPath: string | null; +} + +interface SpillAclBudget { + deadline: number; + perCallMs: number; +} + +export function createResponseSpillPublicationControl(): ResponseSpillPublicationControl { + return { superseded: false, tempPath: null, destinationPath: null }; +} + +export function markResponseSpillPublicationSuperseded(control: ResponseSpillPublicationControl): void { + control.superseded = true; +} export function setSpillIoForTest(io: ResponseSpillIoForTest | null): void { spillIoForTest = io; } +/** Test-only: inject the spill deadline clock. */ +export function setResponseSpillNowForTests(now: (() => number) | null): void { + spillNowOverride = now; +} + +function spillNow(): number { + return spillNowOverride?.() ?? Date.now(); +} + function record(event: "write" | "fsync" | "close" | "harden" | "publish" | "dir-fsync" | "stub-swap"): void { spillIoForTest?.record?.(event); } @@ -175,16 +219,62 @@ function canUseExclusiveCopyFallback(error: unknown): boolean { .some(code => isErrno(error, code)); } -function harden(path: string, mode: number): void { +function spillAclBudget(totalMs: number | undefined): SpillAclBudget | undefined { + if (totalMs === undefined) return undefined; + const bounded = Math.max(1, Math.floor(totalMs)); + return { deadline: spillNow() + bounded, perCallMs: Math.max(1, Math.floor(bounded / 2)) }; +} + +function nextSpillHardenDeadlineMs(budget: SpillAclBudget | undefined): number | undefined { + if (!budget) return undefined; + const remaining = budget.deadline - spillNow(); + if (remaining <= 0) { + throw Object.assign(new Error("Response spill ACL budget exhausted"), { code: "ETIMEDOUT" }); + } + return Math.min(budget.perCallMs, remaining); +} + +function harden(path: string, mode: number, budget?: SpillAclBudget): void { + const aclApplies = budget ? windowsSecretAclApplies() : process.platform === "win32"; + try { + chmodSync(path, mode); + } catch { + if (!aclApplies) throw new Error("Response spill permission hardening failed"); + } + if (aclApplies) { + const deadlineMs = nextSpillHardenDeadlineMs(budget); + const options = { + required: true, + ...(deadlineMs !== undefined ? { deadlineMs } : {}), + }; + const result = mode === 0o700 + ? hardenSecretDir(path, options) + : hardenSecretPath(path, options); + if (!result.ok) throw new Error("Response spill permission hardening failed"); + } +} + +async function hardenAsync( + path: string, + mode: number, + budget: SpillAclBudget, + retryTimedOutOnce = false, +): Promise { try { chmodSync(path, mode); } catch { - if (process.platform !== "win32") throw new Error("Response spill permission hardening failed"); + if (!windowsSecretAclApplies()) throw new Error("Response spill permission hardening failed"); } - if (process.platform === "win32") { + if (windowsSecretAclApplies()) { + const deadlineMs = nextSpillHardenDeadlineMs(budget); + const options = { + required: true, + retryTimedOutOnce, + ...(deadlineMs !== undefined ? { deadlineMs } : {}), + }; const result = mode === 0o700 - ? hardenSecretDir(path, { required: true }) - : hardenSecretPath(path, { required: true }); + ? await hardenSecretDirAsync(path, options) + : await hardenSecretPathAsync(path, options); if (!result.ok) throw new Error("Response spill permission hardening failed"); } } @@ -231,7 +321,94 @@ function unlinkEphemeral(path: string): void { unlink(path, true); } -function publishNoReplace(tempPath: string, destinationPath: string): void { +function supersededPublicationError(): NodeJS.ErrnoException { + return Object.assign(new Error("Response spill publication superseded"), { code: "ECANCELED" }); +} + +function throwIfPublicationSuperseded(control: ResponseSpillPublicationControl | undefined): void { + if (control?.superseded) throw supersededPublicationError(); +} + +function clearOwnedPath( + control: ResponseSpillPublicationControl, + key: "tempPath" | "destinationPath", + ephemeral: boolean, +): unknown { + const path = control[key]; + if (!path) return null; + try { + if (ephemeral) unlinkEphemeral(path); + else unlink(path); + control[key] = null; + return null; + } catch (error) { + if (isErrno(error, "ENOENT")) { + control[key] = null; + return null; + } + return error; + } +} + +/** Claim and remove every path still owned by an abandoned async publication. */ +export function cleanupSupersededResponseSpillPublication( + control: ResponseSpillPublicationControl, +): Error | null { + control.superseded = true; + const ownedDir = control.destinationPath + ? dirname(control.destinationPath) + : control.tempPath + ? dirname(control.tempPath) + : null; + const destinationError = clearOwnedPath(control, "destinationPath", false); + const tempError = clearOwnedPath(control, "tempPath", true); + if (ownedDir) fsyncDirectoryBestEffort(ownedDir); + const cleanupError = destinationError ?? tempError; + return cleanupError ? responseSpillWriteError(cleanupError) : null; +} + +function publishNoReplace( + tempPath: string, + destinationPath: string, + budget?: SpillAclBudget, +): void { + try { + if (spillIoForTest?.link) spillIoForTest.link(tempPath, destinationPath); + else linkSync(tempPath, destinationPath); + } catch (error) { + if (isErrno(error, "EEXIST")) throw error; + if (!canUseExclusiveCopyFallback(error)) throw error; + let copied = false; + try { + if (spillIoForTest?.copyFileExcl) spillIoForTest.copyFileExcl(tempPath, destinationPath); + else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL); + copied = true; + harden(destinationPath, 0o600, budget); + const copyFd = openSync(destinationPath, "r"); + try { + if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd); + else fsyncSync(copyFd); + } finally { + closeSync(copyFd); + } + } catch (copyError) { + if (copied) { + try { unlink(destinationPath); } catch { /* startup GC reclaims an incomplete publication */ } + } + throw copyError; + } + } + record("publish"); +} + +async function publishNoReplaceAsync( + tempPath: string, + destinationPath: string, + budget: SpillAclBudget, + retryTimedOutOnce: boolean, + publicationControl?: ResponseSpillPublicationControl, +): Promise { + throwIfPublicationSuperseded(publicationControl); try { if (spillIoForTest?.link) spillIoForTest.link(tempPath, destinationPath); else linkSync(tempPath, destinationPath); @@ -243,7 +420,8 @@ function publishNoReplace(tempPath: string, destinationPath: string): void { if (spillIoForTest?.copyFileExcl) spillIoForTest.copyFileExcl(tempPath, destinationPath); else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL); copied = true; - harden(destinationPath, 0o600); + await hardenAsync(destinationPath, 0o600, budget, retryTimedOutOnce); + throwIfPublicationSuperseded(publicationControl); const copyFd = openSync(destinationPath, "r"); try { if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd); @@ -258,9 +436,48 @@ function publishNoReplace(tempPath: string, destinationPath: string): void { throw copyError; } } + throwIfPublicationSuperseded(publicationControl); record("publish"); } +function serializedSpill( + responseId: string, + state: Omit, +): { + bytes: Buffer; + digest: string; + idDigest: string; + contentDigest: string; +} { + const payload: ResponseSpillPayload = { + version: 1, + responseId, + createdAt: state.createdAt, + ...(state.clientThreadId ? { clientThreadId: state.clientThreadId } : {}), + items: state.items, + ...(state.providerOutputStart !== undefined ? { providerOutputStart: state.providerOutputStart } : {}), + ...(state.providers ? { providers: state.providers } : {}), + }; + const serialized = JSON.stringify(payload); + if (serialized === undefined) throw new Error("Response spill serialization failed"); + const bytes = Buffer.from(serialized, "utf8"); + const digest = sha256(bytes); + return { + bytes, + digest, + idDigest: sha256(responseId).slice(0, 12), + contentDigest: digest.slice(0, 24), + }; +} + +function responseSpillWriteError(cause: unknown): NodeJS.ErrnoException { + const error = new Error("Response spill write failed", { cause }) as NodeJS.ErrnoException; + if (cause && typeof cause === "object" && "code" in cause) { + error.code = String((cause as { code?: unknown }).code); + } + return error; +} + function validSpillRef(ref: ResponseSpillRef): boolean { return ref.version === 1 && OWNED_SPILL_NAME.test(ref.fileName) @@ -302,28 +519,16 @@ function validPayload(value: unknown, responseId: string): value is ResponseSpil export function writeResponseSpillDurably( responseId: string, state: Omit, + options: ResponseSpillWriteOptions = {}, ): ResponseSpillRef { let tempPath: string | null = null; let fd: number | null = null; try { - const payload: ResponseSpillPayload = { - version: 1, - responseId, - createdAt: state.createdAt, - ...(state.clientThreadId ? { clientThreadId: state.clientThreadId } : {}), - items: state.items, - ...(state.providerOutputStart !== undefined ? { providerOutputStart: state.providerOutputStart } : {}), - ...(state.providers ? { providers: state.providers } : {}), - }; - const serialized = JSON.stringify(payload); - if (serialized === undefined) throw new Error("Response spill serialization failed"); - const bytes = Buffer.from(serialized, "utf8"); - const digest = sha256(bytes); - const idDigest = sha256(responseId).slice(0, 12); - const contentDigest = digest.slice(0, 24); + const aclBudget = spillAclBudget(options.aclBudgetMs); + const { bytes, digest, idDigest, contentDigest } = serializedSpill(responseId, state); const dir = responseSpillDirectory(); mkdirSync(dir, { recursive: true, mode: 0o700 }); - harden(dir, 0o700); + harden(dir, 0o700, aclBudget); tempPath = join(dir, `.response-spill.${process.pid}.${randomBytes(8).toString("hex")}.tmp`); fd = openSync(tempPath, "wx", 0o600); @@ -331,7 +536,7 @@ export function writeResponseSpillDurably( fsyncFile(fd); closeFile(fd); fd = null; - harden(tempPath, 0o600); + harden(tempPath, 0o600, aclBudget); record("harden"); const publishTempPath = tempPath; @@ -341,7 +546,7 @@ export function writeResponseSpillDurably( if (!OWNED_SPILL_NAME.test(fileName)) throw new Error("Response spill name allocation failed"); const destinationPath = join(dir, fileName); try { - publishNoReplace(publishTempPath, destinationPath); + publishNoReplace(publishTempPath, destinationPath, aclBudget); fsyncDirectoryBestEffort(dir); unlinkEphemeral(publishTempPath); tempPath = null; @@ -352,14 +557,114 @@ export function writeResponseSpillDurably( } } throw new Error("Response spill publication retries exhausted"); - } catch { + } catch (cause) { if (fd !== null) { try { closeSync(fd); } catch { /* best effort */ } } if (tempPath) { try { unlinkEphemeral(tempPath); } catch { /* best effort */ } } - throw new Error("Response spill write failed"); + throw responseSpillWriteError(cause); + } +} + +/** + * Windows runtime counterpart of `writeResponseSpillDurably`. + * + * The filesystem publication contract stays identical, but required NTFS ACL subprocesses are + * awaited through Bun.spawn instead of Bun.spawnSync. State ownership and serialization remain in + * `state.ts`; callers must compare the resident generation again before installing the returned + * reference because another response can replace it while ACL hardening is pending. + */ +export async function writeResponseSpillDurablyAsync( + responseId: string, + state: Omit, + options: ResponseSpillWriteOptions & { aclBudgetMs: number }, +): Promise { + const publicationControl = options.publicationControl; + const aclBudget = spillAclBudget(options.aclBudgetMs); + if (!aclBudget) throw new Error("Response spill async ACL budget is required"); + let tempPath: string | null = null; + let fd: number | null = null; + try { + throwIfPublicationSuperseded(publicationControl); + const { bytes, digest, idDigest, contentDigest } = serializedSpill(responseId, state); + const dir = responseSpillDirectory(); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + await hardenAsync(dir, 0o700, aclBudget, options.retryTimedOutOnce === true); + throwIfPublicationSuperseded(publicationControl); + + tempPath = join(dir, `.response-spill.${process.pid}.${randomBytes(8).toString("hex")}.tmp`); + fd = openSync(tempPath, "wx", 0o600); + if (publicationControl) publicationControl.tempPath = tempPath; + writeAll(fd, bytes); + fsyncFile(fd); + closeFile(fd); + fd = null; + await hardenAsync(tempPath, 0o600, aclBudget, options.retryTimedOutOnce === true); + throwIfPublicationSuperseded(publicationControl); + record("harden"); + const publishTempPath = tempPath; + + for (let attempt = 0; attempt < RESPONSE_SPILL_PUBLISH_RETRIES; attempt++) { + throwIfPublicationSuperseded(publicationControl); + spillGeneration += 1; + const fileName = `${sanitizeResponseId(responseId)}.${idDigest}.${contentDigest}.${spillGeneration}.${bytes.byteLength}.spill.json`; + if (!OWNED_SPILL_NAME.test(fileName)) throw new Error("Response spill name allocation failed"); + const destinationPath = join(dir, fileName); + if (publicationControl) publicationControl.destinationPath = destinationPath; + try { + throwIfPublicationSuperseded(publicationControl); + await publishNoReplaceAsync( + publishTempPath, + destinationPath, + aclBudget, + options.retryTimedOutOnce === true, + publicationControl, + ); + throwIfPublicationSuperseded(publicationControl); + fsyncDirectoryBestEffort(dir); + unlinkEphemeral(publishTempPath); + tempPath = null; + if (publicationControl) { + publicationControl.tempPath = null; + publicationControl.destinationPath = null; + } + return { version: 1, fileName, digest, payloadBytes: bytes.byteLength }; + } catch (error) { + if (publicationControl?.superseded) throw error; + if (publicationControl) publicationControl.destinationPath = null; + if (isErrno(error, "EEXIST")) continue; + throw error; + } + } + throw new Error("Response spill publication retries exhausted"); + } catch (cause) { + if (fd !== null) { + try { closeSync(fd); } catch { /* best effort */ } + } + if (tempPath) { + try { + unlinkEphemeral(tempPath); + if (publicationControl?.tempPath === tempPath) publicationControl.tempPath = null; + } catch (error) { + if (isErrno(error, "ENOENT") && publicationControl?.tempPath === tempPath) { + publicationControl.tempPath = null; + } + } + } + if (publicationControl) { + const destinationPath = publicationControl.destinationPath; + if (destinationPath) { + try { + unlink(destinationPath); + publicationControl.destinationPath = null; + } catch (error) { + if (isErrno(error, "ENOENT")) publicationControl.destinationPath = null; + } + } + } + throw responseSpillWriteError(cause); } } diff --git a/src/responses/state.ts b/src/responses/state.ts index 940de10e69..35540a0ee7 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -3,16 +3,23 @@ import { uptime } from "node:os"; import { dirname, join } from "node:path"; import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config"; import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory"; +import { windowsSecretAclApplies } from "../lib/windows-secret-acl"; import type { OcxProviderContinuationState } from "../types"; import { + cleanupSupersededResponseSpillPublication, + createResponseSpillPublicationControl, deleteResponseSpill, + MAX_RESPONSE_SPILL_PAYLOAD_BYTES, noteStubSwapForTest, readResponseSpill, recoverOrphanedResponseSpills, responseSpillDirectory, responseSpillPayloadCap, + markResponseSpillPublicationSuperseded, + type ResponseSpillPublicationControl, type ResponseSpillRef, writeResponseSpillDurably, + writeResponseSpillDurablyAsync, } from "./spill-store"; const MAX_STORED_RESPONSES = 1_000; @@ -51,6 +58,10 @@ const PERIODIC_TEMP_MAX_CLEANUPS = 64; const PERIODIC_TEMP_SCAN_DEADLINE_MS = 25; const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/; const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4; +const RESPONSE_SPILL_SHUTDOWN_BUDGET_MS = 5_000; +const RESPONSE_SPILL_SHUTDOWN_FALLBACK_RESERVE_MS = 4_000; +const RESPONSE_SPILL_ASYNC_ACL_ATTEMPT_BUDGET_MS = 30_000; +const RESPONSE_SPILL_SHUTDOWN_TERMINALIZATION_MAX_PASSES = MAX_STORED_RESPONSES + 1; interface ResidentResponseState { kind: "resident"; @@ -160,6 +171,424 @@ const pendingSpillUnlinks: ResponseSpillRef[] = []; // structured 400 — bounded-loss, never silent corruption or unbounded disk. const PENDING_SPILL_UNLINKS_MAX = 128; +/** + * Windows keeps the candidate replayable while required ACL hardening runs off the event loop. + * Pending bytes are pinned, not evictable; cap them below the process-owned 512 MiB ceiling so an + * icacls outage cannot turn the serialized queue into an unbounded resident backlog. + */ +const MAX_PENDING_RESPONSE_SPILL_BYTES = MAX_RESPONSE_SPILL_PAYLOAD_BYTES; + +interface PendingResponseSpill { + id: string; + candidate: ResidentResponseState | null; + supersededSpill?: ResponseSpillRef; + directAdmission: boolean; + running: boolean; + cancelled: boolean; + released: boolean; + sizeBytes: number; + publicationControl: ResponseSpillPublicationControl; +} + +const pendingResponseSpills = new Set(); +const pendingResponseSpillById = new Map(); +let pendingResponseSpillBytes = 0; +let responseSpillPublicationTail: Promise = Promise.resolve(); +let responseSpillShutdownBudgetOverride: { totalMs: number; fallbackReserveMs: number } | null = null; +let responseSpillShutdownTerminalizationPassLimitOverride: number | null = null; +let responseSpillAsyncAclAttemptBudgetOverride: number | null = null; + +function deferSupersededSpill(ref: ResponseSpillRef | undefined): void { + if (!ref) return; + pendingSpillUnlinks.push(ref); + while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) { + deleteResponseSpill(pendingSpillUnlinks.shift()!); + } +} + +function releasePendingResponseSpill(job: PendingResponseSpill): void { + if (job.released) return; + job.released = true; + pendingResponseSpillBytes = Math.max(0, pendingResponseSpillBytes - job.sizeBytes); + pendingResponseSpills.delete(job); + if (pendingResponseSpillById.get(job.id) === job) pendingResponseSpillById.delete(job.id); + job.candidate = null; +} + +function cancelPendingResponseSpill(id: string): ResponseSpillRef | undefined { + const job = pendingResponseSpillById.get(id); + if (!job) return undefined; + pendingResponseSpillById.delete(id); + job.cancelled = true; + markResponseSpillPublicationSuperseded(job.publicationControl); + const superseded = job.supersededSpill; + // A queued job has not captured the candidate in an async frame yet, so release it now. + // A running job retains its accounting until settlement and will discard its stale file. + if (!job.running) releasePendingResponseSpill(job); + return superseded; +} + +function isAclTimeout(error: unknown): boolean { + return !!error && typeof error === "object" && "code" in error + && String((error as { code?: unknown }).code) === "ETIMEDOUT"; +} + +function spillPayloadForResident(candidate: ResidentResponseState): Parameters[1] { + return { + createdAt: candidate.createdAt, + ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}), + items: candidate.items, + ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}), + ...(candidate.providers ? { providers: candidate.providers } : {}), + }; +} + +async function runPendingResponseSpill(job: PendingResponseSpill): Promise { + if (job.cancelled || !job.candidate) return; + job.running = true; + const candidate = job.candidate; + let ref: ResponseSpillRef | null = null; + try { + const state = spillPayloadForResident(candidate); + try { + ref = await writeResponseSpillDurablyAsync(job.id, state, { + aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(), + publicationControl: job.publicationControl, + }); + } catch (error) { + if (!isAclTimeout(error)) throw error; + // The ACL helper permits exactly one caller-owned recovery budget. The resident generation + // remains replayable during both attempts, so a transient timeout never becomes a tombstone. + ref = await writeResponseSpillDurablyAsync(job.id, state, { + aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(), + retryTimedOutOnce: true, + publicationControl: job.publicationControl, + }); + } + if (ref.payloadBytes > responseSpillPayloadCap()) { + deleteResponseSpill(ref); + ref = null; + if (job.directAdmission) admissionCounters.oversizedDrops += 1; + throw Object.assign(new Error("Response spill payload exceeds replay ceiling"), { code: "EFBIG" }); + } + if (states.get(job.id) !== candidate || job.cancelled) { + deleteResponseSpill(ref); + ref = null; + return; + } + if (swapResidentForSpill(job.id, candidate, ref)) { + ref = null; + spillCounters.writes += 1; + if (job.directAdmission) admissionCounters.directSpills += 1; + deferSupersededSpill(job.supersededSpill); + } + } catch { + if (ref) deleteResponseSpill(ref); + if (states.get(job.id) === candidate && !job.cancelled) { + spillCounters.writeFailures += 1; + replaceWithSpillFailure(job.id, candidate); + deferSupersededSpill(job.supersededSpill); + } + } finally { + const cancelled = job.cancelled; + releasePendingResponseSpill(job); + recomputeOldestResident(); + if (!cancelled) { + schedulePersist(); + pruneResponses(); + enforceAppOwnedMemoryBudget(); + } + } +} + +function queuePendingResponseSpill( + id: string, + candidate: ResidentResponseState, + options: { supersededSpill?: ResponseSpillRef; directAdmission?: boolean } = {}, +): void { + const inheritedSpill = cancelPendingResponseSpill(id) ?? options.supersededSpill; + if (pendingResponseSpillBytes + candidate.sizeBytes > MAX_PENDING_RESPONSE_SPILL_BYTES) { + spillCounters.writeFailures += 1; + replaceWithSpillFailure(id, candidate); + deferSupersededSpill(inheritedSpill); + return; + } + const job: PendingResponseSpill = { + id, + candidate, + ...(inheritedSpill ? { supersededSpill: inheritedSpill } : {}), + directAdmission: options.directAdmission === true, + running: false, + cancelled: false, + released: false, + sizeBytes: candidate.sizeBytes, + publicationControl: createResponseSpillPublicationControl(), + }; + pendingResponseSpills.add(job); + pendingResponseSpillById.set(id, job); + pendingResponseSpillBytes += job.sizeBytes; + recomputeOldestResident(); + responseSpillPublicationTail = responseSpillPublicationTail + .then(() => runPendingResponseSpill(job), () => runPendingResponseSpill(job)); +} + +function replaceWithPendingResponseSpill( + id: string, + candidate: ResidentResponseState, + expected: StoredResponseState | undefined, + options: { directAdmission?: boolean } = {}, +): boolean { + const inheritedSpill = pendingResponseSpillById.get(id)?.supersededSpill + ?? (expected?.kind === "spill" ? expected.spill : undefined); + if (!replaceMapEntry(id, candidate, expected)) return false; + queuePendingResponseSpill(id, candidate, { + ...(inheritedSpill ? { supersededSpill: inheritedSpill } : {}), + directAdmission: options.directAdmission === true, + }); + return true; +} + +/** Test-only: settle every serialized Windows spill publication. */ +export async function flushPendingResponseSpillsForTests(): Promise { + await drainResponseSpillPublications(); +} + +/** Test-only: observe ordinary queue settlement without invoking shutdown fallback. */ +export async function awaitResponseSpillPublicationTailForTests(): Promise { + await responseSpillPublicationTail; +} + +/** Test-only: observe the bounded queue without exposing payloads. */ +export function pendingResponseSpillMetricsForTests(): { count: number; bytes: number } { + return { count: pendingResponseSpills.size, bytes: pendingResponseSpillBytes }; +} + +/** Test-only: shorten the shutdown drain/fallback budget (null restores production values). */ +export function setResponseSpillShutdownBudgetForTests( + budget: { totalMs: number; fallbackReserveMs: number } | null, +): void { + responseSpillShutdownBudgetOverride = budget; +} + +/** Test-only: shorten the ordinary async whole-attempt ACL budget. */ +export function setResponseSpillAsyncAclAttemptBudgetForTests(budgetMs: number | null): void { + responseSpillAsyncAclAttemptBudgetOverride = budgetMs; +} + +function responseSpillAsyncAclAttemptBudgetMs(): number { + return responseSpillAsyncAclAttemptBudgetOverride ?? RESPONSE_SPILL_ASYNC_ACL_ATTEMPT_BUDGET_MS; +} + +/** Test-only: lower the hard terminalization pass guard (null restores production). */ +export function setResponseSpillShutdownTerminalizationPassLimitForTests(limit: number | null): void { + responseSpillShutdownTerminalizationPassLimitOverride = limit; +} + +function responseSpillShutdownTerminalizationPassLimit(): number { + return responseSpillShutdownTerminalizationPassLimitOverride + ?? RESPONSE_SPILL_SHUTDOWN_TERMINALIZATION_MAX_PASSES; +} + +function responseSpillShutdownBudget(): { totalMs: number; fallbackReserveMs: number } { + return responseSpillShutdownBudgetOverride ?? { + totalMs: RESPONSE_SPILL_SHUTDOWN_BUDGET_MS, + fallbackReserveMs: RESPONSE_SPILL_SHUTDOWN_FALLBACK_RESERVE_MS, + }; +} + +function awaitResponseSpillTailUntil(observed: Promise, deadline: number): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) return Promise.resolve(false); + return new Promise(resolve => { + let finished = false; + const finish = (settled: boolean): void => { + if (finished) return; + finished = true; + clearTimeout(timer); + resolve(settled); + }; + const timer = setTimeout(() => finish(false), remaining); + observed.then(() => finish(true), () => finish(true)); + }); +} + +function installShutdownFallbackSpill( + job: PendingResponseSpill, + candidate: ResidentResponseState, + aclBudgetMs: number, +): void { + let ref: ResponseSpillRef | null = null; + try { + ref = writeResponseSpillDurably(job.id, spillPayloadForResident(candidate), { aclBudgetMs }); + if (ref.payloadBytes > responseSpillPayloadCap()) { + deleteResponseSpill(ref); + ref = null; + if (job.directAdmission) admissionCounters.oversizedDrops += 1; + throw Object.assign(new Error("Response spill payload exceeds replay ceiling"), { code: "EFBIG" }); + } + if (states.get(job.id) !== candidate) { + deleteResponseSpill(ref); + ref = null; + return; + } + if (swapResidentForSpill(job.id, candidate, ref)) { + ref = null; + spillCounters.writes += 1; + if (job.directAdmission) admissionCounters.directSpills += 1; + deferSupersededSpill(job.supersededSpill); + } + } catch (error) { + if (ref) deleteResponseSpill(ref); + if (states.get(job.id) === candidate) { + spillCounters.writeFailures += 1; + replaceWithSpillFailure(job.id, candidate); + deferSupersededSpill(job.supersededSpill); + } + throw error; + } +} + +function terminalizeShutdownFallbackCandidate( + job: PendingResponseSpill, + candidate: ResidentResponseState, +): void { + if (states.get(job.id) !== candidate) return; + spillCounters.writeFailures += 1; + replaceWithSpillFailure(job.id, candidate); + deferSupersededSpill(job.supersededSpill); +} + +function pendingShutdownFallbackCandidates(): Array<{ + job: PendingResponseSpill; + candidate: ResidentResponseState; +}> { + return [...pendingResponseSpills] + .map(job => ({ job, candidate: job.candidate })) + .filter((entry): entry is { job: PendingResponseSpill; candidate: ResidentResponseState } => !!entry.candidate); +} + +function supersedeShutdownFallbackBatch( + pending: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>, + failures: Error[], +): void { + for (const { job } of pending) { + job.cancelled = true; + markResponseSpillPublicationSuperseded(job.publicationControl); + } + for (const { job } of pending) { + const cleanupFailure = cleanupSupersededResponseSpillPublication(job.publicationControl); + if (cleanupFailure) failures.push(cleanupFailure); + releasePendingResponseSpill(job); + } +} + +function stopAtShutdownTerminalizationPassLimit( + pending: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>, + failures: Error[], +): void { + failures.push(Object.assign(new Error("Response spill shutdown terminalization pass limit exceeded"), { code: "ELOOP" })); + supersedeShutdownFallbackBatch(pending, failures); + for (const { job, candidate } of pending) { + terminalizeShutdownFallbackCandidate(job, candidate); + } + for (const [id, state] of [...states]) { + if (state.kind !== "resident") continue; + spillCounters.writeFailures += 1; + replaceWithSpillFailure(id, state); + } + recomputeOldestResident(); + pruneResponses(); + enforceAppOwnedMemoryBudget(); +} + +function terminalizeExhaustedShutdownFallback( + initial: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>, + failures: Error[], +): void { + let pending = initial; + let passes = 0; + const passLimit = responseSpillShutdownTerminalizationPassLimit(); + // Every pass replaces each captured resident with a tombstone. Pruning may expose + // another finite batch, but resident count strictly decreases until none can requeue. + while (pending.length > 0) { + if (passes >= passLimit) { + stopAtShutdownTerminalizationPassLimit(pending, failures); + return; + } + passes += 1; + supersedeShutdownFallbackBatch(pending, failures); + for (const { job, candidate } of pending) { + failures.push(Object.assign(new Error("Response spill shutdown fallback budget exhausted"), { code: "ETIMEDOUT" })); + terminalizeShutdownFallbackCandidate(job, candidate); + } + recomputeOldestResident(); + pruneResponses(); + enforceAppOwnedMemoryBudget(); + pending = pendingShutdownFallbackCandidates(); + } +} + +function fallbackPendingResponseSpills(reserveMs: number): Error[] { + const deadline = Date.now() + reserveMs; + const failures: Error[] = []; + for (;;) { + const pending = pendingShutdownFallbackCandidates(); + if (pending.length === 0) return failures; + if (Date.now() >= deadline) { + terminalizeExhaustedShutdownFallback(pending, failures); + return failures; + } + + supersedeShutdownFallbackBatch(pending, failures); + let reserveExhausted = false; + for (let index = 0; index < pending.length; index += 1) { + const { job, candidate } = pending[index]!; + if (states.get(job.id) !== candidate) continue; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + reserveExhausted = true; + for (const exhausted of pending.slice(index)) { + failures.push(Object.assign(new Error("Response spill shutdown fallback budget exhausted"), { code: "ETIMEDOUT" })); + terminalizeShutdownFallbackCandidate(exhausted.job, exhausted.candidate); + } + break; + } + try { + installShutdownFallbackSpill(job, candidate, remaining); + } catch (error) { + failures.push(error instanceof Error ? error : new Error("Response spill shutdown fallback failed")); + } + } + recomputeOldestResident(); + pruneResponses(); + enforceAppOwnedMemoryBudget(); + if (reserveExhausted || Date.now() >= deadline) { + terminalizeExhaustedShutdownFallback(pendingShutdownFallbackCandidates(), failures); + return failures; + } + } +} + +async function drainResponseSpillPublications(): Promise { + const budget = responseSpillShutdownBudget(); + const fallbackReserveMs = Math.min(budget.totalMs, Math.max(1, budget.fallbackReserveMs)); + const drainDeadline = Date.now() + Math.max(0, budget.totalMs - fallbackReserveMs); + + for (;;) { + if (pendingResponseSpills.size === 0) return; + const observed = responseSpillPublicationTail; + const settled = await awaitResponseSpillTailUntil(observed, drainDeadline); + if (!settled) { + const failures = fallbackPendingResponseSpills(fallbackReserveMs); + if (failures.length > 0) { + throw new AggregateError(failures, "Response spill shutdown fallback incomplete"); + } + return; + } + if (observed === responseSpillPublicationTail) return; + } +} + function byteCap(): number { return byteCapOverride ?? MAX_STORED_RESPONSE_BYTES; } @@ -200,6 +629,7 @@ function recomputeOldestResident(): void { oldestResidentAt = null; for (const [id, state] of states) { if (state.kind !== "resident") continue; + if (pendingResponseSpillById.get(id)?.candidate === state) continue; if (oldestResidentAt !== null && state.createdAt >= oldestResidentAt) continue; oldestResidentId = id; oldestResidentAt = state.createdAt; @@ -248,6 +678,7 @@ function deleteOwnedSpills(entry: StoredResponseState): void { function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void { const existing = states.get(id); if (!existing) return; + const supersededSpill = cancelPendingResponseSpill(id); storedResponseBytes -= existing.sizeBytes; if (existing.kind === "resident") { residentResponseBytes -= existing.sizeBytes; @@ -258,6 +689,7 @@ function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void if (oldestResidentId === id) recomputeOldestResident(); stateRevision += 1; if (options.deleteSpill !== false) deleteOwnedSpills(existing); + if (options.deleteSpill !== false && supersededSpill) deleteResponseSpill(supersededSpill); } function replaceWithSpillFailure( @@ -362,11 +794,18 @@ function setResidentEntry(id: string, entry: ResidentInput): void { pruneResponses(); return; } + const pending = pendingResponseSpillById.get(id); + if (windowsSecretAclApplies() && (expected?.kind === "spill" || pending?.supersededSpill)) { + replaceWithPendingResponseSpill(id, candidate, expected); + pruneResponses(); + return; + } if (expected?.kind === "spill") { replaceSpillEntryAtomically(id, expected, candidate); pruneResponses(); return; } + if (windowsSecretAclApplies()) cancelPendingResponseSpill(id); if (!replaceMapEntry(id, candidate, expected)) return; pruneResponses(); } @@ -389,6 +828,10 @@ function admitOversizedCandidate( replaceWithSpillFailure(id, expected, { deferSpillUnlink: true }); return; } + if (windowsSecretAclApplies()) { + replaceWithPendingResponseSpill(id, candidate, expected, { directAdmission: true }); + return; + } try { const ref = writeResponseSpillDurably(id, { createdAt: candidate.createdAt, @@ -927,8 +1370,7 @@ function schedulePersist(): void { schedulePersistAt(snapshotPath()); } -/** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */ -export async function flushResponseState(): Promise { +async function flushResponseSnapshot(): Promise { if (persistTimer) { await persistNow(pendingPersistPath ?? snapshotPath(), true); return; @@ -941,6 +1383,23 @@ export async function flushResponseState(): Promise { if (persistTimer) await persistNow(pendingPersistPath ?? snapshotPath(), true); } +/** Flush publications and snapshot state; report drain failure only after persistence completes. */ +export async function flushResponseState(): Promise { + const failures: unknown[] = []; + try { + await drainResponseSpillPublications(); + } catch (error) { + failures.push(error); + } + try { + await flushResponseSnapshot(); + } catch (error) { + failures.push(error); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, "Response state shutdown flush incomplete"); +} + function inputItems(input: unknown): unknown[] { if (input === undefined) return []; if (Array.isArray(input)) return input; @@ -1048,7 +1507,11 @@ function pruneResponses(at = now()): void { // Unconditional RAM cap. Resident payloads demote durably; stubs/tombstones are // deleted only when even their bounded metadata cannot fit the override. while (storedResponseBytes > byteCap() && states.size > 0) { - const oldestResident = [...states].find(([, entry]) => entry.kind === "resident"); + const oldestResident = [...states].find(([id, entry]) => entry.kind === "resident" + && pendingResponseSpillById.get(id)?.candidate !== entry); + const hasPendingResident = !oldestResident && [...states].some(([id, entry]) => entry.kind === "resident" + && pendingResponseSpillById.get(id)?.candidate === entry); + if (hasPendingResident) break; const oldestId = oldestResident?.[0] ?? states.keys().next().value as string | undefined; if (!oldestId) break; const entry = states.get(oldestId)!; @@ -1056,6 +1519,10 @@ function pruneResponses(at = now()): void { deleteEntry(oldestId); continue; } + if (windowsSecretAclApplies()) { + queuePendingResponseSpill(oldestId, entry); + continue; + } try { const ref = writeResponseSpillDurably(oldestId, { createdAt: entry.createdAt, @@ -1143,11 +1610,18 @@ export function sweepAbandonedResponseStateTemps(): number { } export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot { + let currentPendingBytes = 0; + for (const job of pendingResponseSpills) { + if (job.candidate && states.get(job.id) === job.candidate) currentPendingBytes += job.sizeBytes; + } + const detachedPendingBytes = Math.max(0, pendingResponseSpillBytes - currentPendingBytes); + const bytes = storedResponseBytes + detachedPendingBytes; + const evictableBytes = Math.max(0, residentResponseBytes - currentPendingBytes); return { count: states.size, - bytes: storedResponseBytes, - evictableBytes: residentResponseBytes, - pinnedBytes: Math.max(0, storedResponseBytes - residentResponseBytes), + bytes, + evictableBytes, + pinnedBytes: Math.max(0, bytes - evictableBytes), oldestAt: oldestResidentAt, }; } @@ -1157,6 +1631,11 @@ export function evictOldestResponseContinuationForBudget(): number { const id = oldestResidentId; const entry = states.get(id); if (!entry || entry.kind !== "resident") return 0; + if (windowsSecretAclApplies()) { + queuePendingResponseSpill(id, entry); + schedulePersist(); + return 0; + } try { const ref = writeResponseSpillDurably(id, { createdAt: entry.createdAt, @@ -1380,7 +1859,7 @@ export function responseStateMetrics(): ResponseStateMetrics { residentCount, spillStubCount, tombstoneCount, - totalBytes: storedResponseBytes, + totalBytes: responseContinuationRetainedStoreSnapshot().bytes, spillPayloadBytes, largestBytes, oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0, @@ -1492,6 +1971,8 @@ export function clearResponseStateMemoryForTests(): void { persistTimer = null; } pendingPersistPath = null; + for (const id of [...pendingResponseSpillById.keys()]) cancelPendingResponseSpill(id); + pendingResponseSpillById.clear(); states.clear(); storedResponseBytes = 0; residentResponseBytes = 0; diff --git a/src/server/index.ts b/src/server/index.ts index 6c7e53f062..18e4e5254a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -66,6 +66,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { availableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, resolveCodexModelEntitlements, } from "../codex/model-entitlements"; export { @@ -1211,10 +1212,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { const target = accountTargets.get(selector); const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; - const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined; - const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false; return [selector, slugs.filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true) + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) + || (accountId !== undefined + && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") ))] as const; })) : new Map(); diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index b81d9bf46f..bf5d4ee292 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -458,8 +458,9 @@ export function trackStreamLifetime( export async function drainAndShutdown( server: ReturnType | undefined, timeoutMs: number, -): Promise { +): Promise { const s = server ?? _serverRef; + let shutdownSucceeded = true; // One absolute budget covers both a pre-existing scoped profile drain and // ordinary in-flight turns. A stuck scoped owner must not pin shutdown forever. const deadline = Date.now() + Math.max(0, timeoutMs); @@ -491,9 +492,11 @@ export async function drainAndShutdown( // shutdown is usually part of. const stateFlush = await Promise.allSettled([flushResponseState(), flushAntigravityReplay()]); if (stateFlush[0]?.status === "rejected") { + shutdownSucceeded = false; console.warn("[responses] state flush during shutdown failed"); } if (stateFlush[1]?.status === "rejected") { + shutdownSucceeded = false; console.warn("[antigravity] replay flush during shutdown failed"); } @@ -546,4 +549,5 @@ export async function drainAndShutdown( // never resume admission merely because shutdown cleanup returned. } } + return shutdownSucceeded; } diff --git a/src/server/management-api.ts b/src/server/management-api.ts index c850c3bbda..9f19831576 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -276,8 +276,13 @@ export async function handleManagementAPI( const { stripGrokConfig } = await import("../grok/inject"); const grok = stripGrokConfig(); setTimeout(async () => { - await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000); - process.exit(0); + let shutdownSucceeded = false; + try { + shutdownSucceeded = await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000); + } catch { + console.warn("[opencodex] shutdown drain failed"); + } + process.exit(shutdownSucceeded ? 0 : 1); }, 200); const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`; return jsonResponse(restore.success diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index c4e6ca0210..592c0c11e7 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -25,6 +25,7 @@ import { providerContextCap } from "../../providers/context-cap"; import { isVisionReasoningEffort } from "../../reasoning-effort"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import type { OcxConfig } from "../../types"; +import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements"; import { fetchAllModels } from "./shared"; /** @@ -47,8 +48,16 @@ export type ManagementModelRow = Partial & { * models the GUI's Models tab shows — including this function's `disabled` computation, * which the export core (src/clients/config-export.ts) deliberately does not perform. */ -export async function listManagementModelRows(config: OcxConfig): Promise { - const models = await fetchAllModels(config); +export async function listManagementModelRows( + config: OcxConfig, + options: { entitlementWaitMs?: number } = {}, +): Promise { + const [models] = await Promise.all([ + fetchAllModels(config), + ensureCodexEntitlementFreshness(config, { + waitMs: options.entitlementWaitMs ?? 3_000, + }), + ]); const disabled = new Set(config.disabledModels ?? []); // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced // from the static supported set so a disabled model stays listed and re-enableable. diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index b0314831e1..d71397de8f 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -53,6 +53,7 @@ import { codexAccountNamespaceProviderCollisionError } from "../../codex/account import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache"; +import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; import { resolveCodexHomeDir } from "../../codex/home"; @@ -474,6 +475,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise, + drainPromise: Promise, deadlineMs: number, now: () => number, scheduleDeadline: NonNullable, @@ -105,7 +105,7 @@ function waitForRestartDrain( cancelDeadline = scheduleDeadline(() => finish("deadline"), remainingMs); if (settled) cancelDeadline(); void drainPromise.then( - () => finish("completed"), + succeeded => finish(succeeded === false ? "failed" : "completed"), () => finish("rejected"), ); }); @@ -382,7 +382,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): { await completeDeadlineRestartHandoff(io, exitProcess, restartPort, scheduleDeadline); return; } - if (drainOutcome === "rejected") { + if (drainOutcome === "failed" || drainOutcome === "rejected") { // drainAndShutdown stops the listener in finally. Even if ancillary cleanup // rejects, an accepted restart must still reach replacement or terminal exit. console.warn("Drain-and-restart cleanup failed; continuing terminal restart handoff"); @@ -422,7 +422,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): { return; } (io.markRecycling ?? markRecyclingForExit)(); - exitProcess(0); + exitProcess(drainOutcome === "failed" || drainOutcome === "rejected" ? 1 : 0); }, 200); } diff --git a/src/server/startup-action-control.ts b/src/server/startup-action-control.ts index a3165a85e8..aca1b111b7 100644 --- a/src/server/startup-action-control.ts +++ b/src/server/startup-action-control.ts @@ -233,8 +233,9 @@ function applyReconciliationOutcome( /** * Execute the existing fixed CLI installer outside the proxy event loop. * - * Repair mode (`options.repair`) runs `ocx service repair` — asset rewrite + restart - * without Task Scheduler re-registration, so it must not enter the UAC elevation path. + * Repair mode (`options.repair`) runs `ocx service repair`. A stale definition may be + * re-registered and elevate inside repair; this wrapper must not retry it through the separate + * fresh-install UAC path. * * After an elevation request timeout the lock becomes `indeterminate` until the * original elevated transaction completes and is reconciled. A process restart diff --git a/src/service.ts b/src/service.ts index 4dfb0de6d7..ab780711e8 100644 --- a/src/service.ts +++ b/src/service.ts @@ -46,10 +46,17 @@ import { hardenSecretPath, } from "./lib/windows-secret-acl"; import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths"; +import { + cachedCurrentWindowsIdentity, + resolveCurrentWindowsPrincipal, + WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS, +} from "./lib/windows-user-principal"; import { recordOwnedConfigPath } from "./lib/config-ownership"; import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers"; +import { withWindowsServiceMutationLock } from "./lib/windows-service-mutation-lock"; import { maybeShowStarPrompt } from "./cli/star-prompt"; import { systemdProperty } from "./service-manager-probe"; +import { isTestHomeGuardArmed } from "./lib/test-home-guard"; const LABEL = "com.opencodex.proxy"; const TASK = "opencodex-proxy"; @@ -322,11 +329,10 @@ export function readServiceBackend(): ServiceBackend { /** * The `ocx` argv that refreshes an already-installed service after an update. * - * `repair` discovers the installed backend itself and, on Windows scheduler installs, - * rewrites the wrapper assets and restarts the existing task WITHOUT `schtasks /create` - * (see repairService below). `install` always reaches `/create`, which requires - * elevation — so an ordinary non-elevated `ocx update` used to stop a working proxy and - * then fail to bring its service back. + * `repair` discovers the installed backend itself. A healthy Windows scheduler task only + * gets refreshed assets plus a restart; a stale live definition is re-registered and may + * require elevation. `install` always reaches `/create`, so using repair here avoids an + * unnecessary admin prompt for the common healthy update path. * * The historical export name is kept for callers outside this module. */ @@ -721,11 +727,11 @@ async function reportServiceServing( } /** - * The command that repairs the CURRENTLY INSTALLED backend without re-registering it. + * The command that repairs the CURRENTLY INSTALLED backend without switching it. * * `ocx service repair` reads the recorded backend itself, so it cannot silently switch a - * WinSW install to Task Scheduler the way a plain `ocx service install` would, and on - * Windows it needs no elevation because it never calls `schtasks /create`. + * WinSW install to Task Scheduler the way a plain `ocx service install` would. A healthy + * scheduler definition needs no elevation; a stale definition can be re-registered and prompt. */ function serviceRepairCommand(): string { return "ocx service repair"; @@ -900,6 +906,20 @@ function windowsWscript(): string { let querySchtasksForTests: ((args: string[]) => string) | null = null; function querySchtasks(args: string[]): string { + // The repository preload isolates HOME and OPENCODEX_HOME, but Task Scheduler is + // machine-global. A partially-faked service test once fell through here and replaced the + // user's real `opencodex-proxy` task with a launcher inside its temporary test home; the + // test passed and cleanup deleted that launcher. Queries are observation-only, but every + // other operation must be injected while the explicit test-home guard is armed. + if ( + isTestHomeGuardArmed() + && args[0]?.trim().toLowerCase() !== "/query" + ) { + throw new Error( + "refusing to mutate the machine-global Windows Task Scheduler from an armed test process; " + + "inject the scheduler operation instead of calling the live manager.", + ); + } if (querySchtasksForTests) return querySchtasksForTests(args); return runFile(windowsSchtasks(), args); } @@ -1730,8 +1750,8 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath } /** Build the fixed scheduler-create command from an explicit staged XML document. */ -export function buildWindowsSchtasksCreateArgsForXml(xml: string): string[] { - return ["/create", "/tn", TASK, "/xml", xml, "/f"]; +export function buildWindowsSchtasksCreateArgsForXml(xml: string, replace = true): string[] { + return ["/create", "/tn", TASK, "/xml", xml, ...(replace ? ["/f"] : [])]; } /** @@ -1760,15 +1780,45 @@ function windowsTaskDescription(attemptNonce?: string): string { : "OpenCodex proxy service wrapper"; } +/** + * Session transitions that must be able to bring the proxy back. + * + * The task runs under `InteractiveToken`, so the proxy lives inside the interactive session + * and Windows tears it down with that session — the wrapper records the kill as exit code + * 1073807364 (`STATUS_CONTROL_C_EXIT`). With `LogonTrigger` as the only trigger there was no + * recovery path short of a fresh logon, so signing out of a Remote Desktop session left the + * proxy down until the next interactive logon. On one machine's logs 19 such kills produced + * gaps of up to ~60 hours. + * + * These triggers do not stop the kill; they make it recoverable at the next connect. Console + * transitions are included because a local session can be disconnected the same way, and + * `MultipleInstancesPolicy=IgnoreNew` keeps a still-running proxy from being started twice. + */ +const WINDOWS_SESSION_RECOVERY_STATE_CHANGES = [ + "RemoteConnect", + "SessionUnlock", + "ConsoleConnect", +] as const; + export function buildWindowsTaskXml( script = windowsServiceScriptPath(), launcher = windowsLauncherVbsPath(), attemptNonce?: string, + sessionTriggerUserId = cachedCurrentWindowsIdentity()?.name, ): string { const escapedWscript = taskXmlString(windowsWscript()); // Escape the launcher path independently for the element; quoting it // keeps spaces intact, and /b (batch mode) suppresses script error popups. const escapedLauncherArgs = taskXmlString(`/b /nologo "${launcher}"`); + // `UserId` is optional in the schema, and omitting it makes a SessionStateChangeTrigger + // fire for ANY account's session change. Scope it to the installing account when that + // account is already known. The lookup is never forced here: this builder is synchronous + // and its output is validated before registration, so a failed or unavailable lookup must + // degrade to the unscoped trigger rather than leave the task with no recovery at all. + // `LogonTrigger` above is unscoped for the same reason and predates this change. + const sessionUserIdElement = sessionTriggerUserId + ? `\n ${taskXmlString(sessionTriggerUserId)}` + : ""; return ` @@ -1778,6 +1828,10 @@ export function buildWindowsTaskXml( true + ${WINDOWS_SESSION_RECOVERY_STATE_CHANGES.map(stateChange => ` + true${sessionUserIdElement} + ${stateChange} + `).join("\n ")} @@ -1907,8 +1961,47 @@ export function windowsTaskRegistrationOwnedByAttempt(xml: string, attemptNonce: ); } -/** Validate the security/lifecycle-critical fields of the registered scheduler task. */ -export function windowsTaskRegistrationHealthy( +/** + * Every session-recovery trigger present and enabled, scoped to . + * + * Each StateChange is matched inside its OWN element: a document + * carrying one disabled trigger plus a different enabled one must not pass because the two + * halves were found in unrelated elements. + */ +function windowsTaskHasSessionRecoveryTriggers(triggers: string, expectedUserId: string | undefined): boolean { + const scoped = triggers.match(/]*)?>[\s\S]*?<\/SessionStateChangeTrigger>/gi) ?? []; + return WINDOWS_SESSION_RECOVERY_STATE_CHANGES.every(stateChange => + scoped.some(element => + taskXmlDecodedValueEquals(element, "StateChange", stateChange) + && taskXmlOptionalValueEquals(element, "Enabled", "true") + && windowsTaskTriggerScopeAcceptable(element, expectedUserId))); +} + +/** + * A trigger's scope is acceptable when it is unscoped, or names the expected account. + * + * An unscoped trigger is accepted rather than rejected: the schema makes `UserId` optional, + * the pre-existing `LogonTrigger` is unscoped for the same reason, and rejecting it would + * mean an installation whose account lookup is unavailable loses session recovery entirely. + * An explicitly scoped trigger is accepted only when the current account is known and matches. + * Treating an unknown expected identity as a wildcard would let a fresh status process accept a + * task bound to another user's session and suppress the repair that should replace it. + */ +function windowsTaskTriggerScopeAcceptable(element: string, expectedUserId: string | undefined): boolean { + // A prefixed `` is a real scope this validator cannot read: taskXmlElementCount() + // counts only unprefixed tags, so without this the element below would look ABSENT and the + // trigger would be accepted as unscoped even though it is bound to some other account. + // Reject it outright rather than guess, and do so before the optional-field check. + if (taskXmlHasPrefixedTag(element, "UserId")) return false; + const userIdCount = taskXmlElementCount(element, "UserId"); + if (userIdCount === 0) return true; + if (userIdCount !== 1) return false; + if (expectedUserId === undefined) return false; + return taskXmlDecodedValueEquals(element, "UserId", expectedUserId); +} + +/** Validate the stable OpenCodex action, principal, settings, and logon trigger. */ +function windowsTaskRegistrationBaseHealthy( xml: string, wscript = windowsWscript(), launcher = windowsLauncherVbsPath(), @@ -1941,6 +2034,35 @@ export function windowsTaskRegistrationHealthy( && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`); } +/** Validate the security/lifecycle-critical fields of the registered scheduler task. */ +export function windowsTaskRegistrationHealthy( + xml: string, + wscript = windowsWscript(), + launcher = windowsLauncherVbsPath(), + expectedUserId: string | null = cachedCurrentWindowsIdentity()?.name ?? null, +): boolean { + const scrubbed = taskXmlWithoutCommentsAndCdata(xml); + const triggers = taskXmlSection(scrubbed, "Triggers"); + return windowsTaskRegistrationBaseHealthy(xml, wscript, launcher) + // Without these the task can only recover at the next logon, so a disconnected session + // leaves the proxy down indefinitely. Treating their absence as unhealthy is what lets + // an already-registered task from an older install get repaired instead of staying broken. + && windowsTaskHasSessionRecoveryTriggers(triggers, expectedUserId ?? undefined); +} + +/** + * The only stale definition repair may replace automatically: the previous OpenCodex task + * shape whose action/principal/settings are still exact and which has no session triggers yet. + * Arbitrary unhealthy or partially modified fixed-name tasks are preserved for manual review. + */ +function windowsTaskRegistrationRefreshableLegacy(xml: string): boolean { + const scrubbed = taskXmlWithoutCommentsAndCdata(xml); + const triggers = taskXmlSection(scrubbed, "Triggers"); + return windowsTaskRegistrationBaseHealthy(xml) + && taskXmlElementCount(triggers, "SessionStateChangeTrigger") === 0 + && !taskXmlHasPrefixedTag(triggers, "SessionStateChangeTrigger"); +} + export interface WindowsSchedulerXmlState { installed: boolean; enabled: boolean; @@ -1956,6 +2078,7 @@ export function readWindowsSchedulerXmlState( xml: string, wscript?: string, launcher?: string, + expectedUserId: string | null = cachedCurrentWindowsIdentity()?.name ?? null, ): WindowsSchedulerXmlState { const installed = xml.length > 0; if (!installed) return { installed: false, enabled: false, registrationHealthy: false }; @@ -1965,7 +2088,7 @@ export function readWindowsSchedulerXmlState( return { installed: true, enabled: !hasData && taskXmlOptionalValueEquals(settings, "Enabled", "true"), - registrationHealthy: windowsTaskRegistrationHealthy(xml, wscript, launcher), + registrationHealthy: windowsTaskRegistrationHealthy(xml, wscript, launcher, expectedUserId), }; } @@ -2108,8 +2231,8 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut } /** - * Rewrite on-disk scheduler assets (script/VBS/XML) without re-registering the task. - * Used by fresh install (before schtasks /create) and by repair (no elevation). + * Rewrite on-disk scheduler assets (script/VBS/XML) without itself registering the task. + * Fresh install creates it afterwards; repair does so only when the live definition is stale. */ function writeWindowsSchedulerAssets(): void { if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); @@ -2230,9 +2353,15 @@ function removeWindowsSchedulerRegistrationStage(xmlPath: string): void { export interface FreshWindowsSchedulerRegistrationDeps { create?: (args: string[]) => void; - elevate?: (taskName: string, xml: string) => Promise; + elevate?: ( + taskName: string, + xml: string, + replace: boolean, + expectedExistingXml?: string, + ) => Promise; probe?: () => WindowsSchedulerTaskProbe; queryXml?: () => string; + readExistingXml?: () => string; rollback?: () => Promise; } @@ -2240,8 +2369,27 @@ export async function registerFreshWindowsSchedulerTask( xmlPath: string, attemptNonce: string, deps: FreshWindowsSchedulerRegistrationDeps = {}, + expectedExistingXml?: string, ): Promise { - const args = buildWindowsSchtasksCreateArgsForXml(xmlPath); + const replace = expectedExistingXml !== undefined; + const readExistingXml = deps.readExistingXml ?? statusWindowsXml; + const assertReplacementPrecondition = (): void => { + if (!replace) return; + if (!expectedExistingXml?.trim()) { + throw new Error("Task Scheduler replacement requires a non-empty captured registration."); + } + let currentXml = ""; + try { + currentXml = readExistingXml(); + } catch { + throw new Error("Task Scheduler replacement was refused because the current registration could not be read."); + } + if (!windowsSchedulerRegistrationMatchesSnapshot(currentXml, expectedExistingXml)) { + throw new Error("Task Scheduler replacement was refused because the current registration changed."); + } + }; + assertReplacementPrecondition(); + const args = buildWindowsSchtasksCreateArgsForXml(xmlPath, replace); // Capture and validate the exact definition before an access-denied attempt can // cross the UAC boundary. The elevated fallback receives these immutable bytes, // never the caller-writable staging pathname. @@ -2264,11 +2412,24 @@ export async function registerFreshWindowsSchedulerTask( } // Register from the captured XML string inside the elevated process. Another // same-user process can mutate its own temp files, but cannot change this command. - const elevate = deps.elevate ?? (async (taskName: string, xml: string) => { - const exitCode = await runWindowsElevatedScheduledTaskRegistration(taskName, xml); + // UAC can remain open for an arbitrary amount of time. Recheck the captured predecessor + // before launch; the elevated helper repeats the same check after consent and before Force. + assertReplacementPrecondition(); + const elevate = deps.elevate ?? (async ( + taskName: string, + xml: string, + replaceCurrent: boolean, + previousXml?: string, + ) => { + const exitCode = await runWindowsElevatedScheduledTaskRegistration( + taskName, + xml, + replaceCurrent, + previousXml, + ); if (exitCode !== 0) throw new Error(`Background service install failed with exit code ${exitCode}.`); }); - await elevate(TASK, expectedXml); + await elevate(TASK, expectedXml, replace, expectedExistingXml); } const rollbackTask = deps.rollback ?? (() => rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK)); @@ -2328,8 +2489,17 @@ export interface RemoveNativeWindowsServiceDeps { export function removeNativeWindowsServiceForScheduler( deps: RemoveNativeWindowsServiceDeps = {}, ): void { - const status = deps.status ?? statusWinswRaw; const uninstall = deps.uninstall ?? uninstallWinswService; + // The test home cannot contain SCM. A partially mocked scheduler install must inject + // the native-service mutation too; otherwise it can stop/delete the user's live WinSW + // registration even though every filesystem path points at the isolated test home. + if (isTestHomeGuardArmed() && uninstall === uninstallWinswService) { + throw new Error( + "refusing to mutate the machine-global Windows native service from an armed test process; " + + "inject the native-service removal instead of calling the live manager.", + ); + } + const status = deps.status ?? statusWinswRaw; const sleep = deps.sleep ?? Bun.sleepSync; const settleChecks = Math.max(1, deps.settleChecks ?? 20); // Transactional backend switch: installing the scheduler backend removes a native @@ -2361,6 +2531,107 @@ function installWindows(): void { writeServiceInstallState("scheduler"); } +/** + * Re-register an already-installed scheduler task from a freshly staged definition. + * + * Reuses the fresh-install staging and registration path, so the same ownership and shape + * validation applies and an access-denied `schtasks /create` still escalates through the + * existing elevated fallback. The staged XML is removed on every exit. + */ +async function reregisterWindowsSchedulerTask( + attemptNonce: string, + expectedExistingXml: string, +): Promise { + const stagedXml = stageWindowsSchedulerRegistrationXml(attemptNonce); + try { + await registerFreshWindowsSchedulerTask(stagedXml, attemptNonce, {}, expectedExistingXml); + } finally { + removeWindowsSchedulerRegistrationStage(stagedXml); + } +} + +function stageWindowsSchedulerRestoreXml(registeredXml: string): string { + if (!registeredXml.trim()) { + throw new Error("Cannot restore an empty Task Scheduler registration."); + } + const stageDir = mkdtempSync(join(tmpdir(), WINDOWS_SCHEDULER_STAGE_PREFIX)); + const xmlPath = join(stageDir, "task.xml"); + try { + try { chmodSync(stageDir, 0o700); } catch { /* required Windows ACL is authoritative */ } + hardenSecretDir(stageDir, { required: true }); + writeFileSync( + xmlPath, + `\uFEFF${registeredXml.replace(/^\uFEFF/, "")}`, + { encoding: "utf16le", flag: "wx", mode: 0o600 }, + ); + hardenSecretPath(xmlPath, { required: true }); + ownedWindowsSchedulerStages.add(xmlPath); + return xmlPath; + } catch (error) { + try { + cleanupWindowsSchedulerStage(stageDir, xmlPath, path => { rmdirSync(path); }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Task Scheduler rollback staging failed and could not be cleaned up.", + ); + } + throw error; + } +} + +/** Compare two live scheduler snapshots conservatively without treating formatting as mutation. */ +function windowsSchedulerRegistrationMatchesSnapshot(currentXml: string, previousXml: string): boolean { + const normalize = (xml: string) => xml + .replace(/^\uFEFF/, "") + .replace(/\r\n?/g, "\n") + .trim(); + const current = normalize(currentXml); + const previous = normalize(previousXml); + return current.length > 0 && previous.length > 0 && current === previous; +} + +/** + * Restore the captured registration only while the fixed task name is still absent. + * + * Both publication paths deliberately omit force: another writer appearing after the + * absence probe must make this operation fail instead of being overwritten. Exact live + * XML readback is required before the caller may restart the recovered task. + */ +async function restoreWindowsSchedulerTaskIfAbsent(registeredXml: string): Promise { + const before = probeWindowsSchedulerTask(TASK); + if (before.status !== "absent") { + throw new Error(before.status === "present" + ? "A Task Scheduler registration appeared before recovery and was preserved." + : `Task Scheduler absence could not be re-verified before recovery (${before.detail}).`); + } + const stagedXml = stageWindowsSchedulerRestoreXml(registeredXml); + try { + const args = buildWindowsSchtasksCreateArgsForXml(stagedXml, false); + try { + schtasks(args); + } catch (error) { + if ( + !(error instanceof WindowsSchtasksError) + || error.operation !== "create" + || error.reason !== "access-denied" + ) { + throw error; + } + const exitCode = await runWindowsElevatedScheduledTaskRegistration(TASK, registeredXml, false); + if (exitCode !== 0) { + throw new Error(`Task Scheduler rollback failed with exit code ${exitCode}.`); + } + } + const recoveredXml = statusWindowsXml(); + if (!windowsSchedulerRegistrationMatchesSnapshot(recoveredXml, registeredXml)) { + throw new Error("The recovered Task Scheduler registration did not match the captured definition."); + } + } finally { + removeWindowsSchedulerRegistrationStage(stagedXml); + } +} + export interface RepairServiceDeps { diagnose?: () => ServiceDiagnostic; assertEnv?: () => void; @@ -2373,14 +2644,69 @@ export interface RepairServiceDeps { repairNative?: () => void | Promise; repairLaunchd?: () => void; repairSystemd?: () => void; + /** Reads live registered task XML; may be called again after failure, empty when unreadable. */ + readSchedulerXml?: () => string; + /** Bounded wait before retrying an unreadable live registration snapshot. */ + settleSchedulerRead?: (delayMs: number) => void | Promise; + /** Proves fixed-name task presence when its live XML is empty or unreadable. */ + probeScheduler?: () => WindowsSchedulerTaskProbe; + /** Re-registers the task from freshly staged XML. Used only when the definition is stale. */ + reregisterScheduler?: (attemptNonce: string, expectedExistingXml: string) => Promise; + /** Publishes the captured registration only when the fixed task name remains absent. */ + restoreSchedulerIfAbsent?: (registeredXml: string) => Promise; + /** Resolves the account the registered triggers must match; null when it cannot be resolved. */ + resolveExpectedUserId?: (registeredXml: string) => string | null; /** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */ platform?: NodeJS.Platform; } +async function assertSchedulerSnapshotBeforeStart( + readSchedulerXml: () => string, + expectedXml: string, + settle: (delayMs: number) => void | Promise, + changedMessage: string, + unreadableMessage: string, +): Promise { + await assertSchedulerRegistrationBeforeStart( + readSchedulerXml, + settle, + currentXml => windowsSchedulerRegistrationMatchesSnapshot(currentXml, expectedXml), + changedMessage, + unreadableMessage, + ); +} + +async function assertSchedulerRegistrationBeforeStart( + readSchedulerXml: () => string, + settle: (delayMs: number) => void | Promise, + matchesExpected: (currentXml: string) => boolean, + changedMessage: string, + unreadableMessage: string, +): Promise { + for (let attempt = 0; attempt <= SCHEDULER_SETTLE_DELAYS_MS.length; attempt += 1) { + let beforeStartXml = ""; + try { + beforeStartXml = readSchedulerXml(); + } catch { + // Treat query errors like the default reader's empty result and retry below. + } + if (beforeStartXml.trim()) { + if (!matchesExpected(beforeStartXml)) { + throw new Error(changedMessage); + } + return; + } + const delayMs = SCHEDULER_SETTLE_DELAYS_MS[attempt]; + if (delayMs === undefined) break; + await settle(delayMs); + } + throw new Error(unreadableMessage); +} + /** - * Repair an already-installed background service without Task Scheduler re-registration. + * Repair the already-installed background-service backend without switching managers. * - * Windows scheduler: rewrite assets + stop/start — no `schtasks /create`, no UAC. + * Windows scheduler: rewrite assets + stop/start; stale definitions are refreshed and may elevate. * Windows native: WinSW asset rewrite + restart (skips `install /p` when present). * macOS/Linux: re-run the user-level install/reload path. */ @@ -2410,8 +2736,159 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise (deps.writeNativeState ?? (() => writeServiceInstallState("native")))(); return; } + const readSchedulerXml = deps.readSchedulerXml ?? statusWindowsXml; + let registeredXml = ""; + try { + registeredXml = readSchedulerXml(); + } catch { + throw new Error( + "Task Scheduler registration could not be read; repair stopped before changing or starting the service.", + ); + } + if (!registeredXml.trim()) { + throw new Error( + "Task Scheduler registration is empty or unreadable; repair stopped before changing or starting the service.", + ); + } + // Judge the definition against the same effective account the diagnostic uses. Relying on + // the cached identity alone would make a scoped task this very version wrote look foreign + // in a fresh process, and the message below would then name the wrong cause. + const expectedUserId = (deps.resolveExpectedUserId ?? resolveWindowsTaskDiagnosticUserId)(registeredXml); + const registrationHealthy = windowsTaskRegistrationHealthy( + registeredXml, + undefined, + undefined, + expectedUserId, + ); + if (!registrationHealthy && !windowsTaskRegistrationRefreshableLegacy(registeredXml)) { + const scopedButUnresolved = expectedUserId === null + && taskXmlElementCount( + taskXmlSection(taskXmlWithoutCommentsAndCdata(registeredXml), "Triggers"), + "UserId", + ) > 0; + throw new Error( + scopedButUnresolved + ? "The registered Task Scheduler triggers name an account, but the current Windows identity could not be resolved, so the registration could not be verified. " + + "It was preserved and not replaced; re-run repair once the account can be resolved." + : "Task Scheduler registration is not a recognized legacy OpenCodex definition; it was preserved for manual review.", + ); + } try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ } (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)(); + // Rewriting the on-disk assets does not touch the definition Task Scheduler holds, so a + // task registered by an older version keeps its old triggers forever: status reports it + // stale, tells the user to run repair, and repair changes nothing it complains about. + // Re-register only when the registered XML is actually stale, so the ordinary repair + // stays free of `schtasks /create` and its UAC prompt. + let startExpectedXml = registeredXml; + if (!registrationHealthy) { + // The task was stopped above, so a failed replacement must not exit here: `/create /f` + // can be rejected, elevation can be cancelled, and staging or verification can fail. + // Any of those would leave a previously runnable proxy stopped and the user worse off + // than before the repair. Restart the definition still registered and surface the + // original failure instead. + const attemptNonce = randomUUID(); + try { + await (deps.reregisterScheduler ?? reregisterWindowsSchedulerTask)(attemptNonce, registeredXml); + let replacementXml = ""; + try { + replacementXml = readSchedulerXml(); + } catch { + throw new Error("The refreshed Task Scheduler registration could not be read back."); + } + if ( + !windowsTaskRegistrationHealthy(replacementXml) + || !windowsTaskRegistrationOwnedByAttempt(replacementXml, attemptNonce) + ) { + throw new Error( + "The refreshed Task Scheduler registration failed live shape or attempt-ownership verification.", + ); + } + startExpectedXml = replacementXml; + } catch (err) { + const recoveryErrors: unknown[] = []; + let restartExpectedXml: string | null = null; + let currentXml: string | null = null; + try { + currentXml = readSchedulerXml(); + } catch { + recoveryErrors.push(new Error( + "Task Scheduler state became unreadable after the failed replacement; it was preserved and not started.", + )); + } + + if (currentXml !== null) { + if (windowsSchedulerRegistrationMatchesSnapshot(currentXml, registeredXml)) { + restartExpectedXml = registeredXml; + } else if (currentXml.trim()) { + const attemptOwned = windowsTaskRegistrationOwnedByAttempt(currentXml, attemptNonce); + if (attemptOwned && windowsTaskRegistrationHealthy(currentXml)) { + restartExpectedXml = currentXml; + } else { + recoveryErrors.push(new Error( + attemptOwned + ? "The failed repair left an unhealthy attempt-owned registration; it was preserved and not started." + : windowsTaskRegistrationHealthy(currentXml) + ? "A different healthy OpenCodex Task Scheduler registration appeared during repair; it was preserved and not started." + : "A different or unhealthy Task Scheduler registration appeared during repair; it was preserved and not started.", + )); + } + } else { + let probe: WindowsSchedulerTaskProbe; + try { + probe = (deps.probeScheduler ?? (() => probeWindowsSchedulerTask(TASK)))(); + } catch { + probe = { status: "unknown", detail: "presence probe failed" }; + } + if (probe.status === "absent") { + try { + await (deps.restoreSchedulerIfAbsent ?? restoreWindowsSchedulerTaskIfAbsent)(registeredXml); + restartExpectedXml = registeredXml; + } catch (error) { + recoveryErrors.push(error); + } + } else { + recoveryErrors.push(new Error(probe.status === "present" + ? "A Task Scheduler registration is present but its XML is unreadable; it was preserved and not started." + : `Task Scheduler state is unknown after the failed replacement (${probe.detail}); no registration was overwritten or started.`)); + } + } + } + + if (restartExpectedXml !== null) { + try { + await assertSchedulerSnapshotBeforeStart( + readSchedulerXml, + restartExpectedXml, + deps.settleSchedulerRead ?? settleDelay, + "The Task Scheduler registration changed again before restart; the newer definition was preserved and not started.", + "Task Scheduler state remained unreadable before restart; the registration was preserved and not started.", + ); + (deps.startScheduler ?? startWindows)(); + } catch (error) { + recoveryErrors.push(error); + } + } + if (recoveryErrors.length > 0) { + throw new AggregateError( + [err, ...recoveryErrors], + "Task Scheduler repair failed; concurrent or unverified scheduler state was preserved.", + ); + } + throw err; + } + } + // The final live read is the proof that `/run` still targets the definition this repair + // verified. A failed `schtasks /query` becomes an empty string, so allow only a bounded + // retry for that unreadable state. A readable mismatch is authoritative and fails + // immediately; presence alone cannot prove that the fixed-name task still has our XML. + await assertSchedulerSnapshotBeforeStart( + readSchedulerXml, + startExpectedXml, + deps.settleSchedulerRead ?? settleDelay, + "Task Scheduler registration changed before restart; the current definition was preserved and not started.", + "Task Scheduler registration became unreadable before restart; it was preserved and not started.", + ); (deps.startScheduler ?? startWindows)(); (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))(); return; @@ -3017,6 +3494,11 @@ export interface FreshWindowsSchedulerInstallDeps { prepare?: () => Promise; removeNativeService?: () => void; publishAssets?: () => void; + verifyBeforeRun?: (attemptNonce: string) => void | Promise; + /** Reads the newly registered task; empty or throwing reads are retried before rollback. */ + readSchedulerXml?: () => string; + /** Bounded wait before retrying an unreadable fresh-install registration. */ + settleSchedulerRead?: (delayMs: number) => void | Promise; runTask?: () => void; writeState?: () => void; rollbackTask?: (attemptNonce: string) => Promise; @@ -3040,6 +3522,18 @@ export async function installFreshWindowsSchedulerSafely( const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler")); const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler; const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets; + const verifyBeforeRun = deps.verifyBeforeRun ?? ((nonce: string) => ( + assertSchedulerRegistrationBeforeStart( + deps.readSchedulerXml ?? statusWindowsXml, + deps.settleSchedulerRead ?? settleDelay, + liveXml => ( + windowsTaskRegistrationHealthy(liveXml) + && windowsTaskRegistrationOwnedByAttempt(liveXml, nonce) + ), + "The fresh Task Scheduler registration changed before start; it was preserved and not run.", + "The fresh Task Scheduler registration remained unreadable before start; it was preserved and not run.", + ) + )); const runTask = deps.runTask ?? startWindows; const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler")); const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => ( @@ -3074,6 +3568,7 @@ export async function installFreshWindowsSchedulerSafely( await prepare(); removeNativeService(); publishAssets(); + await verifyBeforeRun(attemptNonce); runTask(); started = true; writeState(); @@ -3234,6 +3729,36 @@ export function serviceStartableFromTray(service: ServiceDiagnostic): boolean { return service.startable && !service.stale && !service.conflict; } +export interface WindowsTaskDiagnosticIdentityDeps { + currentIdentity?: () => Readonly<{ name: string }> | null; + resolvePrincipal?: (timeoutMs: number) => string; +} + +/** + * Resolve the effective account only when the registered task carries an explicit unprefixed + * trigger scope. Empty/unscoped tasks do not need identity and must not pay a repeated sync + * lookup timeout; prefixed scopes remain unreadable and fail closed in the XML validator. + */ +export function resolveWindowsTaskDiagnosticUserId( + schedulerXml: string, + deps: WindowsTaskDiagnosticIdentityDeps = {}, +): string | null { + const currentIdentity = deps.currentIdentity ?? cachedCurrentWindowsIdentity; + const cached = currentIdentity(); + if (cached) return cached.name; + + const scrubbed = taskXmlWithoutCommentsAndCdata(schedulerXml); + const triggers = taskXmlSection(scrubbed, "Triggers"); + if (taskXmlElementCount(triggers, "UserId") === 0) return null; + + try { + (deps.resolvePrincipal ?? resolveCurrentWindowsPrincipal)(WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS); + } catch { + return null; + } + return currentIdentity()?.name ?? null; +} + export interface WindowsServiceDiagnosticInputs { /** * Raw `schtasks /query /xml` output; empty when no task is registered. Passed as @@ -3242,6 +3767,8 @@ export interface WindowsServiceDiagnosticInputs { * silently reintroduce the stale-status false positive (#432). */ schedulerXml: string; + /** Resolved effective account for explicit scheduler trigger scopes; null means unknown. */ + schedulerExpectedUserId?: string | null; /** Whether the on-disk service assets exist. A filesystem concern, not an XML one. */ schedulerAssetsPresent: boolean; nativeStatus: "started" | "stopped" | "nonexistent" | "unknown"; @@ -3252,7 +3779,15 @@ export interface WindowsServiceDiagnosticInputs { } export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic { - const schedulerState = readWindowsSchedulerXmlState(inputs.schedulerXml); + const expectedUserId = inputs.schedulerExpectedUserId === undefined + ? cachedCurrentWindowsIdentity()?.name ?? null + : inputs.schedulerExpectedUserId; + const schedulerState = readWindowsSchedulerXmlState( + inputs.schedulerXml, + undefined, + undefined, + expectedUserId, + ); const schedulerInstalled = schedulerState.installed; const schedulerEnabled = schedulerState.enabled; const schedulerAssetsHealthy = inputs.schedulerAssetsPresent && schedulerState.registrationHealthy; @@ -3298,6 +3833,17 @@ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticI }; } +/** Bind the live Windows identity to a scheduler snapshot before deriving service health. */ +export function deriveWindowsServiceDiagnosticForCurrentUser( + inputs: Omit, + identityDeps: WindowsTaskDiagnosticIdentityDeps = {}, +): ServiceDiagnostic { + return deriveWindowsServiceDiagnostic({ + ...inputs, + schedulerExpectedUserId: resolveWindowsTaskDiagnosticUserId(inputs.schedulerXml, identityDeps), + }); +} + /** * Fail-closed restart diagnostic. Presence alone is never enough: conflicting * managers, stale baked paths, disabled registrations, and unknown/stopped @@ -3325,7 +3871,7 @@ export function diagnoseService(): ServiceDiagnostic { const recordedBackend: ServiceBackend | null = !installState ? null : installState.backend === "native" ? "native" : "scheduler"; - return deriveWindowsServiceDiagnostic({ + return deriveWindowsServiceDiagnosticForCurrentUser({ schedulerXml, schedulerAssetsPresent, nativeStatus, @@ -3483,7 +4029,8 @@ export function probeServiceInstallation( /** * A bare invocation is an idempotent "make the installed service current" * operation. First-time setup still installs, but an existing registration must - * use the repair path so Windows does not re-run the elevated `schtasks /create`. + * use the repair path so Windows avoids unconditional elevated registration; repair may + * still refresh a stale scheduler definition. * Backend flags remain an explicit install request because they select which * registration mechanism to create. */ @@ -3560,12 +4107,15 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs { export async function serviceCommand(...args: (string | undefined)[]): Promise { const filteredArgs = args.filter((a): a is string => Boolean(a)); - const plan = planServiceCommand(filteredArgs); - if (!plan.ok) { - console.error(plan.message); - process.exit(1); - } - const { parsed, command } = plan; + const execute = async (): Promise => { + // Planning reads manager state. Repeat it only after the writer lock is held, otherwise a + // bare command can choose install from a snapshot another service command already changed. + const plan = planServiceCommand(filteredArgs); + if (!plan.ok) { + console.error(plan.message); + process.exit(1); + } + const { parsed, command } = plan; if (command === "repair") { assertServiceEnvironmentMatchesInstall(); assertServiceAuthEnvironment(); @@ -3704,9 +4254,20 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { let rows: Awaited> = []; - try { rows = await listManagementModelRows(config); } catch { rows = []; } + try { rows = await listManagementModelRows(config, { entitlementWaitMs: 0 }); } catch { rows = []; } const byKey = new Map(); for (const row of rows) { if (row.disabled === true) continue; diff --git a/src/update/codex-cli-update-launch-policy.d.mts b/src/update/codex-cli-update-launch-policy.d.mts new file mode 100644 index 0000000000..5e4221031d --- /dev/null +++ b/src/update/codex-cli-update-launch-policy.d.mts @@ -0,0 +1,18 @@ +export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS: readonly [ + "ASDF_DATA_DIR", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + "MISE_DATA_DIR", + "NODENV_ROOT", + "NVS_HOME", + "NVS_NODE_PATH", + "N_PREFIX", + "NVM_DIR", + "NVM_HOME", + "NVM_SYMLINK", + "PROTO_HOME", + "SCOOP", + "SCOOP_GLOBAL", + "VOLTA_HOME", +]; +export function isCodexCliUpdateInspectionArgv(argv: readonly string[]): boolean; diff --git a/src/update/codex-cli-update-launch-policy.mjs b/src/update/codex-cli-update-launch-policy.mjs new file mode 100644 index 0000000000..0d12c40338 --- /dev/null +++ b/src/update/codex-cli-update-launch-policy.mjs @@ -0,0 +1,30 @@ +export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS = Object.freeze([ + "ASDF_DATA_DIR", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + "MISE_DATA_DIR", + "NODENV_ROOT", + "NVS_HOME", + "NVS_NODE_PATH", + "N_PREFIX", + "NVM_DIR", + "NVM_HOME", + "NVM_SYMLINK", + "PROTO_HOME", + "SCOOP", + "SCOOP_GLOBAL", + "VOLTA_HOME", +]); + +/** + * Detect the read-only Codex CLI updater inspection namespace before Bun loads. + * Keep this exact and argument-position based: malformed actions still inherit + * the zero-effect launcher contract and are rejected by the Bun-side parser. + */ +export function isCodexCliUpdateInspectionArgv(argv) { + // Bun consumes every internal launch-proof argument before ordinary command + // parsing. Classify the same effective argv here so a user-supplied invalid + // proof cannot hide this namespace from the pre-Bun zero-effect policy. + const args = argv.slice(2).filter(value => !value.startsWith("--ocx-internal-launch-proof=")); + return args[0] === "system" && args[1] === "codex-cli-update"; +} diff --git a/src/update/index.ts b/src/update/index.ts index 9996d62564..05e2d9aa73 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -347,8 +347,9 @@ export async function runUpdate(): Promise { } } if (!serviceRefreshed || !serviceViable) { - // A repair needs no elevation (it never calls `schtasks /create`), but it can - // still fail — or exit 0 while leaving stale/missing assets that never start + // Repair normally avoids elevation for a healthy scheduler task, but a stale + // definition may require guarded create/elevation. It can also fail — or exit 0 + // while leaving stale/missing assets that never start // the proxy. Fall back to a direct detached proxy start so the update // never leaves the user without a running proxy — but only when the port is free. if (!freed) { diff --git a/src/update/job.ts b/src/update/job.ts index 2f021715e8..33dd907265 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -459,8 +459,8 @@ export function restartCommand( const startArgs = pinPort ? [launcher, "start", "--port", String(Math.trunc(port))] : [launcher, "start"]; - // Default to the non-registering refresh: an update path reaching here has an already - // installed service, and `install` would demand elevation on Windows scheduler backends. + // Default to the in-place refresh: `install` always registers, while repair reuses a healthy + // Windows scheduler definition and re-registers only when the live definition is stale. const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "repair"])] : startArgs; if (installer === "npm") { const bin = nodeBin(); @@ -1116,12 +1116,10 @@ async function restartAfterUpdate( const preServiceAllow = reclaimKillAllowlist(); const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow)); let skipServiceInstall = false; - // This skip existed because the refresh ran `ocx service install`, whose Windows - // scheduler path always reaches `schtasks /create` — elevation the GUI update worker - // (OCX_SERVICE=1) never has. `service repair` rewrites the wrapper assets and - // restarts the EXISTING task with no `/create`, so the reason no longer applies and - // skipping would leave the dashboard-triggered update — the most common Windows - // path — with a stale service it could have refreshed. + // This skip existed because refresh ran `ocx service install`, whose Windows path always + // registers. `service repair` normally reuses the live task and can refresh a stale + // definition through its guarded create/elevation path, so the install-only skip no longer + // applies and would leave the common dashboard update with stale service assets. // // Only a caller that still passes install argv keeps the old behavior. const refreshRegisters = (svcArgs ?? []).includes("install"); @@ -1161,9 +1159,10 @@ async function restartAfterUpdate( const result = run(job, cmd.bin, cmd.args); serviceOk = result.status === 0; if (!serviceOk) { - // The refresh that just failed was `ocx service repair` (serviceReinstallArgs), - // which needs no elevation because it never calls `schtasks /create`. Advising - // `install` here would send the user to re-registration — a UAC prompt on + // The refresh that just failed was `ocx service repair` (serviceReinstallArgs). + // It normally reuses a healthy registration, but a stale definition may have tried + // guarded re-registration/elevation. Advising `install` here would unconditionally + // send the user to re-registration — a UAC prompt on // Windows and a possible WinSW-to-scheduler backend switch — to fix a service // that is already registered. Point at the same command that failed so its // output explains why, on every platform. diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 12995b8c93..f6bdaa1740 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -4,9 +4,9 @@ | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/config.ts` | Persisted `~/.opencodex/config.json` schema, defaults, migrations, transactions, and compatibility re-exports for split config modules. | @@ -80,6 +80,15 @@ tracked sibling before mutation and rolls back earlier siblings in reverse order Failures warn without changing the requested command's exit behavior. The probe uses read-only config diagnostics only for a confirmed candidate and never reads adjacent auth state. +Codex CLI update inspection is split from mutation. `system codex-cli-update check` makes no +package-registry request and reads bounded provenance evidence for the configured launcher candidate, npm ownership layout, +package metadata, and shim binding. The proof-bound launcher snapshot does not attest successful Codex execution; +environment and persisted candidates remain report-only and cannot produce a managed classification in this one-shot command. +On Windows this first slice performs no candidate/configuration filesystem I/O: it preserves only proof-captured +absolute environment candidates for lexical app-bundle/version-manager reporting and otherwise fails closed. +This check does not attest or admit a selected runtime. The command exposes no private mutation authority and does not query +a registry, execute Codex/npm, install, repair, stop, restart, or change configuration/cache state. + The bridge enforces a heartbeat stall deadline. It defaults to 300 seconds sampled on a 2 s tick (`src/stall-timeout.ts`) and is configurable, so treat the number as a default rather than an invariant; sidecars keep their own clocks. On expiry the stream is closed and the upstream request diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 3685fb5bb4..8411884b5c 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -173,6 +173,50 @@ are consumed incrementally and at most 512 stale files are attempted per process - 다른 대안 대신 이 방식을 선택한 이유: It repairs known remnants without broad authority over unrelated temp files or active writers. - 장점, 단점 및 영향: Old dead-PID files are reclaimed automatically; locked or conservatively classified files remain for a later retry. +Windows runtime response spills never wait on `icacls` through `Bun.spawnSync`. Linux and macOS +retain the immediate synchronous publication path. On Windows, the resident continuation enters one +serialized publication queue and remains replayable while `hardenSecretDirAsync` and +`hardenSecretPathAsync` run. Publication installs a spill stub only when the map still contains the +same resident object; a superseded job deletes its newly published file instead of overwriting newer +state. Pending payloads are pinned and capped at 256 MiB, so an ACL outage cannot grow an unbounded +queue or be misreported as evictable memory. One caller-owned retry is allowed after a real +`ETIMEDOUT`; the first timeout does not install a `spill-failed` tombstone. Required ACL failures +remain fail-closed after that bounded recovery. Optional config-directory hardening uses a separate +per-directory async single-flight, while required config mutation writers retain their existing +awaited or synchronous fail-closed boundary. + +Each ordinary async spill write attempt owns one 30-second ACL budget shared across directory, temp, +and exclusive-copy destination hardening; the single timeout retry receives one fresh whole-attempt +budget. No harden step may reopen an independent 30-second window inside either attempt. +Both icacls and effective-principal subprocess waits are settlement-bounded: at deadline the child is +killed, unref'd, and abandoned without awaiting `proc.exited`. The caller-level deadline also bounds +injected/shared runners, so a child that ignores termination cannot pin the serialized spill queue. + +Graceful shutdown drains that serialized publication queue to a stable fixed point before snapshot +serialization. The drain has a wall-clock cap with a reserved synchronous fallback budget; expiry +supersedes the async writer, claims and removes any temp or destination it still owns, and only then +starts fallback publication. The writer rechecks supersession before no-replace publication, while +the fallback splits its reserve across the directory and file ACL hardens. This ordering is +load-bearing because resident entries over 2 MiB are deliberately excluded from +`responses-state.json`: serializing first could omit the resident before its durable spill stub +exists, losing the continuation on restart. Cleanup is attempted for every abandoned writer; any +failure is retained while fallback and snapshot persistence continue, then returned through the +shutdown status so process exit is non-zero without sacrificing unrelated replay state. +If the fallback reserve expires, every remaining resident candidate is terminalized as a bounded +`spill-failed` tombstone before pruning, so no payload remains eligible for shutdown requeue and the +snapshot flush always regains control. +The terminalization pass itself is hard-capped at `MAX_STORED_RESPONSES + 1`; exceeding that +structural bound records a bounded failure, fail-closes every remaining resident, and returns control +to snapshot persistence instead of relying on the progress argument alone. + +[Decision Log] +- 목적과 의도: Keep `/healthz` and unrelated requests responsive during intermittent Windows ACL stalls without publishing an unhardened continuation. +- 기존 구현 및 제약 조건: Response demotion called the synchronous spill writer from request-time state mutations; `Bun.spawnSync(icacls)` could block the only Bun event loop for the full timeout and immediately replace replayable state with a tombstone. +- 검토한 주요 대안: Increase the ACL timeout, weaken required ACL checks, publish before hardening, move every platform to async state mutation, or isolate only the Windows ACL-dependent publication boundary. +- 선택한 방식: Preserve non-Windows behavior; serialize Windows publications through async ACL APIs, retain the exact resident generation until compare-before-swap succeeds, cap pending bytes, and retry one proven timeout. +- 다른 대안 대신 이 방식을 선택한 이유: Longer waits worsen liveness, early publication weakens secret-file ACLs, and a cross-platform async rewrite would disturb mature immediate memory and crash-ordering contracts that do not cause this incident. +- 장점, 단점 및 영향: Windows health stays schedulable and transient ACL stalls retain continuation replay; pending payloads can temporarily exceed the 64 MiB resident target but are pinned under a 256 MiB local ceiling and remain inside the documented 512 MiB process-owned worst case. + ## Config surface ### OpenCodex home and live process state diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 5db21f986e..3260fe9de1 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -101,8 +101,9 @@ liveness contract. ## Entry shape Routed entries keep Codex-required metadata such as reasoning levels, shell type, API support flags, -base instructions, modalities, auto-compact fields, and strict parser booleans. The public slug and -display name use `provider/model`. +base instructions, modalities, auto-compact fields, and strict parser booleans. The public slug uses +the canonical `provider/model`; its display name uses the qualified provider/model alias when +configured, without changing the routing slug. ## Native passthrough diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index a09d9daccf..546091e4fa 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -15,7 +15,7 @@ existing task. Explicit `ocx service install` remains the operator-owned registr - 검토한 주요 대안: Always repair; keep a boolean installed check; infer presence from saved state alone; use a tri-state live registration probe. - 선택한 방식: Validate arguments first, then use a narrow tri-state platform probe only for a bare backend-neutral invocation; route installed to repair, absent to install, and unknown to a refusal. - 다른 대안 대신 이 방식을 선택한 이유: Saved state can be stale and unconditional repair breaks first install, while a boolean cannot represent the exact uncertainty that must fail closed. -- 장점, 단점 및 영향: Existing services avoid UAC and registration churn, invalid input performs no status I/O, and uncertain Windows hosts require one explicit status/installation decision instead of risking a destructive guess. +- 장점, 단점 및 영향: Healthy existing services avoid UAC and registration churn; stale Windows scheduler definitions may be refreshed and require elevation. Invalid input performs no status I/O, and uncertain Windows hosts require one explicit status/installation decision instead of risking a destructive guess. ## Windows startup ownership listing reuse diff --git a/tests/aside-client.test.ts b/tests/aside-client.test.ts new file mode 100644 index 0000000000..b1433d2049 --- /dev/null +++ b/tests/aside-client.test.ts @@ -0,0 +1,330 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClientPathError, + EXPORT_CLIENTS, + LOOPBACK_API_KEY_PLACEHOLDER, + OPENCODE_PROVIDER_ID, + asideAccountDir, + asideConfigPath, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + type ExportContext, + type PiGeneratedConfig, +} from "../src/clients/config-export"; +import { INTEGRATION_CLIENTS, resolveIntegrationPaths, unresolvedPathHintFor } from "../src/integrations/registry"; +import { readIntegrationState } from "../src/integrations/state"; +import { createIntegrationStateStore } from "../src/integrations/store"; +import { defaultIntegrationIO } from "../src/integrations/config-io"; +import { applyIntegration } from "../src/integrations/writer"; +import type { OcxConfig } from "../src/types"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function context(): ExportContext { + return { + baseUrl: "http://127.0.0.1:10100/v1", + config: CONFIG, + models: [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000, reasoningEfforts: ["low", "medium", "high"] }, + { namespaced: "mystery/model", provider: "mystery", id: "model" }, + ], + }; +} + +let home: string; + +function writeManifest(body: string, accountRoot = true): void { + const root = join(home, ".aside"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "accounts.json"), body); + if (accountRoot) mkdirSync(join(root, "u", "0"), { recursive: true }); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-aside-")); +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); +}); + +describe("Aside client config", () => { + /* + * The shape below is not invented: it was read off a real + * ~/.aside/u/0/models.json that a user had wired to opencodex BY HAND before + * this client existed. Four provider keys, the openai-completions dialect, + * the loopback placeholder, and per-model input/contextWindow/maxTokens with + * a pi-style thinkingLevelMap. + * + * Deliberately NOT asserted as equality against the pi document: Aside reuses + * buildPiClientConfig, so such a test would compare a function to itself and + * prove nothing. Key ORDER also differs from the hand-written file, which is + * fine because Aside parses this file rather than diffing it. What must hold + * is the key SET and the field vocabulary. + */ + test("matches the provider shape observed in a real Aside catalog", () => { + const document = buildClientConfig("aside", context()) as PiGeneratedConfig; + expect(Object.keys(document)).toEqual(["providers"]); + expect(Object.keys(document.providers)).toEqual([OPENCODE_PROVIDER_ID]); + + const provider = document.providers[OPENCODE_PROVIDER_ID]!; + expect(new Set(Object.keys(provider))).toEqual(new Set(["baseUrl", "apiKey", "api", "models"])); + expect(provider.baseUrl).toBe("http://127.0.0.1:10100/v1"); + expect(provider.api).toBe("openai-completions"); + expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER); + + const reasoning = provider.models.find(model => model.id === "openai/gpt-5.6-sol")!; + expect(reasoning.reasoning).toBe(true); + expect(Object.keys(reasoning.thinkingLevelMap!)).toEqual(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + expect(reasoning.thinkingLevelMap!.high).toBe("high"); + // A level the ladder does not declare stays hidden rather than being offered. + expect(reasoning.thinkingLevelMap!.max).toBeNull(); + expect(reasoning.contextWindow).toBe(922_000); + + // No authoritative window means no context-derived fields at all. + const unknown = provider.models.find(model => model.id === "mystery/model")!; + expect(unknown.contextWindow).toBeUndefined(); + expect(unknown.maxTokens).toBeUndefined(); + }); + + test("native JSON round-trips and never carries a credential", () => { + const sentinel = ["sk", "live", "aside", "sentinel"].join("-"); + const withKey = { ...CONFIG, apiKeys: [{ key: sentinel }] } as OcxConfig; + const built = buildClientConfigText("aside", { ...context(), config: withKey }); + expect(built.format).toBe("json"); + expect(JSON.parse(built.text)).toEqual(built.document as never); + expect(built.text).not.toContain(sentinel); + expect(built.text).toContain(LOOPBACK_API_KEY_PLACEHOLDER); + }); + + test("the contribution owns providers.opencodex under Aside's own id", () => { + const contribution = buildClientContribution("aside", context()); + // Reusing Pi's builder must not leak Pi's id into the ownership record, or a + // disable would attribute one client's block to another. + expect(contribution.clientId).toBe("aside"); + expect(contribution.fragments.map(fragment => fragment.path)).toEqual([["providers", OPENCODE_PROVIDER_ID]]); + }); + + test("resolves the catalog of whichever account the manifest calls current", () => { + writeManifest(JSON.stringify({ currentAccountId: 0 })); + expect(asideConfigPath({}, home)).toBe(join(home, ".aside", "u", "0", "models.json")); + + const other = mkdtempSync(join(tmpdir(), "ocx-aside-alt-")); + mkdirSync(join(other, ".aside"), { recursive: true }); + writeFileSync(join(other, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 1 })); + expect(asideConfigPath({}, other)).toBe(join(other, ".aside", "u", "1", "models.json")); + rmSync(other, { recursive: true, force: true }); + }); + + /* + * The whole reason this resolver throws. A machine can hold several accounts, + * so defaulting to 0 when the manifest cannot be read would name a real file + * belonging to a DIFFERENT account, pass the installed-directory check, and + * write into somebody else's catalog. + */ + test("refuses to guess an account when the manifest cannot answer", () => { + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest("{ not json"); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ currentAccountId: "0" })); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ currentAccountId: -1 })); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ accounts: [{ id: 0 }] })); + expect(() => asideConfigPath({}, home)).toThrow(ClientPathError); + }); + + /* + * An operation needs BOTH paths, and both come from the account id in the + * manifest. Resolving them independently means a switch landing between the + * two calls lets an operation verify one account's install and then write a + * different account's catalog. + * + * Caching cannot fix that: any cache keyed on the manifest re-reads exactly + * when the manifest changes, which is the case that needs the consistency. So + * the pair is resolved once, and this asserts the derived paths agree. + */ + test("both paths come from one account read, so they cannot disagree", () => { + writeManifest(JSON.stringify({ currentAccountId: 0 })); + const paths = resolveIntegrationPaths("aside", {}, home); + expect(paths.detectDir).toBe(join(home, ".aside", "u", "0")); + expect(paths.configPath).toBe(join(paths.detectDir, "models.json")); + + // A real switch is observed on the next resolution, as a whole. + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 1 })); + const after = resolveIntegrationPaths("aside", {}, home); + expect(after.detectDir).toBe(join(home, ".aside", "u", "1")); + expect(after.configPath).toBe(join(after.detectDir, "models.json")); + }); + + test("clients whose paths are pure still resolve through the same seam", () => { + const paths = resolveIntegrationPaths("prime", {}, "/home/u"); + expect(paths.configPath).toBe(join("/home/u", ".prime", "agent", "models.json")); + expect(paths.detectDir).toBe(join("/home/u", ".prime", "agent")); + }); + + /* + * The direct writer path, which is the one that stayed broken after the seam + * landed. applyIntegration is public and callers may omit resolvedPaths, so + * preflight used to resolve configPath while the installation check resolved + * detectDir separately. An account switch landing between the two produced a + * successful apply that verified one account and wrote the other's catalog. + * + * The IO seam is where the switch is injected, because that is the moment + * between the two resolutions in the original ordering. + */ + test("a direct apply checks the install of the very account it writes", () => { + writeManifest(JSON.stringify({ currentAccountId: 0 })); + const manifest = join(home, ".aside", "accounts.json"); + writeFileSync(join(home, ".aside", "u", "0", "models.json"), "{}\n"); + mkdirSync(join(home, ".aside", "u", "1"), { recursive: true }); + + const store = createIntegrationStateStore(mkdtempSync(join(tmpdir(), "ocx-aside-store-"))); + const io = defaultIntegrationIO(store); + const statted: string[] = []; + const switching = { + ...io, + statKind: (path: string) => { + statted.push(path); + // Aside switches accounts exactly where the second resolution used to be. + writeFileSync(manifest, JSON.stringify({ currentAccountId: 1 })); + return io.statKind(path); + }, + }; + + const applied = applyIntegration({ + clientId: "aside", models: [], config: CONFIG, port: 10100, + env: {}, home, store, io: switching, + }); + expect(applied.ok).toBe(true); + + /* + * The property that was violated: the account directory whose existence + * authorized the write must be the account the write landed in. With the two + * paths resolved separately, the install check statted u/1 while the catalog + * was written to u/0 -- an apply authorized by an account it never touched. + */ + const accountDirs = statted.filter(path => /[\\/]u[\\/]\d+$/.test(path)); + expect(accountDirs.length).toBeGreaterThan(0); + const authorized = new Set(accountDirs); + + const owning = ([0, 1] as const).filter(account => { + const catalog = join(home, ".aside", "u", String(account), "models.json"); + if (!existsSync(catalog)) return false; + const parsed = JSON.parse(readFileSync(catalog, "utf8")) as { providers?: Record }; + return parsed.providers?.opencodex !== undefined; + }); + expect(owning).toHaveLength(1); + expect(authorized.has(join(home, ".aside", "u", String(owning[0])))).toBe(true); + }); + + test("detects installation by the account directory, not the CLI directory", () => { + // The CLI writes ~/.aside/cli for its own update check before any account + // exists, so the outer directory is not an install signal. + mkdirSync(join(home, ".aside", "cli"), { recursive: true }); + expect(() => INTEGRATION_CLIENTS.aside.detectDir({}, home)).toThrow(ClientPathError); + + writeManifest(JSON.stringify({ currentAccountId: 0 })); + expect(INTEGRATION_CLIENTS.aside.detectDir({}, home)).toBe(join(home, ".aside", "u", "0")); + }); + + test("ships as a loopback-only integration with no env var to export", () => { + const spec = EXPORT_CLIENTS.aside; + // The observed provider block has four keys and none is `headers`, so the + // dedicated admission header has nowhere to live on a remote bind. + expect(spec.loopbackOnly).toBe(true); + expect(spec.apiKeyEnv).toBe(""); + // Not a bare models.json: pi's and prime's downloads would collide with it. + expect(spec.filename).toBe("aside-models.json"); + }); + + /* + * An unsigned-in Aside is not a broken Aside. + * + * Mutation must still refuse: there is no account to write. But the read-only + * state surface used to answer that refusal with `state: "unsafe"` and + * `configPath: ""`, which paints the red Cannot-verify badge and names no + * file. Absent `accounts.json` is the ordinary state of an Aside installed and + * never launched, so the read reports not-installed and names where the + * catalog would go. + */ + test("a never-signed-in Aside reads as not installed, not as unverifiable", () => { + mkdirSync(join(home, ".aside", "cli"), { recursive: true }); + + // Mutation still refuses, because there is no account directory to write. + expect(() => resolveIntegrationPaths("aside", {}, home)).toThrow(ClientPathError); + + // The read-only surface names the account root instead of nothing. + const hint = unresolvedPathHintFor("aside", {}, home); + expect(hint).toBe(join(home, ".aside", "u")); + // Deliberately NOT a writable catalog path: no account, no models.json. + expect(hint.endsWith("models.json")).toBe(false); + + const status = readIntegrationState({ + clientId: "aside", + models: context().models, + config: CONFIG, + port: 10100, + env: {}, + home, + }); + expect(status.state).toBe("absent"); + expect(status.installed).toBe(false); + expect(status.configPath).toBe(hint); + expect(status.reason).toBe("unresolvable-path"); + }); + + test("a client with no hint still reports unverifiable, because there is nothing to name", () => { + // OpenClaw's relative-selector refusal is a misconfiguration, not a + // not-yet-signed-in state, so the danger badge stays correct for it. + expect(unresolvedPathHintFor("openclaw", {}, home)).toBe(""); + const status = readIntegrationState({ + clientId: "openclaw", + models: context().models, + config: CONFIG, + port: 10100, + env: { OPENCLAW_CONFIG_PATH: "relative/config.json" }, + home, + }); + expect(status.state).toBe("unsafe"); + expect(status.configPath).toBe(""); + expect(status.reason).toBe("unresolvable-path"); + }); + + /* + * The hint lookup absorbs a path REFUSAL and nothing else. + * + * An unqualified catch there would read the same for a resolver that threw a + * TypeError from a typo or an EACCES from a filesystem probe: the badge would + * quietly say not-installed while the real cause went unreported. This drives + * a non-ClientPathError through the same seam and requires it to escape. + */ + test("a hint resolver that throws a programming error is not silently degraded", () => { + const spec = INTEGRATION_CLIENTS.aside as { unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string }; + const original = spec.unresolvedPathHint; + try { + spec.unresolvedPathHint = () => { throw new TypeError("join received undefined"); }; + expect(() => unresolvedPathHintFor("aside", {}, home)).toThrow(TypeError); + + // A path refusal is still absorbed, which is the whole point of the seam. + spec.unresolvedPathHint = () => { throw new ClientPathError("no account yet"); }; + expect(unresolvedPathHintFor("aside", {}, home)).toBe(""); + } finally { + spec.unresolvedPathHint = original; + } + }); +}); diff --git a/tests/claude-dotenv-provenance-transport.test.ts b/tests/claude-dotenv-provenance-transport.test.ts index d048f75598..411c949a38 100644 --- a/tests/claude-dotenv-provenance-transport.test.ts +++ b/tests/claude-dotenv-provenance-transport.test.ts @@ -46,7 +46,16 @@ describe("Node launcher context transport", () => { if (result.error) throw result.error; expect(result.status).toBe(0); return JSON.parse(result.stdout) as { - context: { anthropicEnvSlots: string[] } | null; + context: { + anthropicEnvSlots: string[]; + codexCliInspectionEnv: { + codexCliPath: string | null; + path: string | null; + pathExt: string | null; + managerRoots: Record | null; + configDir: string; + } | null; + } | null; args: string[]; contextEnv: string | null; }; @@ -66,6 +75,44 @@ describe("Node launcher context transport", () => { expect(seen.contextEnv).toBeNull(); }); + test("a proof-bound long parent PATH remains trusted for updater inspection", () => { + const longPath = Array.from({ length: 300 }, (_, index) => `C:\\Tools\\${index}`).join(";"); + const payload = JSON.stringify({ + version: 1, + proof, + anthropicEnvSlots: [], + codexCliInspectionEnv: { + codexCliPath: "C:\\npm\\codex.cmd", + path: longPath, + pathExt: ".EXE;.CMD", + managerRoots: { FNM_DIR: "C:\\Tools\\fnm-data" }, + configDir: "C:\\Users\\person\\.opencodex", + }, + }); + expect(payload.length).toBeGreaterThan(2048); + const seen = run([`--ocx-internal-launch-proof=${proof}`, "system"], payload); + expect(seen.context?.codexCliInspectionEnv?.path).toBe(longPath); + expect(seen.context?.codexCliInspectionEnv?.managerRoots).toEqual({ FNM_DIR: "C:\\Tools\\fnm-data" }); + expect(seen.context?.codexCliInspectionEnv?.configDir).toBe("C:\\Users\\person\\.opencodex"); + }); + + test("an unknown manager-root key invalidates the trusted context", () => { + const payload = JSON.stringify({ + version: 1, + proof, + anthropicEnvSlots: [], + codexCliInspectionEnv: { + codexCliPath: "C:\\npm\\codex.cmd", + path: "C:\\npm", + pathExt: ".CMD", + managerRoots: { UNBOUNDED_ROOT: "C:\\" }, + configDir: "C:\\Users\\person\\.opencodex", + }, + }); + const seen = run([`--ocx-internal-launch-proof=${proof}`, "system"], payload); + expect(seen.context).toBeNull(); + }); + test("duplicate internal proofs fail closed and are removed from user argv", () => { const seen = run([ `--ocx-internal-launch-proof=${proof}`, diff --git a/tests/cli-capabilities.test.ts b/tests/cli-capabilities.test.ts index dac18e19ba..17e4beecf0 100644 --- a/tests/cli-capabilities.test.ts +++ b/tests/cli-capabilities.test.ts @@ -73,6 +73,18 @@ describe("capability table is a leaf data module", () => { expect(findCommand("capabilities")?.name).toBe("capabilities"); expect(CAPABILITIES.some(c => c.command[0] === "capabilities")).toBe(true); }); + + test("the check-only Codex CLI updater is declared as a local read capability", () => { + const cap = CAPABILITIES.find(c => c.command.join(" ") === "system codex-cli-update check"); + expect(cap).toBeDefined(); + expect(cap?.routes).toEqual([]); + expect(cap?.mutates).toBe(false); + expect(cap?.json).toBe("envelope"); + expect(cap?.flags.some(flag => flag.name === "--json")).toBe(true); + expect(cap?.summary).toContain("configured Codex CLI candidate"); + expect(cap?.details.join(" ")).toContain("does not attest or admit a selected runtime"); + expect(cap?.details.join(" ")).not.toContain("dry-run"); + }); }); describe("ocx capabilities output", () => { diff --git a/tests/cli-codex-cli-update.test.ts b/tests/cli-codex-cli-update.test.ts new file mode 100644 index 0000000000..e0231e40b6 --- /dev/null +++ b/tests/cli-codex-cli-update.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; +import { handleCodexCliUpdateCommand, parseCodexCliUpdateArgs } from "../src/cli/codex-cli-update"; +import { + initializeNodeLauncherContext, + NODE_LAUNCH_CONTEXT_ENV, + NODE_LAUNCH_PROOF_PREFIX, +} from "../src/cli/launcher-context"; +import type { CodexCliInstallProvenanceDeps, CodexCliInstallReport } from "../src/codex/cli-install-provenance"; + +const report: CodexCliInstallReport = { + schemaVersion: 1, + candidateAvailable: false, + candidateVersion: null, + candidateSource: null, + selectionAttested: false, + versionEvidence: { kind: "unavailable" }, + provenance: "unknown", managed: false, reason: "candidate_unavailable", location: null, + packageVersion: null, + shim: { status: "not-tracked", backingKind: null }, evidence: [], +}; + +describe("Codex CLI update CLI", () => { + test("parses the shared JSON flag spellings within the exact check grammar", () => { + expect(parseCodexCliUpdateArgs(["check"])).toEqual({ json: false }); + for (const flag of ["--json", "--json=true", "-json", "—json"]) { + expect(parseCodexCliUpdateArgs(["check", flag])).toEqual({ json: true }); + } + for (const args of [ + ["check", "--channel", "latest"], + ["dry-run"], + ["apply"], + ["check", "--json", "--json"], + ["check", "-json", "--json=true"], + ]) expect(() => parseCodexCliUpdateArgs(args)).toThrow(); + }); + + /** + * `--json` is accepted in any argv position CLI-wide, so automation that puts output + * flags ahead of the subcommand must not get a usage error. + */ + test("the JSON flag is accepted before the check action", () => { + for (const flag of ["--json", "--json=true", "-json", "—json"]) { + expect(parseCodexCliUpdateArgs([flag, "check"])).toEqual({ json: true }); + } + // Duplicate detection and positional validation still hold in that order. + expect(() => parseCodexCliUpdateArgs(["--json", "check", "--json"])).toThrow(); + expect(() => parseCodexCliUpdateArgs(["--json"])).toThrow(); + expect(() => parseCodexCliUpdateArgs(["--json", "apply"])).toThrow(); + expect(() => parseCodexCliUpdateArgs(["--json", "check", "extra"])).toThrow(); + }); + + test("malformed input performs no inspection", async () => { + let inspectedCalls = 0; + const code = await handleCodexCliUpdateCommand(["apply"], { + inspectInstall: async () => { inspectedCalls += 1; return report; }, + }); + expect(code).toBe(2); + expect(inspectedCalls).toBe(0); + }); + + test("check inspects exactly once", async () => { + let inspectedCalls = 0; + expect(await handleCodexCliUpdateCommand(["check", "--json"], { + inspectInstall: async () => { inspectedCalls += 1; return report; }, + })).toBe(0); + expect(inspectedCalls).toBe(1); + }); + + test("passes only proof-bound manager roots into production provenance inspection", async () => { + const proof = "M".repeat(43); + const env: NodeJS.ProcessEnv = { + [NODE_LAUNCH_CONTEXT_ENV]: JSON.stringify({ + version: 1, + proof, + anthropicEnvSlots: [], + codexCliInspectionEnv: { + codexCliPath: "C:\\managed\\codex.cmd", + path: "C:\\managed", + pathExt: ".CMD", + managerRoots: { FNM_DIR: "C:\\custom-manager" }, + configDir: "C:\\opencodex", + }, + }), + }; + initializeNodeLauncherContext(["bun", "cli", `${NODE_LAUNCH_PROOF_PREFIX}${proof}`], env); + let received: CodexCliInstallProvenanceDeps | null = null; + try { + expect(await handleCodexCliUpdateCommand(["check", "--json"], { + inspectInstall: async deps => { + received = deps; + return report; + }, + })).toBe(0); + expect(received?.env).toEqual({ + FNM_DIR: "C:\\custom-manager", + CODEX_CLI_PATH: "C:\\managed\\codex.cmd", + PATH: "C:\\managed", + PATHEXT: ".CMD", + }); + expect(received?.configDir).toBe("C:\\opencodex"); + } finally { + initializeNodeLauncherContext(["bun", "cli"], {}); + } + }); + + test("a launch without proof passes only sealed inspection dependencies", async () => { + initializeNodeLauncherContext(["bun", "cli"], {}); + let received: CodexCliInstallProvenanceDeps | null = null; + try { + expect(await handleCodexCliUpdateCommand(["check", "--json"], { + inspectInstall: async deps => { + received = deps; + return report; + }, + })).toBe(0); + expect(received?.env).toEqual({ PATH: "" }); + expect(received?.configDir).toBe("."); + } finally { + initializeNodeLauncherContext(["bun", "cli"], {}); + } + }); + + test("JSON output serializes only the public report once", async () => { + const logs: string[] = []; + const oldLog = console.log; + try { + console.log = (...values: unknown[]) => logs.push(values.map(String).join(" ")); + const code = await handleCodexCliUpdateCommand(["check", "--json"], { + inspectInstall: async () => report, + }); + expect(code).toBe(0); + expect(logs).toHaveLength(1); + const output = JSON.parse(logs[0]!) as Record; + expect(output).toEqual(report); + expect(output).toMatchObject({ + candidateAvailable: false, + candidateVersion: null, + candidateSource: null, + selectionAttested: false, + }); + for (const stale of ["selected", "selectedVersion", "selectionSource", "selectionEvidence"]) { + expect(stale in output).toBe(false); + } + expect(logs[0]).not.toContain("authority"); + } finally { + console.log = oldLog; + } + }); + + test("human output uses command-specific scalar lines", async () => { + const logs: string[] = []; + const oldLog = console.log; + try { + console.log = (...values: unknown[]) => logs.push(values.map(String).join(" ")); + expect(await handleCodexCliUpdateCommand(["check"], { + inspectInstall: async () => report, + })).toBe(0); + expect(logs.join("\n")).not.toContain("[object Object]"); + expect(logs).toContain("candidate: no"); + expect(logs).toContain("candidate-source: unavailable"); + expect(logs).toContain("selection-attested: no"); + expect(logs).toContain("candidate-version: unavailable"); + expect(logs).toContain("package-version: unavailable"); + expect(logs).toContain("version-evidence: unavailable"); + expect(logs).toContain("location: unavailable"); + expect(logs).toContain("shim: not-tracked"); + } finally { + console.log = oldLog; + } + }); + + test("human output keeps mismatched candidate and package versions distinct", async () => { + const logs: string[] = []; + const oldLog = console.log; + const mismatchReport: CodexCliInstallReport = { + ...report, + candidateAvailable: true, + candidateVersion: "1.2.3", + candidateSource: "persisted", + versionEvidence: { kind: "advisory-runtime" }, + provenance: "npm-global", + reason: "version_mismatch", + location: "/codex", + packageVersion: "1.2.4", + }; + try { + console.log = (...values: unknown[]) => logs.push(values.map(String).join(" ")); + expect(await handleCodexCliUpdateCommand(["check"], { + inspectInstall: async () => mismatchReport, + })).toBe(0); + expect(logs).toContain("candidate-source: persisted"); + expect(logs).toContain("candidate-version: 1.2.3"); + expect(logs).toContain("package-version: 1.2.4"); + expect(logs).toContain("version-evidence: advisory-runtime"); + expect(logs).toContain("location: /codex"); + expect(logs.some(line => line.startsWith("version: "))).toBe(false); + } finally { + console.log = oldLog; + } + }); +}); diff --git a/tests/cli-export-command.test.ts b/tests/cli-export-command.test.ts index c81fb21828..0498a4df40 100644 --- a/tests/cli-export-command.test.ts +++ b/tests/cli-export-command.test.ts @@ -8,11 +8,13 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { handleExportCommand, exportModelsFromProxyRows } from "../src/cli/export-command"; +import { resetCodexModelEntitlementCacheForTests } from "../src/codex/model-entitlements"; +import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); @@ -61,6 +63,19 @@ function fakeProxy(rows: unknown = ROWS) { return { port: server.port, baseUrl: `http://127.0.0.1:${server.port}` }; } +function managementProxy(managementConfig: OcxConfig) { + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + return await handleManagementAPI(req, url, managementConfig) + ?? new Response("not found", { status: 404 }); + }, + }); + servers.push(server); + return { port: server.port, baseUrl: `http://127.0.0.1:${server.port}` }; +} + function tempDir(): string { const dir = mkdtempSync(join(tmpdir(), "ocx-export-")); tempDirs.push(dir); @@ -86,6 +101,7 @@ afterEach(() => { console.error = originalError; for (const server of servers.splice(0)) server.stop(true); for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + resetCodexModelEntitlementCacheForTests(); }); /** console.log adds exactly one newline per call; this is the byte stream a shell sees. */ @@ -102,6 +118,68 @@ async function run(args: string[], extra: { baseUrl: string; config?: OcxConfig } describe("ocx export --json (accept criterion 1)", () => { + test("the real /api/models handler refreshes expired GPT-5.6 entitlements before export", async () => { + const oldOcxHome = process.env.OPENCODEX_HOME; + const oldCodexHome = process.env.CODEX_HOME; + const originalFetch = globalThis.fetch; + const root = tempDir(); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + process.env.OPENCODEX_HOME = join(root, "opencodex"); + process.env.CODEX_HOME = codexHome; + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "export-token", account_id: "export-main" }, + })); + let entitlementFetches = 0; + globalThis.fetch = (async input => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "chatgpt.com" && url.pathname === "/backend-api/codex/models") { + entitlementFetches += 1; + return Response.json({ models: [ + { slug: "gpt-5.6-sol", supported_in_api: true, visibility: "list" }, + { slug: "gpt-5.6-terra", supported_in_api: true, visibility: "list" }, + { slug: "gpt-5.6-luna", supported_in_api: true, visibility: "list" }, + ] }); + } + return originalFetch(input); + }) as typeof fetch; + try { + const managementConfig = config({ + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + liveModels: false, + models: [], + }, + }, + }); + const proxy = managementProxy(managementConfig); + const result = await run(["--client", "opencode", "--json"], { + baseUrl: proxy.baseUrl, + config: managementConfig, + }); + expect(result.code).toBe(0); + const parsed = JSON.parse(result.stdout) as { + provider: Record }>; + }; + expect(entitlementFetches).toBe(1); + expect(Object.keys(parsed.provider.opencodex!.models)).toEqual(expect.arrayContaining([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + ])); + } finally { + globalThis.fetch = originalFetch; + if (oldOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldOcxHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; + } + }); + test("stdout parses as JSON with zero extra bytes, for both clients", async () => { const proxy = fakeProxy(); for (const client of ["opencode", "pi"] as const) { @@ -142,7 +220,7 @@ describe("ocx export human output (accept criterion 2)", () => { expect(result.code).toBe(0); expect(result.stdout.startsWith("{\n")).toBe(true); expect(result.stdout).toContain(join("opencode", "opencode.json")); - expect(result.stdout).toContain("Merge this provider block into that file; do not replace it."); + expect(result.stdout).toContain("Merge this generated configuration into that file; do not replace it."); expect(result.stdout).toContain("export OPENCODEX_OPENCODE_API_KEY="); // Three visible models; only `custom/no-context` lacks an authoritative window. expect(result.stdout).toContain("3 models; 1 omit context limits"); diff --git a/tests/cli-registry.test.ts b/tests/cli-registry.test.ts index 5c91b97aaf..4accf73048 100644 --- a/tests/cli-registry.test.ts +++ b/tests/cli-registry.test.ts @@ -98,6 +98,12 @@ describe("CLI command registry parity", () => { const names = CLI_COMMANDS.map(entry => entry.name); expect(new Set(names).size).toBe(names.length); }); + + test("system help exposes the exact Codex CLI inspection grammar", () => { + const details = findCommand("system")?.details ?? []; + expect(details).toContain("ocx system codex-cli-update check [--json]"); + expect(details.some(line => line.includes("dry-run"))).toBe(false); + }); }); describe("help banner command coverage", () => { diff --git a/tests/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts index 496804c0cf..4444547aa7 100644 --- a/tests/client-config-export-new-clients.test.ts +++ b/tests/client-config-export-new-clients.test.ts @@ -58,11 +58,12 @@ function ctx(config: OcxConfig = LOOPBACK): ExportContext { describe("no secret reaches a client config", () => { test("the generated client support policy identifies every loopback-only integration", () => { - // Pi, Kimi and Gajae cannot emit the dedicated admission header. OMP and - // Prime can carry provider headers, but remote credential wiring is + // Pi, Kimi, Gajae and Aside cannot emit the dedicated admission header -- + // Aside's observed provider block has four keys and none is `headers`. OMP + // and Prime can carry provider headers, but remote credential wiring is // deliberately deferred from those initial generated integrations. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { @@ -267,11 +268,21 @@ describe("gajae", () => { describe("contributions name every fragment we own", () => { test("single-entry clients own exactly one path", () => { - for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "gajae", "dsh", "mcode", "zcode"] as const) { + for (const id of ["pi", "omp", "hermes", "openclaw", "gajae", "dsh", "mcode", "zcode"] as const) { expect(buildClientContribution(id, ctx()).fragments).toHaveLength(1); } }); + test("opencode owns both provider generations, legacy block first", () => { + // opencode V2 reads `providers` and V1 reads `provider`; only the V2 block's variants + // are applied, so both have to be written and both have to be ours to keep in sync. + // Which generation wins the merge is opencode's call — this pins the paths we own. + expect(buildClientContribution("opencode", ctx()).fragments.map(f => f.path)).toEqual([ + ["provider", OPENCODE_PROVIDER_ID], + ["providers", OPENCODE_PROVIDER_ID], + ]); + }); + test("kimi owns its provider block AND one entry per emitted model", () => { const contribution = buildClientContribution("kimi", ctx()); expect(contribution.fragments.map(f => f.path)).toEqual([ diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index c32d0fdfe9..09c46e1472 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -156,6 +156,91 @@ describe("relocated OpenCode serializer (accept criterion 1)", () => { }); }); +/** + * Reasoning efforts reach opencode as model variants, and only through the V2 `providers` + * block: a `variants` array under the legacy `provider` block is parsed and then ignored + * (verified against opencode 0.0.0-beta-18684), which is why both blocks are emitted. + */ +describe("OpenCode V2 block (reasoning-effort variants)", () => { + const LADDER_ROWS: ExportModel[] = [ + // Deliberately out of canonical order, with a duplicate and an unknown value. + { namespaced: "opencode-go/glm-5.3", provider: "opencode-go", id: "glm-5.3", reasoningEfforts: ["max", "low", "high", "low", "turbo"], contextWindow: 1_000_000 }, + // `none` is a declared sentinel, but the chat ingress has no such wire effort, so it is + // dropped: offering it would be a selection that silently falls back to the proxy default. + // `minimal` is a real wire effort and stays. + { namespaced: "opencode-go/deepseek-v4-flash", provider: "opencode-go", id: "deepseek-v4-flash", reasoningEfforts: ["high", "minimal", "none"], contextWindow: 1_000_000 }, + { namespaced: "opencode-go/no-ladder", provider: "opencode-go", id: "no-ladder", contextWindow: 1_000_000 }, + { namespaced: "opencode-go/empty-ladder", provider: "opencode-go", id: "empty-ladder", reasoningEfforts: [], contextWindow: 1_000_000 }, + // A ladder made only of the dropped sentinel leaves nothing selectable. + { namespaced: "opencode-go/none-only", provider: "opencode-go", id: "none-only", reasoningEfforts: ["none"], contextWindow: 1_000_000 }, + ]; + + function ladderCtx(config: OcxConfig = cfg()): ExportContext { + return { baseUrl: BASE_URL, models: LADDER_ROWS, config }; + } + + test("one variant per declared effort, in canonical ladder order", () => { + const models = (buildClientConfig("opencode", ladderCtx()) as OpencodeGeneratedConfig) + .providers.opencodex!.models; + expect(models["opencode-go/glm-5.3"]!.variants).toEqual([ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + { id: "max", settings: { reasoningEffort: "max" } }, + ]); + expect(models["opencode-go/deepseek-v4-flash"]!.variants).toEqual([ + { id: "minimal", settings: { reasoningEffort: "minimal" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + ]); + }); + + test("`none` is never offered: it has no wire effort and would silently no-op", () => { + const models = (buildClientConfig("opencode", ladderCtx()) as OpencodeGeneratedConfig) + .providers.opencodex!.models; + const ids = models["opencode-go/deepseek-v4-flash"]!.variants!.map(variant => variant.id); + expect(ids).not.toContain("none"); + // A ladder consisting only of `none` leaves nothing selectable at all. + expect(models["opencode-go/none-only"]!.variants).toBeUndefined(); + }); + + test("a model without a usable ladder carries no variants key at all", () => { + const models = (buildClientConfig("opencode", ladderCtx()) as OpencodeGeneratedConfig) + .providers.opencodex!.models; + expect(models["opencode-go/no-ladder"]!.variants).toBeUndefined(); + expect(models["opencode-go/empty-ladder"]!.variants).toBeUndefined(); + }); + + test("the legacy block stays variant-free instead of carrying fields opencode ignores", () => { + const config = buildClientConfig("opencode", ladderCtx()) as OpencodeGeneratedConfig; + for (const entry of Object.values(config.provider.opencodex!.models)) { + expect(entry).not.toHaveProperty("variants"); + expect(entry).not.toHaveProperty("settings"); + } + }); + + test("both blocks describe the same model set and the same connection", () => { + const config = buildClientConfig("opencode", ladderCtx()) as OpencodeGeneratedConfig; + const v1 = config.provider.opencodex!; + const v2 = config.providers.opencodex!; + expect(Object.keys(v2.models)).toEqual(Object.keys(v1.models)); + expect(v2.settings).toEqual(v1.options); + // opencode V2 merges both blocks by provider and model id, so the same ids must not + // produce duplicate picker entries. + expect(v2.package).toBe("@opencode-ai/ai/providers/openai-compatible"); + for (const [key, entry] of Object.entries(v2.models)) { + expect(entry.name).toBe(v1.models[key]!.name); + expect(entry.limit).toEqual(v1.models[key]!.limit); + } + }); + + test("a non-loopback bind moves admission to the header branch in both blocks", () => { + const config = buildClientConfig("opencode", ladderCtx(cfg({ hostname: "0.0.0.0" }))) as OpencodeGeneratedConfig; + for (const block of [config.provider.opencodex!.options, config.providers.opencodex!.settings]) { + expect(block.headers).toEqual({ "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }); + expect(block.apiKey).toBeUndefined(); + } + }); +}); + describe("Pi serializer (accept criterion 2)", () => { test("models is an array keyed by id, not a keyed object", () => { const provider = piConfig().providers.opencodex!; @@ -514,8 +599,8 @@ describe("stable ordering (accept criterion 4)", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the eleven file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); + test("covers exactly the twelve file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); @@ -527,7 +612,7 @@ describe("EXPORT_CLIENTS registry", () => { * a single byte for the two that already shipped — indentation and the one * trailing newline included — and only a fixed expected string proves that. */ - test("opencode bytes are unchanged, to the last newline", () => { + test("opencode bytes carry both provider generations, to the last newline", () => { const built = buildClientConfigText("opencode", ctx({ config: cfg() })); expect(built.format).toBe("json"); expect(built.text).toBe(`{ @@ -567,6 +652,42 @@ describe("EXPORT_CLIENTS registry", () => { } } } + }, + "providers": { + "opencodex": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "name": "OpenCodex", + "settings": { + "baseURL": "http://127.0.0.1:10100/v1", + "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" + }, + "models": { + "anthropic/claude-opus-5": { + "name": "Claude Opus 5 (anthropic)", + "limit": { + "context": 200000, + "output": 32000 + } + }, + "custom/no-context": { + "name": "no-context (custom)" + }, + "gpt-5.6-luna": { + "name": "gpt-5.6-luna (native)", + "limit": { + "context": 272000, + "output": 32000 + } + }, + "tiny/small-ctx": { + "name": "small-ctx (tiny)", + "limit": { + "context": 8000, + "output": 8000 + } + } + } + } } } `); diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index 3e2a9f3f77..01b26f038a 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -89,6 +89,41 @@ describe("codex-account-store CRUD", () => { expect(readCodexAccountRecord("wrapped")).toMatchObject({ credential: cred, generation: 1 }); }); + test("every successful credential commit and tombstone advances one shared mutation epoch", async () => { + const { + commitRefreshedCodexCredentialWithAliases, + markCodexAccountValidated, + readCodexAccountRecord, + saveCodexAccountCredential, + saveCodexAccountCredentialIfGeneration, + tombstoneCodexAccount, + } = await import("../src/codex/account-store"); + const { codexCredentialMutationEpoch } = await import("../src/codex/credential-mutation-epoch"); + const first = { accessToken: "epoch-a", refreshToken: "epoch-r-a", expiresAt: Date.now() + 3600_000, chatgptAccountId: "epoch-account" }; + const second = { ...first, accessToken: "epoch-b", refreshToken: "epoch-r-b" }; + const third = { ...second, accessToken: "epoch-c", refreshToken: "epoch-r-c" }; + const start = codexCredentialMutationEpoch(); + + saveCodexAccountCredential("epoch", first); + expect(codexCredentialMutationEpoch()).toBe(start + 1); + + markCodexAccountValidated("epoch"); + expect(codexCredentialMutationEpoch()).toBe(start + 1); + + const firstGeneration = readCodexAccountRecord("epoch")!.generation; + expect(saveCodexAccountCredentialIfGeneration("epoch", firstGeneration, second)).toBe(true); + expect(codexCredentialMutationEpoch()).toBe(start + 2); + expect(saveCodexAccountCredentialIfGeneration("epoch", firstGeneration, first)).toBe(false); + expect(codexCredentialMutationEpoch()).toBe(start + 2); + + const secondGeneration = readCodexAccountRecord("epoch")!.generation; + expect(commitRefreshedCodexCredentialWithAliases("epoch", secondGeneration, third).committed).toBe(true); + expect(codexCredentialMutationEpoch()).toBe(start + 3); + + tombstoneCodexAccount("epoch"); + expect(codexCredentialMutationEpoch()).toBe(start + 4); + }); + test("remove credential deletes entry", async () => { const { saveCodexAccountCredential, removeCodexAccountCredential, getCodexAccountCredential, listCodexAccountIds, readCodexAccountRecord } = await import("../src/codex/account-store"); saveCodexAccountCredential("temp", { accessToken: "t", refreshToken: "r", expiresAt: 0, chatgptAccountId: "c" }); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 57377ed3b7..f174acbcde 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1921,6 +1921,156 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { } }); + test("provider and model aliases label picker rows without changing routing slugs", async () => { + clearModelCache("google-antigravity"); + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + liveModels: false, + models: ["gemini-3.7-flash"], + alias: "ga", + modelAliases: { "gemini-3.7-flash": "g3f" }, + }, + }, + }); + const row = buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "google-antigravity/gemini-3.7-flash"); + + expect(row?.display_name).toBe("ga/g3f"); + expect(row?.slug).toBe("google-antigravity/gemini-3.7-flash"); + } finally { + clearModelCache("google-antigravity"); + } + }); + + test("the issue reproduction uses the effective model alias for the picker label", async () => { + clearModelCache("google-antigravity"); + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + liveModels: false, + models: ["gemini-3.7-flash"], + modelAliases: { "gemini-3.7-flash": "gemini-3.7" }, + }, + }, + }); + const row = buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "google-antigravity/gemini-3.7-flash"); + + expect(row?.display_name).toBe("google-antigravity/gemini-3.7"); + expect(row?.slug).toBe("google-antigravity/gemini-3.7-flash"); + } finally { + clearModelCache("google-antigravity"); + } + }); + + test("an explicit custom displayName wins over an effective model alias", async () => { + clearModelCache("google-antigravity"); + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + liveModels: false, + models: ["gemini-3.7-flash"], + modelAliases: { "gemini-3.7-flash": "gemini-3.7" }, + }, + }, + customModels: [{ + id: "custom-gemini", + provider: "google-antigravity", + modelId: "gemini-3.7-flash", + displayName: "My Gemini", + addedAt: "2026-01-01T00:00:00.000Z", + }], + }); + const row = buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "google-antigravity/gemini-3.7-flash"); + + expect(row?.display_name).toBe("My Gemini"); + expect(row?.slug).toBe("google-antigravity/gemini-3.7-flash"); + } finally { + clearModelCache("google-antigravity"); + } + }); + + test("a case-folded live model id keeps its configured picker alias", async () => { + clearModelCache("mixed-case-live"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(JSON.stringify({ + data: [{ id: "LIVE-Model" }, { id: "MODEL" }, { id: "model" }], + }), { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "mixed-case-live", + providers: { + "mixed-case-live": { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + authMode: "key", + apiKey: "test-key", + liveModels: true, + modelAliases: { "live-model": "short", "mOdEl": "ambiguous" }, + }, + }, + }); + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(entry => entry.slug === "mixed-case-live/LIVE-Model"); + + expect(row?.display_name).toBe("mixed-case-live/short"); + expect(row?.slug).toBe("mixed-case-live/LIVE-Model"); + expect(entries.find(entry => entry.slug === "mixed-case-live/MODEL")?.display_name) + .toBe("mixed-case-live/MODEL"); + expect(entries.find(entry => entry.slug === "mixed-case-live/model")?.display_name) + .toBe("mixed-case-live/model"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("mixed-case-live"); + } + }); + + test("built-in model aliases label picker rows without changing routing slugs", async () => { + clearModelCache("builtin-alias"); + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "builtin-alias", + providers: { + "builtin-alias": { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + liveModels: false, + defaultAliases: true, + models: ["grok-4.6"], + }, + }, + }); + const row = buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "builtin-alias/grok-4.6"); + + expect(row?.display_name).toBe("builtin-alias/grok"); + expect(row?.slug).toBe("builtin-alias/grok-4.6"); + } finally { + clearModelCache("builtin-alias"); + } + }); + test("a custom row clamps its soft budget to the provider max-input ceiling", async () => { const models = await gatherRoutedModels({ port: 10100, diff --git a/tests/codex-cli-install-provenance.test.ts b/tests/codex-cli-install-provenance.test.ts new file mode 100644 index 0000000000..7f7692cd1f --- /dev/null +++ b/tests/codex-cli-install-provenance.test.ts @@ -0,0 +1,544 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, linkSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + inspectCodexCliInstall, + isAppBundledCodexPath, + isCodexCliUpdateVersionManagerPath, + type CodexCliInstallProvenanceDeps, +} from "../src/codex/cli-install-provenance"; +import { buildUnixCodexShim } from "../src/codex/shim"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(label: string): string { + const root = mkdtempSync(join(tmpdir(), label)); + roots.push(root); + return root; +} + +function noFilesystemDeps(onCall: () => void): Pick< + CodexCliInstallProvenanceDeps, + "exists" | "lstat" | "stat" | "readFile" | "realpath" | "inspectShim" +> { + const fail = (): never => { + onCall(); + throw new Error("Windows lexical inspection must not access the filesystem"); + }; + return { + exists: fail, + lstat: fail as CodexCliInstallProvenanceDeps["lstat"], + stat: fail as CodexCliInstallProvenanceDeps["stat"], + readFile: fail, + realpath: fail, + inspectShim: fail as CodexCliInstallProvenanceDeps["inspectShim"], + }; +} + +function createPosixNpmGlobal(prefix: string): { launcher: string; packageRoot: string; entrypoint: string } { + const launcher = join(prefix, "bin", "codex"); + const packageRoot = join(prefix, "lib", "node_modules", "@openai", "codex"); + const entrypoint = join(packageRoot, "bin", "codex.js"); + mkdirSync(join(prefix, "bin"), { recursive: true }); + mkdirSync(join(packageRoot, "bin"), { recursive: true }); + writeFileSync(entrypoint, "#!/usr/bin/env node\n", "utf8"); + chmodSync(entrypoint, 0o755); + symlinkSync(entrypoint, launcher); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ + name: "@openai/codex", + version: "1.2.3", + bin: { codex: "bin/codex.js" }, + }), "utf8"); + return { launcher, packageRoot, entrypoint }; +} + +describe("Codex CLI install provenance", () => { + test("Windows ordinary, bare, remote, and device candidates fail closed without filesystem access", async () => { + let calls = 0; + const deps = noFilesystemDeps(() => { calls += 1; }); + for (const [command, reason] of [ + ["C:\\Tools\\codex.cmd", "windows_inspection_deferred"], + ["codex", "candidate_path_unavailable"], + ["\\Windows\\codex.cmd", "candidate_path_unavailable"], + ["/Windows/codex.cmd", "candidate_path_unavailable"], + ["\\\\server\\share\\codex.cmd", "candidate_path_unavailable"], + ["\\\\?\\C:\\Tools\\codex.cmd", "candidate_path_unavailable"], + ] as const) { + const report = await inspectCodexCliInstall({ + ...deps, + platform: "win32", + configDir: "\\\\server\\share\\opencodex", + env: { CODEX_CLI_PATH: command, PATH: "\\\\server\\share", PATHEXT: ".CMD" }, + }); + expect(report.candidateAvailable).toBe(true); + expect(report.candidateSource).toBe("environment"); + expect(report.selectionAttested).toBe(false); + expect(report.managed).toBe(false); + expect(report.reason).toBe(reason); + expect(report.packageVersion).toBeNull(); + expect(report.shim.status).toBe("unknown"); + } + const driveRoot = await inspectCodexCliInstall({ + ...deps, + platform: "win32", + env: { CODEX_CLI_PATH: "C:\\Tools\\codex.cmd", NVM_HOME: "C:\\", PATH: "" }, + }); + expect(driveRoot.reason).toBe("windows_inspection_deferred"); + for (const [candidate, managerRoot] of [ + ["C:\\Users\\user\\.fnm\\..\\outside\\codex.cmd", undefined], + ["C:\\custom-store\\..\\Tools\\codex.cmd", "C:\\custom-store"], + ] as const) { + const escaped = await inspectCodexCliInstall({ + ...deps, + platform: "win32", + env: { + CODEX_CLI_PATH: candidate, + PATH: "", + ...(managerRoot ? { FNM_DIR: managerRoot } : {}), + }, + }); + expect(escaped.reason).toBe("windows_inspection_deferred"); + } + expect(calls).toBe(0); + }); + + test("Windows does not read persisted candidate state", async () => { + let calls = 0; + const report = await inspectCodexCliInstall({ + ...noFilesystemDeps(() => { calls += 1; }), + platform: "win32", + configDir: "C:\\OpenCodex", + env: { PATH: "C:\\Tools" }, + }); + expect(report.candidateAvailable).toBe(false); + expect(report.reason).toBe("candidate_unavailable"); + expect(report.shim.status).toBe("unknown"); + expect(calls).toBe(0); + }); + + test("Windows lexical app and version-manager candidates remain report-only", async () => { + let calls = 0; + const deps = noFilesystemDeps(() => { calls += 1; }); + for (const [path, provenance] of [ + ["C:\\Program Files\\WindowsApps\\OpenAI.Codex_1.0.0\\codex.exe", "app-bundle"], + ["C:\\Users\\user\\.fnm\\node-versions\\v22.1.0\\installation\\codex.exe", "version-manager"], + ["C:\\custom-store\\v22\\codex.cmd", "version-manager"], + ] as const) { + const env = path.startsWith("C:\\custom-store") + ? { CODEX_CLI_PATH: path, PATH: "", FNM_DIR: "C:\\custom-store" } + : { CODEX_CLI_PATH: path, PATH: "" }; + const report = await inspectCodexCliInstall({ ...deps, platform: "win32", env }); + expect(report.provenance).toBe(provenance); + expect(report.managed).toBe(false); + expect(report.selectionAttested).toBe(false); + expect(report.packageVersion).toBeNull(); + expect(report.shim.status).toBe("unknown"); + } + expect(calls).toBe(0); + }); + + test("recognizes updater-only version-manager layouts without catching ordinary paths", () => { + expect(isCodexCliUpdateVersionManagerPath("C:\\Users\\u\\.fnm\\node-versions\\v22\\installation\\codex.exe", "win32")).toBe(true); + expect(isCodexCliUpdateVersionManagerPath("C:\\Users\\u\\scoop\\apps\\nodejs\\current\\codex.cmd", "win32")).toBe(true); + expect(isCodexCliUpdateVersionManagerPath("/home/u/.nvm/versions/node/v22.1.0/bin/codex", "linux")).toBe(true); + expect(isCodexCliUpdateVersionManagerPath("/opt/apps/service/releases/v2/data/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/apps/nodejs/current/bin/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/srv/app/versions/2024/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/node-versions/22/installation/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/srv/installs/node/22/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/tools/image/node/22/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/home/u/.nvm/../outside/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/plain\\.nvm\\bin/codex", "linux")).toBe(false); + expect(isCodexCliUpdateVersionManagerPath("/opt/scoop/apps/tools/bin/codex", "linux")).toBe(false); + expect(isAppBundledCodexPath("/opt/plain\\flatpak\\codex", "linux")).toBe(false); + expect(isAppBundledCodexPath("/opt/flatpak/tools/bin/codex", "linux")).toBe(false); + expect(isAppBundledCodexPath( + "/var/lib/flatpak/app/com.openai.Codex/x86_64/stable/active/files/bin/codex", + "linux", + )).toBe(true); + expect(isCodexCliUpdateVersionManagerPath("/usr/local/bin/codex", "linux")).toBe(false); + }); + + test("a POSIX filesystem-root manager setting does not claim unrelated absolute candidates", async () => { + const fileStat = { + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o755, + size: 2, + dev: 1, + ino: 1, + mtimeMs: 0, + }; + for (const candidate of ["/usr/local/bin/codex", "//usr/local/bin/codex", "///usr/local/bin/codex"]) { + const report = await inspectCodexCliInstall({ + platform: "linux", + configDir: "/tmp/opencodex", + env: { CODEX_CLI_PATH: candidate, N_PREFIX: "/", PATH: "" }, + exists: () => true, + lstat: (() => fileStat) as never, + stat: (() => fileStat) as never, + realpath: path => path, + readFile: (() => Buffer.from("{}", "utf8")) as never, + boundedFileReadMode: "injected-test", + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.provenance).toBe("standalone-unverified"); + expect(report.reason).toBe("unverified_standalone"); + } + + let injectedReads = 0; + await inspectCodexCliInstall({ + platform: "linux", + configDir: "/tmp/opencodex", + env: { CODEX_CLI_PATH: "/virtual/codex", PATH: "" }, + exists: () => true, + lstat: (() => fileStat) as never, + stat: (() => fileStat) as never, + realpath: path => path, + readFile: (() => { injectedReads += 1; return Buffer.from("{}", "utf8"); }) as never, + boundedFileReadMode: "invalid" as never, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(injectedReads).toBe(0); + }); + + test.skipIf(process.platform === "win32")("proves a configured POSIX npm symlink and redacts paths", async () => { + const prefix = tempRoot("ocx-codex-posix-npm-"); + const { launcher } = createPosixNpmGlobal(prefix); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report).toMatchObject({ + candidateAvailable: true, + candidateSource: "environment", + selectionAttested: false, + provenance: "npm-global", + managed: false, + reason: "selection_unattested", + packageVersion: "1.2.3", + location: "/codex.js", + }); + expect(JSON.stringify(report)).not.toContain(prefix); + expect(JSON.stringify(report)).not.toContain("authority"); + }); + + test.skipIf(process.platform !== "linux")("does not mistake a flatpak path component for an app bundle", async () => { + const prefix = join(tempRoot("ocx-codex-flatpak-component-"), "flatpak", "tools"); + const { launcher } = createPosixNpmGlobal(prefix); + const report = await inspectCodexCliInstall({ + platform: "linux", + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + + expect(report.provenance).toBe("npm-global"); + expect(report.reason).toBe("selection_unattested"); + expect(report.packageVersion).toBe("1.2.3"); + expect(report.evidence).toEqual(expect.arrayContaining([ + "package_manifest", + "package_manifest_digest", + "global_npm_layout", + ])); + }); + + test.skipIf(process.platform !== "linux")("does not mistake a Scoop path component for a version manager", async () => { + const prefix = join(tempRoot("ocx-codex-scoop-component-"), "scoop", "apps", "tools"); + const { launcher } = createPosixNpmGlobal(prefix); + const report = await inspectCodexCliInstall({ + platform: "linux", + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + + expect(report.provenance).toBe("npm-global"); + expect(report.reason).toBe("selection_unattested"); + expect(report.packageVersion).toBe("1.2.3"); + expect(report.evidence).toEqual(expect.arrayContaining([ + "package_manifest", + "package_manifest_digest", + "global_npm_layout", + ])); + }); + + test.skipIf(process.platform === "win32")("proves a POSIX npm global through a symlinked prefix", async () => { + const prefix = tempRoot("ocx-codex-posix-prefix-"); + const { launcher: physicalLauncher } = createPosixNpmGlobal(prefix); + const alias = `${prefix}-alias`; + roots.push(alias); + symlinkSync(prefix, alias, "dir"); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: join(alias, "bin", "codex"), PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(physicalLauncher).not.toBe(join(alias, "bin", "codex")); + expect(report.provenance).toBe("npm-global"); + expect(report.reason).toBe("selection_unattested"); + expect(JSON.stringify(report)).not.toContain(prefix); + expect(JSON.stringify(report)).not.toContain(alias); + }); + + test.skipIf(process.platform === "win32")("does not adopt a project-local POSIX node_modules layout", async () => { + const prefix = tempRoot("ocx-codex-posix-project-"); + const launcher = join(prefix, "bin", "codex"); + const packageRoot = join(prefix, "node_modules", "@openai", "codex"); + const entrypoint = join(packageRoot, "bin", "codex.js"); + mkdirSync(join(prefix, "bin"), { recursive: true }); + mkdirSync(join(packageRoot, "bin"), { recursive: true }); + writeFileSync(entrypoint, "#!/usr/bin/env node\n", "utf8"); + chmodSync(entrypoint, 0o755); + symlinkSync(entrypoint, launcher); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ + name: "@openai/codex", version: "1.2.3", bin: { codex: "bin/codex.js" }, + }), "utf8"); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.managed).toBe(false); + expect(report.provenance).not.toBe("npm-global"); + expect(report.reason).toBe("npm_global_unverified"); + expect(report.versionEvidence.kind).toBe("unavailable"); + + writeFileSync(join(prefix, "codex-runtime.json"), `${JSON.stringify({ + version: 1, + command: launcher, + source: "path", + selectedVersion: "1.2.3", + updatedAt: "2026-08-28T00:00:00.000Z", + })}\n`, "utf8"); + const persisted = await inspectCodexCliInstall({ + platform: process.platform, + configDir: prefix, + env: { PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(persisted.reason).toBe("npm_global_unverified"); + expect(persisted.versionEvidence.kind).toBe("advisory-runtime"); + }); + + test.skipIf(process.platform === "win32")("fails closed on literal or relative POSIX PATH shadowing", async () => { + const prefix = tempRoot("ocx-codex-posix-path-"); + createPosixNpmGlobal(prefix); + for (const path of [`${join(prefix, "bin")} `, `relative:${join(prefix, "bin")}`]) { + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: "codex", PATH: path }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.reason).toBe("candidate_path_unavailable"); + } + }); + + test.skipIf(process.platform === "win32")("rejects a manifest entrypoint redirected outside its package root", async () => { + const prefix = tempRoot("ocx-codex-external-entry-"); + const packageRoot = join(prefix, "lib", "node_modules", "@openai", "codex"); + const launcher = join(prefix, "bin", "codex"); + const outside = join(prefix, "outside-bin"); + mkdirSync(join(prefix, "bin"), { recursive: true }); + mkdirSync(packageRoot, { recursive: true }); + mkdirSync(outside, { recursive: true }); + const externalEntrypoint = join(outside, "codex.js"); + writeFileSync(externalEntrypoint, "#!/usr/bin/env node\n", "utf8"); + chmodSync(externalEntrypoint, 0o755); + symlinkSync(outside, join(packageRoot, "bin"), "dir"); + symlinkSync(externalEntrypoint, launcher); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ + name: "@openai/codex", version: "1.2.3", bin: { codex: "bin/codex.js" }, + }), "utf8"); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.managed).toBe(false); + expect(report.provenance).not.toBe("npm-global"); + }); + + test("uses canonical POSIX paths instead of escaped manager-looking aliases", async () => { + const lexical = "/home/u/.nvm/bin/codex"; + const canonical = "/opt/outside/codex"; + const managerRoot = "/home/u/.nvm"; + const fileStat = { + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o755, + size: 2, + dev: 1, + ino: 1, + mtimeMs: 0, + }; + const candidatePaths = new Set([lexical, canonical]); + const report = await inspectCodexCliInstall({ + platform: "linux", + configDir: "/tmp/opencodex", + env: { CODEX_CLI_PATH: lexical, NVM_DIR: managerRoot, PATH: "" }, + exists: path => candidatePaths.has(path), + lstat: (path => { + if (!candidatePaths.has(path)) throw new Error("absent"); + return fileStat; + }) as never, + stat: (path => { + if (!candidatePaths.has(path)) throw new Error("absent"); + return fileStat; + }) as never, + realpath: path => path === lexical ? canonical : path, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.provenance).toBe("standalone-unverified"); + expect(report.reason).toBe("unverified_standalone"); + }); + + test.skipIf(process.platform === "win32")("does not classify a manager-looking symlink that resolves outside its root", async () => { + const root = tempRoot("ocx-codex-manager-escape-"); + const managerRoot = join(root, ".nvm"); + const launcher = join(managerRoot, "bin", "codex"); + const outside = join(root, "outside", "codex"); + mkdirSync(join(managerRoot, "bin"), { recursive: true }); + mkdirSync(join(root, "outside"), { recursive: true }); + writeFileSync(outside, "#!/usr/bin/env node\n", "utf8"); + chmodSync(outside, 0o755); + symlinkSync(outside, launcher); + + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, NVM_DIR: managerRoot, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.provenance).toBe("standalone-unverified"); + expect(report.reason).toBe("unverified_standalone"); + }); + + test.skipIf(process.platform === "win32")("persisted and environment npm candidates remain unattested", async () => { + const root = tempRoot("ocx-codex-unattested-"); + const { launcher } = createPosixNpmGlobal(root); + writeFileSync(join(root, "codex-runtime.json"), `${JSON.stringify({ + version: 1, + command: launcher, + source: "path", + selectedVersion: "1.2.3", + updatedAt: "2026-08-28T00:00:00.000Z", + })}\n`, "utf8"); + const persisted = await inspectCodexCliInstall({ + platform: process.platform, + configDir: root, + env: { PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(persisted).toMatchObject({ + candidateSource: "persisted", + candidateVersion: "1.2.3", + selectionAttested: false, + provenance: "npm-global", + managed: false, + reason: "selection_unattested", + versionEvidence: { kind: "package-manifest" }, + }); + + writeFileSync(join(root, "codex-runtime.json"), `${JSON.stringify({ + version: 1, + command: launcher, + source: "path", + selectedVersion: "1.2.4", + updatedAt: "2026-08-28T00:00:00.000Z", + })}\n`, "utf8"); + const mismatched = await inspectCodexCliInstall({ + platform: process.platform, + configDir: root, + env: { PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(mismatched).toMatchObject({ + candidateVersion: "1.2.4", + packageVersion: "1.2.3", + reason: "version_mismatch", + versionEvidence: { kind: "advisory-runtime" }, + }); + + const environment = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(environment).toMatchObject({ + candidateSource: "environment", + candidateVersion: null, + packageVersion: "1.2.3", + selectionAttested: false, + managed: false, + reason: "selection_unattested", + versionEvidence: { kind: "unavailable" }, + }); + }); + + test.skipIf(process.platform === "win32")("rejects a symbolic-link persisted-state file before reading", async () => { + const root = tempRoot("ocx-codex-state-link-"); + let reads = 0; + const report = await inspectCodexCliInstall({ + platform: process.platform, + configDir: root, + env: { PATH: "" }, + lstat: (() => ({ isSymbolicLink: () => true, isFile: () => false })) as never, + readFile: (() => { reads += 1; throw new Error("must not read"); }) as never, + }); + expect(report.reason).toBe("candidate_unavailable"); + expect(reads).toBe(0); + }); + + test.skipIf(process.platform === "win32")("a POSIX version-manager candidate remains report-only", async () => { + const root = tempRoot("ocx-codex-posix-fnm-"); + const launcher = join(root, ".fnm", "codex"); + mkdirSync(join(root, ".fnm"), { recursive: true }); + writeFileSync(launcher, "#!/bin/sh\nexit 0\n", "utf8"); + chmodSync(launcher, 0o755); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.provenance).toBe("version-manager"); + expect(report.managed).toBe(false); + }); + + test.skipIf(process.platform === "win32")("the real POSIX shim inspector keeps wrapper and backing candidates report-only", async () => { + const root = tempRoot("ocx-codex-shim-deferred-"); + const wrapper = join(root, "codex"); + const backing = join(root, "codex.real"); + writeFileSync(wrapper, buildUnixCodexShim( + backing, + join(root, "bun"), + join(root, "cli.ts"), + "bundled", + join(root, "token"), + ), "utf8"); + writeFileSync(backing, "#!/bin/sh\nexit 0\n", "utf8"); + chmodSync(wrapper, 0o755); + chmodSync(backing, 0o755); + const file = { wrapperPath: wrapper, originalPath: wrapper, backupPath: backing }; + writeFileSync(join(root, "codex-shim.json"), `${JSON.stringify({ + platform: process.platform, + ...file, + wrappers: [file], + }, null, 2)}\n`, "utf8"); + const backingAlias = join(root, "codex-alias"); + linkSync(backing, backingAlias); + for (const candidatePath of [wrapper, backing, backingAlias]) { + const report = await inspectCodexCliInstall({ + platform: process.platform, + configDir: root, + env: { CODEX_CLI_PATH: candidatePath, PATH: "" }, + }); + expect(report.reason).toBe("shim_update_deferred"); + expect(report.shim.status).toBe("matched"); + expect(report.managed).toBe(false); + } + }); +}); diff --git a/tests/codex-cli-update-launcher-policy.test.ts b/tests/codex-cli-update-launcher-policy.test.ts new file mode 100644 index 0000000000..b8e4cf84ec --- /dev/null +++ b/tests/codex-cli-update-launcher-policy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { isCodexCliUpdateInspectionArgv } from "../src/update/codex-cli-update-launch-policy.mjs"; + +describe("Codex CLI updater launcher policy", () => { + test("covers the whole exact namespace including malformed actions", () => { + expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "system", "codex-cli-update", "check"])).toBe(true); + expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "system", "codex-cli-update", "bad"])).toBe(true); + expect(isCodexCliUpdateInspectionArgv([ + "node", "ocx", "--ocx-internal-launch-proof=bad", "system", "codex-cli-update", "check", + ])).toBe(true); + expect(isCodexCliUpdateInspectionArgv([ + "node", "ocx", "--ocx-internal-launch-proof=bad", "system", "codex-cli-update", "bad", + ])).toBe(true); + expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "system", "update"])).toBe(false); + }); + + test("launcher skips boot repair and lazy Bun installation for this namespace", () => { + const source = readFileSync(join(import.meta.dir, "..", "bin", "ocx.mjs"), "utf8"); + expect(source).toContain("!codexCliUpdateInspection && isNodeModulesInstall()"); + expect(source).toContain("resolveBun({ allowInstall: !codexCliUpdateInspection })"); + expect(source).toContain("if (allowInstall && existsSync(installJs))"); + }); +}); diff --git a/tests/codex-cli-update-zero-effect.test.ts b/tests/codex-cli-update-zero-effect.test.ts new file mode 100644 index 0000000000..d12a8106ed --- /dev/null +++ b/tests/codex-cli-update-zero-effect.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex CLI updater zero-effect boundary", () => { + test("direct Bun execution of the Node launcher fails before updater inspection", () => { + const result = spawnSync(process.execPath, [ + join(import.meta.dir, "..", "bin", "ocx.mjs"), + "system", "codex-cli-update", "check", "--json", + ], { + cwd: join(import.meta.dir, ".."), encoding: "utf8", timeout: 15_000, + env: { ...process.env }, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("must use the published Node launcher"); + }); + + test("published Node launcher check neither executes the candidate launcher nor rewrites invalid state", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-codex-check-zero-effect-")); + roots.push(root); + const launcher = join(root, process.platform === "win32" ? "codex.cmd" : "codex"); + const marker = join(root, "executed.txt"); + const home = join(root, "home"); + mkdirSync(home, { recursive: true }); + writeFileSync(launcher, process.platform === "win32" + ? `@echo off\r\necho executed>${marker}\r\n` + : `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\n`, "utf8"); + if (process.platform !== "win32") chmodSync(launcher, 0o755); + const statePath = join(home, "codex-shim.json"); + writeFileSync(statePath, "{broken", "utf8"); + const before = readFileSync(statePath); + const result = spawnSync("node", [join(import.meta.dir, "..", "bin", "ocx.mjs"), "system", "codex-cli-update", "check", "--json"], { + cwd: join(import.meta.dir, ".."), + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, OPENCODEX_HOME: home, CODEX_CLI_PATH: launcher }, + windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const report = JSON.parse(result.stdout) as Record; + expect(report.managed).toBe(false); + expect(typeof report.reason).toBe("string"); + expect(report.candidateAvailable).toBe(true); + expect(report.candidateSource).toBe("environment"); + expect(report.selectionAttested).toBe(false); + for (const stale of ["selected", "selectedVersion", "selectionSource", "selectionEvidence"]) { + expect(stale in report).toBe(false); + } + expect(readFileSync(statePath)).toEqual(before); + expect(existsSync(marker)).toBe(false); + }); + + test("published Node launcher rejects malformed updater input before any repair or candidate command", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-codex-invalid-zero-effect-")); + roots.push(root); + const launcher = join(root, process.platform === "win32" ? "codex.cmd" : "codex"); + const marker = join(root, "executed.txt"); + const home = join(root, "home"); + mkdirSync(home, { recursive: true }); + writeFileSync(launcher, process.platform === "win32" + ? `@echo off\r\necho executed>${marker}\r\n` + : `#!/bin/sh\nprintf executed > ${JSON.stringify(marker)}\n`, "utf8"); + if (process.platform !== "win32") chmodSync(launcher, 0o755); + const statePath = join(home, "codex-shim.json"); + writeFileSync(statePath, "{broken", "utf8"); + const before = readFileSync(statePath); + const result = spawnSync("node", [ + join(import.meta.dir, "..", "bin", "ocx.mjs"), + "--ocx-internal-launch-proof=bad", + "system", "codex-cli-update", "invalid", + ], { + cwd: join(import.meta.dir, ".."), encoding: "utf8", timeout: 15_000, + env: { ...process.env, OPENCODEX_HOME: home, CODEX_CLI_PATH: launcher }, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(2); + expect(result.stderr).toContain("codex-cli-update action must be check"); + expect(readFileSync(statePath)).toEqual(before); + expect(existsSync(marker)).toBe(false); + }); +}); diff --git a/tests/codex-main-account-refresh.test.ts b/tests/codex-main-account-refresh.test.ts index 45db2a670e..b0910c823b 100644 --- a/tests/codex-main-account-refresh.test.ts +++ b/tests/codex-main-account-refresh.test.ts @@ -6,6 +6,7 @@ import { getValidMainAccountToken, setMainAuthJsonBeforeRenameHookForTests, } from "../src/codex/main-account"; +import { codexCredentialMutationEpoch } from "../src/codex/credential-mutation-epoch"; let home: string; let previousCodexHome: string | undefined; @@ -46,6 +47,7 @@ describe("native main token refresh", () => { targetDuringPublish = readFileSync(authPath, "utf8"); }); + const epochBefore = codexCredentialMutationEpoch(); const token = await getValidMainAccountToken({ refreshToken: async refreshToken => { expect(refreshToken).toBe("old-refresh"); @@ -70,6 +72,7 @@ describe("native main token refresh", () => { }, }); expect(readdirSync(home).filter(name => name.includes(".tmp"))).toEqual([]); + expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); }); test("refuses to overwrite an external auth writer after refresh", async () => { @@ -90,6 +93,7 @@ describe("native main token refresh", () => { }); setMainAuthJsonBeforeRenameHookForTests(() => writeFileSync(authPath, external)); + const epochBefore = codexCredentialMutationEpoch(); await expect(getValidMainAccountToken({ refreshToken: async () => ({ access: "new-access", @@ -101,6 +105,7 @@ describe("native main token refresh", () => { expect(readFileSync(authPath, "utf8")).toBe(external); expect(readdirSync(home).filter(name => name.includes(".tmp"))).toEqual([]); + expect(codexCredentialMutationEpoch()).toBe(epochBefore); }); test("refresh failure leaves the original auth file byte-identical", async () => { diff --git a/tests/codex-model-entitlements.test.ts b/tests/codex-model-entitlements.test.ts index fddd7051da..0272884269 100644 --- a/tests/codex-model-entitlements.test.ts +++ b/tests/codex-model-entitlements.test.ts @@ -1,12 +1,20 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { + ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS, availableAccountGatedNativeModels, cachedAvailableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, composeGatedClientVersionFloorForTests, compareClientVersionsForTests, + codexEntitlementNegativeMemoForTests, deriveGatedClientVersionFloor, + ensureCodexEntitlementFreshness, entitledCodexAccountIdsForModel, GATED_MODEL_CLIENT_VERSION_FLOOR, + getCodexModelEntitlementStatus, isDirectCallerEntitledToCodexModel, isUsableCodexClientVersion, memoizeRuntimeVersionForTests, @@ -15,11 +23,21 @@ import { resolveCodexModelEntitlements, seedCodexModelEntitlementsForTests, type CodexModelEntitlementCredentialSnapshot, + type CodexModelEntitlementState, } from "../src/codex/model-entitlements"; -import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { + forceRefreshMainAccountToken, + MAIN_CODEX_ACCOUNT_ID, +} from "../src/codex/main-account"; +import { + readCodexAccountRecord, + saveCodexAccountCredential, +} from "../src/codex/account-store"; import { clearCodexRuntimeResolveCache, loadPersistedCodexRuntime } from "../src/codex/runtime"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../src/codex/catalog/native-models"; import upstreamModelsSnapshot from "../src/codex/data/upstream-models.json"; +import { readCodexAccountRecord, saveCodexAccountCredential } from "../src/codex/account-store"; +import { installIsolatedCodexHome } from "./helpers/isolated-codex-home"; const TEST_CLIENT_VERSION = "0.146.0"; const DAYBREAK = "gpt-daybreak-blue-latest"; @@ -42,9 +60,127 @@ function roster(...slugs: string[]): Response { }); } +function projectedEntitlementState( + snapshot: Awaited>, + accountId: string, + modelId: string, +): CodexModelEntitlementState { + return codexModelEntitlementStateForAccount(snapshot, accountId, modelId); +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise(settle => { resolve = settle; }); + return { promise, resolve }; +} + beforeEach(() => resetCodexModelEntitlementCacheForTests()); describe("Codex account model entitlements", () => { + test("keeps parsed-empty distinct from refresh failures end to end", async () => { + const isolated = installIsolatedCodexHome("ocx-entitlement-provenance-"); + const accountId = "pool-provenance"; + const config = { + codexAccounts: [{ id: accountId, email: "pool-provenance@example.test", isMain: false }], + }; + try { + saveCodexAccountCredential(accountId, { + accessToken: "provenance-access", + refreshToken: "provenance-refresh", + expiresAt: Date.now() + 60_000, + chatgptAccountId: "chatgpt-provenance", + }); + const generation = readCodexAccountRecord(accountId)!.generation; + const storedCredential: CodexModelEntitlementCredentialSnapshot = { + accountId, + accessToken: "provenance-access", + chatgptAccountId: "chatgpt-provenance", + credentialIdentity: `pool:${generation}:chatgpt-provenance`, + }; + const cases: Array<{ + name: string; + fetcher: typeof fetch; + expected: Record; + }> = [ + { + name: "parsed-empty", + fetcher: (async () => Response.json({ models: [] })) as typeof fetch, + expected: { status: "unconfirmed-empty" }, + }, + { + name: "http-error", + fetcher: (async () => new Response("upstream failed", { status: 503 })) as typeof fetch, + expected: { status: "failed", reason: "http-error", httpStatus: 503 }, + }, + { + name: "network-error", + fetcher: (async () => { throw new TypeError("connection refused"); }) as typeof fetch, + expected: { status: "failed", reason: "network-error" }, + }, + { + name: "timeout", + fetcher: (async () => { throw new DOMException("timed out", "TimeoutError"); }) as typeof fetch, + expected: { status: "failed", reason: "timeout" }, + }, + { + name: "unparseable", + fetcher: (async () => new Response("not-json")) as typeof fetch, + expected: { status: "failed", reason: "unparseable" }, + }, + ]; + + for (const testCase of cases) { + resetCodexModelEntitlementCacheForTests(); + await resolveCodexModelEntitlements(config, { + credentials: [storedCredential], + fetcher: testCase.fetcher, + now: 1_000, + clientVersion: TEST_CLIENT_VERSION, + }); + expect( + getCodexModelEntitlementStatus(config, 1_001, TEST_CLIENT_VERSION), + testCase.name, + ).toEqual(testCase.expected); + } + } finally { + isolated.restore(); + } + }); + + test("default entitlement status uses the same client-version cache key as resolution", async () => { + const isolated = installIsolatedCodexHome("ocx-entitlement-default-version-"); + const accountId = "pool-default-version"; + const config = { + codexAccounts: [{ id: accountId, email: "pool-default-version@example.test", isMain: false }], + }; + try { + saveCodexAccountCredential(accountId, { + accessToken: "default-version-access", + refreshToken: "default-version-refresh", + expiresAt: Date.now() + 60_000, + chatgptAccountId: "chatgpt-default-version", + }); + const generation = readCodexAccountRecord(accountId)!.generation; + await resolveCodexModelEntitlements(config, { + credentials: [{ + accountId, + accessToken: "default-version-access", + chatgptAccountId: "chatgpt-default-version", + credentialIdentity: `pool:${generation}:chatgpt-default-version`, + }], + fetcher: (async () => roster(SOL)) as typeof fetch, + now: 1_000, + }); + + expect(getCodexModelEntitlementStatus(config, 1_001)).toEqual({ status: "fresh" }); + } finally { + isolated.restore(); + } + }); + test("keeps account-gated models scoped to the authenticated account roster", async () => { const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { credentials: [credential("main"), credential("secondary")], @@ -193,6 +329,683 @@ describe("Codex account model entitlements", () => { }); +describe("tri-state entitlement authority", () => { + const directHeaders = (): Headers => new Headers({ + authorization: "Bearer tri-state-caller", + "chatgpt-account-id": "tri-state-account", + }); + + test("an omitted gated slug below its minimum is unknown and uses the failure TTL", async () => { + let fetches = 0; + const backend = (async () => { + fetches += 1; + return roster("gpt-5.5"); + }) as typeof fetch; + + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + fetcher: backend, + now: 1_000, + clientVersion: "0.140.0", + })).toBe(false); + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + fetcher: backend, + now: 15_999, + clientVersion: "0.140.0", + })).toBe(false); + expect(fetches).toBe(1); + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + fetcher: backend, + now: 16_001, + clientVersion: "0.140.0", + })).toBe(false); + expect(fetches).toBe(2); + + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main")], + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 20_000, + clientVersion: "0.140.0", + }); + expect(snapshot.clientVersionByAccount.get("main")).toBe("0.140.0"); + expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("unknown"); + expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + }); + + test("an omitted gated slug at its minimum is denied", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main")], + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 1_000, + clientVersion: "0.144.0", + }); + + expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("denied"); + expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + }); + + test("a present gated slug below its minimum is granted", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main")], + fetcher: (async () => roster("gpt-5.5", SOL)) as typeof fetch, + now: 1_000, + clientVersion: "0.140.0", + }); + + expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("granted"); + expect([...entitledCodexAccountIdsForModel(snapshot, SOL)!]).toEqual(["main"]); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(true); + }); + + test("Daybreak omission remains denied without a known minimum", async () => { + expect(ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS.get(SOL)).toBe("0.144.0"); + expect(ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS.has(DAYBREAK)).toBe(false); + + for (const clientVersion of ["0.140.0", "0.200.0"]) { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential(`main-${clientVersion}`)], + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 1_000, + clientVersion, + }); + expect(projectedEntitlementState(snapshot, `main-${clientVersion}`, DAYBREAK)).toBe("denied"); + expect(entitledCodexAccountIdsForModel(snapshot, DAYBREAK)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(DAYBREAK)).toBe(false); + } + }); + + test("CHARACTERIZATION: no positive projection returns a gated slug absent from the roster", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main")], + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 1_000, + clientVersion: "0.140.0", + }); + expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + + seedCodexModelEntitlementsForTests("main", ["gpt-5.5"], 1_000, "0.140.0"); + expect(cachedAvailableAccountGatedNativeModels(1_001, undefined, "0.140.0").has(SOL)) + .toBe(false); + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 1_000, + clientVersion: "0.140.0", + })).toBe(false); + }); + + test("CHARACTERIZATION: an unconfirmed roster cannot grant a present gated slug", () => { + const snapshot = { + modelsByAccount: new Map([["main", new Set([SOL])]]), + clientVersionByAccount: new Map([["main", "0.140.0"]]), + confirmedAccountIds: new Set(), + credentialIdentities: new Map([["main", "test:main"]]), + }; + + expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("unknown"); + expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + }); +}); + +describe("ensureCodexEntitlementFreshness", () => { + const originalOpenCodexHome = process.env.OPENCODEX_HOME; + const originalCodexHome = process.env.CODEX_HOME; + let root = ""; + let codexHome = ""; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-entitlement-freshness-")); + codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + process.env.OPENCODEX_HOME = join(root, "opencodex"); + process.env.CODEX_HOME = codexHome; + }); + + afterEach(() => { + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + rmSync(root, { recursive: true, force: true }); + resetCodexModelEntitlementCacheForTests(); + }); + + const poolConfig = (...ids: string[]) => ({ + codexAccounts: ids.map(id => ({ id, email: `${id}@example.test`, isMain: false })), + }); + + const savePoolCredential = (accountId: string, suffix: string): void => { + saveCodexAccountCredential(accountId, { + accessToken: `access-${suffix}`, + refreshToken: `refresh-${suffix}`, + expiresAt: Date.now() + 60 * 60_000, + chatgptAccountId: `chatgpt-${suffix}`, + }); + }; + + const storedCredentialSnapshot = async ( + accountId: string, + ): Promise => { + if (accountId === MAIN_CODEX_ACCOUNT_ID) return null; + const record = readCodexAccountRecord(accountId); + if (!record?.credential || record.deletedAt != null) return null; + return { + accountId, + accessToken: record.credential.accessToken, + chatgptAccountId: record.credential.chatgptAccountId, + credentialIdentity: `pool:${record.generation}:${record.credential.chatgptAccountId}`, + }; + }; + + const writeMainAuth = (suffix: string): void => { + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { + access_token: `access-${suffix}`, + account_id: `chatgpt-${suffix}`, + }, + })); + }; + + const mainCredentialSnapshot = async ( + accountId: string, + ): Promise => { + if (accountId !== MAIN_CODEX_ACCOUNT_ID) return null; + const parsed = JSON.parse(readFileSync(join(codexHome, "auth.json"), "utf8")) as { + tokens: { access_token: string; account_id: string }; + }; + return { + accountId, + accessToken: parsed.tokens.access_token, + chatgptAccountId: parsed.tokens.account_id, + credentialIdentity: `main:${parsed.tokens.account_id}`, + }; + }; + + test("reports an expired roster during outer-flight credential acquisition", async () => { + const accountId = "pool-outer-flight"; + const config = poolConfig(accountId); + savePoolCredential(accountId, "outer-flight"); + const baseOptions = { + clientVersion: TEST_CLIENT_VERSION, + fetcher: (async () => roster(SOL)) as typeof fetch, + }; + await ensureCodexEntitlementFreshness(config, { + ...baseOptions, + waitMs: 1_000, + now: 1_000, + credentialSnapshot: storedCredentialSnapshot, + }); + + const credentialReadStarted = deferred(); + const releaseCredentialRead = deferred(); + const blockedCredentialSnapshot = async ( + candidateAccountId: string, + ): Promise => { + if (candidateAccountId !== accountId) return null; + credentialReadStarted.resolve(); + await releaseCredentialRead.promise; + return storedCredentialSnapshot(candidateAccountId); + }; + const refreshOptions = { + ...baseOptions, + waitMs: 0, + now: 301_001, + credentialSnapshot: blockedCredentialSnapshot, + }; + try { + await ensureCodexEntitlementFreshness(config, refreshOptions); + await credentialReadStarted.promise; + expect(getCodexModelEntitlementStatus(config, 301_001, TEST_CLIENT_VERSION)) + .toEqual({ status: "expired-refresh-in-flight" }); + } finally { + releaseCredentialRead.resolve(); + await ensureCodexEntitlementFreshness(config, { ...refreshOptions, waitMs: 1_000 }); + } + }); + + test("a fresh ensure uses identity reads but performs zero full credential snapshots or network calls", async () => { + savePoolCredential("pool-fresh", "fresh"); + let credentialReads = 0; + let fetches = 0; + const options = { + waitMs: 1_000, + now: 1_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: async (accountId: string) => { + credentialReads += 1; + return storedCredentialSnapshot(accountId); + }, + fetcher: (async () => { fetches += 1; return roster(SOL, TERRA, LUNA); }) as typeof fetch, + }; + + await ensureCodexEntitlementFreshness(poolConfig("pool-fresh"), options); + expect(credentialReads).toBe(2); + expect(fetches).toBe(1); + + credentialReads = 0; + await ensureCodexEntitlementFreshness(poolConfig("pool-fresh"), { ...options, now: 1_001 }); + expect(credentialReads).toBe(0); + expect(fetches).toBe(1); + }); + + test("logged-out polls memoize the missing credential for exactly the bounded window", async () => { + let credentialReads = 0; + const options = { + waitMs: 1_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: async () => { credentialReads += 1; return null; }, + }; + + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { ...options, now: 10_000 }); + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { ...options, now: 14_999 }); + expect(credentialReads).toBe(1); + + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { ...options, now: 15_001 }); + expect(credentialReads).toBe(2); + }); + + test("a credential commit invalidates a negative memo before the next ensure", async () => { + let poolReads = 0; + let fetches = 0; + const snapshot = async (accountId: string) => { + if (accountId === "pool-login") poolReads += 1; + return storedCredentialSnapshot(accountId); + }; + const options = { + waitMs: 1_000, + now: 20_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: snapshot, + fetcher: (async () => { fetches += 1; return roster(SOL); }) as typeof fetch, + }; + + await ensureCodexEntitlementFreshness(poolConfig("pool-login"), options); + expect(poolReads).toBe(1); + expect(fetches).toBe(0); + + savePoolCredential("pool-login", "new-login"); + await ensureCodexEntitlementFreshness(poolConfig("pool-login"), options); + expect(poolReads).toBe(2); + expect(fetches).toBe(1); + }); + + test("a same-identity main-token write invalidates a failed credential memo by epoch", async () => { + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { + access_token: "access-memo-same", + refresh_token: "refresh-memo-same", + account_id: "chatgpt-memo-same", + }, + })); + let credentialReads = 0; + let fetches = 0; + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { + waitMs: 1_000, + now: 25_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: async () => { credentialReads += 1; return null; }, + }); + expect(credentialReads).toBe(1); + + await forceRefreshMainAccountToken("access-memo-same", { + refreshToken: async () => ({ + access: "access-memo-same-new", + refresh: "refresh-memo-same-new", + expires: Date.now() + 60 * 60_000, + accountId: "chatgpt-memo-same", + }), + }); + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { + waitMs: 1_000, + now: 25_001, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: async accountId => { + credentialReads += 1; + return mainCredentialSnapshot(accountId); + }, + fetcher: (async () => { fetches += 1; return roster(SOL); }) as typeof fetch, + }); + expect(credentialReads).toBe(2); + expect(fetches).toBe(1); + }); + + test("a local write after absence observation fences stale negative-memo publication by epoch", async () => { + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { + access_token: "access-publication-local", + refresh_token: "refresh-publication-local", + account_id: "chatgpt-publication-local", + }, + })); + savePoolCredential("pool-publication-local-blocker", "publication-local-blocker"); + const siblingFetchStarted = deferred(); + const releaseSiblingFetch = deferred(); + const initial = ensureCodexEntitlementFreshness( + poolConfig("pool-publication-local-blocker"), + { + waitMs: 60_000, + now: 26_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: async accountId => accountId === MAIN_CODEX_ACCOUNT_ID + ? null + : storedCredentialSnapshot(accountId), + fetcher: (async () => { + siblingFetchStarted.resolve(); + await releaseSiblingFetch.promise; + return roster(SOL); + }) as typeof fetch, + }, + ); + + await siblingFetchStarted.promise; + await forceRefreshMainAccountToken("access-publication-local", { + refreshToken: async () => ({ + access: "access-publication-local-new", + refresh: "refresh-publication-local-new", + expires: Date.now() + 60 * 60_000, + accountId: "chatgpt-publication-local", + }), + }); + releaseSiblingFetch.resolve(); + await initial; + + expect(codexEntitlementNegativeMemoForTests(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + }); + + test("an external replacement after absence observation fences stale negative-memo publication by identity", async () => { + writeMainAuth("publication-external-old"); + savePoolCredential("pool-publication-external-blocker", "publication-external-blocker"); + const siblingFetchStarted = deferred(); + const releaseSiblingFetch = deferred(); + const initial = ensureCodexEntitlementFreshness( + poolConfig("pool-publication-external-blocker"), + { + waitMs: 60_000, + now: 27_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: async accountId => accountId === MAIN_CODEX_ACCOUNT_ID + ? null + : storedCredentialSnapshot(accountId), + fetcher: (async () => { + siblingFetchStarted.resolve(); + await releaseSiblingFetch.promise; + return roster(SOL); + }) as typeof fetch, + }, + ); + + await siblingFetchStarted.promise; + writeMainAuth("publication-external-new"); + releaseSiblingFetch.resolve(); + await initial; + + expect(codexEntitlementNegativeMemoForTests(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + }); + + test("a delayed sibling fetch preserves negative-memo expiry from the absence observation", async () => { + savePoolCredential("pool-publication-delay-blocker", "publication-delay-blocker"); + const originalNow = Date.now; + const siblingFetchStarted = deferred(); + const releaseSiblingFetch = deferred(); + let credentialReads = 0; + let wallNow = 10_000; + Date.now = () => wallNow; + try { + const options = { + waitMs: 60_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: async (accountId: string) => { + if (accountId === MAIN_CODEX_ACCOUNT_ID) { + credentialReads += 1; + return null; + } + return storedCredentialSnapshot(accountId); + }, + fetcher: (async () => { + siblingFetchStarted.resolve(); + await releaseSiblingFetch.promise; + return roster(SOL); + }) as typeof fetch, + }; + const initial = ensureCodexEntitlementFreshness( + poolConfig("pool-publication-delay-blocker"), + options, + ); + await siblingFetchStarted.promise; + + wallNow = 40_000; + releaseSiblingFetch.resolve(); + await initial; + + expect(codexEntitlementNegativeMemoForTests(MAIN_CODEX_ACCOUNT_ID)?.expiresAt).toBe(15_000); + wallNow = 40_001; + await ensureCodexEntitlementFreshness(poolConfig("pool-publication-delay-blocker"), { + ...options, + waitMs: 1_000, + }); + expect(credentialReads).toBe(2); + } finally { + Date.now = originalNow; + releaseSiblingFetch.resolve(); + } + }); + + test("a local credential write during a flight cannot mask the replacement", async () => { + savePoolCredential("pool-local-race", "old"); + const firstFetchStarted = deferred(); + const releaseFirstFetch = deferred(); + let fetches = 0; + const fetcher = (async () => { + fetches += 1; + if (fetches === 1) { + firstFetchStarted.resolve(); + await releaseFirstFetch.promise; + } + return roster(SOL); + }) as typeof fetch; + const config = poolConfig("pool-local-race"); + const options = { + waitMs: 0, + now: 30_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: storedCredentialSnapshot, + fetcher, + }; + + await ensureCodexEntitlementFreshness(config, options); + await firstFetchStarted.promise; + savePoolCredential("pool-local-race", "replacement"); + await ensureCodexEntitlementFreshness(config, { ...options, waitMs: 1_000 }); + expect(fetches).toBe(2); + + releaseFirstFetch.resolve(); + await ensureCodexEntitlementFreshness(config, { ...options, waitMs: 1_000 }); + expect(fetches).toBe(2); + }); + + test("a same-identity main-token write cannot join its pre-write roster flight", async () => { + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { + access_token: "access-same-identity", + refresh_token: "refresh-same-identity", + account_id: "chatgpt-same-identity", + }, + })); + const firstFetchStarted = deferred(); + const releaseFirstFetch = deferred(); + let fetches = 0; + const fetcher = (async () => { + fetches += 1; + if (fetches === 1) { + firstFetchStarted.resolve(); + await releaseFirstFetch.promise; + } + return roster(SOL); + }) as typeof fetch; + const options = { + waitMs: 0, + now: 35_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: mainCredentialSnapshot, + fetcher, + }; + + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, options); + await firstFetchStarted.promise; + await forceRefreshMainAccountToken("access-same-identity", { + refreshToken: async () => ({ + access: "access-same-identity-new", + refresh: "refresh-same-identity-new", + expires: Date.now() + 60 * 60_000, + accountId: "chatgpt-same-identity", + }), + }); + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { ...options, waitMs: 1_000 }); + expect(fetches).toBe(2); + + releaseFirstFetch.resolve(); + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { ...options, waitMs: 1_000 }); + expect(fetches).toBe(2); + }); + + test("an external auth.json replacement during a flight starts a new identity flight", async () => { + writeMainAuth("external-old"); + const firstFetchStarted = deferred(); + const releaseFirstFetch = deferred(); + let fetches = 0; + const fetcher = (async () => { + fetches += 1; + if (fetches === 1) { + firstFetchStarted.resolve(); + await releaseFirstFetch.promise; + } + return roster(SOL); + }) as typeof fetch; + const options = { + waitMs: 0, + now: 40_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: mainCredentialSnapshot, + fetcher, + }; + + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, options); + await firstFetchStarted.promise; + writeMainAuth("external-new"); + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { ...options, waitMs: 1_000 }); + expect(fetches).toBe(2); + + releaseFirstFetch.resolve(); + await ensureCodexEntitlementFreshness({ codexAccounts: [] }, { ...options, waitMs: 1_000 }); + expect(fetches).toBe(2); + }); + + test("an account expiring during an A-only flight is added to a distinct workset", async () => { + savePoolCredential("pool-work-b", "work-b"); + const counts = new Map(); + let holdA = false; + const aFetchStarted = deferred(); + let bRefreshStarted = false; + const releaseA = deferred(); + const fetcher = (async (_input, init) => { + const accountId = new Headers(init?.headers).get("chatgpt-account-id") ?? ""; + counts.set(accountId, (counts.get(accountId) ?? 0) + 1); + if (accountId === "chatgpt-work-a" && holdA) { + aFetchStarted.resolve(); + await releaseA.promise; + } + if (accountId === "chatgpt-work-b" && (counts.get(accountId) ?? 0) === 2) { + bRefreshStarted = true; + } + return roster(SOL); + }) as typeof fetch; + const baseOptions = { + waitMs: 1_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: storedCredentialSnapshot, + fetcher, + }; + + await ensureCodexEntitlementFreshness(poolConfig("pool-work-b"), { + ...baseOptions, + now: 1_000, + }); + expect(counts.get("chatgpt-work-b")).toBe(1); + + savePoolCredential("pool-work-a", "work-a"); + holdA = true; + await ensureCodexEntitlementFreshness(poolConfig("pool-work-a", "pool-work-b"), { + ...baseOptions, + waitMs: 0, + now: 300_999, + }); + await aFetchStarted.promise; + + await ensureCodexEntitlementFreshness(poolConfig("pool-work-a", "pool-work-b"), { + ...baseOptions, + waitMs: 0, + now: 301_001, + }); + for (let i = 0; i < 10 && !bRefreshStarted; i += 1) await Promise.resolve(); + expect(bRefreshStarted).toBe(true); + expect(counts.get("chatgpt-work-b")).toBe(2); + + releaseA.resolve(); + await ensureCodexEntitlementFreshness(poolConfig("pool-work-a", "pool-work-b"), { + ...baseOptions, + now: 301_001, + }); + expect(counts.get("chatgpt-work-a")).toBe(1); + expect(counts.get("chatgpt-work-b")).toBe(2); + }); + + test("a late waiter spends only the flight's remaining management wait budget", async () => { + savePoolCredential("pool-wait", "wait"); + const originalNow = Date.now; + const fetchStarted = deferred(); + const releaseFetch = deferred(); + let wallNow = 1_000; + Date.now = () => wallNow; + try { + const options = { + waitMs: 0, + now: 50_000, + clientVersion: TEST_CLIENT_VERSION, + credentialSnapshot: storedCredentialSnapshot, + fetcher: (async () => { + fetchStarted.resolve(); + await releaseFetch.promise; + return roster(SOL); + }) as typeof fetch, + }; + await ensureCodexEntitlementFreshness(poolConfig("pool-wait"), options); + await fetchStarted.promise; + + wallNow = 5_000; + let lateWaiterSettled = false; + const lateWaiter = ensureCodexEntitlementFreshness(poolConfig("pool-wait"), { + ...options, + waitMs: 3_000, + }).then(() => { lateWaiterSettled = true; }); + await Promise.resolve(); + await Promise.resolve(); + expect(lateWaiterSettled).toBe(true); + + releaseFetch.resolve(); + await lateWaiter; + await ensureCodexEntitlementFreshness(poolConfig("pool-wait"), { + ...options, + waitMs: 1_000, + }); + } finally { + Date.now = originalNow; + releaseFetch.resolve(); + } + }); +}); + describe("entitlement client version (#2886)", () => { /** * Upstream filters this roster by the client version it is told, and `client_version` is a diff --git a/tests/codex-reset-credit-operation-ledger.test.ts b/tests/codex-reset-credit-operation-ledger.test.ts new file mode 100644 index 0000000000..e5eaf39ac6 --- /dev/null +++ b/tests/codex-reset-credit-operation-ledger.test.ts @@ -0,0 +1,1424 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { Database } from "bun:sqlite"; +import { join } from "node:path"; +import { + NestedConfigMutationError, + prepareConfigMutationDatabasePathForWrite, + withConfigMutationLockSync, +} from "../src/config"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/account-id"; +import { + MAX_MANUAL_RESET_CREDIT_OPERATION_IDS, + MAX_RESET_CREDIT_OPERATION_ACCOUNTS, + markManualResetCreditOperationAmbiguous, + markResetCreditOperationAmbiguous, + openManualResetCreditOperation, + openResetCreditOperation, + setResetCreditOperationMigrationFaultForTests, + settleManualResetCreditOperation, + settleResetCreditOperation, + type ManualResetCreditOperationIdentity, +} from "../src/codex/reset-credit-operation-ledger"; +import { + compareCodexResetCreditRecoveryGenerationOrder, + isCodexResetCreditOperationId, + type CodexResetCreditRecoveryGeneration, +} from "../src/codex/reset-credit-recovery"; + +const GENERATION: CodexResetCreditRecoveryGeneration = { + accountId: "pool-a", + credentialGeneration: 4, + exhaustionGeneration: 9, +}; +const CHILD_READY_TIMEOUT_MS = 10_000; +const CHILD_EXIT_TIMEOUT_MS = 5_000; +const CONTENTION_TEST_TIMEOUT_MS = 25_000; +const CONTENTION_FAIL_FAST_MS = 2_000; + +const LEGACY_OPERATION_SCHEMA_SQL = `CREATE TABLE reset_credit_operations ( + account_key TEXT PRIMARY KEY, + credential_generation INTEGER NOT NULL CHECK (credential_generation >= 0), + exhaustion_generation INTEGER NOT NULL CHECK (exhaustion_generation >= 0), + operation_id TEXT NOT NULL, + state TEXT NOT NULL, + code TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at) + ) STRICT, WITHOUT ROWID`; + +const PRIOR_OPERATION_SCHEMA_SQL = `CREATE TABLE reset_credit_operations ( + account_key TEXT PRIMARY KEY, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('recovery', 'manual')), + credential_generation INTEGER, + exhaustion_generation INTEGER, + operation_id TEXT NOT NULL, + state TEXT NOT NULL, + code TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at), + CHECK ( + (operation_kind = 'recovery' + AND credential_generation IS NOT NULL AND credential_generation >= 0 + AND exhaustion_generation IS NOT NULL AND exhaustion_generation >= 0) + OR + (operation_kind = 'manual' + AND credential_generation IS NULL AND exhaustion_generation IS NULL) + ) + ) STRICT, WITHOUT ROWID`; + +const CURRENT_OPERATION_SCHEMA_SHA256 = + "dec4cc8c4871ab8bce2f259268f2a674a9064aa239441bb04abea91de1b7f9fd"; +const CURRENT_MANUAL_ID_SCHEMA_SHA256 = + "c3ceff1059417c22cd7a3984f49eef54cd125213631882eb998b223567ce741a"; + +function schemaHash(sql: string | undefined): string | undefined { + return sql === undefined ? undefined : createHash("sha256").update(sql).digest("hex"); +} + +const originalOpenCodexHome = process.env.OPENCODEX_HOME; +let isolatedHome: string | undefined; + +beforeAll(() => { + isolatedHome = mkdtempSync(join(tmpdir(), "ocx-reset-credit-ledger-")); + process.env.OPENCODEX_HOME = isolatedHome; +}); + +afterAll(() => { + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + if (isolatedHome) { + Bun.gc(true); + rmSync(isolatedHome, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + } +}); + +function databasePath(): string { + return join(process.env.OPENCODEX_HOME!, "config-mutation.sqlite"); +} + +function corruptFirstRecord(): void { + const database = new Database(databasePath()); + try { + database.run("UPDATE reset_credit_operations SET operation_id = 'not-a-uuid'"); + } finally { + database.close(); + } +} + +function fixtureOperationId(index: number): string { + return `00000000-0000-4000-8000-${index.toString(16).padStart(12, "0")}`; +} + +const MIGRATION_REJECTION_FIXTURES = [ + { + label: "legacy recovery", + kind: "legacy" as const, + schema: LEGACY_OPERATION_SCHEMA_SQL, + backupTable: "reset_credit_operations_legacy_v1", + }, + { + label: "prior manual", + kind: "prior" as const, + schema: PRIOR_OPERATION_SCHEMA_SQL, + backupTable: "reset_credit_operations_legacy_v2", + }, +] as const; +const MIGRATION_REJECTION_CASES = ["malformed row", "duplicate operation id", "over capacity"] as const; + +function seedRejectedMigration( + kind: "legacy" | "prior", + rejection: (typeof MIGRATION_REJECTION_CASES)[number], +): Record[] { + const database = new Database(databasePath(), { create: true }); + try { + database.exec(kind === "legacy" ? LEGACY_OPERATION_SCHEMA_SQL : PRIOR_OPERATION_SCHEMA_SQL); + const insert = kind === "legacy" + ? database.prepare(` + INSERT INTO reset_credit_operations VALUES (?, ?, ?, ?, 'pending', NULL, 1, 1) + `) + : database.prepare(` + INSERT INTO reset_credit_operations VALUES ( + ?, 'manual', NULL, NULL, ?, 'pending', NULL, 1, 1 + ) + `); + const rowCount = rejection === "over capacity" + ? MAX_RESET_CREDIT_OPERATION_ACCOUNTS + 1 + : rejection === "duplicate operation id" ? 2 : 1; + const duplicatedOperationId = fixtureOperationId(0x22000); + database.exec("BEGIN IMMEDIATE"); + for (let index = 0; index < rowCount; index += 1) { + const key = createHash("sha256") + .update(`migration-rejection-${kind}-${rejection}-${index}`) + .digest("hex"); + const operationId = rejection === "malformed row" + ? "not-a-uuid" + : rejection === "duplicate operation id" + ? duplicatedOperationId + : fixtureOperationId(0x23000 + index); + if (kind === "legacy") { + insert.run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, operationId); + } else { + insert.run(key, operationId); + } + } + database.exec("COMMIT"); + return database.query, []>( + "SELECT * FROM reset_credit_operations ORDER BY account_key", + ).all(); + } catch (error) { + try { database.exec("ROLLBACK"); } catch { /* preserve the fixture error */ } + throw error; + } finally { + database.close(); + } +} + +function seedValidMigration(kind: "legacy" | "prior"): Record[] { + const database = new Database(databasePath(), { create: true }); + try { + database.exec(kind === "legacy" ? LEGACY_OPERATION_SCHEMA_SQL : PRIOR_OPERATION_SCHEMA_SQL); + const key = createHash("sha256").update(`valid-migration-${kind}`).digest("hex"); + const operationId = fixtureOperationId(kind === "legacy" ? 0x24100 : 0x24101); + if (kind === "legacy") { + database.prepare(` + INSERT INTO reset_credit_operations VALUES (?, ?, ?, ?, 'ambiguous', NULL, 100, 200) + `).run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, operationId); + } else { + database.prepare(` + INSERT INTO reset_credit_operations VALUES ( + ?, 'manual', NULL, NULL, ?, 'ambiguous', NULL, 100, 200 + ) + `).run(key, operationId); + } + return database.query, []>( + "SELECT * FROM reset_credit_operations ORDER BY account_key", + ).all(); + } finally { + database.close(); + } +} + +function appendTerminalManualHistory(startIndex: number, count: number): void { + const database = new Database(databasePath()); + try { + const insert = database.prepare(` + INSERT INTO reset_credit_manual_operation_ids ( + operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + ) VALUES (?, ?, ?, 'no_credit', 1, 1) + `); + database.exec("BEGIN IMMEDIATE"); + for (let offset = 0; offset < count; offset += 1) { + const operationId = fixtureOperationId(startIndex + offset); + const key = createHash("sha256") + .update(`synthetic-terminal-manual-history-${startIndex + offset}`) + .digest("hex"); + insert.run(operationId, key, operationId); + } + database.exec("COMMIT"); + } catch (error) { + try { database.exec("ROLLBACK"); } catch { /* preserve the fixture error */ } + throw error; + } finally { + database.close(); + } +} + +async function waitForPath(path: string): Promise { + const deadline = Date.now() + CHILD_READY_TIMEOUT_MS; + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`); + await Bun.sleep(10); + } +} + +async function terminateChild(child: Bun.Subprocess): Promise { + child.kill(); + let exited = await Promise.race([ + child.exited.then(() => true), + Bun.sleep(CHILD_EXIT_TIMEOUT_MS).then(() => false), + ]); + if (!exited) { + child.kill("SIGKILL"); + exited = await Promise.race([ + child.exited.then(() => true), + Bun.sleep(CHILD_EXIT_TIMEOUT_MS).then(() => false), + ]); + } + if (!exited) throw new Error("reset-credit ledger lock child did not exit"); +} + +function createLaxDuplicateLedger(): void { + const database = new Database(databasePath(), { create: true }); + try { + database.exec(` + CREATE TABLE reset_credit_operations ( + account_key TEXT, + credential_generation INTEGER, + exhaustion_generation INTEGER, + operation_id TEXT, + state TEXT, + code TEXT, + created_at INTEGER, + updated_at INTEGER + )`); + const key = createHash("sha256") + .update(`codex-reset-credit-operation\0${GENERATION.accountId}`) + .digest("hex"); + const insert = database.prepare(` + INSERT INTO reset_credit_operations VALUES (?, ?, ?, ?, 'pending', NULL, 1, 1)`); + insert.run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, + "00000000-0000-4000-8000-000000000001"); + insert.run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, + "00000000-0000-4000-8000-000000000002"); + } finally { + database.close(); + } +} + +beforeEach(() => { + const database = new Database(databasePath(), { create: true }); + try { + database.exec(` + DROP TABLE IF EXISTS reset_credit_manual_operation_ids; + DROP TABLE IF EXISTS reset_credit_operations; + DROP TABLE IF EXISTS reset_credit_operations_legacy_v1; + DROP TABLE IF EXISTS reset_credit_operations_legacy_v2; + `); + } + finally { database.close(); } +}); + +afterEach(() => { + setResetCreditOperationMigrationFaultForTests(null); +}); + +describe("Codex reset-credit operation ledger", () => { + test("exports strict operation-id, generation-order, and nested-mutation contracts", () => { + const operationId = fixtureOperationId(0xabc); + expect(isCodexResetCreditOperationId(operationId)).toBeTrue(); + expect(isCodexResetCreditOperationId(operationId.toUpperCase())).toBeFalse(); + expect(isCodexResetCreditOperationId("00000000-0000-5000-8000-000000000001")).toBeFalse(); + expect(isCodexResetCreditOperationId("00000000-0000-4000-7000-000000000001")).toBeFalse(); + expect(isCodexResetCreditOperationId(` ${operationId}`)).toBeFalse(); + expect(isCodexResetCreditOperationId("00000000-0000-0000-0000-000000000000")).toBeFalse(); + expect(isCodexResetCreditOperationId(null)).toBeFalse(); + expect(compareCodexResetCreditRecoveryGenerationOrder(GENERATION, GENERATION)).toBe(0); + expect(compareCodexResetCreditRecoveryGenerationOrder( + { ...GENERATION, credentialGeneration: GENERATION.credentialGeneration + 1 }, + GENERATION, + )).toBe(1); + expect(compareCodexResetCreditRecoveryGenerationOrder( + { ...GENERATION, exhaustionGeneration: GENERATION.exhaustionGeneration - 1 }, + GENERATION, + )).toBe(-1); + expect(prepareConfigMutationDatabasePathForWrite()).toBe(databasePath()); + expect(() => withConfigMutationLockSync(() => prepareConfigMutationDatabasePathForWrite())) + .toThrow(NestedConfigMutationError); + }); + + test("bootstraps the canonical ledger when its config directory and database are absent", () => { + const path = databasePath(); + if (!isolatedHome) throw new Error("isolated home was not initialized"); + rmSync(isolatedHome, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + expect(existsSync(isolatedHome)).toBeFalse(); + expect(existsSync(path)).toBeFalse(); + + expect(openResetCreditOperation(GENERATION, 100)) + .toMatchObject({ kind: "execute", resumed: false }); + expect(existsSync(path)).toBeTrue(); + }); + + test("fails closed when the primary ledger table is missing but manual identity state remains", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + const database = new Database(databasePath()); + try { + database.exec("DROP TABLE reset_credit_operations"); + } finally { + database.close(); + } + + expect(openResetCreditOperation(GENERATION, 200)).toEqual({ kind: "unavailable" }); + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ name: string }, []>(` + SELECT name FROM main.sqlite_schema + WHERE type = 'table' AND name LIKE 'reset_credit_%' + ORDER BY name + `).all()).toEqual([{ name: "reset_credit_manual_operation_ids" }]); + } finally { + verifier.close(); + } + }); + + test("fails closed when the manual identity table is missing from an existing ledger", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + const database = new Database(databasePath()); + try { + database.exec("DROP TABLE reset_credit_manual_operation_ids"); + } finally { + database.close(); + } + + expect(openResetCreditOperation(GENERATION, 200)).toEqual({ kind: "unavailable" }); + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ operation_id: string }, []>( + "SELECT operation_id FROM reset_credit_operations", + ).get()?.operation_id).toBe(opened.operationId); + expect(verifier.query<{ name: string }, []>(` + SELECT name FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_manual_operation_ids' + `).get()).toBeNull(); + } finally { + verifier.close(); + } + }); + + test("snapshots recovery generations once and rejects inherited fields", () => { + let credentialReads = 0; + const accessorGeneration = { + accountId: GENERATION.accountId, + get credentialGeneration() { + credentialReads += 1; + return credentialReads === 1 ? GENERATION.credentialGeneration : GENERATION.credentialGeneration + 1; + }, + exhaustionGeneration: GENERATION.exhaustionGeneration, + }; + + const opened = openResetCreditOperation(accessorGeneration, 100); + expect(opened).toMatchObject({ kind: "execute", resumed: false }); + expect(credentialReads).toBe(1); + if (opened.kind !== "execute") throw new Error("reservation failed"); + expect(markResetCreditOperationAmbiguous(accessorGeneration, opened.operationId, 200)) + .toEqual({ kind: "mismatch" }); + expect(credentialReads).toBe(2); + + const inheritedGeneration = Object.create(GENERATION) as CodexResetCreditRecoveryGeneration; + expect(() => openResetCreditOperation(inheritedGeneration, 300)) + .toThrow("generation fields must be own properties"); + expect(() => markResetCreditOperationAmbiguous(inheritedGeneration, opened.operationId, 300)) + .toThrow("generation fields must be own properties"); + expect(() => settleResetCreditOperation(inheritedGeneration, opened.operationId, "reset", 300)) + .toThrow("generation fields must be own properties"); + }); + + test("snapshots manual identities once and rejects inherited fields", () => { + const recovery = openResetCreditOperation(GENERATION, 100); + if (recovery.kind !== "execute") throw new Error("recovery reservation failed"); + const callerOperationId = fixtureOperationId(0xdef); + let accountReads = 0; + let credentialReads = 0; + let operationReads = 0; + const accessorIdentity = { + get accountId() { + accountReads += 1; + return "pool-manual-snapshot"; + }, + get chatgptAccountId() { + credentialReads += 1; + return "chatgpt-manual-snapshot"; + }, + get operationId() { + operationReads += 1; + return operationReads <= 2 ? callerOperationId : recovery.operationId; + }, + }; + + expect(openManualResetCreditOperation(accessorIdentity, 200)).toEqual({ + kind: "execute", + operationId: callerOperationId, + resumed: false, + }); + expect({ accountReads, credentialReads, operationReads }).toEqual({ + accountReads: 1, + credentialReads: 1, + operationReads: 1, + }); + const database = new Database(databasePath(), { readonly: true }); + try { + const rows = database.query<{ operation_id: string }, []>( + "SELECT operation_id FROM reset_credit_operations ORDER BY operation_id", + ).all(); + expect(rows.map(row => row.operation_id)).toEqual([ + callerOperationId, + recovery.operationId, + ].sort()); + } finally { + database.close(); + } + + const inheritedIdentity = Object.create({ + accountId: "pool-manual-inherited", + chatgptAccountId: "chatgpt-manual-inherited", + operationId: fixtureOperationId(0xeee), + }) as ManualResetCreditOperationIdentity; + expect(() => openManualResetCreditOperation(inheritedIdentity, 300)) + .toThrow("manual reset-credit identity fields must be own properties"); + expect(() => markManualResetCreditOperationAmbiguous(inheritedIdentity, 300)) + .toThrow("manual reset-credit identity fields must be own properties"); + expect(() => settleManualResetCreditOperation(inheritedIdentity, "reset", 300)) + .toThrow("manual reset-credit identity fields must be own properties"); + }); + + test("migrates the exact prior recovery schema without changing durable state", () => { + const database = new Database(databasePath(), { create: true }); + const key = createHash("sha256") + .update(`codex-reset-credit-operation\0${GENERATION.accountId}`) + .digest("hex"); + const operationId = fixtureOperationId(699); + try { + database.exec(LEGACY_OPERATION_SCHEMA_SQL); + database.prepare(` + INSERT INTO reset_credit_operations VALUES (?, ?, ?, ?, 'ambiguous', NULL, 100, 200) + `).run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, operationId); + } finally { + database.close(); + } + + expect(openResetCreditOperation(GENERATION, 300)).toEqual({ + kind: "execute", + operationId, + resumed: true, + }); + const migrated = new Database(databasePath(), { readonly: true }); + try { + expect(schemaHash(migrated.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_operations' + `).get()?.sql)).toBe(CURRENT_OPERATION_SCHEMA_SHA256); + expect(migrated.query<{ name: string }, []>(` + SELECT name FROM main.sqlite_schema + WHERE name = 'reset_credit_operations_legacy_v1' + `).get()).toBeNull(); + expect(migrated.query, []>( + "SELECT * FROM reset_credit_operations", + ).get()).toMatchObject({ + account_key: key, + operation_kind: "recovery", + credential_generation: GENERATION.credentialGeneration, + exhaustion_generation: GENERATION.exhaustionGeneration, + operation_id: operationId, + state: "ambiguous", + created_at: 100, + updated_at: 200, + }); + } finally { + migrated.close(); + } + }); + + test("migrates the prior manual schema before persisting a joined retry id", () => { + const original = fixtureOperationId(690); + const joined = fixtureOperationId(691); + const physicalAccount = "chatgpt-prior-manual"; + const key = createHash("sha256") + .update(`codex-reset-credit-manual-physical\0${physicalAccount}`) + .digest("hex"); + const database = new Database(databasePath(), { create: true }); + try { + database.exec(PRIOR_OPERATION_SCHEMA_SQL); + database.prepare(` + INSERT INTO reset_credit_operations VALUES ( + ?, 'manual', NULL, NULL, ?, 'ambiguous', NULL, 100, 200 + ) + `).run(key, original); + } finally { + database.close(); + } + + expect(openManualResetCreditOperation({ + accountId: "pool-prior-manual", + chatgptAccountId: physicalAccount, + operationId: joined, + }, 300)).toEqual({ kind: "execute", operationId: original, resumed: true }); + + const migrated = new Database(databasePath(), { readonly: true }); + try { + expect(schemaHash(migrated.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_operations' + `).get()?.sql)).toBe(CURRENT_OPERATION_SCHEMA_SHA256); + expect(migrated.query<{ name: string }, []>(` + SELECT name FROM main.sqlite_schema + WHERE name = 'reset_credit_operations_legacy_v2' + `).get()).toBeNull(); + expect(migrated.query<{ operation_id: string; joined_operation_id: string }, []>(` + SELECT operation_id, joined_operation_id FROM reset_credit_operations + `).get()).toEqual({ operation_id: original, joined_operation_id: joined }); + expect(migrated.query<{ + operation_id: string; + canonical_operation_id: string; + terminal_code: string | null; + }, []>(` + SELECT operation_id, canonical_operation_id, terminal_code + FROM reset_credit_manual_operation_ids + ORDER BY operation_id + `).all()).toEqual([ + { operation_id: original, canonical_operation_id: original, terminal_code: null }, + { operation_id: joined, canonical_operation_id: original, terminal_code: null }, + ]); + } finally { + migrated.close(); + } + }); + + for (const fixture of MIGRATION_REJECTION_FIXTURES) { + test(`rolls back ${fixture.label} migration after the first schema write`, () => { + const before = seedValidMigration(fixture.kind); + setResetCreditOperationMigrationFaultForTests("after_first_write"); + const result = fixture.kind === "legacy" + ? openResetCreditOperation({ ...GENERATION, accountId: "pool-migration-fault" }, 300) + : openManualResetCreditOperation({ + accountId: "pool-migration-fault", + chatgptAccountId: "chatgpt-migration-fault", + operationId: fixtureOperationId(0x24102), + }, 300); + expect(result).toEqual({ kind: "unavailable" }); + + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(schemaHash(verifier.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_operations' + `).get()?.sql)).toBe(schemaHash(fixture.schema)); + expect(verifier.query, []>( + "SELECT * FROM reset_credit_operations ORDER BY account_key", + ).all()).toEqual(before); + expect(verifier.query<{ name: string }, []>(` + SELECT name FROM main.sqlite_schema + WHERE type = 'table' AND name LIKE 'reset_credit_%' + ORDER BY name + `).all()).toEqual([{ name: "reset_credit_operations" }]); + } finally { + verifier.close(); + } + }); + + for (const rejection of MIGRATION_REJECTION_CASES) { + test(`refuses ${fixture.label} migration with ${rejection} without rewriting state`, () => { + const before = seedRejectedMigration(fixture.kind, rejection); + const result = fixture.kind === "legacy" + ? openResetCreditOperation({ ...GENERATION, accountId: "pool-migration-probe" }, 300) + : openManualResetCreditOperation({ + accountId: "pool-migration-probe", + chatgptAccountId: "chatgpt-migration-probe", + operationId: fixtureOperationId(0x24000), + }, 300); + expect(result).toEqual({ kind: "unavailable" }); + + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(schemaHash(verifier.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_operations' + `).get()?.sql)).toBe(schemaHash(fixture.schema)); + expect(verifier.query, []>( + "SELECT * FROM reset_credit_operations ORDER BY account_key", + ).all()).toEqual(before); + expect(verifier.query<{ name: string }, [string, string]>(` + SELECT name FROM main.sqlite_schema + WHERE name = ? OR name = ? + ORDER BY name + `).all(fixture.backupTable, "reset_credit_manual_operation_ids")).toEqual([]); + } finally { + verifier.close(); + } + }); + } + } + + test("manual operations resume one intent and short-circuit its terminal result", () => { + const identity = { + accountId: "pool-manual", + chatgptAccountId: "chatgpt-manual", + operationId: fixtureOperationId(700), + }; + expect(openManualResetCreditOperation(identity, 100)).toEqual({ + kind: "execute", + operationId: identity.operationId, + resumed: false, + }); + expect(markManualResetCreditOperationAmbiguous(identity, 200)).toEqual({ kind: "updated" }); + expect(openManualResetCreditOperation(identity, 300)).toEqual({ + kind: "execute", + operationId: identity.operationId, + resumed: true, + }); + expect(settleManualResetCreditOperation( + identity, + "not-a-reset-code" as never, + 350, + )).toEqual({ kind: "mismatch" }); + expect(settleManualResetCreditOperation(identity, "already_redeemed", 400)) + .toEqual({ kind: "updated" }); + expect(openManualResetCreditOperation(identity, 500)).toEqual({ + kind: "terminal", + operationId: identity.operationId, + code: "already_redeemed", + }); + }); + + test("a distinct manual id after settlement opens one explicit new intent", () => { + const first = { + accountId: "pool-manual-new-intent", + chatgptAccountId: "chatgpt-new-intent", + operationId: fixtureOperationId(706), + }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(first, "reset", 200)).toEqual({ kind: "updated" }); + const second = { ...first, operationId: fixtureOperationId(707) }; + expect(openManualResetCreditOperation(second, 300)).toEqual({ + kind: "execute", + operationId: second.operationId, + resumed: false, + }); + expect(openManualResetCreditOperation(second, 400)).toEqual({ + kind: "execute", + operationId: second.operationId, + resumed: true, + }); + }); + + test("an uppercase terminal id cannot reopen as a lowercase retry", () => { + const identity = { + accountId: "pool-manual-uppercase-terminal", + chatgptAccountId: "chatgpt-uppercase-terminal", + operationId: fixtureOperationId(708), + }; + expect(openManualResetCreditOperation(identity, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(identity, "reset", 200)).toEqual({ kind: "updated" }); + const uppercase = identity.operationId.toUpperCase(); + const database = new Database(databasePath()); + try { + database.prepare("UPDATE reset_credit_operations SET operation_id = ?").run(uppercase); + } finally { + database.close(); + } + + expect(openManualResetCreditOperation(identity, 300)).toEqual({ kind: "unavailable" }); + const stored = new Database(databasePath(), { readonly: true }); + try { + expect(stored.query<{ operation_id: string; state: string; code: string }, []>(` + SELECT operation_id, state, code FROM reset_credit_operations + `).get()).toEqual({ operation_id: uppercase, state: "confirmed", code: "reset" }); + } finally { + stored.close(); + } + }); + + test("fails closed when a manual id loses its canonical history mapping", () => { + const identity = { + accountId: "pool-manual-history-corrupt", + chatgptAccountId: "chatgpt-manual-history-corrupt", + operationId: fixtureOperationId(710), + }; + expect(openManualResetCreditOperation(identity, 100)).toMatchObject({ kind: "execute" }); + const missingCanonical = fixtureOperationId(711); + const database = new Database(databasePath()); + try { + database.prepare(` + UPDATE reset_credit_manual_operation_ids + SET canonical_operation_id = ? + WHERE operation_id = ? + `).run(missingCanonical, identity.operationId); + } finally { + database.close(); + } + + expect(openManualResetCreditOperation(identity, 200)).toEqual({ kind: "unavailable" }); + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ canonical_operation_id: string }, [string]>(` + SELECT canonical_operation_id + FROM reset_credit_manual_operation_ids + WHERE operation_id = ? + `).get(identity.operationId)?.canonical_operation_id).toBe(missingCanonical); + } finally { + verifier.close(); + } + }); + + test("manual operations preserve every joined caller id across later terminal intents", () => { + const first = { + accountId: "pool-manual-fence", + chatgptAccountId: "chatgpt-a", + operationId: fixtureOperationId(701), + }; + const joined = { ...first, operationId: fixtureOperationId(702) }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(openManualResetCreditOperation(joined, 200)) + .toEqual({ kind: "execute", operationId: first.operationId, resumed: true }); + const secondJoined = { + ...first, + accountId: "pool-manual-alias", + operationId: fixtureOperationId(703), + }; + expect(openManualResetCreditOperation(secondJoined, 300)) + .toEqual({ kind: "execute", operationId: first.operationId, resumed: true }); + // A third alias advanced durable time to 300. Settlement remains valid if + // the wall clock then moves backwards. + expect(settleManualResetCreditOperation(first, "reset", 250)).toEqual({ kind: "updated" }); + expect(openManualResetCreditOperation(first, 360)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + expect(openManualResetCreditOperation(joined, 370)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + expect(openManualResetCreditOperation(secondJoined, 375)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + const next = { ...first, operationId: fixtureOperationId(709) }; + expect(openManualResetCreditOperation(next, 380)).toEqual({ + kind: "execute", + operationId: next.operationId, + resumed: false, + }); + expect(settleManualResetCreditOperation(next, "no_credit", 390)).toEqual({ kind: "updated" }); + expect(openManualResetCreditOperation(joined, 395)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + expect(openManualResetCreditOperation(secondJoined, 396)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + const otherPhysical = { + ...first, + chatgptAccountId: "chatgpt-b", + operationId: fixtureOperationId(704), + }; + expect(openManualResetCreditOperation(otherPhysical, 400)) + .toEqual({ kind: "execute", operationId: otherPhysical.operationId, resumed: false }); + }); + + test("manual operations reject a caller UUID already owned by another physical account", () => { + const operationId = fixtureOperationId(705); + const first = { + accountId: "pool-manual-first", + chatgptAccountId: "chatgpt-first", + operationId, + }; + const second = { + accountId: "pool-manual-second", + chatgptAccountId: "chatgpt-second", + operationId, + }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(openManualResetCreditOperation(second, 200)).toEqual({ kind: "identity-mismatch" }); + expect(openManualResetCreditOperation(first, 300)).toEqual({ + kind: "execute", + operationId, + resumed: true, + }); + }); + + test("creates the exact canonical SQLite schema", () => { + expect(openResetCreditOperation(GENERATION, 100)) + .toMatchObject({ kind: "execute", resumed: false }); + const database = new Database(databasePath(), { readonly: true }); + try { + expect(schemaHash(database.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_operations' + `).get()?.sql)).toBe(CURRENT_OPERATION_SCHEMA_SHA256); + expect(schemaHash(database.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_manual_operation_ids' + `).get()?.sql)).toBe(CURRENT_MANUAL_ID_SCHEMA_SHA256); + } finally { + database.close(); + } + }); + + test("durably reserves before dispatch and restores the same operation identity", () => { + const first = openResetCreditOperation(GENERATION, 100); + expect(first).toMatchObject({ kind: "execute", resumed: false }); + if (first.kind !== "execute") throw new Error("reservation failed"); + + const restarted = openResetCreditOperation(GENERATION, 200); + expect(restarted).toEqual({ kind: "execute", operationId: first.operationId, resumed: true }); + expect(settleResetCreditOperation(GENERATION, first.operationId, "reset", 400)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION, 500)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + }); + + test("supports durable recovery generations for the main account", () => { + const generation = { ...GENERATION, accountId: MAIN_CODEX_ACCOUNT_ID }; + const first = openResetCreditOperation(generation, 100); + expect(first).toMatchObject({ kind: "execute", resumed: false }); + if (first.kind !== "execute") throw new Error("reservation failed"); + + expect(openResetCreditOperation(generation, 200)).toEqual({ + kind: "execute", + operationId: first.operationId, + resumed: true, + }); + expect(settleResetCreditOperation(generation, first.operationId, "already_redeemed", 300)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(generation, 400)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "already_redeemed", + }); + }); + + test("retains ambiguous operations and never allocates a replacement id", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + expect(markResetCreditOperationAmbiguous(GENERATION, opened.operationId, 150)).toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION, 200)).toEqual({ + kind: "execute", + operationId: opened.operationId, + resumed: true, + }); + expect(openResetCreditOperation({ ...GENERATION, exhaustionGeneration: 10 })).toEqual({ + kind: "unresolved-prior-generation", + }); + }); + + test("keeps timestamps monotonic when the wall clock rolls back", () => { + const opened = openResetCreditOperation(GENERATION, 200); + if (opened.kind !== "execute") throw new Error("reservation failed"); + expect(markResetCreditOperationAmbiguous(GENERATION, opened.operationId, 100)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION, 50)).toEqual({ + kind: "execute", + operationId: opened.operationId, + resumed: true, + }); + expect(settleResetCreditOperation(GENERATION, opened.operationId, "reset", 50)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION, 25)).toEqual({ + kind: "terminal", + operationId: opened.operationId, + code: "reset", + }); + }); + + test("returns terminal outcomes without another execution and permits a newer generation", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + expect(settleResetCreditOperation(GENERATION, opened.operationId, "already_redeemed", 200)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION)).toEqual({ + kind: "terminal", + operationId: opened.operationId, + code: "already_redeemed", + }); + expect(openResetCreditOperation({ ...GENERATION, exhaustionGeneration: 10 })) + .toMatchObject({ kind: "execute", resumed: false }); + }); + + test("persists every recovery terminal code without reopening execution", () => { + for (const [index, code] of (["nothing_to_reset", "no_credit"] as const).entries()) { + const generation = { + accountId: `pool-terminal-code-${index}`, + credentialGeneration: 1, + exhaustionGeneration: 1, + }; + const opened = openResetCreditOperation(generation, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + expect(settleResetCreditOperation(generation, opened.operationId, code, 200)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(generation, 300)).toEqual({ + kind: "terminal", + operationId: opened.operationId, + code, + }); + } + }); + + test("terminal recovery and manual operations reject late ambiguity and conflicting settlement", () => { + const recovery = openResetCreditOperation(GENERATION, 100); + if (recovery.kind !== "execute") throw new Error("reservation failed"); + expect(settleResetCreditOperation(GENERATION, recovery.operationId, "reset", 200)) + .toEqual({ kind: "updated" }); + expect(markResetCreditOperationAmbiguous(GENERATION, recovery.operationId, 300)) + .toEqual({ kind: "mismatch" }); + expect(settleResetCreditOperation(GENERATION, recovery.operationId, "no_credit", 400)) + .toEqual({ kind: "mismatch" }); + expect(openResetCreditOperation(GENERATION, 500)).toEqual({ + kind: "terminal", + operationId: recovery.operationId, + code: "reset", + }); + + const manual = { + accountId: "pool-manual-terminal-fence", + chatgptAccountId: "chatgpt-manual-terminal-fence", + operationId: fixtureOperationId(709), + }; + expect(openManualResetCreditOperation(manual, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(manual, "reset", 200)).toEqual({ kind: "updated" }); + expect(markManualResetCreditOperationAmbiguous(manual, 300)).toEqual({ kind: "mismatch" }); + expect(settleManualResetCreditOperation(manual, "no_credit", 400)) + .toEqual({ kind: "mismatch" }); + expect(openManualResetCreditOperation(manual, 500)).toEqual({ + kind: "terminal", + operationId: manual.operationId, + code: "reset", + }); + }); + + test("rejects stale generations and mismatched settlement", () => { + const current = openResetCreditOperation(GENERATION); + if (current.kind !== "execute") throw new Error("reservation failed"); + expect(openResetCreditOperation({ ...GENERATION, exhaustionGeneration: 8 })) + .toEqual({ kind: "stale-generation" }); + const reauthenticated = { + ...GENERATION, + credentialGeneration: GENERATION.credentialGeneration + 1, + exhaustionGeneration: 0, + }; + expect(openResetCreditOperation(reauthenticated)) + .toEqual({ kind: "unresolved-prior-generation" }); + expect(markResetCreditOperationAmbiguous(reauthenticated, current.operationId)) + .toEqual({ kind: "mismatch" }); + expect(settleResetCreditOperation(reauthenticated, current.operationId, "reset")) + .toEqual({ kind: "mismatch" }); + expect(settleResetCreditOperation(GENERATION, "00000000-0000-4000-8000-000000000999", "reset")) + .toEqual({ kind: "mismatch" }); + }); + + test("fails closed for malformed durable rows without overwriting them", () => { + const opened = openResetCreditOperation(GENERATION); + if (opened.kind !== "execute") throw new Error("reservation failed"); + corruptFirstRecord(); + expect(openResetCreditOperation(GENERATION)).toEqual({ kind: "unavailable" }); + expect(markResetCreditOperationAmbiguous(GENERATION, opened.operationId)).toEqual({ kind: "unavailable" }); + expect(settleResetCreditOperation(GENERATION, opened.operationId, "reset")) + .toEqual({ kind: "unavailable" }); + const database = new Database(databasePath(), { readonly: true }); + try { + expect(database.query<{ operation_id: string }, []>( + "SELECT operation_id FROM reset_credit_operations", + ).get()?.operation_id).toBe("not-a-uuid"); + } finally { + database.close(); + } + }); + + test("rejects a nonterminal row carrying any code without overwriting it", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + const database = new Database(databasePath()); + try { + database.run("UPDATE reset_credit_operations SET code = 'garbage'"); + } finally { + database.close(); + } + expect(openResetCreditOperation(GENERATION, 200)).toEqual({ kind: "unavailable" }); + expect(markResetCreditOperationAmbiguous(GENERATION, opened.operationId, 300)) + .toEqual({ kind: "unavailable" }); + const stored = new Database(databasePath(), { readonly: true }); + try { + expect(stored.query<{ code: string }, []>( + "SELECT code FROM reset_credit_operations", + ).get()?.code).toBe("garbage"); + } finally { + stored.close(); + } + }); + + test("fails closed for a noncanonical uppercase operation id", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + const uppercase = opened.operationId.toUpperCase(); + const database = new Database(databasePath()); + try { + database.prepare("UPDATE reset_credit_operations SET operation_id = ?").run(uppercase); + } finally { + database.close(); + } + expect(openResetCreditOperation(GENERATION, 200)).toEqual({ kind: "unavailable" }); + expect(settleResetCreditOperation(GENERATION, opened.operationId, "reset", 300)) + .toEqual({ kind: "unavailable" }); + const stored = new Database(databasePath(), { readonly: true }); + try { + expect(stored.query<{ operation_id: string }, []>( + "SELECT operation_id FROM reset_credit_operations", + ).get()?.operation_id).toBe(uppercase); + } finally { + stored.close(); + } + }); + + test("refuses a lax duplicate schema without choosing or replacing an operation", () => { + createLaxDuplicateLedger(); + expect(openResetCreditOperation(GENERATION)).toEqual({ kind: "unavailable" }); + expect(markResetCreditOperationAmbiguous( + GENERATION, + "00000000-0000-4000-8000-000000000001", + )).toEqual({ kind: "unavailable" }); + const database = new Database(databasePath(), { readonly: true }); + try { + expect(database.query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM reset_credit_operations", + ).get()?.count).toBe(2); + } finally { + database.close(); + } + }); + + test("refuses a canonical ledger that reuses an operation id across accounts", () => { + const first = openResetCreditOperation(GENERATION, 100); + if (first.kind !== "execute") throw new Error("reservation failed"); + const database = new Database(databasePath()); + try { + const secondKey = createHash("sha256") + .update("codex-reset-credit-operation\0pool-b") + .digest("hex"); + database.prepare(` + INSERT INTO reset_credit_operations ( + account_key, operation_kind, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) VALUES (?, 'recovery', ?, ?, ?, 'pending', NULL, 1, 1)`) + .run( + secondKey, + GENERATION.credentialGeneration, + GENERATION.exhaustionGeneration, + first.operationId, + ); + } finally { + database.close(); + } + expect(openResetCreditOperation(GENERATION)).toEqual({ kind: "unavailable" }); + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-c" })) + .toEqual({ kind: "unavailable" }); + }); + + test("isolates recovery validation from unrelated manual history but rejects cross-table id reuse", () => { + const manual = { + accountId: "pool-manual-isolation", + chatgptAccountId: "chatgpt-manual-isolation", + operationId: fixtureOperationId(0x25000), + }; + expect(openManualResetCreditOperation(manual, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(manual, "no_credit", 200)).toEqual({ kind: "updated" }); + + const database = new Database(databasePath()); + try { + const unrelated = fixtureOperationId(0x25001); + database.prepare(` + INSERT INTO reset_credit_manual_operation_ids ( + operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + ) VALUES (?, ?, ?, 'no_credit', 1, 1) + `).run( + unrelated, + createHash("sha256").update("unrelated-corrupt-manual-history").digest("hex"), + fixtureOperationId(0x25002), + ); + } finally { + database.close(); + } + + const generation = { ...GENERATION, accountId: "pool-recovery-isolation" }; + const opened = openResetCreditOperation(generation, 300); + expect(opened).toMatchObject({ kind: "execute", resumed: false }); + if (opened.kind !== "execute") throw new Error("recovery reservation failed"); + + const duplicate = new Database(databasePath()); + try { + duplicate.prepare(` + INSERT INTO reset_credit_manual_operation_ids ( + operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + ) VALUES (?, ?, ?, 'no_credit', 1, 1) + `).run( + opened.operationId, + createHash("sha256").update("cross-table-duplicate-recovery-id").digest("hex"), + opened.operationId, + ); + } finally { + duplicate.close(); + } + expect(openResetCreditOperation(generation, 400)).toEqual({ kind: "unavailable" }); + }); + + test("refuses a trigger without replacing the terminal reservation", () => { + const first = openResetCreditOperation(GENERATION, 100); + if (first.kind !== "execute") throw new Error("reservation failed"); + expect(settleResetCreditOperation(GENERATION, first.operationId, "reset", 200)) + .toEqual({ kind: "updated" }); + const database = new Database(databasePath()); + try { + database.exec(` + CREATE TRIGGER reset_credit_tamper AFTER UPDATE ON reset_credit_operations + BEGIN + DELETE FROM reset_credit_operations WHERE account_key = NEW.account_key; + END`); + } finally { + database.close(); + } + expect(openResetCreditOperation({ ...GENERATION, exhaustionGeneration: 10 }, 300)) + .toEqual({ kind: "unavailable" }); + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ operation_id: string }, []>( + "SELECT operation_id FROM reset_credit_operations", + ).get()?.operation_id).toBe(first.operationId); + } finally { + verifier.close(); + } + }); + + test("fails fast under cross-process mutation contention and recovers after abrupt exit", async () => { + expect(openResetCreditOperation(GENERATION)).toMatchObject({ kind: "execute", resumed: false }); + const readyPath = join(process.env.OPENCODEX_HOME!, "ledger-lock-ready"); + const child = Bun.spawn([process.execPath, "-e", ` + import { writeFileSync } from "node:fs"; + import { Database } from "bun:sqlite"; + const database = new Database(process.env.OCX_LEDGER_DB_PATH); + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + writeFileSync(process.env.OCX_LEDGER_READY_PATH, "ready"); + while (true) Bun.sleepSync(50); + `], { + cwd: join(import.meta.dir, ".."), + env: { + ...process.env, + OCX_LEDGER_DB_PATH: databasePath(), + OCX_LEDGER_READY_PATH: readyPath, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + try { + await waitForPath(readyPath); + const startedAt = performance.now(); + const blocked = openResetCreditOperation({ ...GENERATION, accountId: "pool-b" }); + const elapsedMs = performance.now() - startedAt; + expect(blocked).toEqual({ kind: "unavailable" }); + expect(elapsedMs).toBeLessThan(CONTENTION_FAIL_FAST_MS); + } finally { + await terminateChild(child); + } + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-b" })) + .toMatchObject({ kind: "execute", resumed: false }); + }, CONTENTION_TEST_TIMEOUT_MS); + + test("never authorizes execution from inside an uncommitted config transaction", () => { + let nested: unknown; + expect(() => withConfigMutationLockSync(() => { + nested = openResetCreditOperation(GENERATION); + expect(nested).toEqual({ kind: "unavailable" }); + throw new Error("roll back outer config transaction"); + })).toThrow("roll back outer config transaction"); + expect(openResetCreditOperation(GENERATION)) + .toMatchObject({ kind: "execute", resumed: false }); + }); + + test("keeps terminal manual ids immutable and fails closed when identity history is full", () => { + const first = { + accountId: "pool-manual-history-cap", + chatgptAccountId: "chatgpt-manual-history-cap", + operationId: fixtureOperationId(9000), + }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(first, "reset", 200)).toEqual({ kind: "updated" }); + + const database = new Database(databasePath()); + try { + const insert = database.prepare(` + INSERT INTO reset_credit_manual_operation_ids ( + operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + ) VALUES (?, ?, ?, 'no_credit', 1, 1) + `); + database.exec("BEGIN IMMEDIATE"); + for (let index = 1; index < MAX_MANUAL_RESET_CREDIT_OPERATION_IDS; index += 1) { + const operationId = fixtureOperationId(9000 + index); + const key = createHash("sha256") + .update(`manual-history-cap-${index}`) + .digest("hex"); + insert.run(operationId, key, operationId); + } + database.exec("COMMIT"); + } catch (error) { + try { database.exec("ROLLBACK"); } catch { /* preserve fixture error */ } + throw error; + } finally { + database.close(); + } + + expect(openManualResetCreditOperation(first, 300)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + expect(openManualResetCreditOperation({ + ...first, + operationId: fixtureOperationId(15000), + }, 400)).toEqual({ kind: "capacity" }); + expect(openResetCreditOperation({ + ...GENERATION, + accountId: "pool-recovery-at-manual-history-cap", + }, 500)).toMatchObject({ kind: "execute", resumed: false }); + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM reset_credit_manual_operation_ids", + ).get()?.count).toBe(MAX_MANUAL_RESET_CREDIT_OPERATION_IDS); + } finally { + verifier.close(); + } + }); + + test("refuses a new alias for an active manual operation when identity history is full", () => { + const canonical = { + accountId: "pool-manual-active-cap", + chatgptAccountId: "chatgpt-manual-active-cap", + operationId: fixtureOperationId(0x27000), + }; + expect(openManualResetCreditOperation(canonical, 100)).toEqual({ + kind: "execute", + operationId: canonical.operationId, + resumed: false, + }); + appendTerminalManualHistory(0x28000, MAX_MANUAL_RESET_CREDIT_OPERATION_IDS - 1); + + expect(openManualResetCreditOperation(canonical, 200)).toEqual({ + kind: "execute", + operationId: canonical.operationId, + resumed: true, + }); + const alias = { ...canonical, operationId: fixtureOperationId(0x27001) }; + expect(openManualResetCreditOperation(alias, 300)).toEqual({ kind: "capacity" }); + + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM reset_credit_manual_operation_ids", + ).get()?.count).toBe(MAX_MANUAL_RESET_CREDIT_OPERATION_IDS); + expect(verifier.query<{ count: number }, [string]>(` + SELECT COUNT(*) AS count FROM reset_credit_manual_operation_ids WHERE operation_id = ? + `).get(alias.operationId)?.count).toBe(0); + expect(verifier.query<{ joined_operation_id: string | null; updated_at: number }, []>(` + SELECT joined_operation_id, updated_at FROM reset_credit_operations + `).get()).toEqual({ joined_operation_id: null, updated_at: 100 }); + } finally { + verifier.close(); + } + expect(settleManualResetCreditOperation(canonical, "reset", 400)).toEqual({ kind: "updated" }); + }); + + test("fails closed without rewriting manual identity history at max plus one", () => { + const manual = { + accountId: "pool-manual-over-cap", + chatgptAccountId: "chatgpt-manual-over-cap", + operationId: fixtureOperationId(0x29000), + }; + expect(openManualResetCreditOperation(manual, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(manual, "reset", 200)).toEqual({ kind: "updated" }); + const recoveryGeneration = { ...GENERATION, accountId: "pool-recovery-over-manual-cap" }; + expect(openResetCreditOperation(recoveryGeneration, 300)) + .toMatchObject({ kind: "execute", resumed: false }); + + const before = new Database(databasePath(), { readonly: true }); + let operationRows: Record[]; + try { + operationRows = before.query, []>( + "SELECT * FROM reset_credit_operations ORDER BY account_key", + ).all(); + } finally { + before.close(); + } + appendTerminalManualHistory(0x2a000, MAX_MANUAL_RESET_CREDIT_OPERATION_IDS); + + expect(openManualResetCreditOperation(manual, 400)).toEqual({ kind: "unavailable" }); + expect(openResetCreditOperation(recoveryGeneration, 500)).toEqual({ kind: "unavailable" }); + + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM reset_credit_manual_operation_ids", + ).get()?.count).toBe(MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + 1); + expect(verifier.query, []>( + "SELECT * FROM reset_credit_operations ORDER BY account_key", + ).all()).toEqual(operationRows); + expect(schemaHash(verifier.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_manual_operation_ids' + `).get()?.sql)).toBe(CURRENT_MANUAL_ID_SCHEMA_SHA256); + } finally { + verifier.close(); + } + }); + + test("admits existing accounts but refuses a new account at capacity", () => { + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-0" })) + .toMatchObject({ kind: "execute", resumed: false }); + const database = new Database(databasePath()); + try { + const insert = database.prepare(` + INSERT INTO reset_credit_operations ( + account_key, operation_kind, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) VALUES (?, 'recovery', ?, ?, ?, 'pending', NULL, 1, 1)`); + database.exec("BEGIN IMMEDIATE"); + for (let index = 1; index < MAX_RESET_CREDIT_OPERATION_ACCOUNTS; index += 1) { + const accountId = `pool-${index}`; + const key = createHash("sha256") + .update(`codex-reset-credit-operation\0${accountId}`) + .digest("hex"); + const operationId = fixtureOperationId(index); + insert.run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, operationId); + } + database.exec("COMMIT"); + } catch (error) { + try { database.exec("ROLLBACK"); } catch { /* surface the original fixture error */ } + throw error; + } finally { + database.close(); + } + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-over-cap" })) + .toEqual({ kind: "capacity" }); + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-0" })) + .toMatchObject({ kind: "execute", resumed: true }); + + const overflow = new Database(databasePath()); + try { + const key = createHash("sha256") + .update("codex-reset-credit-operation\0pool-corrupt-over-cap") + .digest("hex"); + overflow.prepare(` + INSERT INTO reset_credit_operations ( + account_key, operation_kind, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) VALUES (?, 'recovery', ?, ?, ?, 'pending', NULL, 1, 1)`) + .run( + key, + GENERATION.credentialGeneration, + GENERATION.exhaustionGeneration, + // SELECT_ALL intentionally reads MAX + 1 rows so the corrupt + // over-capacity state cannot be mistaken for an ordinary full ledger. + fixtureOperationId(MAX_RESET_CREDIT_OPERATION_ACCOUNTS + 1), + ); + } finally { + overflow.close(); + } + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-0" })) + .toEqual({ kind: "unavailable" }); + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-new" })) + .toEqual({ kind: "unavailable" }); + }); +}); diff --git a/tests/codex-shim-autorestore.test.ts b/tests/codex-shim-autorestore.test.ts index f6bd4b2b54..d4905ac5ca 100644 --- a/tests/codex-shim-autorestore.test.ts +++ b/tests/codex-shim-autorestore.test.ts @@ -41,6 +41,11 @@ describe("Codex shim CLI auto-restore policy", () => { expect(skipsCodexShimAutoRestore("codex-shim", ["codex-shim", subcommand])).toBe(true); } expect(skipsCodexShimAutoRestore("codex-shim", ["codex-shim", "status"])).toBe(false); + for (const action of ["check", "future-action", "bad", undefined]) { + const args = ["system", "codex-cli-update", ...(action ? [action] : [])]; + expect(skipsCodexShimAutoRestore("system", args)).toBe(true); + } + expect(skipsCodexShimAutoRestore("system", ["system", "update", "check"])).toBe(false); expect(skipsCodexShimAutoRestore("status", ["status"])).toBe(false); }); @@ -124,6 +129,53 @@ describe("Codex shim CLI auto-restore policy", () => { } }); + test("an actionable shim replacement stays byte-identical for the updater inspection namespace", async () => { + if (process.platform === "win32") return; + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-update-inspection-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-update-inspection-home-")); + const wrapper = join(binDir, "codex"); + const backup = join(binDir, "codex.opencodex-real"); + const statePath = join(home, "codex-shim.json"); + const replacement = "#!/bin/sh\necho externally updated codex\n"; + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + try { + process.env.PATH = binDir; + process.env.OPENCODEX_HOME = home; + writeFileSync(wrapper, "#!/bin/sh\necho original codex\n", "utf8"); + chmodSync(wrapper, 0o755); + expect(installCodexShim().installed).toBe(true); + writeFileSync(wrapper, replacement, "utf8"); + chmodSync(wrapper, 0o755); + await Bun.sleep(120); + const beforeWrapper = readFileSync(wrapper); + const beforeBackup = readFileSync(backup); + const beforeState = readFileSync(statePath); + + const result = spawnSync(process.execPath, [ + join(import.meta.dir, "..", "src", "cli", "index.ts"), + "system", "codex-cli-update", "check", "--json", + ], { + encoding: "utf8", + env: { ...process.env, PATH: binDir, OPENCODEX_HOME: home }, + }); + + expect(result.status).toBe(0); + expect(() => JSON.parse(result.stdout)).not.toThrow(); + expect(result.stderr).not.toContain("automatic repair after Codex update"); + expect(readFileSync(wrapper)).toEqual(beforeWrapper); + expect(readFileSync(backup)).toEqual(beforeBackup); + expect(readFileSync(statePath)).toEqual(beforeState); + } finally { + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, 20_000); + test("shim replaced -> next ocx command auto-restores and warns", async () => { if (process.platform === "win32") return; const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-activation-bin-")); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index 7ab67f595d..cb8f8839ba 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -1,9 +1,9 @@ import { afterAll, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { tmpdir } from "node:os"; -import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isVersionManagerOwnedCodexPath, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimFreshWriteHookForTests, setCodexShimGuardedWriteHookForTests, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, setCodexShimRollbackRestoreHookForTests, uninstallCodexShim } from "../src/codex/shim"; +import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, inspectCodexShimBackingForCommand, installCodexShim, isLocalAbsoluteInspectionPath, isVersionManagerOwnedCodexPath, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimFreshWriteHookForTests, setCodexShimGuardedWriteHookForTests, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, setCodexShimRollbackRestoreHookForTests, uninstallCodexShim } from "../src/codex/shim"; const SHIM_MARKER = "opencodex codex autostart shim"; const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2"; @@ -2011,7 +2011,8 @@ describe("version-manager shim destruction (#2412)", () => { expect(isVersionManagerOwnedCodexPath("/home/u/.asdf/installs/codex/1.0/bin/codex")).toBe(true); expect(isVersionManagerOwnedCodexPath("/home/u/.asdf/shims/codex")).toBe(true); expect(isVersionManagerOwnedCodexPath("/home/u/.volta/bin/codex")).toBe(true); - expect(isVersionManagerOwnedCodexPath("C:\\Users\\u\\.volta\\bin\\codex.cmd")).toBe(true); + expect(isVersionManagerOwnedCodexPath("C:\\Users\\u\\.volta\\bin\\codex.cmd", "win32")).toBe(true); + expect(isVersionManagerOwnedCodexPath("/opt/plain\\.volta\\bin/codex", "linux")).toBe(false); expect(isVersionManagerOwnedCodexPath("/usr/local/bin/codex")).toBe(false); expect(isVersionManagerOwnedCodexPath("/home/u/.npm-global/bin/codex")).toBe(false); expect(isVersionManagerOwnedCodexPath("/opt/homebrew/bin/codex")).toBe(false); @@ -2031,3 +2032,107 @@ describe("version-manager shim destruction (#2412)", () => { }); }); }); + +describe("Codex shim read-only backing inspection", () => { + test("local inspection paths reject Windows remote and device namespaces", () => { + expect(isLocalAbsoluteInspectionPath("/usr/local/bin/codex", "linux")).toBe(true); + expect(isLocalAbsoluteInspectionPath("C:\\OpenCodex\\codex.cmd", "win32")).toBe(true); + for (const path of [ + "\\Windows\\codex.cmd", + "/Windows/codex.cmd", + "\\\\server\\share\\codex.cmd", + "//server/share/codex.cmd", + "\\\\?\\C:\\OpenCodex\\codex.cmd", + "//?/C:/OpenCodex/codex.cmd", + "\\\\.\\PhysicalDrive0", + ]) { + expect(isLocalAbsoluteInspectionPath(path, "win32")).toBe(false); + } + expect(isLocalAbsoluteInspectionPath("codex.cmd", "win32")).toBe(false); + }); + + test("Windows backing inspection fails closed before pathname access on every host", () => { + expect(inspectCodexShimBackingForCommand( + "C:\\remote-or-local\\codex.cmd", + "win32", + "C:\\OpenCodex", + )).toEqual({ + status: "unknown", + reason: "binding_unavailable", + }); + }); + + test.skipIf(process.platform === "win32")("selects only the recorded wrapper backing and fails closed on preserve-only state", () => { + withInstalledShim(({ wrappers, backups, statePath }) => { + expect(inspectCodexShimBackingForCommand(wrappers[0]!)).toMatchObject({ + status: "matched", + selectedRole: "wrapper", + backingPath: backups[0]!, + backingKind: "backup", + }); + expect(inspectCodexShimBackingForCommand(backups[0]!)).toMatchObject({ + status: "matched", + selectedRole: "backing", + backingPath: backups[0]!, + backingKind: "backup", + }); + + const state = JSON.parse(readFileSync(statePath, "utf8")) as { wrappers: Array> }; + state.wrappers[0]!.preserveOnly = true; + writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + expect(inspectCodexShimBackingForCommand(wrappers[0]!)).toEqual({ + status: "unknown", + reason: "preserve_only", + }); + }); + }); + + test.skipIf(process.platform === "win32")("matches hard-link aliases of a recorded wrapper or backing by file identity", () => { + withInstalledShim(({ wrappers, backups }) => { + const wrapperAlias = `${wrappers[0]!}.alias`; + const backingAlias = `${backups[0]!}.alias`; + linkSync(wrappers[0]!, wrapperAlias); + linkSync(backups[0]!, backingAlias); + expect(inspectCodexShimBackingForCommand(wrapperAlias)).toMatchObject({ + status: "matched", + selectedRole: "wrapper", + backingPath: backups[0]!, + }); + expect(inspectCodexShimBackingForCommand(backingAlias)).toMatchObject({ + status: "matched", + selectedRole: "backing", + backingPath: backups[0]!, + }); + }); + }); + + test.skipIf(process.platform === "win32")("distinguishes absent state from invalid state", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-shim-inspect-invalid-")); + const oldHome = process.env.OPENCODEX_HOME; + try { + process.env.OPENCODEX_HOME = home; + expect(inspectCodexShimBackingForCommand(join(home, "codex"))).toEqual({ status: "not-tracked" }); + writeFileSync(join(home, "codex-shim.json"), "{broken", "utf8"); + expect(inspectCodexShimBackingForCommand(join(home, "codex"))).toEqual({ + status: "unknown", + reason: "state_invalid", + }); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(home, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === "win32")("fails closed when the recorded backing aliases the wrapper", () => { + withInstalledShim(({ wrappers, backups }) => { + rmSync(backups[0]!); + linkSync(wrappers[0]!, backups[0]!); + expect(inspectCodexShimBackingForCommand(wrappers[0]!)).toEqual({ + status: "unknown", + reason: "ambiguous_match", + }); + }); + }); + +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index 6f0416b35f..62d3f6d782 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -35,6 +35,7 @@ import * as windowsAcl from "../src/lib/windows-secret-acl"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../src/lib/windows-elevation"; import { AtomicWriteResidualTempError, atomicWriteFile, atomicWriteFileAsync, hardenConfigDir, hardenExistingSecret, renameAtomicFile, saveConfig } from "../src/config"; import { nextAtomicTempSequence } from "../src/config/atomic-write"; +import { flushConfigDirHardeningForTests } from "../src/config/paths"; import { providerManagementConfigError } from "../src/server/auth-cors"; let testDir = ""; @@ -2613,25 +2614,37 @@ describe("config.ts – Windows ACL hardening integration", () => { } }); - test("hardenConfigDir delegates to hardenSecretDir with required:false on win32", () => { + test("hardenConfigDir delegates to one async optional flight on win32", async () => { const origPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32", configurable: true }); try { - const spy = spyOn(windowsAcl, "hardenSecretDir").mockReturnValue({ ok: true }); + let release!: () => void; + const pending = new Promise(resolve => { release = resolve; }); + const spy = spyOn(windowsAcl, "hardenSecretDirAsync").mockImplementation(async () => { + await pending; + return { ok: true }; + }); mkdirSync(testDir, { recursive: true }); hardenConfigDir(); - expect(spy).toHaveBeenCalledWith(testDir, { required: false }); - spy.mockRestore(); + hardenConfigDir(); + try { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(testDir, { required: false }); + } finally { + release(); + await flushConfigDirHardeningForTests(); + spy.mockRestore(); + } } finally { Object.defineProperty(process, "platform", { value: origPlatform, configurable: true }); } }); - test("hardenConfigDir does not call hardenSecretDir on non-Windows", () => { + test("hardenConfigDir does not call async ACL hardening on non-Windows", () => { const origPlatform = process.platform; Object.defineProperty(process, "platform", { value: "linux", configurable: true }); try { - const spy = spyOn(windowsAcl, "hardenSecretDir"); + const spy = spyOn(windowsAcl, "hardenSecretDirAsync"); mkdirSync(testDir, { recursive: true }); hardenConfigDir(); expect(spy).not.toHaveBeenCalled(); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 3fde1260ce..de92758870 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -827,6 +827,7 @@ describe("Cursor blob handshake", () => { expect(tool.case).toBe("mcpToolCall"); if (tool.case === "mcpToolCall") { expect(tool.value.args?.toolCallId).toBe("ocxc1e_"); + expect(tool.value.args?.toolName).toBe("ocx_client_read_file"); expect(tool.value.result?.result.case).toBe("success"); if (tool.value.result?.result.case === "success") { const content = tool.value.result.result.value.content[0]?.content; diff --git a/tests/cursor-eof-terminal.test.ts b/tests/cursor-eof-terminal.test.ts index 18d4da50fa..614f04498b 100644 --- a/tests/cursor-eof-terminal.test.ts +++ b/tests/cursor-eof-terminal.test.ts @@ -235,8 +235,8 @@ describe("Cursor clean-EOF terminal gate", () => { test("clean Connect END_STREAM preserves a drained client-tool terminal before its grace timer", async () => { await withH2Server(respondWith([ - toolCallStartedFrame("call_client_1", "echo_a"), - clientToolArgsFrame("call_client_1", "echo_a", "A"), + toolCallStartedFrame("call_client_1", "ocx_client_echo_a"), + clientToolArgsFrame("call_client_1", "ocx_client_echo_a", "A"), cleanConnectEndFrame(), ]), async baseUrl => { const { messages, failure } = await drain(baseUrl, runRequest(ECHO_TOOL)); @@ -250,8 +250,8 @@ describe("Cursor clean-EOF terminal gate", () => { test("clean Connect END_STREAM keeps a later open sibling fail-closed after a client-tool drain", async () => { await withH2Server(respondWith([ - toolCallStartedFrame("call_client_2", "echo_a"), - clientToolArgsFrame("call_client_2", "echo_a", "A"), + toolCallStartedFrame("call_client_2", "ocx_client_echo_a"), + clientToolArgsFrame("call_client_2", "ocx_client_echo_a", "A"), toolCallStartedFrame("call_open_2", "apply_patch"), cleanConnectEndFrame(), ]), async baseUrl => { diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index e66e25c686..b740fb8617 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -748,8 +748,8 @@ describe("Cursor live transport unexpected EOF", () => { case: "mcpToolCall", value: create(McpToolCallSchema, { args: create(McpArgsSchema, { - name: "get_time", - toolName: "get_time", + name: "ocx_client_get_time", + toolName: "ocx_client_get_time", toolCallId: "call_1", providerIdentifier: "opencodex-responses", }), diff --git a/tests/cursor-http1-transport.test.ts b/tests/cursor-http1-transport.test.ts index e995e447d8..9c57963fcf 100644 --- a/tests/cursor-http1-transport.test.ts +++ b/tests/cursor-http1-transport.test.ts @@ -404,8 +404,8 @@ describe("Cursor HTTP/1.1 compatibility transport", () => { case: "mcpToolCall", value: create(McpToolCallSchema, { args: create(McpArgsSchema, { - name: "get_time", - toolName: "get_time", + name: "ocx_client_get_time", + toolName: "ocx_client_get_time", toolCallId: "call_1", providerIdentifier: "opencodex-responses", }), diff --git a/tests/cursor-live-transport.test.ts b/tests/cursor-live-transport.test.ts index 20538c1899..d2217fa000 100644 --- a/tests/cursor-live-transport.test.ts +++ b/tests/cursor-live-transport.test.ts @@ -18,6 +18,7 @@ import { setBackgroundShellRuntimeForTests, } from "../src/adapters/cursor/native-exec-shell"; import { BackgroundShellSpawnArgsSchema, ExecServerMessageSchema } from "../src/adapters/cursor/gen/agent_pb"; +import type { CursorProtobufEventState } from "../src/adapters/cursor/protobuf-events"; class TransportFakeChild extends EventEmitter { readonly stdin = new PassThrough(); @@ -339,21 +340,24 @@ describe("Cursor live transport context estimate wiring (#373)", () => { async function captureOpen(request: Record): Promise<{ encoded: Uint8Array | undefined; estimate: number | undefined; + state: CursorProtobufEventState | undefined; }> { const transport = makeTransport(); const internals = transport as unknown as { open( encodedRequest: Uint8Array, signal: AbortSignal | undefined, - state: { estimatedInputTokens?: number }, + state: CursorProtobufEventState, ...rest: unknown[] ): void; }; let encoded: Uint8Array | undefined; let estimate: number | undefined; + let capturedState: CursorProtobufEventState | undefined; internals.open = (encodedRequest, _signal, state) => { encoded = encodedRequest; estimate = state.estimatedInputTokens; + capturedState = state; throw new Error("stop-after-open"); }; @@ -361,7 +365,7 @@ describe("Cursor live transport context estimate wiring (#373)", () => { for await (const _ of transport.run(request as never)) { /* not reached */ } } catch { /* open() throws by design */ } transport.close?.(); - return { encoded, estimate }; + return { encoded, estimate, state: capturedState }; } const baseRequest = { @@ -395,4 +399,21 @@ describe("Cursor live transport context estimate wiring (#373)", () => { // estimate stays on. This pins the condition rather than the tracker's contents. expect(first).toBeGreaterThan(0); }); + + test("wire-isolated freeform tools keep client-name validation and return mapping", async () => { + const { state } = await captureOpen({ + ...baseRequest, + conversationId: "c-wire-freeform", + tools: [{ + name: "script", + description: "Run a script", + parameters: {}, + freeform: true, + }], + }); + + expect(state?.clientToolNames?.has("ocx_client_script")).toBe(true); + expect(state?.freeformToolNames?.has("script")).toBe(true); + expect(state?.cursorToolNameMap?.get("ocx_client_script")).toBe("script"); + }); }); diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index b77cc317c9..8da5485215 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -123,6 +123,84 @@ describe("Cursor protobuf tool-call events", () => { ]); }); + test("maps a provider-isolated Cursor client-tool alias back to Claude Desktop's bare tool name", () => { + const state = createCursorProtobufEventState({ + clientToolNames: ["ocx_client_read"], + toolSchemas: new Map([[ + "ocx_client_read", + { type: "object", properties: { path: { type: "string" } }, required: ["path"] }, + ]]), + cursorToolNameMap: new Map([["ocx_client_read", "read"]]), + }); + const toolCall = mcpToolCall("ocx_client_read", { path: "README.md" }); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { callId: "call_read", modelCallId: "model_read", toolCall }), + }), state)).toEqual([ + { type: "tool_call_start", id: "call_read", name: "read" }, + { type: "tool_call_delta", arguments: "{\"path\":\"README.md\"}" }, + { type: "tool_call_end", id: "call_read" }, + ]); + }); + + test("rejects malformed freeform arguments after restoring a provider-isolated alias", () => { + const state = createCursorProtobufEventState({ + clientToolNames: ["ocx_client_script"], + freeformToolNames: ["script"], + cursorToolNameMap: new Map([["ocx_client_script", "script"]]), + }); + const toolCall = mcpToolCall("ocx_client_script", { wrong_key: "not a wrapper" }); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: "call_bad_freeform", modelCallId: "model_bad_freeform", toolCall, + }), + }), state)).toEqual([ + { type: "error", message: "script call had invalid freeform arguments; expected {input:string}" }, + ]); + expect(state.openToolCalls.has("call_bad_freeform")).toBe(false); + expect(state.completedToolCalls.has("call_bad_freeform")).toBe(true); + }); + + test("keeps an aliased partial freeform wrapper open for native arguments", () => { + const state = createCursorProtobufEventState({ + clientToolNames: ["ocx_client_script"], + freeformToolNames: ["script"], + cursorToolNameMap: new Map([["ocx_client_script", "script"]]), + }); + const toolCall = mcpToolCall("ocx_client_script", {}); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallStarted", + value: create(ToolCallStartedUpdateSchema, { + callId: "call_partial_alias", modelCallId: "model_partial_alias", toolCall, + }), + }), state)).toEqual([]); + expect(mapCursorProtobufServerMessage(interaction({ + case: "partialToolCall", + value: create(PartialToolCallUpdateSchema, { + callId: "call_partial_alias", + modelCallId: "model_partial_alias", + toolCall, + argsTextDelta: '{"inpu', + }), + }), state)).toEqual([]); + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: "call_partial_alias", modelCallId: "model_partial_alias", toolCall, + }), + }), state)).toEqual([]); + expect(state.openToolCalls.get("call_partial_alias")).toMatchObject({ + name: "script", + args: '{"inpu', + awaitingNativeArgs: true, + }); + expect(state.completedToolCalls.has("call_partial_alias")).toBe(false); + }); + test("keeps genuine run_shell tool name when no exec_command alias was advertised", () => { const state = createCursorProtobufEventState({ clientToolNames: ["run_shell"], diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index c895091ca1..0b25b2a356 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -40,6 +40,44 @@ describe("Cursor tool definitions", () => { expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(tool.parameters); }); + test("isolates ordinary bare client identities without renaming proxy-owned or namespaced tools", () => { + expect(cursorToolWireName({ name: "read" })).toBe("ocx_client_read"); + expect(cursorToolWireName({ name: "ocx_client_read" })).toBe("ocx_client_ocx_client_read"); + expect(cursorToolWireName({ name: "read", namespace: "mcp__workspace" })).toBe("mcp__workspace__read"); + const bare: OcxTool = { name: "read", description: "Read", parameters: {} }; + expect(buildCursorToolDefinitions([bare], { name: "read" }).map(tool => tool.toolName)) + .toEqual(["ocx_client_read"]); + expect(buildCursorToolDefinitions([bare], { name: "ocx_client_read" }).map(tool => tool.toolName)) + .toEqual(["ocx_client_read"]); + + for (const name of [ + "exec", + "wait", + "exec_command", + "shell_command", + "apply_patch", + "edit_file", + "multi_edit", + "tool_search", + ]) { + expect(cursorToolWireName({ name })).toBe(name); + } + }); + + test("prefers a semantic tool name over a generated client wire alias", () => { + const tools: OcxTool[] = [ + { name: "read", description: "Read", parameters: {} }, + { name: "ocx_client_read", description: "Literal client-prefixed tool", parameters: {} }, + ]; + + expect(buildCursorToolDefinitions(tools, { name: "ocx_client_read" }).map(tool => tool.toolName)) + .toEqual(["ocx_client_ocx_client_read"]); + expect(buildCursorToolDefinitions(tools, { mode: "required", allowedTools: ["ocx_client_read"] }).map(tool => tool.toolName)) + .toEqual(["ocx_client_ocx_client_read"]); + expect(buildCursorToolDefinitions(tools, { name: "read" }).map(tool => tool.toolName)) + .toEqual(["ocx_client_read"]); + }); + test("advertises bare exec_command with compact native exec schema", () => { const tool: OcxTool = { name: "exec_command", @@ -255,7 +293,7 @@ describe("Cursor tool definitions", () => { expect(cursorToolsForActivePrompt(tools, "Use any 10 tools including MCP resources")?.map(tool => cursorToolWireName(tool))).toEqual([ "exec_command", "tool_search", - "list_mcp_resources", + "ocx_client_list_mcp_resources", ]); }); @@ -408,7 +446,7 @@ describe("Cursor tool definitions", () => { expect(note).toBeDefined(); if (!note) throw new Error("Expected Cursor tool guidance note"); - expect(note).toContain("available tool names are exactly `exec_command`, `Glob`"); + expect(note).toContain("available tool names are exactly `exec_command`, `ocx_client_Glob`"); expect(note).toContain("This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Bash`, `LS`"); expect(note).not.toContain("`Read`, `Grep`, `Glob`, `Bash`, `LS`"); }); @@ -425,7 +463,7 @@ describe("Cursor tool definitions", () => { expect(note).toBeDefined(); if (!note) throw new Error("Expected Cursor tool guidance note"); - expect(note).toContain("available tool names are exactly `exec_command`, `read`, `find`, `bash`"); + expect(note).toContain("available tool names are exactly `exec_command`, `ocx_client_read`, `ocx_client_find`, `ocx_client_bash`"); expect(note).toContain("This turn does not expose neighboring-agent tool names `Grep`, `LS`"); expect(note).not.toContain("`Read`"); expect(note).not.toContain("`Glob`"); diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index 31374cae72..f245e917b5 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -170,7 +170,7 @@ describe("Grok fence lifecycle wiring", () => { expect(startFn).toContain("if (!restored.success)"); expect(startFn).toContain("cleanupSucceeded = false"); expect(startFn).toContain("Native Codex restore failed during shutdown"); - expect(startFn).toContain("process.exit(restored ? 0 : 1)"); + expect(startFn).toContain("process.exit(restored && shutdownSucceeded ? 0 : 1)"); }); }); @@ -222,6 +222,12 @@ describe("POST /api/stop teardown", () => { expect(handler).toContain("stripGrokConfig()"); }); + test("maps a failed shutdown drain to a nonzero process exit", () => { + const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); + expect(handler).toContain("shutdownSucceeded = await drainAndShutdown"); + expect(handler).toContain("process.exit(shutdownSucceeded ? 0 : 1)"); + }); + test("a 409 does not escalate to a forced kill", () => { // Escalating would run the daemon's cleanup and strip shared config while the foreign // service keeps the proxy alive — the exact hole the ownership gate exists to close. diff --git a/tests/helpers/responses-state-never-settling-acl-child.ts b/tests/helpers/responses-state-never-settling-acl-child.ts new file mode 100644 index 0000000000..790b750fdd --- /dev/null +++ b/tests/helpers/responses-state-never-settling-acl-child.ts @@ -0,0 +1,59 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearResponseStateMemoryForTests, + awaitResponseSpillPublicationTailForTests, + pendingResponseSpillMetricsForTests, + rememberResponseState, + responseStateMetrics, + setResponseSpillAsyncAclAttemptBudgetForTests, + setResponseStateByteCapForTests, +} from "../../src/responses/state"; +import { + setAsyncIcaclsRunnerForTests, + setPlatformForTests, +} from "../../src/lib/windows-secret-acl"; +import { setAsyncWindowsPrincipalRunnerForTests } from "../../src/lib/windows-user-principal"; + +type Mode = "principal" | "icacls"; + +function rememberLarge(id: string): void { + const text = id.repeat(1_000); + rememberResponseState( + { model: "test/model", input: text, store: false }, + { id, output: [{ type: "message", role: "assistant", content: text }], status: "completed" }, + undefined, + { force: true }, + ); +} + +const mode = process.argv[2]; +if (mode !== "principal" && mode !== "icacls") { + throw new Error(`Unknown never-settling ACL mode: ${mode ?? ""}`); +} + +const home = mkdtempSync(join(tmpdir(), "ocx-never-settling-acl-child-")); +process.env.OPENCODEX_HOME = home; +clearResponseStateMemoryForTests(); +setPlatformForTests("win32"); +setResponseSpillAsyncAclAttemptBudgetForTests(100); +setResponseStateByteCapForTests(1_024); + +if (mode === "principal") { + setAsyncWindowsPrincipalRunnerForTests(() => new Promise(() => {})); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); +} else { + setAsyncIcaclsRunnerForTests(() => new Promise(() => {})); +} + +rememberLarge(`resp_never_settling_${mode}_first`); +rememberLarge(`resp_never_settling_${mode}_second`); +await awaitResponseSpillPublicationTailForTests(); + +console.log(JSON.stringify({ + settled: true, + pending: pendingResponseSpillMetricsForTests(), + metrics: responseStateMetrics(), +})); +rmSync(home, { recursive: true, force: true }); diff --git a/tests/helpers/responses-state-shutdown-budget-child.ts b/tests/helpers/responses-state-shutdown-budget-child.ts new file mode 100644 index 0000000000..a335d3fa5c --- /dev/null +++ b/tests/helpers/responses-state-shutdown-budget-child.ts @@ -0,0 +1,132 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearResponseStateForTests, + clearResponseStateMemoryForTests, + expandPreviousResponseInput, + flushResponseState, + pendingResponseSpillMetricsForTests, + rememberResponseState, + responseStateMetrics, + setResponseSpillShutdownBudgetForTests, + setResponseSpillShutdownTerminalizationPassLimitForTests, + setResponseStateByteCapForTests, +} from "../../src/responses/state"; +import { + resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, + setIcaclsRunnerForTests, + setPlatformForTests, +} from "../../src/lib/windows-secret-acl"; + +type Scenario = "exhaustion" | "guard"; + +function fixedResponse(id: string, output: unknown[]): { id: string; output: unknown[]; status: string } { + return { id, output, status: "completed" }; +} + +function rememberLarge(id: string, text: string): void { + rememberResponseState( + { model: "test/model", input: text, store: false }, + fixedResponse(id, [{ type: "message", role: "assistant", content: text }]), + undefined, + { force: true }, + ); +} + +function errorMessages(error: unknown): string[] { + if (!(error instanceof Error)) return []; + const nested = error instanceof AggregateError + ? error.errors.flatMap(errorMessages) + : []; + return [error.message, ...nested]; +} + +async function runScenario(scenario: Scenario): Promise> { + const home = mkdtempSync(join(tmpdir(), "ocx-shutdown-budget-child-")); + const priorHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + clearResponseStateMemoryForTests(); + try { + setPlatformForTests("win32"); + setResponseSpillShutdownBudgetForTests({ totalMs: 60, fallbackReserveMs: 40 }); + setResponseSpillShutdownTerminalizationPassLimitForTests(scenario === "guard" ? 0 : null); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let announced = false; + setAsyncIcaclsRunnerForTests(async () => { + if (!announced) { + announced = true; + entered(); + await gate; + } + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setIcaclsRunnerForTests((_args, timeoutMs) => { + Bun.sleepSync(timeoutMs + 50); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setResponseStateByteCapForTests(1_024); + rememberLarge("resp_budget_exhausted_first", "a".repeat(2 * 1024 * 1024 + 4_096)); + await started; + rememberLarge("resp_budget_exhausted_final", "b".repeat(2 * 1024 * 1024 + 4_096)); + setResponseStateByteCapForTests(1_000_000_000); + rememberResponseState( + { model: "test/model", input: "budget-safe-input", store: false }, + fixedResponse("resp_budget_unrelated", [{ type: "message", role: "assistant", content: "budget-safe-output" }]), + undefined, + { force: true }, + ); + + let reported: unknown; + try { + const flushing = flushResponseState(); + setResponseStateByteCapForTests(scenario === "guard" ? 1 : 1_024); + await flushing; + } catch (error) { + reported = error; + } finally { + release(); + } + const pending = pendingResponseSpillMetricsForTests(); + const metrics = responseStateMetrics(); + const messages = errorMessages(reported); + + clearResponseStateMemoryForTests(); + setResponseStateByteCapForTests(1_024); + const replay = JSON.stringify(expandPreviousResponseInput({ + previous_response_id: "resp_budget_unrelated", + input: "next", + })); + return { + settled: true, + reported: reported instanceof Error, + pending, + metrics, + replayedUnrelated: replay.includes("budget-safe-input") && replay.includes("budget-safe-output"), + guardReported: messages.some(message => message.includes("terminalization pass limit")), + }; + } finally { + setAsyncIcaclsRunnerForTests(null); + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setResponseSpillShutdownBudgetForTests(null); + setResponseSpillShutdownTerminalizationPassLimitForTests(null); + setResponseStateByteCapForTests(null); + clearResponseStateForTests(); + rmSync(home, { recursive: true, force: true }); + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + } +} + +const scenario = process.argv[2]; +if (scenario !== "exhaustion" && scenario !== "guard") { + throw new Error(`Unknown shutdown budget scenario: ${scenario ?? ""}`); +} + +console.log(JSON.stringify(await runScenario(scenario))); diff --git a/tests/integrations-invariants.test.ts b/tests/integrations-invariants.test.ts index 0d25ae7bd6..d9c91813a1 100644 --- a/tests/integrations-invariants.test.ts +++ b/tests/integrations-invariants.test.ts @@ -47,6 +47,17 @@ const TEST_ENV = {} as NodeJS.ProcessEnv; */ function installClient(clientId: IntegrationClientId): string { const spec = INTEGRATION_CLIENTS[clientId]; + /* + * Aside resolves its config path THROUGH its account manifest, so unlike + * every other client the path does not exist as a pure function of home. It + * throws rather than guessing an account, which is the point of that design, + * so the fixture has to establish which account is current before any + * resolver runs. + */ + if (clientId === "aside") { + mkdirSync(join(home, ".aside"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 0 })); + } mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); const configPath = spec.configPath(TEST_ENV, home); mkdirSync(dirname(configPath), { recursive: true }); @@ -66,9 +77,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same eleven ids", async () => { + test("every list of clients holds exactly the same twelve ids", async () => { /* - * Five lists name the same eleven clients, and two of them are maintained by + * Five lists name the same twelve clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -79,7 +90,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(11); + expect(expected).toHaveLength(12); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -156,6 +167,8 @@ describe("every client survives a full lifecycle", () => { zcode: '{\n "provider": {\n "builtin:zai-start-plan": { "name": "Keep Me", "kind": "anthropic" }\n }\n}\n', // Prime reads Pi's models.json contract, so it seeds the same shape. prime: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', + // Aside reads the same models.json contract as Pi and Prime. + aside: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', }; for (const clientId of INTEGRATION_CLIENT_IDS) { @@ -250,30 +263,37 @@ describe("a container we would have to replace is refused, not overwritten", () * our fragment path expects an object lost it to an apply that reported * success. Per client, because each one's path shape differs. */ - const NON_OBJECT: Partial> = { - pi: '{\n "providers": ["user-value"]\n}\n', - opencode: '{\n "provider": ["user-value"]\n}\n', - hermes: "providers:\n - user-value\n", - kimi: 'models = ["user-value"]\n', + const NON_OBJECT: Partial> = { + pi: ['{\n "providers": ["user-value"]\n}\n'], + // Two containers to check: opencode owns both blocks, so a user value under either + // one has to be refused rather than replaced on the way to our leaf. + opencode: [ + '{\n "provider": ["user-value"]\n}\n', + '{\n "providers": ["user-value"]\n}\n', + ], + hermes: ["providers:\n - user-value\n"], + kimi: ['models = ["user-value"]\n'], }; - for (const [clientId, seed] of Object.entries(NON_OBJECT) as [IntegrationClientId, string][]) { - test(`${clientId}: apply refuses and leaves the user's value untouched`, () => { - const configPath = installClient(clientId); - writeFileSync(configPath, seed); - - const result = applyIntegration({ - clientId, models: MODELS, config: CONFIG, port: 10100, - env: TEST_ENV, home, store, + for (const [clientId, seeds] of Object.entries(NON_OBJECT) as [IntegrationClientId, string[]][]) { + for (const [index, seed] of seeds.entries()) { + test(`${clientId}: apply refuses and leaves the user's value untouched (${index + 1})`, () => { + const configPath = installClient(clientId); + writeFileSync(configPath, seed); + + const result = applyIntegration({ + clientId, models: MODELS, config: CONFIG, port: 10100, + env: TEST_ENV, home, store, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + // The bytes are exactly as the user left them — not restored from a + // snapshot afterwards, never written in the first place. + expect(readFileSync(configPath, "utf8")).toBe(seed); + expect(store.listOperations()).toHaveLength(0); }); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("unsafe"); - // The bytes are exactly as the user left them — not restored from a - // snapshot afterwards, never written in the first place. - expect(readFileSync(configPath, "utf8")).toBe(seed); - expect(store.listOperations()).toHaveLength(0); - }); + } } test("openclaw: a collision in the NESTED container is refused too", () => { diff --git a/tests/integrations-state.test.ts b/tests/integrations-state.test.ts index 040b5fa40a..3f2bdc5e65 100644 --- a/tests/integrations-state.test.ts +++ b/tests/integrations-state.test.ts @@ -755,9 +755,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi, gajae, dsh, mcode, zcode and prime are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime and aside are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/integrations-writer.test.ts b/tests/integrations-writer.test.ts index 875f75cfd8..17aca22612 100644 --- a/tests/integrations-writer.test.ts +++ b/tests/integrations-writer.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { buildClientContribution, type ExportModel } from "../src/clients/config-export"; import { fileIO, type IntegrationIO } from "../src/integrations/config-io"; +import { canonicalContribution, fingerprint } from "../src/integrations/ownership"; import { protectedContributionFingerprint } from "../src/integrations/ownership-policy"; import { INTEGRATION_CLIENTS } from "../src/integrations/registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "../src/integrations/store"; @@ -106,6 +107,14 @@ function installZcode(): string { return configPath; } +function installOpencode(): string { + const spec = INTEGRATION_CLIENTS.opencode; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; +} + function input(overrides: Partial = {}): IntegrationWriteInput { return { clientId: "hermes", @@ -152,6 +161,92 @@ describe("apply", () => { expect(rows[0]!.snapshot.kind).toBe("none"); }); + /** + * opencode owns two fragments now, and only the V2 one carries the reasoning-effort + * variants. None of the other clients exercise a two-block document, so the writer has to + * be shown putting the variants on disk — not just building them. + */ + test("opencode writes the reasoning-effort variants and keeps them on refresh", () => { + const configPath = installOpencode(); + const models: ExportModel[] = [ + { + namespaced: "opencode-go/glm-5.3", + provider: "opencode-go", + id: "glm-5.3", + contextWindow: 1_000_000, + reasoningEfforts: ["max", "low", "high"], + }, + { namespaced: "openai/gpt-5.5", provider: "openai", id: "gpt-5.5", contextWindow: 400_000 }, + ]; + const request = input({ clientId: "opencode", models }); + expect(applyIntegration(request).ok).toBe(true); + + const doc = JSON.parse(readFileSync(configPath, "utf8")) as { + provider: { opencodex: { models: Record> } }; + providers: { + opencodex: { models: Record }> }; + }; + }; + expect(doc.providers.opencodex.models["opencode-go/glm-5.3"]!.variants!.map(v => v.id)) + .toEqual(["low", "high", "max"]); + // The legacy block stays variant-free, and a model without a ladder gets no key at all. + expect(doc.provider.opencodex.models["opencode-go/glm-5.3"]).not.toHaveProperty("variants"); + expect(doc.providers.opencodex.models["openai/gpt-5.5"]!.variants).toBeUndefined(); + + expect(readIntegrationState(request)).toMatchObject({ state: "current" }); + expect(applyIntegration(request).ok).toBe(true); + const after = JSON.parse(readFileSync(configPath, "utf8")) as typeof doc; + expect(after.providers.opencodex.models["opencode-go/glm-5.3"]!.variants!.map(v => v.id)) + .toEqual(["low", "high", "max"]); + }); + + /** + * Every opencode installation that predates the second block has a one-fragment record, so + * this is the migration path every existing user takes. Kimi has an equivalent test; opencode + * is the client that actually meets it in the field. + */ + test("a legacy opencode record migrates to two fragments and disables cleanly", () => { + const configPath = installOpencode(); + const request = input({ clientId: "opencode" }); + expect(applyIntegration(request).ok).toBe(true); + + // Rewind the file and the record to the pre-V2 shape: one fragment, one container, and + // fingerprints computed from exactly that state — a record whose fingerprints disagree + // with its own fragments is a foreign edit, which is a different (and correct) refusal. + const document = JSON.parse(readFileSync(configPath, "utf8")) as { + provider: { opencodex: unknown }; + }; + delete (document as Record).providers; + const legacyText = `${JSON.stringify(document, null, 2)}\n`; + writeFileSync(configPath, legacyText); + + const legacy = { ...store.readRecords().opencode! }; + legacy.fragmentPaths = [["provider", "opencodex"]]; + legacy.createdContainers = ["provider"]; + legacy.fileFingerprint = fingerprint(legacyText); + legacy.blockFingerprint = fingerprint(canonicalContribution({ + clientId: "opencode", + fragments: [{ path: ["provider", "opencodex"], value: document.provider.opencodex }], + })); + store.putRecord(legacy); + + expect(readIntegrationState(request)).toMatchObject({ state: "stale" }); + expect(applyIntegration(request).ok).toBe(true); + + const migrated = JSON.parse(readFileSync(configPath, "utf8")) as Record; + expect(migrated.providers).toBeDefined(); + expect(store.readRecords().opencode!.fragmentPaths).toEqual([ + ["provider", "opencodex"], + ["providers", "opencodex"], + ]); + + // Disabling has to take both fragments with it, including the container we created. + expect(disableIntegration(request).ok).toBe(true); + const after = JSON.parse(readFileSync(configPath, "utf8")) as Record; + expect(after.provider).toBeUndefined(); + expect(after.providers).toBeUndefined(); + }); + test("is idempotent: applying twice changes nothing the second time", () => { installHermes(); expect(applyIntegration(input()).ok).toBe(true); diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts index 0df9ba44b8..5490e66a44 100644 --- a/tests/management-client-config-route.test.ts +++ b/tests/management-client-config-route.test.ts @@ -1,6 +1,9 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { + GATED_MODEL_CLIENT_VERSION_FLOOR, resetCodexModelEntitlementCacheForTests, seedCodexModelEntitlementsForTests, } from "../src/codex/model-entitlements"; @@ -28,7 +31,31 @@ import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; */ const REAL_LOOKING_KEY = "ocx_live_9f3c7a2b41d84e6fa05c8e17b3d92764"; -afterEach(() => resetCodexModelEntitlementCacheForTests()); +const originalOpenCodexHome = process.env.OPENCODEX_HOME; +const originalCodexHome = process.env.CODEX_HOME; +let entitlementTestRoot = ""; +let entitlementCodexHome = ""; + +beforeAll(() => { + entitlementTestRoot = mkdtempSync(join(tmpdir(), "ocx-client-config-entitlement-")); + entitlementCodexHome = join(entitlementTestRoot, "codex"); + mkdirSync(entitlementCodexHome, { recursive: true }); + process.env.OPENCODEX_HOME = join(entitlementTestRoot, "opencodex"); + process.env.CODEX_HOME = entitlementCodexHome; +}); + +afterAll(() => { + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + rmSync(entitlementTestRoot, { recursive: true, force: true }); +}); + +afterEach(() => { + resetCodexModelEntitlementCacheForTests(); + rmSync(join(entitlementCodexHome, "auth.json"), { force: true }); +}); interface ClientConfigEnvelope { client: string; @@ -196,7 +223,16 @@ describe("GET /api/client-config", () => { }, 15_000); test("DSH response keeps management reasoning metadata in the rc.6 model map", async () => { - seedCodexModelEntitlementsForTests("main", ["gpt-5.6-luna"]); + writeFileSync(join(entitlementCodexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "dsh-token", account_id: "dsh-main" }, + })); + seedCodexModelEntitlementsForTests( + "main", + ["gpt-5.6-luna"], + Date.now(), + GATED_MODEL_CLIENT_VERSION_FLOOR, + "main:dsh-main", + ); const response = await clientConfigApi(baseConfig(), "?client=dsh"); expect(response.status).toBe(200); const body = await response.json() as ClientConfigEnvelope; @@ -215,6 +251,57 @@ describe("GET /api/client-config", () => { }); }, 15_000); + test("an expired management roster is refreshed once before client-config is projected", async () => { + writeFileSync(join(entitlementCodexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "client-config-token", account_id: "client-config-account" }, + })); + const originalFetch = globalThis.fetch; + let entitlementFetches = 0; + globalThis.fetch = (async input => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "chatgpt.com" && url.pathname === "/backend-api/codex/models") { + entitlementFetches += 1; + return Response.json({ models: [ + { slug: "gpt-5.6-sol", supported_in_api: true, visibility: "list" }, + { slug: "gpt-5.6-terra", supported_in_api: true, visibility: "list" }, + { slug: "gpt-5.6-luna", supported_in_api: true, visibility: "list" }, + ] }); + } + return originalFetch(input); + }) as typeof fetch; + try { + const config = baseConfig({ + providers: { + ...baseConfig().providers, + openai: { authMode: "forward", liveModels: false, models: [] }, + }, + }); + const response = await clientConfigApi(config, "?client=opencode"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + const models = (body.config as OpencodeGeneratedConfig).provider[OPENCODE_PROVIDER_ID].models; + expect(entitlementFetches).toBe(1); + expect(Object.keys(models)).toEqual(expect.arrayContaining([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + ])); + } finally { + globalThis.fetch = originalFetch; + } + }, 15_000); + + test("an entitlement ensure rejection cannot turn client-config into a 503", async () => { + const config = baseConfig(); + Object.defineProperty(config, "codexAccounts", { + get() { throw new Error("entitlement identity unavailable"); }, + configurable: true, + }); + + const response = await clientConfigApi(config, "?client=opencode"); + expect(response.status).toBe(200); + }, 15_000); + test("MCode response carries catalog context and its usable reasoning ladder", async () => { const response = await clientConfigApi(baseConfig(), "?client=mcode"); expect(response.status).toBe(200); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 22464fb4ba..68e4c6010d 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -3,7 +3,7 @@ import { managementFetch as fetch, ManagementRequest as Request } from "./helper import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { readCodexAccountRecord, saveCodexAccountCredential } from "../src/codex/account-store"; import { getTrackedCodexWebSocketCountForAccount } from "../src/codex/websocket-registry"; import { clearAccountNeedsReauth, clearAccountQuota, getAccountQuota, isAccountNeedsReauth, markAccountNeedsReauth, updateAccountQuota } from "../src/codex/auth-api"; import { @@ -32,7 +32,12 @@ import { handleManagementAPI } from "../src/server/management-api"; import { providerManagementConfigError } from "../src/server/auth-cors"; import { providerEmptyToolOutputConfigError } from "../src/config/provider-validation"; import { providerServiceTierConfigError, withProviderServiceTierDTO } from "../src/server/management/provider-capability-config"; -import { clearModelCache, markProviderDiscoveryFailed } from "../src/codex/model-cache"; +import { clearModelCache, markProviderDiscoveryFailed, markProviderDiscoveryOk } from "../src/codex/model-cache"; +import { + resetCodexModelEntitlementCacheForTests, + resolveCodexModelEntitlements, + type CodexModelEntitlementCredentialSnapshot, +} from "../src/codex/model-entitlements"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -690,6 +695,77 @@ describe("provider management validation", () => { } }); + test("provider discovery stays ok while entitlement status changes independently", async () => { + const accountId = "pool-entitlement-diagnostic"; + const now = Date.now(); + const liveConfig: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: poolProviders(), + codexAccounts: [{ + id: accountId, + email: "pool-entitlement-diagnostic@example.test", + isMain: false, + }], + }; + saveCodexAccountCredential(accountId, { + accessToken: "entitlement-diagnostic-access", + refreshToken: "entitlement-diagnostic-refresh", + expiresAt: now + 60_000, + chatgptAccountId: "chatgpt-entitlement-diagnostic", + }); + const generation = readCodexAccountRecord(accountId)!.generation; + const storedCredential: CodexModelEntitlementCredentialSnapshot = { + accountId, + accessToken: "entitlement-diagnostic-access", + chatgptAccountId: "chatgpt-entitlement-diagnostic", + credentialIdentity: `pool:${generation}:chatgpt-entitlement-diagnostic`, + }; + const readOpenAi = async (config: OcxConfig): Promise> => { + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI(new Request(requestUrl), requestUrl, config); + const providers = await response!.json() as Array>; + return providers.find(provider => provider.name === "openai")!; + }; + + markProviderDiscoveryOk("openai", 1); + try { + await resolveCodexModelEntitlements(liveConfig, { + credentials: [storedCredential], + fetcher: (async () => Response.json({ models: [{ + slug: "gpt-5.6-sol", + supported_in_api: true, + visibility: "list", + }] })) as typeof fetch, + now, + }); + expect(await readOpenAi(liveConfig)).toMatchObject({ + discovery: { status: "ok" }, + entitlement: { status: "fresh" }, + }); + + resetCodexModelEntitlementCacheForTests(); + await resolveCodexModelEntitlements(liveConfig, { + credentials: [storedCredential], + fetcher: (async () => new Response("upstream failed", { status: 503 })) as typeof fetch, + now, + }); + expect(await readOpenAi(liveConfig)).toMatchObject({ + discovery: { status: "ok" }, + entitlement: { status: "failed", reason: "http-error", httpStatus: 503 }, + }); + + resetCodexModelEntitlementCacheForTests(); + expect(await readOpenAi({ ...liveConfig, codexAccounts: [] })).toMatchObject({ + discovery: { status: "ok" }, + entitlement: { status: "unavailable" }, + }); + } finally { + resetCodexModelEntitlementCacheForTests(); + clearModelCache(); + } + }); + test("provider management rejects externally supplied forward auth providers", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 9fcbaa187d..37ec607585 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeDisplayName, @@ -26,6 +29,7 @@ import { NATIVE_GPT56_CONTEXT_WINDOW, NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW, native import type { OcxConfig } from "../src/types"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../src/codex/catalog/native-models"; import { + GATED_MODEL_CLIENT_VERSION_FLOOR, resetCodexModelEntitlementCacheForTests, seedCodexModelEntitlementsForTests, } from "../src/codex/model-entitlements"; @@ -664,37 +668,117 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }); test("management API surfaces: /api/models leads with native rows; subagent available drops disabled bare slugs", async () => { - resetCodexModelEntitlementCacheForTests(); - const config = makeConfig({ disabledModels: ["gpt-5.6-sol"] }); - - const modelsRes = await handleManagementAPI( - new Request("http://localhost/api/models"), new URL("http://localhost/api/models"), config, - ); - const rows = await modelsRes!.json() as Array<{ namespaced: string; native?: boolean; disabled: boolean }>; - const nativeRows = rows.filter(r => r.native); - expect(nativeRows.map(r => r.namespaced)).toEqual( - NATIVE_OPENAI_MODELS.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)), - ); + const oldOcxHome = process.env.OPENCODEX_HOME; + const oldCodexHome = process.env.CODEX_HOME; + const root = mkdtempSync(join(tmpdir(), "ocx-native-model-management-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + process.env.OPENCODEX_HOME = join(root, "opencodex"); + process.env.CODEX_HOME = codexHome; + try { + resetCodexModelEntitlementCacheForTests(); + const config = makeConfig({ disabledModels: ["gpt-5.6-sol"] }); + + const modelsRes = await handleManagementAPI( + new Request("http://localhost/api/models"), new URL("http://localhost/api/models"), config, + ); + const rows = await modelsRes!.json() as Array<{ namespaced: string; native?: boolean; disabled: boolean }>; + const nativeRows = rows.filter(r => r.native); + expect(nativeRows.map(r => r.namespaced)).toEqual( + NATIVE_OPENAI_MODELS.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)), + ); + + // A confirmed roster makes the gated rows selectable again; a bare disable still wins. + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "toggle-token", account_id: "toggle-main" }, + })); + seedCodexModelEntitlementsForTests( + "main", + ["gpt-5.6-sol"], + Date.now(), + GATED_MODEL_CLIENT_VERSION_FLOOR, + "main:toggle-main", + ); + const confirmedRes = await handleManagementAPI( + new Request("http://localhost/api/models"), new URL("http://localhost/api/models"), config, + ); + const confirmedRows = (await confirmedRes!.json() as Array<{ namespaced: string; native?: boolean; disabled: boolean }>) + .filter(r => r.native); + expect(confirmedRows.map(r => r.namespaced)).toContain("gpt-5.6-sol"); + expect(confirmedRows.find(r => r.namespaced === "gpt-5.6-sol")?.disabled).toBe(true); + // Native rows lead the response so the GUI pins the group first. + expect(rows[0]?.native).toBe(true); + + const subRes = await handleManagementAPI( + new Request("http://localhost/api/subagent-models"), new URL("http://localhost/api/subagent-models"), config, + ); + const sub = await subRes!.json() as { available: string[] }; + // Bare disabled slugs flow through the existing namespaced-string filter automatically. + expect(sub.available).not.toContain("gpt-5.6-sol"); + expect(sub.available).toContain("gpt-5.6-terra"); + } finally { + if (oldOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldOcxHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; + rmSync(root, { recursive: true, force: true }); + } + }); - // A confirmed roster makes the gated rows selectable again; a bare disable still wins. - seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); - const confirmedRes = await handleManagementAPI( - new Request("http://localhost/api/models"), new URL("http://localhost/api/models"), config, - ); - const confirmedRows = (await confirmedRes!.json() as Array<{ namespaced: string; native?: boolean; disabled: boolean }>) - .filter(r => r.native); - expect(confirmedRows.map(r => r.namespaced)).toContain("gpt-5.6-sol"); - expect(confirmedRows.find(r => r.namespaced === "gpt-5.6-sol")?.disabled).toBe(true); - // Native rows lead the response so the GUI pins the group first. - expect(rows[0]?.native).toBe(true); - - const subRes = await handleManagementAPI( - new Request("http://localhost/api/subagent-models"), new URL("http://localhost/api/subagent-models"), config, + test("an expired confirmed roster is refreshed before /api/models projects native rows", async () => { + const oldOcxHome = process.env.OPENCODEX_HOME; + const oldCodexHome = process.env.CODEX_HOME; + const originalFetch = globalThis.fetch; + const root = mkdtempSync(join(tmpdir(), "ocx-native-model-expired-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + process.env.OPENCODEX_HOME = join(root, "opencodex"); + process.env.CODEX_HOME = codexHome; + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "expired-token", account_id: "expired-main" }, + })); + seedCodexModelEntitlementsForTests( + "main", + ["gpt-5.6-sol"], + 1_000, + GATED_MODEL_CLIENT_VERSION_FLOOR, + "main:expired-main", ); - const sub = await subRes!.json() as { available: string[] }; - // Bare disabled slugs flow through the existing namespaced-string filter automatically. - expect(sub.available).not.toContain("gpt-5.6-sol"); - expect(sub.available).toContain("gpt-5.6-terra"); + let entitlementFetches = 0; + globalThis.fetch = (async input => { + const url = new URL(input instanceof globalThis.Request ? input.url : String(input)); + if (url.hostname === "chatgpt.com" && url.pathname === "/backend-api/codex/models") { + entitlementFetches += 1; + return Response.json({ models: [ + { slug: "gpt-5.6-sol", supported_in_api: true, visibility: "list" }, + { slug: "gpt-5.6-terra", supported_in_api: true, visibility: "list" }, + { slug: "gpt-5.6-luna", supported_in_api: true, visibility: "list" }, + ] }); + } + return originalFetch(input); + }) as typeof fetch; + try { + const response = await handleManagementAPI( + new Request("http://localhost/api/models"), + new URL("http://localhost/api/models"), + makeConfig(), + ); + const rows = await response!.json() as Array<{ namespaced: string; native?: boolean }>; + const nativeIds = rows.filter(row => row.native).map(row => row.namespaced); + expect(entitlementFetches).toBe(1); + expect(nativeIds).toEqual(expect.arrayContaining([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + ])); + } finally { + globalThis.fetch = originalFetch; + if (oldOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldOcxHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; + rmSync(root, { recursive: true, force: true }); + } }); }); import { ManagementRequest as Request } from "./helpers/management-auth"; diff --git a/tests/native-profile-manager.test.ts b/tests/native-profile-manager.test.ts index 669b910409..c05e60e851 100644 --- a/tests/native-profile-manager.test.ts +++ b/tests/native-profile-manager.test.ts @@ -14,6 +14,7 @@ import { type NativeEnvelopeSnapshot, } from "../src/codex/native-profile-store"; import { NativeProfileError, type NativeProfileKey, type NativeProfileKeyProvider } from "../src/codex/native-profile-types"; +import { codexCredentialMutationEpoch } from "../src/codex/credential-mutation-epoch"; const roots: string[] = []; @@ -1027,6 +1028,7 @@ describe("native main profile transactions", () => { test("automatic recovery reports an externally refreshed target", async () => { const f = await enrolledFixture(); await leavePendingJournal(f); + const epochBefore = codexCredentialMutationEpoch(); const refreshed = envelope("account-target", "target-refreshed-auto"); writeFileSync(f.manager.context.authPath, refreshed); expect(await f.manager.recover(false)).toMatchObject({ @@ -1036,6 +1038,7 @@ describe("native main profile transactions", () => { restartRequired: true, }); expect(readFileSync(f.manager.context.authPath, "utf8")).toBe(refreshed); + expect(codexCredentialMutationEpoch()).toBe(epochBefore); }); // The manager can spend up to 5 s acquiring its SQLite transaction lock. @@ -1055,6 +1058,7 @@ describe("native main profile transactions", () => { expect(vaultText).not.toContain("account-target"); expect(() => readFileSync(join(f.stage.stagingCodexHome, "auth.json"))).toThrow(); + const epochBefore = codexCredentialMutationEpoch(); const switched = await f.manager.switch("work", true); expect(switched.restartRequired).toBe(true); expect(readFileSync(join(f.codexHome, "auth.json"), "utf8")).toBe(f.target); @@ -1067,6 +1071,7 @@ describe("native main profile transactions", () => { "account-source->account-target", "account-target->account-source", ]); + expect(codexCredentialMutationEpoch()).toBe(epochBefore + 2); }, 10_000); // This rollback case uses the same encrypted-vault and SQLite setup as the @@ -1084,6 +1089,7 @@ describe("native main profile transactions", () => { return atomic(path, content); }, }); + const epochBefore = codexCredentialMutationEpoch(); let caught: unknown; try { await failing.switch("work", true); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(NativeProfileError); @@ -1091,6 +1097,7 @@ describe("native main profile transactions", () => { expect(readFileSync(join(f.codexHome, "auth.json"), "utf8")).toBe(f.source); expect((await failing.doctor()).recoveryPending).toBe(false); expect((await failing.list()).activeProfileId).toBe(f.sourceProfile.id); + expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); }, 10_000); test("rollback verification failure retains the encrypted recovery journal and never claims success", async () => { @@ -1255,6 +1262,7 @@ describe("native main profile transactions", () => { await leavePendingJournal(f); const refreshedTarget = envelope("account-target", "target-refreshed"); writeFileSync(f.manager.context.authPath, refreshedTarget); + const epochBefore = codexCredentialMutationEpoch(); const result = await f.manager.recover(true, true); @@ -1280,6 +1288,7 @@ describe("native main profile transactions", () => { ); try { expect(decrypted.text).toBe(refreshedTarget); } finally { decrypted.raw.fill(0); key!.key.fill(0); } expect(f.transitions).toEqual(["account-target->account-source"]); + expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); }); test("rollback preservation failure leaves refreshed target auth untouched and journaled", async () => { diff --git a/tests/ocx-launcher-source.test.ts b/tests/ocx-launcher-source.test.ts index 5eca57d3d1..d44855b2da 100644 --- a/tests/ocx-launcher-source.test.ts +++ b/tests/ocx-launcher-source.test.ts @@ -28,7 +28,7 @@ describe("ocx.mjs npm launcher (source invariants)", () => { expect(spawnCall).toContain("[BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source"); // Path and source come from one resolution, so the marker cannot describe another binary. - expect(source).toContain("const bunRuntime = resolveBun();"); + expect(source).toContain("const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection });"); expect(source).toContain("const bun = bunRuntime.path;"); expect(source).toContain('return { path: bin, source: "bundled" };'); @@ -36,6 +36,16 @@ describe("ocx.mjs npm launcher (source invariants)", () => { expect(runtimeSource).toContain('export const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE";'); }); + test("the updater inspection namespace rejects direct Bun execution of the Node launcher", () => { + expect(source).toContain('codexCliUpdateInspection && typeof process.versions.bun === "string"'); + expect(source).toContain("codex-cli-update inspection must use the published Node launcher"); + }); + + test("the Node launcher proof-binds the bounded version-manager root allowlist", () => { + expect(source).toContain("CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS"); + expect(source).toContain("managerRoots: preBunCodexCliManagerRoots"); + }); + test("the long-running Bun child stays hidden under a headless Windows launcher (#1236)", () => { const spawnStart = source.indexOf("const child = spawn(bun, [cliPath"); expect(spawnStart).toBeGreaterThanOrEqual(0); @@ -79,12 +89,51 @@ describe("ocx.mjs npm launcher (source invariants)", () => { expect(source).toContain("typeof process.env[name] === \"string\" && process.env[name] !== \"\""); }); + /** + * Windows caps a process environment block at 32,767 characters. The inspection snapshot + * already carries PATH, PATHEXT, and the manager-root slots as proof-bound values, and + * `inspectCodexCliInstall` reads them from that snapshot rather than the live environment. + * Inheriting them again spends the budget twice, so a large-but-valid shell environment + * could stop the Bun child from spawning and fail the command before it reports anything. + */ + test("the inspection child does not inherit a duplicate copy of the snapshotted values", () => { + expect(source).toContain("const inheritedEnv = { ...process.env };"); + expect(source).toContain("...inheritedEnv,"); + // Windows spells the variable `Path` in practice, so an upper-case-only delete would + // leave the duplicate behind. The match must be on the lowercase form of every key. + expect(source).toContain("if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];"); + expect(source).toContain('["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase())'); + + // The de-duplication is scoped to the one-shot inspection launch; every other launch + // must still inherit PATH, or the long-running proxy child loses its tooling lookup. + const guard = source.indexOf("if (codexCliUpdateInspection) {", source.indexOf("const inheritedEnv")); + expect(guard).toBeGreaterThan(-1); + + // The spawn must no longer splat the raw environment, or the deletes above are pointless. + const spawnStart = source.indexOf("const child = spawn(bun, [cliPath,"); + expect(spawnStart).toBeGreaterThan(-1); + expect(source.slice(spawnStart)).not.toContain("...process.env,"); + }); + + /** + * A bare `CODEX_CLI_PATH` such as `codex` is an executable-lookup name, not a relative + * path. Resolving it against the launch cwd would make the inspector treat it as an + * explicit path and stop searching the proof-captured PATH, so a working configuration + * would report as unavailable. + */ + test("only separator-bearing configured Codex paths are resolved against the launch cwd", () => { + expect(source).toContain("const preBunCodexCliPath = configuredCodexCliPath !== null"); + expect(source).toContain('configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)'); + expect(source).toContain("? resolve(configuredCodexCliPath)"); + expect(source).toContain(": configuredCodexCliPath;"); + }); + test("valid Bun overrides are selected before the bundled runtime", () => { expect(source).toContain('const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";'); expect(source).toContain("const overridePath = resolve(override);"); expect(source).toContain('if (isRealBunBinary(overridePath)) return { path: overridePath, source: "override" };'); - const resolveStart = source.indexOf("function resolveBun() {"); + const resolveStart = source.indexOf("function resolveBun({ allowInstall = true } = {}) {"); const overrideCheck = source.indexOf("process.env[BUN_OVERRIDE_ENV]?.trim()", resolveStart); const overrideResolve = source.indexOf("resolve(override)", overrideCheck); const bundledLookup = source.indexOf("bunDir = bunBinDir()", resolveStart); diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts index 66a50cd6b3..5a14d273f1 100644 --- a/tests/opencode-cli.test.ts +++ b/tests/opencode-cli.test.ts @@ -14,6 +14,8 @@ import { buildOpencodeEnv, buildOpencodeProviderBlock, buildOpencodeProviderBlockFromCatalog, + buildOpencodeProviderBlocksFromCatalog, + buildOpencodeV2ProviderBlock, fetchOpencodeProxyModels, isOpencodeRuntimeConfigError, mergeOpencodeRuntimeConfig, @@ -159,7 +161,7 @@ describe("ocx opencode runtime config", () => { expect(parsed.provider?.[OPENCODE_PROVIDER_ID]).toBeTruthy(); }); - test("merges inherited inline settings and overrides only provider.opencodex", () => { + test("merges inherited inline settings and overrides only our own provider blocks", () => { const inherited = JSON.stringify({ model: "other/default", agents: { coder: { model: "x" } }, @@ -167,23 +169,37 @@ describe("ocx opencode runtime config", () => { other: { npm: "@other/pkg", name: "Other" }, [OPENCODE_PROVIDER_ID]: { npm: "stale", name: "Stale" }, }, + providers: { + other: { package: "@other/pkg", name: "Other" }, + [OPENCODE_PROVIDER_ID]: { package: "stale", name: "Stale" }, + }, }); - const block = buildOpencodeProviderBlock(10100, [], [{ provider: "kiro", id: "glm-5" }]); - const merged = mergeOpencodeRuntimeConfig(inherited, block); + const routed = [{ provider: "kiro", id: "glm-5" }]; + const block = buildOpencodeProviderBlock(10100, [], routed); + const v2Block = buildOpencodeV2ProviderBlock(10100, [], routed); + const merged = mergeOpencodeRuntimeConfig(inherited, { v1: block, v2: v2Block }); expect(isOpencodeRuntimeConfigError(merged)).toBe(false); if (isOpencodeRuntimeConfigError(merged)) return; expect(merged.model).toBe("other/default"); expect(merged.agents).toEqual({ coder: { model: "x" } }); expect(merged.provider.other).toEqual({ npm: "@other/pkg", name: "Other" }); expect(merged.provider[OPENCODE_PROVIDER_ID]).toEqual(block); + expect(merged.providers.other).toEqual({ package: "@other/pkg", name: "Other" }); + expect(merged.providers[OPENCODE_PROVIDER_ID]).toEqual(v2Block); }); test("rejects invalid inherited OPENCODE_CONFIG_CONTENT", () => { const block = buildOpencodeProviderBlock(10100, [], []); - expect(mergeOpencodeRuntimeConfig("{ not json", block)).toEqual({ error: "OPENCODE_CONFIG_CONTENT is not valid JSON." }); - expect(mergeOpencodeRuntimeConfig("[]", block)).toEqual({ error: "OPENCODE_CONFIG_CONTENT must be a JSON object." }); - expect(mergeOpencodeRuntimeConfig(JSON.stringify({ provider: "bad" }), block)) + const v2Block = buildOpencodeV2ProviderBlock(10100, [], []); + const blocks = { v1: block, v2: v2Block }; + expect(mergeOpencodeRuntimeConfig("{ not json", blocks)) + .toEqual({ error: "OPENCODE_CONFIG_CONTENT is not valid JSON." }); + expect(mergeOpencodeRuntimeConfig("[]", blocks)) + .toEqual({ error: "OPENCODE_CONFIG_CONTENT must be a JSON object." }); + expect(mergeOpencodeRuntimeConfig(JSON.stringify({ provider: "bad" }), blocks)) .toEqual({ error: "OPENCODE_CONFIG_CONTENT provider must be a JSON object when present." }); + expect(mergeOpencodeRuntimeConfig(JSON.stringify({ providers: "bad" }), blocks)) + .toEqual({ error: "OPENCODE_CONFIG_CONTENT providers must be a JSON object when present." }); }); }); @@ -310,6 +326,44 @@ describe("ocx opencode proxy model catalog", () => { } }); + test("carries /api/models effort ladders into the V2 block the launcher injects", () => { + // The launcher's own path: proxy rows -> catalog -> blocks. A renamed field here would + // ship a launcher without selectable efforts while every unit test stayed green. + const rows = [ + { namespaced: "opencode-go/glm-5.3", provider: "opencode-go", id: "glm-5.3", reasoningEfforts: ["max", "low", "high"] }, + { namespaced: "opencode-go/plain", provider: "opencode-go", id: "plain" }, + { namespaced: "opencode-go/hidden", provider: "opencode-go", id: "hidden", disabled: true, reasoningEfforts: ["low"] }, + ]; + const catalog = opencodeCatalogFromProxyRows(rows, cfg()); + const blocks = buildOpencodeProviderBlocksFromCatalog(10100, catalog, undefined, cfg()); + + expect(blocks.v2.models["opencode-go/glm-5.3"]!.variants).toEqual([ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + { id: "max", settings: { reasoningEffort: "max" } }, + ]); + expect(blocks.v2.models["opencode-go/plain"]!.variants).toBeUndefined(); + // The legacy block never carries variants, and both generations describe the same models: + // that is what makes opencode's merge produce one entry per model. + expect(blocks.v1.models["opencode-go/glm-5.3"]).not.toHaveProperty("variants"); + expect(Object.keys(blocks.v2.models)).toEqual(Object.keys(blocks.v1.models)); + expect(Object.keys(blocks.v1.models)).not.toContain("opencode-go/hidden"); + }); + + test("the launcher's V1 and V2 blocks share one connection", () => { + const blocks = buildOpencodeProviderBlocksFromCatalog( + 10100, + [{ namespaced: "opencode-go/glm-5.3", provider: "opencode-go", id: "glm-5.3" }], + "192.168.4.10", + cfg({ hostname: "0.0.0.0" }), + ); + // Built in one pass, so a later tweak to one generation cannot desync the endpoint. + expect(blocks.v2.settings).toEqual(blocks.v1.options); + expect(blocks.v2.settings.headers).toEqual({ + "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF, + }); + }); + test("fetchOpencodeProxyModels aborts stalled /api/models fetch and body reads", async () => { const live = { port: 10100, hostname: "127.0.0.1", pid: 1 }; const stall = (init?: RequestInit) => new Promise((_, reject) => { @@ -402,7 +456,16 @@ describe("ocx opencode native slug selection", () => { }); }); +/** + * Every case passes an empty env and a temp home. Without them the global branch reads the + * developer's real ~/.config/opencode/opencode.json, so on a machine that has the integration + * applied these tests would assert against that machine instead of their own fixture. + */ describe("ocx opencode project-layer detection", () => { + function detect(cwd: string, home: string): string | null { + return opencodeProviderOverridePath(cwd, {}, home); + } + test("detects a global config that redefines our provider key", () => { const home = mkdtempSync(join(tmpdir(), "ocx-opencode-global-")); const globalDir = join(home, ".config", "opencode"); @@ -416,7 +479,15 @@ describe("ocx opencode project-layer detection", () => { test("detects a project config that redefines our provider key", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); writeFileSync(join(dir, "opencode.json"), JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); - expect(projectConfigOverridesProvider(dir)).toBe(join(dir, "opencode.json")); + expect(detect(dir, dir)).toBe(join(dir, "opencode.json")); + }); + + test("detects a project config that defines only the V2 provider key", () => { + // The launcher overwrites `providers.opencodex` as well, so a V2-only config has to warn + // exactly like the legacy spelling does. + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + writeFileSync(join(dir, "opencode.json"), JSON.stringify({ providers: { [OPENCODE_PROVIDER_ID]: { package: "x" } } })); + expect(detect(dir, dir)).toBe(join(dir, "opencode.json")); }); test("detects opencode.jsonc and parent directories up to the git root", () => { @@ -427,7 +498,7 @@ describe("ocx opencode project-layer detection", () => { // project override "provider": { "${OPENCODE_PROVIDER_ID}": { "npm": "x" } } }`); - expect(projectConfigOverridesProvider(join(root, "packages", "app"))).toBe(join(root, "packages", "opencode.jsonc")); + expect(detect(join(root, "packages", "app"), root)).toBe(join(root, "packages", "opencode.jsonc")); }); test("does not walk above the git root", () => { @@ -437,60 +508,82 @@ describe("ocx opencode project-layer detection", () => { mkdirSync(repo, { recursive: true }); mkdirSync(join(repo, ".git")); writeFileSync(join(root, "opencode.json"), JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); - expect(projectConfigOverridesProvider(join(repo, "src"))).toBeNull(); + expect(detect(join(repo, "src"), root)).toBeNull(); }); test("ignores a project config that defines other providers", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); writeFileSync(join(dir, "opencode.json"), JSON.stringify({ provider: { other: { npm: "x" } } })); - expect(projectConfigOverridesProvider(dir)).toBeNull(); + expect(detect(dir, dir)).toBeNull(); }); test("no project config is not a warning", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); - expect(projectConfigOverridesProvider(dir)).toBeNull(); + expect(detect(dir, dir)).toBeNull(); }); }); describe("ocx opencode env assembly", () => { - test("OPENCODE_CONFIG_CONTENT carries only the runtime provider block", () => { - const block = buildOpencodeProviderBlock(10100, [], [{ provider: "kiro", id: "glm-5" }]); - const built = buildOpencodeEnv(block, "sk-ocx-123", { OPENCODE_CONFIG: "/user/mine.json", PATH: "/bin" }); + test("OPENCODE_CONFIG_CONTENT carries only the runtime provider blocks", () => { + const routed = [{ provider: "kiro", id: "glm-5" }]; + const blocks = { + v1: buildOpencodeProviderBlock(10100, [], routed), + v2: buildOpencodeV2ProviderBlock(10100, [], routed), + }; + const built = buildOpencodeEnv(blocks, "sk-ocx-123", { OPENCODE_CONFIG: "/user/mine.json", PATH: "/bin" }); expect(isOpencodeRuntimeConfigError(built)).toBe(false); if (isOpencodeRuntimeConfigError(built)) return; expect(built.OPENCODE_CONFIG).toBe("/user/mine.json"); expect(built.PATH).toBe("/bin"); - const parsed = JSON.parse(built[OPENCODE_CONFIG_CONTENT_ENV]!) as { provider?: Record }; + const parsed = JSON.parse(built[OPENCODE_CONFIG_CONTENT_ENV]!) as { + provider?: Record; + providers?: Record; + }; expect(Object.keys(parsed.provider ?? {})).toEqual([OPENCODE_PROVIDER_ID]); + expect(Object.keys(parsed.providers ?? {})).toEqual([OPENCODE_PROVIDER_ID]); }); test("preserves inherited inline settings in OPENCODE_CONFIG_CONTENT", () => { - const block = buildOpencodeProviderBlock(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const routed = [{ provider: "kiro", id: "glm-5" }]; + const block = buildOpencodeProviderBlock(10100, [], routed); + const v2Block = buildOpencodeV2ProviderBlock(10100, [], routed); const inherited = JSON.stringify({ model: "custom/model", provider: { other: { npm: "@other/pkg" } }, }); - const built = buildOpencodeEnv(block, "sk-ocx-123", { [OPENCODE_CONFIG_CONTENT_ENV]: inherited }); + const built = buildOpencodeEnv( + { v1: block, v2: v2Block }, + "sk-ocx-123", + { [OPENCODE_CONFIG_CONTENT_ENV]: inherited }, + ); expect(isOpencodeRuntimeConfigError(built)).toBe(false); if (isOpencodeRuntimeConfigError(built)) return; const parsed = JSON.parse(built[OPENCODE_CONFIG_CONTENT_ENV]!) as { model?: string; provider?: Record; + providers?: Record; }; expect(parsed.model).toBe("custom/model"); expect(parsed.provider?.other).toEqual({ npm: "@other/pkg" }); expect(parsed.provider?.[OPENCODE_PROVIDER_ID]).toEqual(block); + expect(parsed.providers?.[OPENCODE_PROVIDER_ID]).toEqual(v2Block); }); test("surfaces invalid inherited OPENCODE_CONFIG_CONTENT as an error", () => { - const block = buildOpencodeProviderBlock(10100, [], []); - expect(buildOpencodeEnv(block, "sk-ocx-123", { [OPENCODE_CONFIG_CONTENT_ENV]: "[]" })) + const blocks = { + v1: buildOpencodeProviderBlock(10100, [], []), + v2: buildOpencodeV2ProviderBlock(10100, [], []), + }; + expect(buildOpencodeEnv(blocks, "sk-ocx-123", { [OPENCODE_CONFIG_CONTENT_ENV]: "[]" })) .toEqual({ error: "OPENCODE_CONFIG_CONTENT must be a JSON object." }); }); test("the admission key travels in the child env, matching the config's {env:…} reference", () => { - const block = buildOpencodeProviderBlock(10100, [], []); - const built = buildOpencodeEnv(block, "sk-ocx-123", {}); + const blocks = { + v1: buildOpencodeProviderBlock(10100, [], []), + v2: buildOpencodeV2ProviderBlock(10100, [], []), + }; + const built = buildOpencodeEnv(blocks, "sk-ocx-123", {}); expect(isOpencodeRuntimeConfigError(built)).toBe(false); if (isOpencodeRuntimeConfigError(built)) return; expect(built[OPENCODE_API_KEY_ENV]).toBe("sk-ocx-123"); diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index aa05690e38..3422d8447b 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -43,20 +43,26 @@ import { responseStatePersistPendingForTests, responseContinuationRetainedStoreSnapshot, runPendingResponseStatePersistForTests, + setResponseSpillAsyncAclAttemptBudgetForTests, setResponseStateByteCapForTests, setResponseStatePersistAttemptHookForTests, + setResponseSpillShutdownBudgetForTests, getStoredResponseBytesForTests, + flushPendingResponseSpillsForTests, + pendingResponseSpillMetricsForTests, } from "../src/responses/state"; import { readResponseSpill, deleteResponseSpill, recoverOrphanedResponseSpills, responseSpillDirectory, + setResponseSpillNowForTests, setResponseSpillPayloadCapForTests, setSpillIoForTest, writeResponseSpillDurably, } from "../src/responses/spill-store"; import { adapterNeedsForcedContinuation, injectDeveloperMessage } from "../src/server/responses"; +import { watchdogMs } from "./helpers/ci-watchdog"; /** * Windows without Developer Mode or admin cannot create a file symlink (EPERM). @@ -80,8 +86,11 @@ import { hardenSecretPath, hardenedSecretPathCountForTests, resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, + setNowForTests, setPlatformForTests, + setStatForTests, timedOutSecretPathCountForTests, } from "../src/lib/windows-secret-acl"; @@ -117,6 +126,100 @@ function spillFileNames(home: string): string[] { return existsSync(dir) ? readdirSync(dir).filter(name => name.endsWith(".spill.json")) : []; } +function spillTempNames(home: string): string[] { + const dir = responseSpillDirectory(home); + return existsSync(dir) ? readdirSync(dir).filter(name => name.endsWith(".tmp")) : []; +} + +interface ShutdownBudgetChildResult { + settled: boolean; + reported: boolean; + pending: { count: number; bytes: number }; + metrics: { residentCount: number; tombstoneCount: number }; + replayedUnrelated: boolean; + guardReported: boolean; +} + +interface NeverSettlingAclChildResult { + settled: boolean; + pending: { count: number; bytes: number }; + metrics: { tombstoneCount: number }; +} + +async function runShutdownBudgetChild( + scenario: "exhaustion" | "guard", +): Promise { + const timeoutMs = watchdogMs(3_000); + const child = Bun.spawn([ + process.execPath, + join(import.meta.dir, "helpers", "responses-state-shutdown-budget-child.ts"), + scenario, + ], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env }, + stdout: "pipe", + stderr: "pipe", + }); + const stdoutPromise = new Response(child.stdout).text(); + const stderrPromise = new Response(child.stderr).text(); + let timedOut = false; + let timer: ReturnType | undefined; + const timeout = new Promise(resolve => { + timer = setTimeout(() => { + timedOut = true; + try { child.kill("SIGKILL"); } catch { /* already exited */ } + void child.exited.then(resolve, () => resolve(-1)); + }, timeoutMs); + }); + const exitCode = await Promise.race([child.exited, timeout]); + if (timer !== undefined) clearTimeout(timer); + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); + if (timedOut) { + throw new Error(`response spill shutdown budget child timed out after ${timeoutMs}ms (${scenario})`); + } + if (exitCode !== 0) { + throw new Error(`response spill shutdown budget child exited ${exitCode} (${scenario}): ${stderr.trim()}`); + } + const line = stdout.trim().split(/\r?\n/).at(-1); + if (!line) throw new Error(`response spill shutdown budget child produced no result (${scenario})`); + return JSON.parse(line) as ShutdownBudgetChildResult; +} + +async function runNeverSettlingAclChild( + mode: "principal" | "icacls", +): Promise { + const timeoutMs = watchdogMs(1_500); + const child = Bun.spawn([ + process.execPath, + join(import.meta.dir, "helpers", "responses-state-never-settling-acl-child.ts"), + mode, + ], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env }, + stdout: "pipe", + stderr: "pipe", + }); + const stdoutPromise = new Response(child.stdout).text(); + const stderrPromise = new Response(child.stderr).text(); + let timedOut = false; + let timer: ReturnType | undefined; + const timeout = new Promise(resolve => { + timer = setTimeout(() => { + timedOut = true; + try { child.kill("SIGKILL"); } catch { /* already exited */ } + void child.exited.then(resolve, () => resolve(-1)); + }, timeoutMs); + }); + const exitCode = await Promise.race([child.exited, timeout]); + if (timer !== undefined) clearTimeout(timer); + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); + if (timedOut) throw new Error(`never-settling ${mode} child timed out after ${timeoutMs}ms`); + if (exitCode !== 0) throw new Error(`never-settling ${mode} child exited ${exitCode}: ${stderr.trim()}`); + const line = stdout.trim().split(/\r?\n/).at(-1); + if (!line) throw new Error(`never-settling ${mode} child produced no result`); + return JSON.parse(line) as NeverSettlingAclChildResult; +} + function rememberLarge(id: string, text: string, providers?: Parameters[2]): void { rememberResponseState( { model: "test/model", input: text, store: false }, @@ -168,9 +271,16 @@ describe("Responses previous_response_id state", () => { afterEach(() => { setSpillIoForTest(null); + setResponseSpillNowForTests(null); + setAsyncIcaclsRunnerForTests(null); setIcaclsRunnerForTests(null); + setNowForTests(null); setPlatformForTests(null); + setStatForTests(null); resetHardenedStateForTests(); + delete process.env.OPENCODEX_ACL_TIMEOUT_MS; + setResponseSpillShutdownBudgetForTests(null); + setResponseSpillAsyncAclAttemptBudgetForTests(null); setResponseStateByteCapForTests(null); clearResponseStateForTests(); rmSync(home, { recursive: true, force: true }); @@ -710,6 +820,491 @@ describe("Responses previous_response_id state", () => { expect(events).toEqual(["write", "fsync", "close", "harden", "publish", "dir-fsync", "stub-swap"]); }); + test("Windows spill ACL hardening yields the event loop and swaps only after publication", async () => { + setPlatformForTests("win32"); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let announced = false; + setAsyncIcaclsRunnerForTests(async () => { + if (!announced) { + announced = true; + entered(); + } + await gate; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setResponseStateByteCapForTests(1_024); + + rememberLarge("resp_async_acl", "x".repeat(8_000)); + await started; + try { + let unrelatedTickRan = false; + setTimeout(() => { unrelatedTickRan = true; }, 0); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(unrelatedTickRan).toBe(true); + expect(pendingResponseSpillMetricsForTests()).toMatchObject({ count: 1 }); + expect(responseStateMetrics()).toMatchObject({ residentCount: 1, spillStubCount: 0, spillWriteFailures: 0 }); + expect(JSON.stringify(expandPreviousResponseInput({ previous_response_id: "resp_async_acl", input: "next" }))) + .toContain("xxxxxxxx"); + } finally { + release(); + } + await flushPendingResponseSpillsForTests(); + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1, spillWrites: 1, spillWriteFailures: 0 }); + }); + + test("Windows spill retries one transient ACL timeout without installing a tombstone", async () => { + setPlatformForTests("win32"); + process.env.OPENCODEX_ACL_TIMEOUT_MS = "1000"; + let clock = 0; + let grantCalls = 0; + setNowForTests(() => clock); + setAsyncIcaclsRunnerForTests(async args => { + if (args.includes("/grant:r")) { + grantCalls += 1; + if (grantCalls === 1) { + clock = 1_000; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + } + } + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setResponseStateByteCapForTests(1_024); + + rememberLarge("resp_async_acl_retry", "r".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + + expect(grantCalls).toBeGreaterThanOrEqual(2); + expect(responseStateMetrics()).toMatchObject({ + residentCount: 0, + spillStubCount: 1, + tombstoneCount: 0, + spillWrites: 1, + spillWriteFailures: 0, + }); + }); + + test("Windows async spill attempts share one bounded ACL budget across every harden", async () => { + setPlatformForTests("win32"); + let clock = 0; + let firstGrant = true; + const deadlines: number[] = []; + const grantDeadlines: number[] = []; + const hardenTargets: string[] = []; + setNowForTests(() => clock); + setResponseSpillNowForTests(() => clock); + setAsyncIcaclsRunnerForTests(async (args, timeoutMs) => { + deadlines.push(timeoutMs); + if (args.includes("/grant:r")) grantDeadlines.push(timeoutMs); + if (firstGrant && args.includes("/grant:r")) { + firstGrant = false; + clock += timeoutMs; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + } + const target = String(args[0]); + if (!hardenTargets.includes(target)) hardenTargets.push(target); + clock += [6_000, 2_000, 1_000][hardenTargets.indexOf(target)] ?? 1_000; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setSpillIoForTest({ + link: () => { throw Object.assign(new Error("injected link fallback"), { code: "EPERM" }); }, + }); + setResponseStateByteCapForTests(1_024); + + rememberLarge("resp_async_acl_attempt_budget", "q".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + + expect(deadlines.length).toBeGreaterThanOrEqual(10); + expect(Math.max(...deadlines)).toBeLessThanOrEqual(15_000); + const retryAttemptGrantDeadlines = grantDeadlines.slice(-3); + expect(retryAttemptGrantDeadlines).toHaveLength(3); + expect(retryAttemptGrantDeadlines[1]!).toBeLessThan(retryAttemptGrantDeadlines[0]!); + expect(retryAttemptGrantDeadlines[2]!).toBeLessThan(retryAttemptGrantDeadlines[1]!); + expect(responseStateMetrics()).toMatchObject({ + residentCount: 0, + spillStubCount: 1, + tombstoneCount: 0, + spillWrites: 1, + spillWriteFailures: 0, + }); + }); + + test("Windows spill queue advances past never-settling principal and icacls runners", async () => { + for (const mode of ["principal", "icacls"] as const) { + const result = await runNeverSettlingAclChild(mode); + expect(result).toMatchObject({ + settled: true, + pending: { count: 0, bytes: 0 }, + metrics: { tombstoneCount: 2 }, + }); + } + }, { timeout: (2 * watchdogMs(1_500)) + 2_000 }); + + test("Windows pending spill publication cannot overwrite a newer same-id generation", async () => { + setPlatformForTests("win32"); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let announced = false; + setAsyncIcaclsRunnerForTests(async () => { + if (!announced) { + announced = true; + entered(); + } + await gate; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setResponseStateByteCapForTests(1_024); + + rememberLarge("resp_async_replace", `old-${"a".repeat(8_000)}`); + await started; + rememberLarge("resp_async_replace", `new-${"b".repeat(8_000)}`); + release(); + await flushPendingResponseSpillsForTests(); + + const replay = JSON.stringify(expandPreviousResponseInput({ + previous_response_id: "resp_async_replace", + input: "next", + })); + expect(replay).toContain("new-bbbbbbbb"); + expect(replay).not.toContain("old-aaaaaaaa"); + expect(spillFileNames(home)).toHaveLength(1); + expect(responseStateMetrics()).toMatchObject({ spillStubCount: 1, spillWriteFailures: 0 }); + }); + + test("shutdown flush stays pending while Windows spill ACL publication is gated", async () => { + setPlatformForTests("win32"); + setResponseSpillShutdownBudgetForTests({ totalMs: 1_000, fallbackReserveMs: 500 }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let announced = false; + setAsyncIcaclsRunnerForTests(async () => { + if (!announced) { + announced = true; + entered(); + } + await gate; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setResponseStateByteCapForTests(1_024); + rememberLarge("resp_shutdown_pending", "p".repeat(2 * 1024 * 1024 + 4_096)); + await started; + + let flushed = false; + const flushing = flushResponseState().then(() => { flushed = true; }); + try { + await new Promise(resolve => setTimeout(resolve, 25)); + expect(flushed).toBe(false); + } finally { + release(); + } + await flushing; + }); + + test("shutdown flush installs an oversized spill before snapshot and restart replay", async () => { + setPlatformForTests("win32"); + setResponseSpillShutdownBudgetForTests({ totalMs: 1_000, fallbackReserveMs: 500 }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let announced = false; + setAsyncIcaclsRunnerForTests(async () => { + if (!announced) { + announced = true; + entered(); + } + await gate; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setResponseStateByteCapForTests(1_024); + const payload = `restart-${"r".repeat(2 * 1024 * 1024 + 4_096)}`; + rememberLarge("resp_shutdown_restart", payload); + await started; + + let flushed = false; + const flushing = flushResponseState().then(() => { flushed = true; }); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(flushed).toBe(false); + release(); + await flushing; + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1 }); + + clearResponseStateMemoryForTests(); + setResponseStateByteCapForTests(1_024); + const replay = JSON.stringify(expandPreviousResponseInput({ + previous_response_id: "resp_shutdown_restart", + input: "next", + })); + expect(replay).toContain("restart-rrrrrrrr"); + expect(responseStateMetrics().spillStubCount).toBe(1); + }); + + test("shutdown drain reaches a stable tail after a publication is appended mid-drain", async () => { + setPlatformForTests("win32"); + setResponseSpillShutdownBudgetForTests({ totalMs: 1_000, fallbackReserveMs: 500 }); + let releaseFirst!: () => void; + let releaseSecond!: () => void; + let firstEntered!: () => void; + let secondEntered!: () => void; + const firstGate = new Promise(resolve => { releaseFirst = resolve; }); + const secondGate = new Promise(resolve => { releaseSecond = resolve; }); + const firstStarted = new Promise(resolve => { firstEntered = resolve; }); + const secondStarted = new Promise(resolve => { secondEntered = resolve; }); + let aclCalls = 0; + setAsyncIcaclsRunnerForTests(async () => { + aclCalls += 1; + if (aclCalls === 1) { + firstEntered(); + await firstGate; + } else if (aclCalls === 7) { + secondEntered(); + await secondGate; + } + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setResponseStateByteCapForTests(1_024); + rememberLarge("resp_fixed_point_first", "a".repeat(8_000)); + await firstStarted; + + let flushed = false; + const flushing = flushResponseState().then(() => { flushed = true; }); + rememberLarge("resp_fixed_point_second", "b".repeat(8_000)); + releaseFirst(); + await secondStarted; + try { + await new Promise(resolve => setTimeout(resolve, 25)); + expect(flushed).toBe(false); + } finally { + releaseSecond(); + } + await flushing; + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 2 }); + }); + + test("shutdown drain cap expiry enters the synchronous spill fallback", async () => { + setPlatformForTests("win32"); + setResponseSpillShutdownBudgetForTests({ totalMs: 120, fallbackReserveMs: 80 }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + setAsyncIcaclsRunnerForTests(async () => { + entered(); + await gate; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + let synchronousCalls = 0; + setIcaclsRunnerForTests(() => { + synchronousCalls += 1; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setResponseStateByteCapForTests(1_024); + rememberLarge("resp_shutdown_fallback", "f".repeat(2 * 1024 * 1024 + 4_096)); + await started; + + try { + await flushResponseState(); + expect(synchronousCalls).toBeGreaterThan(0); + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1 }); + } finally { + release(); + } + }); + + test("shutdown fallback spends only its reserved ACL budget", async () => { + setPlatformForTests("win32"); + const totalMs = 500; + const fallbackReserveMs = 300; + setResponseSpillShutdownBudgetForTests({ totalMs, fallbackReserveMs }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let aclClock = 0; + setNowForTests(() => aclClock); + setAsyncIcaclsRunnerForTests(async () => { + entered(); + await gate; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + const deadlines: number[] = []; + setIcaclsRunnerForTests((_args, timeoutMs) => { + deadlines.push(timeoutMs); + aclClock += 20; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setResponseStateByteCapForTests(1_024); + rememberLarge("resp_shutdown_budget", "b".repeat(2 * 1024 * 1024 + 4_096)); + await started; + + try { + await flushResponseState(); + } finally { + release(); + } + const logicalElapsedMs = totalMs - fallbackReserveMs + aclClock; + expect(deadlines.length).toBeGreaterThanOrEqual(6); + expect(Math.max(...deadlines)).toBeLessThanOrEqual(Math.floor(fallbackReserveMs / 2)); + expect(logicalElapsedMs).toBeLessThanOrEqual(totalMs); + }); + + test("late async spill completion cannot overwrite the shutdown fallback", async () => { + setPlatformForTests("win32"); + setStatForTests(() => ({ dev: 1n, ino: 10n, ctimeNs: 100n })); + setResponseSpillShutdownBudgetForTests({ totalMs: 120, fallbackReserveMs: 80 }); + let release!: () => void; + let entered!: () => void; + let tempHardenFinished!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const hardened = new Promise(resolve => { tempHardenFinished = resolve; }); + let publishCount = 0; + setSpillIoForTest({ + record: event => { + if (event !== "publish") return; + publishCount += 1; + }, + }); + let tempHardenCalls = 0; + setAsyncIcaclsRunnerForTests(async args => { + if (String(args[0]).includes(".response-spill.")) { + tempHardenCalls += 1; + if (tempHardenCalls === 1) { + entered(); + await gate; + } + if (tempHardenCalls === 3) tempHardenFinished(); + } + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + setResponseStateByteCapForTests(1_024); + const payload = `fallback-${"z".repeat(2 * 1024 * 1024 + 4_096)}`; + rememberLarge("resp_shutdown_late", payload); + await started; + + let fallbackFile: string | undefined; + let abandonedTempCount = -1; + try { + await flushResponseState(); + fallbackFile = spillFileNames(home)[0]; + expect(fallbackFile).toBeDefined(); + abandonedTempCount = spillTempNames(home).length; + } finally { + release(); + } + await hardened; + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(spillFileNames(home)).toEqual([fallbackFile!]); + expect({ abandonedTempCount, publishCount }).toEqual({ abandonedTempCount: 0, publishCount: 1 }); + const replay = JSON.stringify(expandPreviousResponseInput({ + previous_response_id: "resp_shutdown_late", + input: "next", + })); + expect(replay).toContain("fallback-zzzzzzzz"); + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1, spillWrites: 1 }); + }); + + test("shutdown cleanup failure still persists unrelated response state and reports failure", async () => { + setPlatformForTests("win32"); + // The drain must expire, but the fallback reserve must NOT: this test asserts that a + // cleanup failure still persists unrelated state. Under load the previous 80ms reserve + // could itself expire, terminalizing `resp_cleanup_unrelated` into a tombstone and + // failing the replay assertion. The gate below — not the clock — is what forces drain + // expiry, so the reserve is sized to never be the thing that runs out. + setResponseSpillShutdownBudgetForTests({ totalMs: 30_120, fallbackReserveMs: 30_000 }); + let release!: () => void; + let entered!: () => void; + let tempHardenFinished!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const hardened = new Promise(resolve => { tempHardenFinished = resolve; }); + let tempHardenCalls = 0; + setAsyncIcaclsRunnerForTests(async args => { + if (String(args[0]).includes(".response-spill.")) { + tempHardenCalls += 1; + if (tempHardenCalls === 1) { + entered(); + await gate; + } + if (tempHardenCalls === 3) tempHardenFinished(); + } + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + setResponseStateByteCapForTests(1_024); + rememberLarge("resp_cleanup_failure", "x".repeat(2 * 1024 * 1024 + 4_096)); + await started; + const abandonedTempPath = join(responseSpillDirectory(home), spillTempNames(home)[0]!); + setSpillIoForTest({ + unlink: path => { + if (path === abandonedTempPath) { + throw Object.assign(new Error("injected abandoned temp unlink failure"), { code: "EPERM" }); + } + unlinkSync(path); + }, + }); + rememberResponseState( + { model: "test/model", input: "safe-small-input", store: false }, + fixedResponse("resp_cleanup_unrelated", [{ type: "message", role: "assistant", content: "safe-small-output" }]), + undefined, + { force: true }, + ); + + let reported: unknown; + try { + await flushResponseState(); + } catch (error) { + reported = error; + } finally { + release(); + } + await hardened; + await new Promise(resolve => setTimeout(resolve, 0)); + expect(reported).toBeInstanceOf(Error); + + setSpillIoForTest(null); + clearResponseStateMemoryForTests(); + setResponseStateByteCapForTests(1_024); + const replay = JSON.stringify(expandPreviousResponseInput({ + previous_response_id: "resp_cleanup_unrelated", + input: "next", + })); + expect(replay).toContain("safe-small-input"); + expect(replay).toContain("safe-small-output"); + }); + + test("shutdown fallback budget exhaustion is contained by a child watchdog", async () => { + const result = await runShutdownBudgetChild("exhaustion"); + expect(result).toMatchObject({ + settled: true, + reported: true, + pending: { count: 0, bytes: 0 }, + metrics: { residentCount: 1, tombstoneCount: 2 }, + replayedUnrelated: true, + }); + }, { timeout: watchdogMs(3_000) + 2_000 }); + + test("shutdown terminalization pass guard reports a bounded failure", async () => { + const result = await runShutdownBudgetChild("guard"); + expect(result).toMatchObject({ + settled: true, + reported: true, + pending: { count: 0, bytes: 0 }, + guardReported: true, + }); + }, { timeout: watchdogMs(3_000) + 2_000 }); + test("directory fsync follows spill unlink", () => { const ref = writeResponseSpillDurably("resp_unlink_order", { createdAt: Date.now(), items: ["x"] }); const events: string[] = []; diff --git a/tests/service.test.ts b/tests/service.test.ts index 3b48a76c43..4e9247e4b8 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url"; import * as serviceModule from "../src/service"; import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; import type { ServiceDiagnostic } from "../src/service"; import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../src/service"; import { buildWinswXml } from "../src/lib/winsw"; @@ -420,6 +420,7 @@ describe("Windows service task", () => { expect(args).not.toContain("/tr"); expect(args).not.toContain("/sc"); expect(args).not.toContain("/du"); + expect(buildWindowsSchtasksCreateArgsForXml("recovery.xml", false)).not.toContain("/f"); expect(args).not.toContain("/rl"); expect(args).not.toContain("highest"); expect(args.join(" ")).toContain("a&b"); @@ -446,7 +447,127 @@ describe("Windows service task", () => { expect(xml).not.toContain("C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd"); }); + /** + * The task runs under InteractiveToken, so Windows kills the proxy with the interactive + * session and the wrapper records exit code 1073807364 (STATUS_CONTROL_C_EXIT). With a lone + * LogonTrigger there was no way back before the next interactive logon, so signing out of a + * Remote Desktop session left the proxy down — observed gaps of up to ~60 hours in the + * wrapper log. These triggers do not prevent the kill; they make it recoverable on connect. + */ + test("registers session-reconnect triggers so a disconnected session can restart the proxy", () => { + const xml = buildWindowsTaskXml("s.cmd", "l.vbs"); + const triggers = /([\s\S]*?)<\/Triggers>/i.exec(xml)?.[1] ?? ""; + // Logon recovery is kept; the session triggers are additive. + expect(triggers).toContain(""); + for (const stateChange of ["RemoteConnect", "SessionUnlock", "ConsoleConnect"]) { + expect(triggers).toContain(`${stateChange}`); + } + // Re-entry is safe only because a live proxy is not started twice. + expect(xml).toContain("IgnoreNew"); + }); + + test("a task registered without session-reconnect triggers reads as unhealthy", () => { + const wscript = "C:\\Windows\\System32\\wscript.exe"; + const launcher = "C:\\Users\\Test\\.opencodex\\service-launcher.vbs"; + const xml = buildWindowsTaskXml("ignored.cmd", launcher).replace(/.*?<\/Command>/, `${wscript}`); + expect(windowsTaskRegistrationHealthy(xml, wscript, launcher)).toBe(true); + + // A task left over from an older install must be repaired, not accepted as-is. + const legacy = xml.replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + expect(legacy).not.toContain("SessionStateChangeTrigger"); + expect(windowsTaskRegistrationHealthy(legacy, wscript, launcher)).toBe(false); + + // Present but disabled is not recovery either, and the Enabled/StateChange pair must be + // matched within ONE element rather than found in two unrelated ones. + const disabled = xml.replace( + /(?:(?!<\/SessionStateChangeTrigger>)[\s\S])*?RemoteConnect<\/StateChange>[\s\S]*?<\/SessionStateChangeTrigger>/i, + "falseRemoteConnect", + ); + expect(disabled).toContain("RemoteConnect"); + expect(disabled).toContain("false"); + expect(windowsTaskRegistrationHealthy(disabled, wscript, launcher)).toBe(false); + }); + + /** + * `UserId` is optional in the schema, and omitting it makes a SessionStateChangeTrigger fire + * for any account's session change. Scope it to the installing account when that account is + * known. The builder is synchronous and cannot force an account lookup, so an unknown + * account degrades to the unscoped trigger — the same position the pre-existing + * `LogonTrigger` is already in, and still better than having no recovery trigger at all. + */ + test("scopes session-recovery triggers to the installing account when it is known", () => { + const scoped = buildWindowsTaskXml("s.cmd", "l.vbs", undefined, "MACHINE\\installer"); + const elements = scoped.match(/[\s\S]*?<\/SessionStateChangeTrigger>/gi) ?? []; + expect(elements).toHaveLength(3); + for (const element of elements) expect(element).toContain("MACHINE\\installer"); + + // Unknown account: unscoped rather than absent. Passed explicitly because the parameter + // defaults to a process-cached identity that other tests in this file may have populated. + const unscoped = buildWindowsTaskXml("s.cmd", "l.vbs", undefined, ""); + expect(unscoped).toContain("RemoteConnect"); + expect(unscoped).not.toContain(""); + }); + + /** + * sessionStateChangeTriggerType orders its children as optional `UserId`, optional `Delay`, + * then required `StateChange`. Keep the generated document in schema order even though + * some Windows builds accept and normalize the reversed form; a local string validator + * alone cannot prove that a document is portable across Task Scheduler implementations. + */ + test("emits UserId before StateChange so a scoped task passes schema validation", () => { + const scoped = buildWindowsTaskXml("s.cmd", "l.vbs", undefined, "MACHINE\\installer"); + const elements = scoped.match(/[\s\S]*?<\/SessionStateChangeTrigger>/gi) ?? []; + expect(elements).toHaveLength(3); + for (const element of elements) { + const userIdAt = element.indexOf(""); + const stateChangeAt = element.indexOf(""); + expect(userIdAt).toBeGreaterThan(-1); + expect(stateChangeAt).toBeGreaterThan(-1); + expect(userIdAt).toBeLessThan(stateChangeAt); + } + }); + + test("accepts an explicit session scope only for the known matching identity", () => { + const wscript = "C:\\Windows\\System32\\wscript.exe"; + const launcher = "C:\\Users\\Test\\.opencodex\\service-launcher.vbs"; + const scoped = buildWindowsTaskXml("ignored.cmd", launcher, undefined, "MACHINE\\installer") + .replace(/.*?<\/Command>/, `${wscript}`); + const foreign = scoped.replaceAll("MACHINE\\installer", "OTHER\\account"); + const unscoped = buildWindowsTaskXml("ignored.cmd", launcher, undefined, "") + .replace(/.*?<\/Command>/, `${wscript}`); + + expect(windowsTaskRegistrationHealthy(scoped, wscript, launcher, null)).toBe(false); + expect(windowsTaskRegistrationHealthy(scoped, wscript, launcher, "MACHINE\\installer")).toBe(true); + expect(windowsTaskRegistrationHealthy(foreign, wscript, launcher, "MACHINE\\installer")).toBe(false); + expect(windowsTaskRegistrationHealthy(unscoped, wscript, launcher, null)).toBe(true); + }); + test("validates the registered scheduler action, trigger, principal, and settings", () => { + // Guard first: a prefixed is a real scope the unprefixed element counter cannot + // see. Treating it as ABSENT would accept a task bound to somebody else's session as + // healthy, and repair would then leave that foreign scope in place. + const guardWscript = "C:\\Windows\\System32\\wscript.exe"; + const guardLauncher = "C:\\Users\\Test\\.opencodex\\service-launcher.vbs"; + const guardXml = buildWindowsTaskXml("ignored.cmd", guardLauncher, undefined, "") + .replace(/.*?<\/Command>/, `${guardWscript}`); + expect(windowsTaskRegistrationHealthy(guardXml, guardWscript, guardLauncher)).toBe(true); + const foreignPrefixed = guardXml.replace( + /(\s*true<\/Enabled>)/i, + "$1\n OTHER\\\\account", + ); + expect(foreignPrefixed).toContain(""); + expect(windowsTaskRegistrationHealthy(foreignPrefixed, guardWscript, guardLauncher)).toBe(false); + + const scoped = buildWindowsTaskXml("ignored.cmd", guardLauncher, undefined, "MACHINE\\installer") + .replace(/.*?<\/Command>/, `${guardWscript}`); + const duplicateScope = scoped.replace( + "MACHINE\\installer", + "MACHINE\\installerMACHINE\\installer", + ); + const emptyScope = scoped.replace("MACHINE\\installer", ""); + expect(windowsTaskRegistrationHealthy(duplicateScope, guardWscript, guardLauncher, "MACHINE\\installer")).toBe(false); + expect(windowsTaskRegistrationHealthy(emptyScope, guardWscript, guardLauncher, "MACHINE\\installer")).toBe(false); + const wscript = "C:\\Windows\\System32\\wscript.exe"; const launcher = "C:\\Users\\Test\\.opencodex\\service-launcher.vbs"; const xml = buildWindowsTaskXml("ignored.cmd", launcher).replace(/.*?<\/Command>/, `${wscript}`); @@ -1034,6 +1155,58 @@ describe("launchd service plist", () => { }); describe("service lifecycle cleanup ordering", () => { + test("an armed test cannot fall through to a live Task Scheduler mutation", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + const attemptNonce = "test-home-guard-registration"; + const xmlPath = join(TEST_DIR, "guarded-task.xml"); + writeFileSync( + xmlPath, + `\uFEFF${buildWindowsTaskXml(undefined, undefined, attemptNonce)}`, + { encoding: "utf16le" }, + ); + const observedCalls: string[][] = []; + serviceModule.setQuerySchtasksForTests(args => { + observedCalls.push([...args]); + return ""; + }); + try { + await expect(registerFreshWindowsSchedulerTask(xmlPath, attemptNonce)).rejects.toThrow( + "refusing to mutate the machine-global Windows Task Scheduler from an armed test process", + ); + // The guard runs before even the test recorder. Before this regression fix the recorder + // receives `/create /tn opencodex-proxy ... /f`, proving the live runner was reachable. + expect(observedCalls).toEqual([]); + } finally { + serviceModule.setQuerySchtasksForTests(null); + } + }); + + test("an armed partial install cannot fall through to live native-service removal", async () => { + const calls: string[] = []; + await expect(installFreshWindowsSchedulerSafely({ + stageRegistrationXml: () => { calls.push("stage"); return "attempt.xml"; }, + register: async () => { calls.push("register"); }, + recordOwnership: () => { calls.push("record-ownership"); return true; }, + prepare: async () => { calls.push("prepare"); }, + // Intentionally omit removeNativeService: the production default must fail closed. + publishAssets: () => { calls.push("publish-assets"); }, + runTask: () => { calls.push("run-task"); }, + writeState: () => { calls.push("write-state"); }, + rollbackTask: async () => { calls.push("rollback-task"); return null; }, + removeStagedXml: () => { calls.push("remove-stage"); }, + })).rejects.toThrow( + "refusing to mutate the machine-global Windows native service from an armed test process", + ); + expect(calls).toEqual([ + "stage", + "register", + "remove-stage", + "record-ownership", + "prepare", + "rollback-task", + ]); + }); + test("native service switch treats unknown as installed and requires confirmed absence", () => { const calls: string[] = []; const statuses: Array<"unknown" | "stopped" | "nonexistent"> = [ @@ -1123,7 +1296,7 @@ describe("service lifecycle cleanup ordering", () => { const calls: string[] = []; const parent = mkdtempSync(join(tmpdir(), "ocx-service-fixed-create-")); const stagedXml = join(parent, "attempt.xml"); - const expectedArgs = buildWindowsSchtasksCreateArgsForXml(stagedXml); + const expectedArgs = buildWindowsSchtasksCreateArgsForXml(stagedXml, false); const expectedXml = buildWindowsTaskXml(undefined, undefined, registrationAttemptNonce); try { writeFileSync(stagedXml, `\uFEFF${expectedXml}`, "utf16le"); @@ -1132,9 +1305,11 @@ describe("service lifecycle cleanup ordering", () => { calls.push(`create:${args.join(" ")}`); throw new WindowsSchtasksError("create", "access-denied", "denied"); }, - elevate: async (taskName, xml) => { + elevate: async (taskName, xml, replace, previousXml) => { calls.push(`elevate:${taskName}`); expect(xml).toBe(expectedXml.trimEnd()); + expect(replace).toBe(false); + expect(previousXml).toBeUndefined(); }, probe: () => ({ status: "present", detail: "present" }), queryXml: () => expectedXml, @@ -1298,6 +1473,7 @@ describe("service lifecycle cleanup ordering", () => { prepare: async () => { calls.push("prepare:stop-managers-and-proxy"); }, removeNativeService: () => { calls.push("remove-native-service"); }, publishAssets: () => { calls.push("publish-assets"); }, + verifyBeforeRun: nonce => { expect(nonce).toBe(stagedNonce); calls.push("verify-before-run"); }, runTask: () => { calls.push("run-task"); }, writeState: () => { calls.push("write-state"); }, rollbackTask: async () => { calls.push("rollback-task"); return null; }, @@ -1312,12 +1488,66 @@ describe("service lifecycle cleanup ordering", () => { "prepare:stop-managers-and-proxy", "remove-native-service", "publish-assets", + "verify-before-run", "run-task", "write-state", ]); expect(stagedNonce).not.toBe(""); }); + for (const [label, unreadable] of [ + ["is empty", () => ""], + ["throws", () => { throw new Error("query denied"); }], + ] as const) { + test(`fresh scheduler install retries when the pre-start registration ${label} transiently`, async () => { + const calls: string[] = []; + const delays: number[] = []; + let reads = 0; + let stagedNonce = ""; + + await installFreshWindowsSchedulerSafely({ + stageRegistrationXml: nonce => { + stagedNonce = nonce; + calls.push("stage"); + return "attempt.xml"; + }, + register: async () => { calls.push("register"); }, + recordOwnership: () => { calls.push("record-ownership"); return true; }, + prepare: async () => { calls.push("prepare"); }, + removeNativeService: () => { calls.push("remove-native-service"); }, + publishAssets: () => { calls.push("publish-assets"); }, + readSchedulerXml: () => { + calls.push("read"); + reads += 1; + return reads === 1 + ? unreadable() + : buildWindowsTaskXml(undefined, undefined, stagedNonce); + }, + settleSchedulerRead: delayMs => { calls.push(`settle:${delayMs}`); delays.push(delayMs); }, + runTask: () => { calls.push("run-task"); }, + writeState: () => { calls.push("write-state"); }, + rollbackTask: async () => { calls.push("rollback-task"); return null; }, + removeStagedXml: () => { calls.push("remove-stage"); }, + }); + + expect(calls).toEqual([ + "stage", + "register", + "remove-stage", + "record-ownership", + "prepare", + "remove-native-service", + "publish-assets", + "read", + "settle:50", + "read", + "run-task", + "write-state", + ]); + expect(delays).toEqual([50]); + }); + } + test("fresh scheduler staging hardens its private directory and XML before registration", async () => { const parent = mkdtempSync(join(tmpdir(), "ocx-service-stage-order-")); const stageDir = join(parent, "private-stage"); @@ -1345,6 +1575,7 @@ describe("service lifecycle cleanup ordering", () => { prepare: async () => {}, removeNativeService: () => {}, publishAssets: () => {}, + verifyBeforeRun: () => {}, runTask: () => {}, writeState: () => {}, rollbackTask: async () => null, @@ -1482,6 +1713,7 @@ describe("service lifecycle cleanup ordering", () => { prepare: async () => {}, removeNativeService: () => {}, publishAssets: () => {}, + verifyBeforeRun: () => {}, runTask: () => {}, writeState: () => {}, rollbackTask: async () => null, @@ -1540,6 +1772,39 @@ describe("service lifecycle cleanup ordering", () => { } }); + test("a fresh install that changes before start is preserved and never run", async () => { + const calls: string[] = []; + await expect(installFreshWindowsSchedulerSafely({ + stageRegistrationXml: () => "attempt.xml", + register: async () => { calls.push("register"); }, + recordOwnership: () => { calls.push("record-ownership"); return true; }, + prepare: async () => { calls.push("prepare"); }, + removeNativeService: () => { calls.push("remove-native-service"); }, + publishAssets: () => { calls.push("publish-assets"); }, + verifyBeforeRun: () => { + calls.push("verify-before-run"); + throw new Error("The fresh Task Scheduler registration changed before start; it was preserved and not run."); + }, + runTask: () => { calls.push("run-task"); }, + writeState: () => { calls.push("write-state"); }, + rollbackTask: async () => { calls.push("rollback-task"); return null; }, + removeStagedXml: () => { calls.push("remove-stage"); }, + })).rejects.toThrow(/changed before start; it was preserved and not run/); + + // The task is never started and install state is never published, so a task that another + // process registered under the fixed name cannot be adopted as this attempt's own. + expect(calls).toEqual([ + "register", + "remove-stage", + "record-ownership", + "prepare", + "remove-native-service", + "publish-assets", + "verify-before-run", + "rollback-task", + ]); + }); + test("a state-write failure leaves the already-started task for explicit diagnosis", async () => { const calls: string[] = []; await expect(installFreshWindowsSchedulerSafely({ @@ -1549,6 +1814,7 @@ describe("service lifecycle cleanup ordering", () => { prepare: async () => { calls.push("prepare"); }, removeNativeService: () => { calls.push("remove-native-service"); }, publishAssets: () => { calls.push("publish-assets"); }, + verifyBeforeRun: () => { calls.push("verify-before-run"); }, runTask: () => { calls.push("run-task"); }, writeState: () => { calls.push("write-state"); throw new Error("state write failed"); }, rollbackTask: async () => { calls.push("rollback-task"); return null; }, @@ -1562,6 +1828,7 @@ describe("service lifecycle cleanup ordering", () => { "prepare", "remove-native-service", "publish-assets", + "verify-before-run", "run-task", "write-state", ]); @@ -1862,6 +2129,82 @@ describe("service diagnostics", () => { const installedEnabled = { schedulerXml: healthyTaskXml() }; const installedDisabled = { schedulerXml: disabledTaskXml() }; + test("resolves an explicit scheduler scope once at the Windows diagnostic boundary", () => { + const scoped = buildWindowsTaskXml(undefined, undefined, undefined, "MACHINE\\installer"); + const foreign = scoped.replaceAll("MACHINE\\installer", "OTHER\\account"); + const unscoped = buildWindowsTaskXml(undefined, undefined, undefined, ""); + let identity: Readonly<{ name: string }> | null = null; + let resolutions = 0; + const timeouts: number[] = []; + const deps = { + currentIdentity: () => identity, + resolvePrincipal: (timeoutMs: number) => { + timeouts.push(timeoutMs); + resolutions += 1; + identity = { name: "MACHINE\\installer" }; + return "*S-1-5-21-111-222-333-1001"; + }, + }; + + const matching = deriveWindowsServiceDiagnosticForCurrentUser({ + ...base, + schedulerXml: scoped, + recordedBackend: "scheduler", + }, deps); + expect(identity).toEqual({ name: "MACHINE\\installer" }); + expect(resolutions).toBe(1); + expect(matching).toMatchObject({ viable: true, stale: false }); + expect(deriveWindowsServiceDiagnosticForCurrentUser({ + ...base, + schedulerXml: foreign, + recordedBackend: "scheduler", + }, deps)).toMatchObject({ viable: false, stale: true }); + + // The cached identity suppresses another resolver call. Empty and unscoped registrations + // also skip resolution when no identity has been cached, avoiding repeated sync timeouts. + expect(deriveWindowsServiceDiagnosticForCurrentUser({ + ...base, + schedulerXml: scoped, + recordedBackend: "scheduler", + }, deps)).toMatchObject({ viable: true, stale: false }); + expect(resolutions).toBe(1); + identity = null; + expect(deriveWindowsServiceDiagnosticForCurrentUser({ ...base, schedulerXml: "" }, deps)).toMatchObject({ installed: false }); + expect(deriveWindowsServiceDiagnosticForCurrentUser({ + ...base, + schedulerXml: unscoped, + recordedBackend: "scheduler", + }, deps)).toMatchObject({ viable: true, stale: false }); + expect(resolutions).toBe(1); + expect(timeouts).toEqual([30_000]); + }); + + test("keeps scoped diagnostics stale when identity resolution fails", () => { + const scoped = buildWindowsTaskXml(undefined, undefined, undefined, "MACHINE\\installer"); + const unscoped = buildWindowsTaskXml(undefined, undefined, undefined, ""); + let resolutions = 0; + const deps = { + currentIdentity: () => null, + resolvePrincipal: () => { + resolutions += 1; + throw new Error("identity unavailable"); + }, + }; + + expect(deriveWindowsServiceDiagnosticForCurrentUser({ + ...base, + schedulerXml: scoped, + recordedBackend: "scheduler", + }, deps)).toMatchObject({ viable: false, stale: true }); + expect(resolutions).toBe(1); + expect(deriveWindowsServiceDiagnosticForCurrentUser({ + ...base, + schedulerXml: unscoped, + recordedBackend: "scheduler", + }, deps)).toMatchObject({ viable: true, stale: false }); + expect(resolutions).toBe(1); + }); + test("fails closed for disabled, stale, conflicting, stopped, and ghost Windows services", () => { expect(deriveWindowsServiceDiagnostic({ ...base, ...installedEnabled, recordedBackend: "scheduler" })).toMatchObject({ viable: true, backend: "scheduler" }); expect(deriveWindowsServiceDiagnostic({ ...base, ...installedDisabled })).toMatchObject({ viable: false, enabled: false }); @@ -2034,6 +2377,10 @@ describe("service repair", () => { assertAuth: () => { calls.push("auth"); }, stopScheduler: () => { calls.push("stop"); }, writeSchedulerAssets: () => { calls.push("assets"); }, + // This test owns every repair dependency. Leaving either default live probe/re-register + // here would cross the temporary test home into the machine-global Task Scheduler. + readSchedulerXml: () => buildWindowsTaskXml(), + reregisterScheduler: async () => { calls.push("reregister"); }, startScheduler: () => { calls.push("start"); }, writeSchedulerState: () => { calls.push("state"); }, repairNative: async () => { calls.push("native"); }, @@ -2042,6 +2389,454 @@ describe("service repair", () => { expect(calls).toEqual(["env", "auth", "stop", "assets", "start", "state"]); }); + /** + * Rewriting the on-disk assets leaves the definition Task Scheduler holds untouched, so a + * task registered by an older version keeps its old triggers: status reports it stale and + * sends the user to repair, and repair changes nothing status is complaining about. Repair + * therefore re-registers, but only when the registered XML is actually stale, so the common + * case stays free of `schtasks /create` and its UAC prompt. + */ + test("repair re-registers a scheduler task whose registered definition is stale", async () => { + const calls: string[] = []; + let attemptNonce = ""; + const stale = buildWindowsTaskXml() + .replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + expect(windowsTaskRegistrationHealthy(stale)).toBe(false); + await repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => attemptNonce + ? buildWindowsTaskXml(undefined, undefined, attemptNonce) + : stale, + reregisterScheduler: async (nonce, previousXml) => { + calls.push("reregister"); + expect(previousXml).toBe(stale); + attemptNonce = nonce; + }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + repairNative: async () => { calls.push("native"); }, + repairSystemd: () => { calls.push("systemd"); }, + }); + // Re-registration happens after the assets exist and before the task is started. + expect(calls).toEqual(["env", "auth", "stop", "assets", "reregister", "start", "state"]); + }); + + test("repair leaves a healthy registration alone", async () => { + const calls: string[] = []; + await repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => buildWindowsTaskXml(), + reregisterScheduler: async () => { calls.push("reregister"); }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + repairNative: async () => { calls.push("native"); }, + repairSystemd: () => { calls.push("systemd"); }, + }); + expect(calls).toEqual(["env", "auth", "stop", "assets", "start", "state"]); + }); + + /** + * Only a recognizable OpenCodex definition that predates session triggers may be replaced + * automatically. Anything else registered under the fixed task name belongs to someone + * else, so repair preserves it instead of overwriting it with `/create /f`. + */ + for (const [label, xml] of [ + ["a foreign definition", "notepad.exe"], + ["a partially recognizable definition", buildWindowsTaskXml().replace(/[\s\S]*?<\/Principals>/i, "")], + ] as const) { + test(`repair preserves ${label} instead of replacing it`, async () => { + const calls: string[] = []; + expect(windowsTaskRegistrationHealthy(xml)).toBe(false); + await expect(repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { calls.push("read"); return xml; }, + reregisterScheduler: async () => { throw new Error("an unrecognized definition must not be replaced"); }, + restoreSchedulerIfAbsent: async () => { throw new Error("an unrecognized definition must not be restored over"); }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + })).rejects.toThrow(/not a recognized legacy OpenCodex definition/); + + // Nothing was stopped, rewritten, replaced, or started. + expect(calls).toEqual(["env", "auth", "read"]); + }); + } + + for (const [label, read] of [ + ["is empty", () => ""], + ["throws", () => { throw new Error("query denied"); }], + ] as const) { + test(`repair fails closed before stopping when the registered XML ${label}`, async () => { + const calls: string[] = []; + await expect(repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { calls.push("read"); return read(); }, + reregisterScheduler: async () => { calls.push("reregister"); }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + })).rejects.toThrow(/could not be read|empty or unreadable/i); + expect(calls).toEqual(["env", "auth", "read"]); + }); + } + + /** + * Repair stops the task before replacing a stale definition, so a failed replacement must + * preserve whichever verified definition now owns the fixed task name. It may restart an + * exact prior or healthy successor, but never overwrite or run foreign/unknown state. + */ + for (const [label, failure] of [ + ["registration is rejected", new Error("ERROR: Access is denied.")], + ["elevation is cancelled", new Error("The operation was canceled by the user.")], + ] as const) { + test(`repair restarts the existing task when ${label}`, async () => { + const calls: string[] = []; + let reads = 0; + const stale = buildWindowsTaskXml() + .replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + expect(windowsTaskRegistrationHealthy(stale)).toBe(false); + + await expect(repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { + calls.push("read"); + reads += 1; + return reads === 1 ? stale : `\uFEFF\r\n${stale.replace(/\n/g, "\r\n")}\r\n`; + }, + reregisterScheduler: async nonce => { + calls.push("reregister"); + expect(nonce).toMatch(/^[0-9a-f-]{36}$/); + throw failure; + }, + restoreSchedulerIfAbsent: async () => { throw new Error("unchanged registration must not be restored"); }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + repairNative: async () => { calls.push("native"); }, + repairSystemd: () => { calls.push("systemd"); }, + })).rejects.toThrow(failure.message); + + // The definition is read before the task is stopped, so an unreadable registration + // never costs the user a running proxy. The proxy is then running again on whatever + // definition is still registered, and the install state is NOT rewritten. Skipping + // restore also avoids a second UAC prompt. + expect(calls).toEqual(["env", "auth", "read", "stop", "assets", "reregister", "read", "read", "start"]); + }); + } + + test("repair recreates the prior task only after proven absence", async () => { + const calls: string[] = []; + let reads = 0; + const stale = buildWindowsTaskXml() + .replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + const failure = new Error("replacement failed"); + await expect(repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { + calls.push("read"); + reads += 1; + return reads === 1 || reads === 3 ? stale : ""; + }, + probeScheduler: () => { calls.push("probe"); return { status: "absent" }; }, + reregisterScheduler: async () => { calls.push("reregister"); throw failure; }, + restoreSchedulerIfAbsent: async xml => { calls.push("restore"); expect(xml).toBe(stale); }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + })).rejects.toThrow(failure.message); + expect(calls).toEqual(["env", "auth", "read", "stop", "assets", "reregister", "read", "probe", "restore", "read", "start"]); + }); + + test("repair preserves but does not start a healthy concurrent successor", async () => { + const calls: string[] = []; + let reads = 0; + const stale = buildWindowsTaskXml().replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + const successor = buildWindowsTaskXml(); + const failure = new Error("replacement failed"); + const result = repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { calls.push("read"); reads += 1; return reads === 1 ? stale : successor; }, + reregisterScheduler: async () => { calls.push("reregister"); throw failure; }, + restoreSchedulerIfAbsent: async () => { throw new Error("concurrent registration must not be overwritten"); }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + }); + await expect(result).rejects.toBeInstanceOf(AggregateError); + await result.catch(error => { + expect((error as AggregateError).errors[0]).toBe(failure); + expect((error as AggregateError).errors[1]).toHaveProperty("message", expect.stringContaining("different healthy")); + }); + expect(calls).toEqual(["env", "auth", "read", "stop", "assets", "reregister", "read"]); + }); + + test("repair does not start or publish when a successful refresh changes before restart", async () => { + const calls: string[] = []; + let reads = 0; + let attemptNonce = ""; + const stale = buildWindowsTaskXml().replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + await expect(repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => {}, + assertAuth: () => {}, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { + reads += 1; + if (reads === 1) return stale; + if (reads === 2) return buildWindowsTaskXml(undefined, undefined, attemptNonce); + return buildWindowsTaskXml(undefined, undefined, "newer-attempt"); + }, + reregisterScheduler: async nonce => { calls.push("reregister"); attemptNonce = nonce; }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + })).rejects.toThrow(/changed before restart/i); + expect(calls).toEqual(["stop", "assets", "reregister"]); + }); + + /** + * The default read turns a failed `schtasks /query` into an empty string, so an + * unreadable pre-start readback must not be mistaken for a concurrent replacement. + * The task was already stopped by this point, so aborting there would leave a + * previously running proxy down for a purely transient query failure. + */ + test("repair still restarts when the pre-start readback is only transiently unreadable", async () => { + const calls: string[] = []; + const delays: number[] = []; + let reads = 0; + let attemptNonce = ""; + const stale = buildWindowsTaskXml().replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + await repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => {}, + assertAuth: () => {}, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { + reads += 1; + if (reads === 1) return stale; + if (reads === 2) return buildWindowsTaskXml(undefined, undefined, attemptNonce); + // The verified replacement is already in place; only the first final query fails. + if (reads === 3) throw new Error("query denied"); + return buildWindowsTaskXml(undefined, undefined, attemptNonce); + }, + settleSchedulerRead: delayMs => { delays.push(delayMs); }, + reregisterScheduler: async nonce => { calls.push("reregister"); attemptNonce = nonce; }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + }); + + // The proxy is running again on the definition this attempt verified. + expect(calls).toEqual(["stop", "assets", "reregister", "start", "state"]); + expect(delays).toEqual([50]); + }); + + for (const [label, unreadable] of [ + ["is empty", () => ""], + ["throws", () => { throw new Error("query denied"); }], + ] as const) { + test(`repair does not start when the pre-start registration ${label} persistently`, async () => { + const calls: string[] = []; + const delays: number[] = []; + let reads = 0; + const healthy = buildWindowsTaskXml(); + await expect(repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => {}, + assertAuth: () => {}, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { + reads += 1; + return reads === 1 ? healthy : unreadable(); + }, + settleSchedulerRead: delayMs => { delays.push(delayMs); }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + })).rejects.toThrow(/became unreadable before restart/i); + + expect(calls).toEqual(["stop", "assets"]); + expect(delays).toEqual([50, 150, 300, 600]); + }); + } + + test("failed replacement recovery retries a transiently unreadable pre-start snapshot", async () => { + const calls: string[] = []; + const delays: number[] = []; + let reads = 0; + const stale = buildWindowsTaskXml().replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + const failure = new Error("replacement failed"); + await expect(repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => {}, + assertAuth: () => {}, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { + reads += 1; + if (reads <= 2) return stale; + if (reads === 3) throw new Error("query denied"); + return stale; + }, + settleSchedulerRead: delayMs => { delays.push(delayMs); }, + reregisterScheduler: async () => { calls.push("reregister"); throw failure; }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + })).rejects.toThrow(failure.message); + + expect(calls).toEqual(["stop", "assets", "reregister", "start"]); + expect(delays).toEqual([50]); + }); + + test("repair rejects a readable successor after an unreadable pre-start snapshot", async () => { + const calls: string[] = []; + const delays: number[] = []; + let reads = 0; + const healthy = buildWindowsTaskXml(); + const successor = healthy.replace("true", "false"); + await expect(repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => {}, + assertAuth: () => {}, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { + reads += 1; + if (reads === 1) return healthy; + if (reads === 2) return ""; + return successor; + }, + settleSchedulerRead: delayMs => { delays.push(delayMs); }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + })).rejects.toThrow(/changed before restart/i); + + expect(calls).toEqual(["stop", "assets"]); + expect(delays).toEqual([50]); + }); + + test("repair preserves and restarts a healthy residual owned by its attempt nonce", async () => { + const calls: string[] = []; + let reads = 0; + let attemptNonce = ""; + const stale = buildWindowsTaskXml().replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + const failure = new Error("verification failed"); + await expect(repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => {}, + assertAuth: () => {}, + stopScheduler: () => {}, + writeSchedulerAssets: () => {}, + readSchedulerXml: () => { + reads += 1; + return reads === 1 ? stale : buildWindowsTaskXml(undefined, undefined, attemptNonce); + }, + reregisterScheduler: async nonce => { calls.push("reregister"); attemptNonce = nonce; throw failure; }, + restoreSchedulerIfAbsent: async () => { throw new Error("attempt-owned task must not be overwritten"); }, + startScheduler: () => { calls.push("start"); }, + })).rejects.toThrow(failure.message); + expect(attemptNonce).not.toBe(""); + expect(calls).toEqual(["reregister", "start"]); + }); + + for (const [label, secondRead, probe] of [ + ["is foreign", () => "foreign", undefined], + ["is unreadable", () => { throw new Error("query denied"); }, undefined], + ["has unknown presence", () => "", () => ({ status: "unknown" as const, detail: "query denied" })], + ] as const) { + test(`repair preserves post-failure state and does not start it when it ${label}`, async () => { + const calls: string[] = []; + let reads = 0; + const stale = buildWindowsTaskXml().replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + const result = repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => {}, + assertAuth: () => {}, + stopScheduler: () => {}, + writeSchedulerAssets: () => {}, + readSchedulerXml: () => { reads += 1; return reads === 1 ? stale : secondRead(); }, + ...(probe ? { probeScheduler: () => { calls.push("probe"); return probe(); } } : {}), + reregisterScheduler: async () => { calls.push("reregister"); throw new Error("replacement failed"); }, + restoreSchedulerIfAbsent: async () => { calls.push("restore"); }, + startScheduler: () => { calls.push("start"); }, + }); + await expect(result).rejects.toBeInstanceOf(AggregateError); + expect(calls).toEqual(probe ? ["reregister", "probe"] : ["reregister"]); + }); + } + + test("repair reports both replacement and absent-task recovery failures", async () => { + const calls: string[] = []; + let reads = 0; + const stale = buildWindowsTaskXml() + .replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + const registrationFailure = new Error("registration rejected"); + const rollbackFailure = new Error("rollback rejected"); + + const result = repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => { calls.push("env"); }, + assertAuth: () => { calls.push("auth"); }, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => { + calls.push("read"); + reads += 1; + return reads === 1 ? stale : ""; + }, + probeScheduler: () => { calls.push("probe"); return { status: "absent" }; }, + reregisterScheduler: async () => { calls.push("reregister"); throw registrationFailure; }, + restoreSchedulerIfAbsent: async () => { calls.push("restore"); throw rollbackFailure; }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + }); + + await expect(result).rejects.toBeInstanceOf(AggregateError); + await result.catch(error => { + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([registrationFailure, rollbackFailure]); + }); + expect(calls).toEqual(["env", "auth", "read", "stop", "assets", "reregister", "read", "probe", "restore"]); + }); + test("repair rejects when nothing is installed", async () => { await expect(repairService({ platform: "win32", diff --git a/tests/sidecar-candidates.test.ts b/tests/sidecar-candidates.test.ts index ab45cafa52..908abe4f5e 100644 --- a/tests/sidecar-candidates.test.ts +++ b/tests/sidecar-candidates.test.ts @@ -6,6 +6,7 @@ import * as modelRowsModule from "../src/server/management/model-rows"; let accountSets: Record; activeAccountId?: string }> = {}; let usableCodexAccounts: Set = new Set(); let managementRows: Array> | Error = []; +let entitlementWaitMs: number | undefined; mock.module("../src/oauth/store", () => ({ ...storeModule, @@ -17,7 +18,8 @@ mock.module("../src/codex/account-usability", () => ({ })); mock.module("../src/server/management/model-rows", () => ({ ...modelRowsModule, - listManagementModelRows: async () => { + listManagementModelRows: async (_config: unknown, options?: { entitlementWaitMs?: number }) => { + entitlementWaitMs = options?.entitlementWaitMs; if (managementRows instanceof Error) throw managementRows; return managementRows; }, @@ -40,6 +42,7 @@ afterEach(() => { accountSets = {}; usableCodexAccounts = new Set(); managementRows = []; + entitlementWaitMs = undefined; }); function loginBoth(): void { @@ -82,6 +85,7 @@ describe("pickerVisibleSidecarCandidates", () => { const cfg = config({ providers: { openai: forward, claude: anthropicOAuth } }); const all = await pickerVisibleSidecarCandidates(cfg, resolveSidecarAuth(cfg)); expect(all.map(c => c.id).sort()).toEqual(["claude-haiku-4-5", "gpt-5.6-luna"]); + expect(entitlementWaitMs).toBe(0); }); test("no logins and empty catalog -> empty set", async () => { diff --git a/tests/strict-semver.test.ts b/tests/strict-semver.test.ts new file mode 100644 index 0000000000..934dabc048 --- /dev/null +++ b/tests/strict-semver.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; + +import { parseStrictSemver } from "../src/lib/strict-semver"; + +/** + * The prerelease section used to be matched by the semver.org pattern verbatim, whose three + * identifier alternatives overlap. Wrapped in a repetition, that gives a backtracking engine an + * exponential number of ways to split one string. CodeQL flagged it as `js/redos` and the cost + * was real rather than theoretical: a 125-character input took 522ms. + * + * The length ceiling did not help. It only chose where on the curve the input landed. + */ +describe("parseStrictSemver ReDoS resistance", () => { + test("the flagged attack shape stays linear at the length ceiling", () => { + // "0.0.0-0." followed by repetitions of "--." is the input CodeQL named. + const attack = ("0.0.0-0." + "--.".repeat(45)).slice(0, 128); + expect(attack.length).toBe(128); + + const started = performance.now(); + expect(parseStrictSemver(attack)).toBeNull(); + const elapsed = performance.now() - started; + + // The vulnerable pattern took ~522ms for this input. Anything in that region means the + // superlinear path is back; a linear parse lands three orders of magnitude below it. + expect(elapsed).toBeLessThan(50); + }); + + test("cost does not grow with the number of repetitions", () => { + const measure = (reps: number): number => { + const input = ("0.0.0-0." + "--.".repeat(reps)).slice(0, 128); + const started = performance.now(); + parseStrictSemver(input); + return performance.now() - started; + }; + + // Under the old pattern, going from 20 to 39 repetitions moved 16ms to 524ms. + measure(20); + const short = measure(20); + const long = measure(39); + expect(short).toBeLessThan(50); + expect(long).toBeLessThan(50); + }); + + test("the length guard still rejects before any matching work", () => { + const huge = "0.0.0-0." + "--.".repeat(200); + expect(huge.length).toBeGreaterThan(128); + expect(parseStrictSemver(huge)).toBeNull(); + expect(parseStrictSemver("1.0.0", 4)).toBeNull(); + }); +}); + +describe("parseStrictSemver grammar", () => { + test("accepts the semver.org examples", () => { + for (const valid of [ + "0.0.0", + "1.2.3", + "10.20.30", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-0.3.7", + "1.0.0-x.7.z.92", + "1.0.0-alpha.beta", + "1.0.0--", + "1.0.0-a-b", + "2.38.0-preview.20260831", + "1.0.0-alpha+001", + "1.0.0+20130313144700", + "1.0.0-beta+exp.sha.5114f85", + "1.0.0+21AF26D3----117B344092BD", + ]) { + expect(parseStrictSemver(valid)?.raw).toBe(valid); + } + }); + + test("rejects leading zeroes, empty identifiers and non-semver shapes", () => { + for (const invalid of [ + "01.0.0", + "1.01.0", + "1.0.01", + "1.0", + "1.0.0.0", + "1.0.0-", + "1.0.0-.", + "1.0.0-01", + "1.0.0-00", + "1.0.0-a..b", + "1.0.0-a.", + "1.0.0-a.01", + "1.0.0+", + "v1.0.0", + "1.0.0-alpha_beta", + "", + ]) { + expect(parseStrictSemver(invalid)).toBeNull(); + } + }); + + test("splits the prerelease into numeric and alphanumeric identifiers", () => { + const parsed = parseStrictSemver("1.0.0-0.3.7-x"); + expect(parsed?.core).toEqual([1n, 0n, 0n]); + expect(parsed?.prerelease).toEqual([0n, 3n, "7-x"]); + }); + + test("a version with no prerelease has an empty prerelease list", () => { + expect(parseStrictSemver("2.38.0")?.prerelease).toEqual([]); + }); +}); diff --git a/tests/system-restart.test.ts b/tests/system-restart.test.ts index 1b43ca2592..cd62c87dd3 100644 --- a/tests/system-restart.test.ts +++ b/tests/system-restart.test.ts @@ -426,6 +426,31 @@ describe("acceptSystemRestart", () => { ]); }); + test("a reported drain failure uses the uncertain-cleanup restart handoff", async () => { + const calls: string[] = []; + let scheduled: (() => void | Promise) | null = null; + + acceptSystemRestart({ + isDraining: () => false, + getActiveTurnCount: () => 0, + isSupervisedServiceChild: () => false, + listenPort: () => 10123, + schedule: fn => { scheduled = fn; }, + scheduleDeadline: () => () => {}, + setDraining: () => {}, + drainAndShutdown: async () => false, + stopListener: () => { calls.push("stop"); }, + spawnStart: (port, waitForHealth) => { + calls.push(`start:${port}:${waitForHealth ? "ready" : "deferred"}`); + }, + markRecycling: () => { calls.push("recycle"); }, + exitProcess: code => { calls.push(`exit:${code}`); }, + }); + + await scheduled!(); + expect(calls).toEqual(["stop", "start:10123:deferred", "recycle", "exit:1"]); + }); + test("late drain rejection after timeout is observed without a second terminal action", async () => { const calls: string[] = []; let scheduled: (() => void | Promise) | null = null; @@ -620,7 +645,7 @@ describe("acceptSystemRestart", () => { }); await scheduled!(); - expect(calls).toEqual(["latched", "drain", "stop", "start:10123", "recycle", "exit:0"]); + expect(calls).toEqual(["latched", "drain", "stop", "start:10123", "recycle", "exit:1"]); }); test("spawn failure clears OCX_SERVICE so exit cleanup can restore fences", async () => { diff --git a/tests/windows-elevation-spawn.test.ts b/tests/windows-elevation-spawn.test.ts index 39a1c07f9e..4f731a2b28 100644 --- a/tests/windows-elevation-spawn.test.ts +++ b/tests/windows-elevation-spawn.test.ts @@ -82,6 +82,16 @@ describe("runWindowsElevated spawn contract", () => { return child; } + test("an armed test cannot launch the live Windows elevation boundary", async () => { + // The probe is deliberately inert: if the guard regresses, it can only start an + // non-RunAs PowerShell executing a fixed exit 0, never UAC or Task Scheduler mutation. + const execution = startPowerShellCommand("exit 0"); + expect(execution.launcherPid).toBeNull(); + await expect(execution.completion).rejects.toThrow( + "Refusing to launch a live Windows elevation process from an armed test process", + ); + }); + test("returns exit code 0", async () => { fakeChild({ code: 0 }); await expect(runWindowsElevated("schtasks.exe", ["/query"])).resolves.toBe(0); @@ -198,11 +208,24 @@ describe("runWindowsElevated spawn contract", () => { expect(elevatedScript).toContain( "Trusted ScheduledTasks module does not export Register-ScheduledTask.", ); - expect(elevatedScript).toContain("& $registerTask -TaskName $taskName -Xml $xml -Force"); + expect(elevatedScript).toContain("& $registerTask -TaskName $taskName -Xml $xml -ErrorAction Stop"); + expect(elevatedScript).not.toContain("-Xml $xml -Force"); expect(elevatedScript.match(/\bRegister-ScheduledTask\b/g)).toHaveLength(2); expect(elevatedScript).toContain(Buffer.from(xml, "utf16le").toString("base64")); expect(commandScript).not.toContain("/xml"); expect(commandScript).not.toContain("task.xml"); + + const predecessor = "captured-predecessor"; + await expect( + runWindowsElevatedScheduledTaskRegistration("opencodex-proxy", xml, true, predecessor), + ).resolves.toBe(0); + const replaceMatch = /-EncodedCommand ([A-Za-z0-9+/=]+)/.exec(commandScript); + expect(replaceMatch).not.toBeNull(); + const replaceScript = Buffer.from(replaceMatch![1]!, "base64").toString("utf16le"); + expect(replaceScript).toContain("& $registerTask -TaskName $taskName -Xml $xml -Force"); + expect(replaceScript).toContain(Buffer.from(predecessor, "utf16le").toString("base64")); + expect(replaceScript).toContain("$currentXml = & $schtasks /query /tn $taskName /xml"); + expect(replaceScript).toContain("Task Scheduler replacement precondition changed."); }); test("maps exit 1223 to cancelled", async () => { diff --git a/tests/windows-service-mutation-lock.test.ts b/tests/windows-service-mutation-lock.test.ts new file mode 100644 index 0000000000..17529c77a2 --- /dev/null +++ b/tests/windows-service-mutation-lock.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + WindowsServiceMutationBusyError, + withWindowsServiceMutationLock, +} from "../src/lib/windows-service-mutation-lock"; + +// The lock path is injected throughout so the suite never opens the real per-user lock and +// therefore never serializes against a genuine `ocx service` run on the developer machine. +let testRoot = ""; +let lockPath = ""; + +const noHardening = { + hardenDirectory: () => {}, + hardenFile: () => {}, +}; + +async function waitForPath(path: string): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + if (existsSync(path)) return; + await Bun.sleep(10); + } + throw new Error(`Timed out waiting for child marker ${path}`); +} + +async function waitForOwnedChild(child: ReturnType): Promise { + const result = await Promise.race([ + child.exited.then(exitCode => ({ exitCode })), + Bun.sleep(10_000).then(() => null), + ]); + if (result) return result.exitCode; + child.kill(); + await child.exited; + throw new Error("Timed out waiting for owned Windows service mutation lock child"); +} + +function spawnHolder(source: string): ReturnType { + return Bun.spawn([process.execPath, "-e", source], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); +} + +beforeEach(() => { + testRoot = mkdtempSync(join(import.meta.dir, ".tmp-windows-service-mutation-lock-")); + lockPath = join(testRoot, "windows-service-mutation.sqlite"); +}); + +afterEach(async () => { + // Windows keeps the SQLite file mapped briefly after a child exits, so a single + // immediate remove can still see EBUSY. Retry, then leave the temp dir behind rather + // than failing an otherwise green assertion on a cleanup race. + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + rmSync(testRoot, { recursive: true, force: true }); + return; + } catch (error) { + if ((error as { code?: string }).code !== "EBUSY") throw error; + await Bun.sleep(25); + } + } +}); + +test("a second service mutation is refused while another process holds the lock", async () => { + const readyPath = join(testRoot, "holder-ready"); + const releasePath = join(testRoot, "holder-release"); + const lockModuleUrl = pathToFileURL(join(import.meta.dir, "../src/lib/windows-service-mutation-lock.ts")).href; + const child = spawnHolder(` + import { existsSync, writeFileSync } from "node:fs"; + import { withWindowsServiceMutationLock } from ${JSON.stringify(lockModuleUrl)}; + await withWindowsServiceMutationLock(async () => { + writeFileSync(${JSON.stringify(readyPath)}, "ready"); + while (!existsSync(${JSON.stringify(releasePath)})) Bun.sleepSync(10); + }, { lockPath: ${JSON.stringify(lockPath)}, hardenDirectory: () => {}, hardenFile: () => {} }); + `); + + try { + try { + await waitForPath(readyPath); + } catch (error) { + child.kill(); + await child.exited; + const stderr = await new Response(child.stderr).text().catch(() => ""); + throw new Error(`${(error as Error).message}\nchild stderr: ${stderr}`); + } + + // Contention fails fast and, critically, without running the operation: a blocked + // `ocx service repair` must never reach `schtasks` behind the holder's back. + let ran = false; + const startedAt = performance.now(); + await expect(withWindowsServiceMutationLock(async () => { + ran = true; + }, { lockPath, ...noHardening })).rejects.toBeInstanceOf(WindowsServiceMutationBusyError); + expect(ran).toBe(false); + expect(performance.now() - startedAt).toBeLessThan(2_000); + } finally { + writeFileSync(releasePath, "release"); + expect(await waitForOwnedChild(child)).toBe(0); + } + + // Once the holder exits, the next mutation plans and runs normally. + let planned = 0; + await withWindowsServiceMutationLock(async () => { planned += 1; }, { lockPath, ...noHardening }); + expect(planned).toBe(1); +}); + +test("an abruptly exited holder releases the OS-backed transaction without stale recovery", async () => { + const enteredPath = join(testRoot, "crashed-holder-entered"); + const lockModuleUrl = pathToFileURL(join(import.meta.dir, "../src/lib/windows-service-mutation-lock.ts")).href; + const child = spawnHolder(` + import { writeFileSync } from "node:fs"; + import { withWindowsServiceMutationLock } from ${JSON.stringify(lockModuleUrl)}; + await withWindowsServiceMutationLock(async () => { + writeFileSync(${JSON.stringify(enteredPath)}, "entered"); + process.exit(0); + }, { lockPath: ${JSON.stringify(lockPath)}, hardenDirectory: () => {}, hardenFile: () => {} }); + `); + + expect(await waitForOwnedChild(child)).toBe(0); + expect(existsSync(enteredPath)).toBe(true); + + // No stale-lock reclamation is needed, because the OS dropped the transaction on exit. + let ran = false; + await withWindowsServiceMutationLock(async () => { ran = true; }, { lockPath, ...noHardening }); + expect(ran).toBe(true); +}); + +test("a failing mutation releases the lock instead of wedging later service commands", async () => { + await expect(withWindowsServiceMutationLock(async () => { + throw new Error("repair failed"); + }, { lockPath, ...noHardening })).rejects.toThrow("repair failed"); + + let ran = false; + await withWindowsServiceMutationLock(async () => { ran = true; }, { lockPath, ...noHardening }); + expect(ran).toBe(true); +}); + +test("nested acquisition in the same process is refused rather than silently reentered", async () => { + await expect(withWindowsServiceMutationLock(async () => { + await withWindowsServiceMutationLock(async () => {}, { lockPath, ...noHardening }); + }, { lockPath, ...noHardening })).rejects.toBeInstanceOf(WindowsServiceMutationBusyError); + + let ran = false; + await withWindowsServiceMutationLock(async () => { ran = true; }, { lockPath, ...noHardening }); + expect(ran).toBe(true); +});